Files
dtf-system/local/customer.py
Cauê Faleiros 52ae13c9a1 fix: rate-limit and audit by real client address
uvicorn does not trust forwarded headers from a peer outside
forwarded_allow_ips, so request.client.host was the web gateway for every
request. The auth-source bucket therefore counted all customers together:
60 failed logins from one attacker locked out everyone. Security events
recorded the gateway address, which made the audit trail useless for
attribution.

The gateway now overwrites X-Forwarded-For with the peer address it observed
instead of appending to whatever the client sent, so the header carries one
value the client cannot choose, and client_ip() resolves it with a fallback to
the connection peer.

The guest-session limiter was keyed on the environment name, making it one
global bucket of 120 per 15 minutes: roughly eight new visitors a minute for
the whole site before legitimate traffic started receiving 429. It is now per
source, and the ceiling is deliberately generous because offices and mobile
carriers put many real customers behind a single address.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:48:31 -03:00

192 lines
11 KiB
Python

"""Customer portal and manual artwork handoff, composed into the local API."""
from datetime import datetime, timedelta, timezone
from uuid import UUID, uuid4, uuid5, NAMESPACE_URL
from fastapi import Depends, HTTPException, Request, Response
from psycopg.errors import UniqueViolation
from psycopg.types.json import Jsonb
from . import db
from .auth import owner, session_row, new_session, password_hash, password_matches, transfer_guest, throttle, DUMMY_PASSWORD_HASH, audit, client_ip
from .models import Register, Login, UploadStart, ArtworkSubmission
from .scanning import require_clean
def install_routes(app, operator, storage, begin, status, part, complete, upload_row, states):
def current(request):
try: return session_row(request)
except HTTPException: return None
@app.post('/api/account/register')
def register(body: Register, request: Request, response: Response):
email = body.customer.mail.strip().lower()
throttle(email, request)
previous = current(request)
encoded = password_hash(body.password)
identity = uuid4()
profile = body.customer.model_dump()
profile['mail'] = email
try:
with db.connect() as c:
if previous and c.execute('SELECT id FROM dtf_local.accounts WHERE id=%s', (previous['owner'],)).fetchone():
raise HTTPException(409, 'Sign out before registering another account')
c.execute('INSERT INTO dtf_local.accounts(id,email,password_hash,profile) VALUES(%s,%s,%s,%s)', (identity,email,encoded,Jsonb(profile)))
if previous: transfer_guest(c, previous, identity)
new_session(c, response, identity)
except UniqueViolation:
raise HTTPException(409, 'An account already exists; sign in')
audit('account_registered', account=str(identity))
return {'customer': profile}
@app.post('/api/account/login')
def login(body: Login, request: Request, response: Response):
email = body.email.strip().lower()
throttle(email, request)
with db.connect() as c:
account = c.execute('SELECT * FROM dtf_local.accounts WHERE email=%s', (email,)).fetchone()
# Comparable password work even when the email is absent.
stored = account['password_hash'] if account else DUMMY_PASSWORD_HASH
matches = password_matches(body.password, stored)
if not account or not matches:
audit('customer_login_failed', ip=client_ip(request))
raise HTTPException(401, 'Invalid email or password')
previous = current(request)
with db.connect() as c:
if not stored.startswith('scrypt-v2$'):
c.execute('UPDATE dtf_local.accounts SET password_hash=%s WHERE id=%s', (password_hash(body.password),account['id']))
if previous:
transfer_guest(c, previous, account['id'])
c.execute('DELETE FROM dtf_local.sessions WHERE id=%s', (previous['id'],))
new_session(c, response, account['id'])
audit('customer_login_success', account=str(account['id']))
return {'customer': account['profile']}
@app.post('/api/account/logout')
def logout(request: Request, response: Response):
previous = current(request)
if previous:
with db.connect() as c:
c.execute('DELETE FROM dtf_local.sessions WHERE id=%s', (previous['id'],))
response.delete_cookie('dtf_session', httponly=True, samesite='strict')
response.headers['Clear-Site-Data'] = '"storage"'
audit('customer_logout')
return {'ok': True}
@app.get('/api/account/me')
def me(identity=Depends(owner)):
with db.connect() as c:
row = c.execute('SELECT profile FROM dtf_local.accounts WHERE id=%s', (identity,)).fetchone()
return {'customer': row['profile'] if row else None}
def owned_order(c, oid, identity, lock=False):
row = c.execute('SELECT * FROM dtf_local.orders WHERE id=%s AND owner=%s'+(' FOR UPDATE' if lock else ''), (oid,identity)).fetchone()
if not row: raise HTTPException(404, 'Order not found')
return row
def file_rows(c, oid):
return c.execute('''SELECT f.id,f.upload_id,f.item_index,f.kind,f.active,f.note,f.created_at,
u.name,u.size,u.expires_at,(u.expires_at<=now()) AS expired
FROM dtf_local.order_files f JOIN dtf_local.uploads u ON u.id=f.upload_id
WHERE order_id=%s ORDER BY f.created_at''', (oid,)).fetchall()
@app.get('/api/customer/orders')
def orders(identity=Depends(owner)):
with db.connect() as c:
rows = c.execute('SELECT id,number,state,version,snapshot,created_at,updated_at FROM dtf_local.orders WHERE owner=%s ORDER BY created_at DESC', (identity,)).fetchall()
quotes = c.execute('''SELECT q.id,q.approved,q.approved_at,q.created_at FROM dtf_local.quotes q
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id WHERE q.owner=%s AND o.id IS NULL ORDER BY q.created_at DESC''', (identity,)).fetchall()
return {'orders': rows, 'quotes': quotes, 'states': states}
@app.get('/api/customer/orders/{oid}')
def detail(oid: UUID, identity=Depends(owner)):
with db.connect() as c:
row = owned_order(c, oid, identity)
history = c.execute('SELECT from_state,to_state,reason,created_at FROM dtf_local.movements WHERE order_id=%s ORDER BY id', (oid,)).fetchall()
return {'id': row['id'], 'number': row['number'], 'state': row['state'], 'version': row['version'],
'snapshot': row['snapshot'], 'history': history, 'files': file_rows(c,oid)}
def submit_files(c, order, body, identity, kind, actor):
if order['version'] != body.version:
raise HTTPException(409, 'Order changed. Refresh before submitting files.')
if kind == 'final' and order['state'] not in ('rec','tra','cor'):
raise HTTPException(409, 'Final files can only change during artwork review')
if kind == 'correction' and order['state'] != 'cor':
raise HTTPException(409, 'This order is not awaiting artwork correction')
if len({f.upload_id for f in body.files}) != len(body.files):
raise HTTPException(422, 'Each uploaded file must appear once')
count = len(order['snapshot']['items'])
if any(f.item_index >= count for f in body.files):
raise HTTPException(422, 'Invalid order item')
if kind == 'final' and {f.item_index for f in body.files} != set(range(count)):
raise HTTPException(422, 'Final-file set must cover every order item')
original_ids = [UUID(uid) for item in order['snapshot']['items'] for uid in item['uploads']]
first = c.execute('SELECT min(created_at) AS first FROM dtf_local.uploads WHERE id=ANY(%s)', (original_ids,)).fetchone()['first']
expiry = first + timedelta(days=30)
if expiry <= datetime.now(timezone.utc):
raise HTTPException(410, 'Order artwork retention has expired')
for ref in body.files:
upload = upload_row(c, ref.upload_id, identity, lock=True)
if not upload['complete']:
raise HTTPException(409, 'Complete all uploads first')
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))
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))
c.execute('UPDATE dtf_local.uploads SET expires_at=LEAST(expires_at,%s) WHERE id=%s', (expiry,ref.upload_id))
c.execute('UPDATE dtf_local.orders SET version=version+1,updated_at=now() WHERE id=%s', (order['id'],))
if kind == 'final':
# Artwork approval, not commercial quote approval, starts original cleanup.
c.execute("UPDATE dtf_local.uploads SET expires_at=LEAST(expires_at,now()+interval '7 days') WHERE id=ANY(%s)", (original_ids,))
return {'ok': True, 'version': order['version']+1, 'expires_at': expiry}
@app.post('/api/customer/orders/{oid}/corrections')
def correction(oid: UUID, body: ArtworkSubmission, identity=Depends(owner)):
with db.connect() as c:
order = owned_order(c, oid, identity, lock=True)
return submit_files(c,order,body,identity,'correction','customer')
@app.get('/api/customer/orders/{oid}/files/{fid}/download')
def customer_download(oid: UUID, fid: UUID, identity=Depends(owner)):
with db.connect() as c:
owned_order(c,oid,identity)
row = c.execute('SELECT u.* FROM dtf_local.order_files f JOIN dtf_local.uploads u ON u.id=f.upload_id WHERE f.id=%s AND f.order_id=%s AND f.active', (fid,oid)).fetchone()
if not row: raise HTTPException(404, 'Active file not found')
if row['expires_at'] <= datetime.now(timezone.utc): raise HTTPException(410, 'File expired')
require_clean(row)
return {'url':storage.download(row['object_key'],row['name']), 'name':row['name']}
def operator_identity(user):
return uuid5(NAMESPACE_URL, 'dtf-local-operator:'+user)
@app.post('/api/operator/orders/{oid}/uploads')
def begin_final(oid: UUID, body: UploadStart, user=Depends(operator)):
with db.connect() as c:
row = c.execute('SELECT state FROM dtf_local.orders WHERE id=%s', (oid,)).fetchone()
if not row: raise HTTPException(404, 'Order not found')
if row['state'] not in ('rec','tra','cor'): raise HTTPException(409, 'Order is not in artwork review')
return begin(body, session_id=operator_identity(user))
@app.get('/api/operator/uploads/{uid}')
def final_status(uid: UUID, user=Depends(operator)):
return status(uid,session_id=operator_identity(user))
@app.post('/api/operator/uploads/{uid}/parts/{number}')
def final_part(uid: UUID, number: int, user=Depends(operator)):
return part(uid,number,session_id=operator_identity(user))
@app.post('/api/operator/uploads/{uid}/complete')
def final_complete(uid: UUID, user=Depends(operator)):
return complete(uid,session_id=operator_identity(user))
@app.get('/api/operator/orders/{oid}/files')
def operator_files(oid: UUID, user=Depends(operator)):
with db.connect() as c:
return file_rows(c,oid)
@app.post('/api/operator/orders/{oid}/final-files')
def final_files(oid: UUID, body: ArtworkSubmission, user=Depends(operator)):
with db.connect() as c:
order = c.execute('SELECT * FROM dtf_local.orders WHERE id=%s FOR UPDATE', (oid,)).fetchone()
if not order: raise HTTPException(404, 'Order not found')
return submit_files(c,order,body,operator_identity(user),'final',user)