fix: harden week-two ordering, artwork and operations

This commit is contained in:
Cauê Faleiros
2026-09-23 10:40:18 -03:00
parent ccc25a2d5d
commit 24013458c9
43 changed files with 1064 additions and 228 deletions

View File

@@ -12,7 +12,7 @@ from ..artwork import submit_files
from ..core.auth import operator
from ..core.models import ArtworkSubmission, UploadStart
from ..runtime import file_rows, operator_identity
from .uploads import begin_upload, complete_upload, part_url, upload_status
from .uploads import begin_upload, cancel_upload, complete_upload, part_url, upload_status
router = APIRouter()
@@ -36,6 +36,10 @@ def final_part(uid: UUID, number: int, user=Depends(operator)):
def final_complete(uid: UUID, user=Depends(operator)):
return complete_upload(uid,session_id=operator_identity(user))
@router.delete('/api/operator/uploads/{uid}')
def final_cancel(uid: UUID, user=Depends(operator)):
return cancel_upload(uid,session_id=operator_identity(user))
@router.get('/api/operator/orders/{oid}/files')
def operator_files(oid: UUID, user=Depends(operator)):
with db.connect() as c:

View File

@@ -1,10 +1,9 @@
"""Health, session bootstrap and freight quoting."""
import os
from fastapi import APIRouter, HTTPException, Request, Response
from ..core import db
from ..core.auth import client_ip, owner, new_session, rate_limit
from ..core.limits import upload_limit_bytes
from ..core.models import Freight
from ..runtime import ENVIRONMENT, GUEST_SESSION_LIMIT, PART_BYTES, freight, storage
@@ -33,7 +32,7 @@ def session(request: Request, response: Response):
with db.connect() as c:
session_id = new_session(c, response)
return {'environment': ENVIRONMENT, 'cart_scope': str(session_id), 'part_bytes': PART_BYTES,
'max_upload_bytes': int(os.environ.get('MAX_UPLOAD_BYTES', '5368709120'))}
'max_upload_bytes': upload_limit_bytes()}
@router.post('/api/freight')
def quote_freight(body: Freight):

View File

@@ -3,9 +3,10 @@ import hashlib
import os
import secrets
from datetime import datetime, timedelta, timezone
from typing import Literal
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from psycopg.types.json import Jsonb
from ..core import db
@@ -65,15 +66,44 @@ def board(user=Depends(operator)):
finished = c.execute("SELECT * FROM dtf_local.orders WHERE state='fin' ORDER BY created_at DESC LIMIT %s",
(BOARD_FINISHED_LIMIT,)).fetchall()
finished_total = c.execute("SELECT count(*) AS n FROM dtf_local.orders WHERE state='fin'").fetchone()['n']
quotes = c.execute('''SELECT q.* FROM dtf_local.quotes q
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id WHERE o.id IS NULL
ORDER BY q.created_at LIMIT %s''', (BOARD_QUOTE_LIMIT,)).fetchall()
pending = c.execute('''SELECT q.* FROM dtf_local.quotes q
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
WHERE o.id IS NULL AND q.approved IS NULL
ORDER BY q.created_at DESC,q.id DESC LIMIT %s''', (BOARD_QUOTE_LIMIT,)).fetchall()
approved = c.execute('''SELECT q.* FROM dtf_local.quotes q
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
WHERE o.id IS NULL AND q.approved IS NOT NULL
ORDER BY q.created_at DESC,q.id DESC LIMIT 20''').fetchall()
pending_total = c.execute('''SELECT count(*) AS n FROM dtf_local.quotes q
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
WHERE o.id IS NULL AND q.approved IS NULL''').fetchone()['n']
approved_total = c.execute('''SELECT count(*) AS n FROM dtf_local.quotes q
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
WHERE o.id IS NULL AND q.approved IS NOT NULL''').fetchone()['n']
return {'states': STATES, 'transitions': TRANSITIONS,
'orders': active + list(reversed(finished)),
'finished_shown': len(finished), 'finished_total': finished_total,
'quotes': [quote_view(c, q) for q in quotes],
'quotes': [quote_view(c, q) for q in pending + approved],
'pending_total': pending_total, 'approved_total': approved_total,
'events': c.execute('SELECT * FROM dtf_local.outbox ORDER BY id DESC LIMIT 100').fetchall()}
@router.get('/api/operator/quotes')
def quote_page(kind: Literal['pending','approved'], before_created_at: datetime | None = None,
before_id: UUID | None = None, limit: int = Query(default=50, ge=1, le=100),
user=Depends(operator)):
if (before_created_at is None) != (before_id is None):
raise HTTPException(422, 'Both quote cursor fields are required')
approved_filter = 'q.approved IS NULL' if kind == 'pending' else 'q.approved IS NOT NULL'
cursor = 'AND (q.created_at,q.id)<(%s,%s)' if before_created_at else ''
params = ((before_created_at,before_id) if before_created_at else ()) + (limit+1,)
with db.connect() as c:
rows = c.execute(f'''SELECT q.* FROM dtf_local.quotes q
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
WHERE o.id IS NULL AND {approved_filter} {cursor}
ORDER BY q.created_at DESC,q.id DESC LIMIT %s''', params).fetchall()
return {'quotes':[quote_view(c,row) for row in rows[:limit]],
'has_more':len(rows)>limit}
@router.post('/api/operator/quotes/{uid}/approve')
def approve(uid: UUID, body: Review, user=Depends(operator)):
with db.connect() as c:
@@ -83,15 +113,22 @@ def approve(uid: UUID, body: Review, user=Depends(operator)):
if row['approved']:
raise HTTPException(409, 'Approved quotes are immutable; request a new quote')
draft = row['draft']
if any(item.get('production', {}).get('version') != 2 for item in draft['items']):
raise HTTPException(409, 'Quote uses an obsolete production layout; customer must request a new quote')
if len(body.items) != len(draft['items']):
raise HTTPException(422, 'Review must cover every item')
items = []
for item, original in zip(body.items, draft['items']):
if item.mode != original['mode'] or list(map(str,item.uploads)) != original['uploads']:
raise HTTPException(422, 'Product mode and attached files cannot change during review')
if item.production.model_dump(mode='json') != original['production'] or item.quality_status != original['quality_status'] or item.quality_acknowledged != original['quality_acknowledged']:
raise HTTPException(422, 'Production instructions and customer acknowledgement cannot change during commercial review')
for upload_id in item.uploads:
require_clean(upload_row(c, upload_id, row['owner']))
items.append({**price(item.mode, str(item.metres), item.grade), 'uploads': original['uploads']})
items.append({**price(item.mode, str(item.metres), item.grade),
'uploads': original['uploads'], 'production': original['production'],
'quality_status': original['quality_status'],
'quality_acknowledged': original['quality_acknowledged']})
quoted_freight = freight.quote(**draft['freight'])
approved = {'customer': draft['customer'], 'items': items, 'freight': quoted_freight,
'total_cents': sum(i['total_cents'] for i in items)+quoted_freight['total_cents']}

View File

@@ -26,8 +26,8 @@ def dev_paid(body: Pay, session_id=Depends(owner)):
quote = payments.approved_quote(c, body.quote_id, session_id)
except payments.PaymentRefused as refusal:
# The quote may already be paid; that is not a refusal.
existing = c.execute('SELECT * FROM dtf_local.orders WHERE quote_id=%s',
(body.quote_id,)).fetchone()
existing = c.execute('SELECT * FROM dtf_local.orders WHERE quote_id=%s AND owner=%s',
(body.quote_id, session_id)).fetchone()
if existing:
return existing
raise HTTPException(404 if 'not found' in str(refusal) else 409, str(refusal))

View File

@@ -1,6 +1,7 @@
"""Quotes: the customer's cart, and the operator-reviewed version of it."""
import hashlib
import json
from decimal import Decimal
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException
@@ -16,6 +17,9 @@ router = APIRouter()
@router.post('/api/quotes')
def create_quote(body: QuoteRequest, session_id=Depends(owner)):
for item in body.items:
if abs(item.metres*100-item.production.height_cm) > Decimal('0.02'):
raise HTTPException(422, 'Quoted metres do not match the submitted layout height')
draft = body.model_dump(mode='json', exclude={'request_key'})
digest = hashlib.sha256(json.dumps(draft, sort_keys=True).encode()).hexdigest()
try:

View File

@@ -12,6 +12,7 @@ from fastapi import APIRouter, Depends, HTTPException
from ..core import db
from ..core.auth import audit, owner, rate_limit
from ..core.limits import upload_limit_bytes
from ..core.models import UploadStart
from ..runtime import PART_BYTES, storage, upload_row
@@ -19,8 +20,8 @@ router = APIRouter()
@router.post('/api/uploads')
def begin_upload(body: UploadStart, session_id=Depends(owner)):
if body.size > int(os.environ.get('MAX_UPLOAD_BYTES', '5368709120')):
raise HTTPException(413, 'File exceeds the upload limit')
if body.size > upload_limit_bytes():
raise HTTPException(413, 'File exceeds the malware scan limit; select a smaller file')
uid = uuid4()
key = f'originals/{uid}'
rate_limit('upload-start', str(session_id), 60, 900)
@@ -30,14 +31,16 @@ def begin_upload(body: UploadStart, session_id=Depends(owner)):
usage = c.execute('''SELECT COALESCE(sum(size),0) AS total,
COALESCE(sum(size) FILTER(WHERE owner=%s),0) AS owned,
count(*) FILTER(WHERE owner=%s AND NOT complete) AS pending
FROM dtf_local.uploads WHERE purged_at IS NULL''', (session_id,session_id)).fetchone()
FROM dtf_local.uploads WHERE purged_at IS NULL''',
(session_id,session_id)).fetchone()
if (usage['total']+body.size > int(os.environ.get('STORAGE_QUOTA_BYTES','53687091200')) or
usage['owned']+body.size > int(os.environ.get('OWNER_UPLOAD_QUOTA_BYTES','10737418240')) or
usage['pending'] >= int(os.environ.get('MAX_PENDING_UPLOADS','10'))):
audit('upload_quota_rejected')
raise HTTPException(429, 'Local storage quota or pending upload limit reached')
multipart = storage.begin(key)
c.execute('INSERT INTO dtf_local.uploads(id,owner,name,size,object_key,multipart_id) VALUES(%s,%s,%s,%s,%s,%s)',
c.execute('''INSERT INTO dtf_local.uploads(id,owner,name,size,object_key,multipart_id,expires_at)
VALUES(%s,%s,%s,%s,%s,%s,now()+interval '1 hour')''',
(uid, session_id, body.name, body.size, key, multipart))
return {'id': uid, 'part_bytes': PART_BYTES}
@@ -81,5 +84,16 @@ def complete_upload(uid: UUID, session_id=Depends(owner)):
existing_size = storage.size(row['object_key'])
if existing_size != row['size']:
raise HTTPException(409, 'Stored size differs from declared size')
c.execute('UPDATE dtf_local.uploads SET complete=true WHERE id=%s', (uid,))
c.execute("UPDATE dtf_local.uploads SET complete=true,expires_at=now()+interval '30 days' WHERE id=%s", (uid,))
return {'id': uid, 'complete': True}
@router.delete('/api/uploads/{uid}')
def cancel_upload(uid: UUID, session_id=Depends(owner)):
with db.connect() as c:
row=upload_row(c,uid,session_id,lock=True)
if row['complete']:
raise HTTPException(409, 'Completed upload cannot be cancelled')
storage.discard(row['object_key'],row['multipart_id'],False)
c.execute('UPDATE dtf_local.uploads SET purged_at=now() WHERE id=%s',(uid,))
audit('upload_cancelled', upload=str(uid))
return {'id':uid,'cancelled':True}

View File

@@ -38,7 +38,12 @@ def submit_files(c, order, body, identity, kind, actor):
require_clean(upload)
if c.execute('SELECT id FROM dtf_local.order_files WHERE upload_id=%s', (ref.upload_id,)).fetchone():
raise HTTPException(409, 'File is already attached. Upload a new revision.')
c.execute('UPDATE dtf_local.order_files SET active=false WHERE order_id=%s AND kind=%s', (order['id'],kind))
# A new customer correction supersedes every final prepared from earlier
# artwork, including finals uploaded while this order was in correction.
if kind == 'correction':
c.execute("UPDATE dtf_local.order_files SET active=false WHERE order_id=%s AND kind IN ('correction','final')", (order['id'],))
else:
c.execute('UPDATE dtf_local.order_files SET active=false WHERE order_id=%s AND kind=%s', (order['id'],kind))
for ref in body.files:
c.execute('INSERT INTO dtf_local.order_files(id,order_id,upload_id,item_index,kind,note,created_by) VALUES(%s,%s,%s,%s,%s,%s,%s)',
(uuid4(),order['id'],ref.upload_id,ref.item_index,kind,body.note,actor))

13
app/core/limits.py Normal file
View File

@@ -0,0 +1,13 @@
"""Limits shared by upload admission and the malware scanner."""
import os
CLAMAV_STREAM_MAX_BYTES = 128 * 1024 * 1024 # infra/clamd.conf
def scan_limit_bytes():
return min(CLAMAV_STREAM_MAX_BYTES, int(os.environ.get('SCAN_MAX_BYTES', '134217728')))
def upload_limit_bytes():
transport = int(os.environ.get('MAX_UPLOAD_BYTES', '5368709120'))
return min(transport, scan_limit_bytes())

View File

@@ -2,7 +2,7 @@ import re
from decimal import Decimal
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
class StrictModel(BaseModel):
model_config = ConfigDict(extra='forbid', allow_inf_nan=False)
@@ -56,11 +56,80 @@ class UploadStart(StrictModel):
raise ValueError('Unsupported artwork file extension')
return value
class ProductionSource(StrictModel):
upload_id: UUID
kind: Literal['sheet', 'artwork']
width_cm: Decimal = Field(gt=0, le=57)
length_cm: Decimal = Field(gt=0, le=6000)
copies: int = Field(ge=1, le=200, strict=True)
rotation_degrees: Literal[0, 90] = 0
mirrored: bool = False
measurement: Literal['file', 'customer']
class ProductionPlacement(StrictModel):
source_index: int = Field(ge=0, le=19, strict=True)
copy_index: int = Field(ge=0, le=199, strict=True)
x_cm: Decimal = Field(ge=0, le=57)
y_cm: Decimal = Field(ge=0, le=1200000)
width_cm: Decimal = Field(gt=0, le=57)
length_cm: Decimal = Field(gt=0, le=6000)
rotation_degrees: Literal[0, 90, 180, 270]
mirrored: bool
class ProductionSpec(StrictModel):
version: Literal[2]
film_width_cm: Decimal
height_cm: Decimal = Field(gt=0, le=1200000)
sources: list[ProductionSource] = Field(min_length=1, max_length=20)
placements: list[ProductionPlacement] = Field(min_length=1, max_length=4000)
class Item(StrictModel):
mode: Literal['file','avulsa','uvfile','uv']
metres: Decimal = Field(gt=0, le=12000)
grade: int = Field(ge=0, le=100, strict=True)
uploads: list[UUID] = Field(min_length=1, max_length=20)
production: ProductionSpec
quality_status: Literal['ok', 'warning', 'unverified']
quality_acknowledged: bool
@model_validator(mode='after')
def production_matches_uploads(self):
if [source.upload_id for source in self.production.sources] != self.uploads:
raise ValueError('Production sources must match uploaded files in order')
is_sheet = self.mode in ('file', 'uvfile')
film_width = Decimal('28.5') if self.mode in ('uvfile', 'uv') else Decimal('57')
if self.production.film_width_cm != film_width:
raise ValueError('Production film width does not match the product')
for source in self.production.sources:
if (source.kind == 'sheet') != is_sheet or source.width_cm > film_width:
raise ValueError('Production source does not fit the selected product')
if is_sheet and (source.rotation_degrees or source.mirrored):
raise ValueError('Finished sheets cannot be rotated or mirrored by the layout')
expected={(index,copy) for index,source in enumerate(self.production.sources)
for copy in range(source.copies)}
placed=set()
tolerance=Decimal('0.02')
for placement in self.production.placements:
key=(placement.source_index,placement.copy_index)
if key not in expected or key in placed:
raise ValueError('Production placement has a missing or duplicate source copy')
placed.add(key)
source=self.production.sources[placement.source_index]
auto_rotation=(placement.rotation_degrees-source.rotation_degrees)%360
if auto_rotation not in (0,90) or placement.mirrored != source.mirrored:
raise ValueError('Production placement changes the source transform')
width,length=(source.width_cm,source.length_cm) if auto_rotation==0 else (source.length_cm,source.width_cm)
if abs(placement.width_cm-width)>tolerance or abs(placement.length_cm-length)>tolerance:
raise ValueError('Production placement changes the source size')
if placement.x_cm+placement.width_cm>film_width+tolerance or placement.y_cm+placement.length_cm>self.production.height_cm+tolerance:
raise ValueError('Production placement is outside the film')
if is_sheet and (placement.x_cm or auto_rotation):
raise ValueError('Finished sheets must retain their original orientation')
if placed != expected:
raise ValueError('Production layout does not cover every source copy')
if self.quality_status == 'warning' and not self.quality_acknowledged:
raise ValueError('Resolution warning must be acknowledged')
return self
class QuoteRequest(StrictModel):
request_key: UUID

View File

@@ -33,6 +33,8 @@ def approved_quote(c, quote_id, owner=None):
raise PaymentRefused('quote not found')
if not row['approved']:
raise PaymentRefused('quote was never reviewed')
if any(item.get('production', {}).get('version') != 2 for item in row['approved']['items']):
raise PaymentRefused('quote uses an obsolete production layout; request a new quote')
if row['approved_at'] < datetime.now(timezone.utc) - timedelta(hours=QUOTE_VALID_HOURS):
raise PaymentRefused('quote expired before payment')
return row
@@ -94,7 +96,7 @@ def apply(c, event):
# underpayment would ship artwork that was not paid for, and overpayment
# means something is wrong that a person should look at.
expected = quote['approved']['total_cents']
if event.amount_cents is not None and event.amount_cents != expected:
if type(event.amount_cents) is not int or event.amount_cents != expected:
audit('payment_amount_mismatch', quote=str(quote_id),
expected_cents=expected, paid_cents=event.amount_cents)
return f'refused: paid {event.amount_cents} but quote total is {expected}'

View File

@@ -58,7 +58,8 @@ def upload_row(c, upload_id, session_id, lock=False):
def quote_view(c, row):
order = c.execute('SELECT id,number,state FROM dtf_local.orders WHERE quote_id=%s', (row['id'],)).fetchone()
expired = row['approved_at'] and row['approved_at'] < datetime.now(timezone.utc)-timedelta(hours=24)
return {'id': row['id'], 'draft': row['draft'], 'approved': row['approved'],
return {'id': row['id'], 'created_at': row['created_at'],
'draft': row['draft'], 'approved': row['approved'],
'status': 'paid' if order else 'expired' if expired else 'approved' if row['approved'] else 'pending_review',
'order': order}

View File

@@ -1,11 +1,11 @@
"""Local ClamAV boundary. Unknown/error/over-limit results NEVER release artwork."""
import os
import socket
import struct
import time
from fastapi import HTTPException
from .core.auth import audit
from .core.db import connect
from .core.limits import scan_limit_bytes
def require_clean(row):
if not row['complete'] or row['scan_state'] != 'clean':
@@ -30,7 +30,7 @@ class ClamAV:
return self.command(b'VERSION').decode('utf-8','replace')
def scan(self, stream, size):
if size > min(134217728, int(os.environ.get('SCAN_MAX_BYTES','134217728'))):
if size > scan_limit_bytes():
return 'rejected', 'File exceeds the malware scan limit'
with socket.create_connection(('scanner',3310),timeout=10) as sock:
sock.settimeout(150)