diff --git a/CONTEXT.md b/CONTEXT.md
index 0742bf4..e78b20f 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -348,8 +348,8 @@ for this local implementation checkpoint.
### Existing assets
-- `dtf-site.html`: the Site. Commercial rules, artwork analysis, cart and
- delivery UI; its behaviour lives in `local/static/site-*.js`.
+- `web/`: the Site (`index.html`), the Kanban and customer portal pages, and
+ the scripts behind them. `web/site-*.js` holds the Site's behaviour.
- `docs/historico/`: the original specification, endpoint sketch, task plan and
status report. Background only — they describe a model this system does not
implement.
@@ -368,7 +368,7 @@ referenced them; recover from Git history if ever needed.
- The original Site had no backend payment, freight quote, order persistence,
authentication, purchase history, or real cart persistence. Local order
persistence, reviewed quotes, mock freight and fake payment now work through
- `local/static/checkout.js`. The local customer portal now provides accounts and
+ `web/checkout.js`. The local customer portal now provides accounts and
order history; cart recovery is browser-local. Production account verification,
recovery, and cross-device cart editing are still absent.
- Without the local bridge, the original freight fallback remains a stub.
diff --git a/LOCAL_SETUP.md b/LOCAL_SETUP.md
index c4307f3..4a60074 100644
--- a/LOCAL_SETUP.md
+++ b/LOCAL_SETUP.md
@@ -135,7 +135,7 @@ Pricing parity requires Python 3.10+ and Node 22+ on the host, no package instal
python3 -m unittest local.test_pricing -v
```
-This executes the real pricing constants/functions extracted from `dtf-site.html`
+This executes the real pricing constants/functions extracted from `web/site-config.js`
and compares all four modes, 101 grades, and 11 lengths (4,444 cases) to backend
pricing, plus assembly and invalid-input checks.
diff --git a/deploy/Dockerfile.web b/deploy/Dockerfile.web
index e7cb226..9df4707 100644
--- a/deploy/Dockerfile.web
+++ b/deploy/Dockerfile.web
@@ -7,7 +7,7 @@ ARG PYTHON_BASE_IMAGE=python:3.12-slim@sha256:2f17fc044b579bab302c2e8054d3a686e2
ARG NGINX_BASE_IMAGE=nginx:alpine@sha256:62ff2089abf5a9ed33bd232895bef5e22f7bb4b200675cec49a5ebc48e3d4ac8
FROM ${PYTHON_BASE_IMAGE} AS policy
WORKDIR /build
-COPY dtf-site.html /build/dtf-site.html
+COPY web /build/web
COPY local /build/local
COPY deploy /build/deploy
ENV NGINX_TEMPLATE=/build/deploy/nginx.conf.template
@@ -22,8 +22,8 @@ LABEL org.opencontainers.image.title="DTF Site and Kanban" \
org.opencontainers.image.source="DTF System repository"
ENV WEB_INDEX=index.html PUBLIC_HOST=invalid.example S3_PUBLIC_ENDPOINT=https://invalid.example
COPY --from=policy /build/default.conf.template /etc/nginx/templates/default.conf.template
-COPY dtf-site.html /usr/share/nginx/html/index.html
-COPY local/static/ /usr/share/nginx/html/
+COPY web/ /usr/share/nginx/html/
+
# The official entrypoint renders the server configuration at startup and Nginx
# writes its PID/cache files. Keep the service non-root while granting it
# ownership of only those runtime locations. This works in Docker Swarm,
diff --git a/deploy/production_preflight.py b/deploy/production_preflight.py
index 2287b2d..30b5e70 100644
--- a/deploy/production_preflight.py
+++ b/deploy/production_preflight.py
@@ -38,7 +38,7 @@ SOURCE_BLOCKERS = {
# matching when R2 support landed; they were removed rather than left to rot.
# What remains is the real blocker: no production payment or messaging adapter
# exists, so these lines must change before a release can be meaningful.
- 'local/app.py': (
+ 'local/runtime.py': (
'payment = FakePayment()',
),
'local/worker.py': (
diff --git a/deploy/test_production_preflight.py b/deploy/test_production_preflight.py
index eb3de97..2da2cdc 100644
--- a/deploy/test_production_preflight.py
+++ b/deploy/test_production_preflight.py
@@ -50,6 +50,26 @@ def valid_config():
return values
+class SourceMarkerTests(unittest.TestCase):
+ """A marker that stops matching weakens the gate without failing it.
+
+ This is how four markers silently died when the runtime gained R2 support,
+ and how the payment one died again when it moved to runtime.py. Assert that
+ every marker still points at something real.
+ """
+
+ def test_every_marker_is_found_in_its_file(self):
+ from deploy.production_preflight import ROOT, SOURCE_BLOCKERS
+ for relative, markers in SOURCE_BLOCKERS.items():
+ path = ROOT / relative
+ self.assertTrue(path.exists(), f'{relative} no longer exists')
+ text = path.read_text()
+ for marker in markers:
+ self.assertIn(marker, text,
+ f'{relative} no longer contains {marker!r}: the gate '
+ 'would pass without the condition being resolved')
+
+
class ProductionPreflightTests(unittest.TestCase):
def test_structurally_complete_metadata_passes(self):
self.assertEqual(config_errors(valid_config()), [])
@@ -80,7 +100,7 @@ class ProductionPreflightTests(unittest.TestCase):
def test_fake_checkout_is_explicitly_blocked(self):
errors = source_errors()
- self.assertTrue(any('local/app.py remains local-only' in error for error in errors))
+ self.assertTrue(any('local/runtime.py remains local-only' in error for error in errors))
self.assertTrue(any('local/worker.py remains local-only' in error for error in errors))
def test_secret_reuse_and_incoherent_limits_are_rejected(self):
diff --git a/local/Dockerfile.web b/local/Dockerfile.web
index 023421c..c2de990 100644
--- a/local/Dockerfile.web
+++ b/local/Dockerfile.web
@@ -1,6 +1,6 @@
FROM python:3.12-slim@sha256:2f17fc044b579bab302c2e8054d3a686e2cb9a83de48e70534b94cd8ebbe06a9 AS policy
WORKDIR /build
-COPY dtf-site.html /build/dtf-site.html
+COPY web /build/web
COPY local /build/local
RUN python local/compile_web.py
@@ -10,5 +10,5 @@ RUN apk upgrade --no-cache
ENV WEB_INDEX=index.html
COPY --from=policy /build/default.conf.template /etc/nginx/templates/default.conf.template
ENV S3_PUBLIC_ENDPOINT=http://localhost:9000
-COPY dtf-site.html /usr/share/nginx/html/index.html
-COPY local/static/ /usr/share/nginx/html/
+COPY web/ /usr/share/nginx/html/
+
diff --git a/local/api/__init__.py b/local/api/__init__.py
new file mode 100644
index 0000000..444401a
--- /dev/null
+++ b/local/api/__init__.py
@@ -0,0 +1,2 @@
+"""HTTP routers, one per resource. They import from local.runtime, never
+from each other or from local.app."""
diff --git a/local/api/artwork.py b/local/api/artwork.py
new file mode 100644
index 0000000..796a6b4
--- /dev/null
+++ b/local/api/artwork.py
@@ -0,0 +1,49 @@
+"""Operator artwork handling: final files, and the uploads that carry them.
+
+These reuse the customer upload routes with the operator's derived identity, so
+one transport serves both and there is only one place where parts are signed.
+"""
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, HTTPException
+
+from .. import db
+from ..artwork import submit_files
+from ..auth import operator
+from ..models import ArtworkSubmission, UploadStart
+from ..runtime import file_rows, operator_identity
+from .uploads import begin_upload, complete_upload, part_url, upload_status
+
+router = APIRouter()
+
+@router.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_upload(body, session_id=operator_identity(user))
+
+@router.get('/api/operator/uploads/{uid}')
+def final_status(uid: UUID, user=Depends(operator)):
+ return upload_status(uid,session_id=operator_identity(user))
+
+@router.post('/api/operator/uploads/{uid}/parts/{number}')
+def final_part(uid: UUID, number: int, user=Depends(operator)):
+ return part_url(uid,number,session_id=operator_identity(user))
+
+@router.post('/api/operator/uploads/{uid}/complete')
+def final_complete(uid: UUID, user=Depends(operator)):
+ return complete_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:
+ return file_rows(c,oid)
+
+@router.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)
diff --git a/local/api/customer.py b/local/api/customer.py
new file mode 100644
index 0000000..05dd8b8
--- /dev/null
+++ b/local/api/customer.py
@@ -0,0 +1,116 @@
+"""Customer accounts, their orders, and the corrections they submit."""
+from datetime import datetime, timedelta, timezone
+from uuid import UUID, uuid4
+
+from fastapi import APIRouter, Depends, HTTPException, Request, Response
+from psycopg.errors import UniqueViolation
+from psycopg.types.json import Jsonb
+
+from .. import db
+from ..artwork import submit_files
+from ..auth import (DUMMY_PASSWORD_HASH, audit, client_ip, new_session, owner,
+ password_hash, password_matches, session_row, throttle, transfer_guest)
+from ..models import ArtworkSubmission, Login, Register
+from ..runtime import STATES, file_rows, owned_order, storage
+from ..scanning import require_clean
+
+router = APIRouter()
+
+
+def current(request):
+ try: return session_row(request)
+ except HTTPException: return None
+
+
+@router.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}
+
+@router.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']}
+
+@router.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}
+
+@router.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}
+
+@router.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}
+
+@router.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)}
+
+@router.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')
+
+@router.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']}
diff --git a/local/api/health.py b/local/api/health.py
new file mode 100644
index 0000000..fb04e32
--- /dev/null
+++ b/local/api/health.py
@@ -0,0 +1,43 @@
+"""Health, session bootstrap and freight quoting."""
+import os
+
+from fastapi import APIRouter, HTTPException, Request, Response
+
+from .. import db
+from ..auth import client_ip, owner, new_session, rate_limit
+from ..models import Freight
+from ..runtime import ENVIRONMENT, GUEST_SESSION_LIMIT, PART_BYTES, freight, storage
+
+router = APIRouter()
+
+@router.get('/health')
+@router.get('/api/health')
+def health():
+ try:
+ with db.connect() as c:
+ c.execute('SELECT 1')
+ storage.health()
+ except Exception:
+ raise HTTPException(503, 'Database or storage unavailable')
+ return {'status': 'ok', 'environment': ENVIRONMENT,
+ 'storage': 'minio' if ENVIRONMENT == 'local' else 'r2', 'integrations': 'fake'}
+
+@router.get('/api/session')
+def session(request: Request, response: Response):
+ try:
+ session_id = owner(request)
+ except HTTPException:
+ # Per source, not per deployment: keyed on the environment name this was a
+ # single global bucket, so ~8 new visitors a minute exhausted it site-wide.
+ rate_limit('guest-sessions', client_ip(request), GUEST_SESSION_LIMIT, 900)
+ 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'))}
+
+@router.post('/api/freight')
+def quote_freight(body: Freight):
+ try:
+ return freight.quote(body.service, body.postal_code)
+ except ValueError as exc:
+ raise HTTPException(422, str(exc))
diff --git a/local/api/operator.py b/local/api/operator.py
new file mode 100644
index 0000000..377832a
--- /dev/null
+++ b/local/api/operator.py
@@ -0,0 +1,146 @@
+"""Kanban: sign-in, the board, commercial review and card movement."""
+import hashlib
+import os
+import secrets
+from datetime import datetime, timedelta, timezone
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, HTTPException, Request, Response
+from psycopg.types.json import Jsonb
+
+from .. import db
+from ..auth import (COOKIE_SECURE, DUMMY_PASSWORD_HASH, audit, client_ip, operator,
+ password_matches, throttle)
+from ..models import Move, OperatorLogin, Review
+from ..pricing import price
+from ..runtime import (BOARD_FINISHED_LIMIT, BOARD_QUOTE_LIMIT, STATES, TRANSITIONS,
+ enqueue, freight, quote_view, storage, upload_row)
+from ..scanning import require_clean
+
+router = APIRouter()
+
+@router.post('/api/operator/login')
+def operator_login(body: OperatorLogin, request: Request, response: Response):
+ email = body.email
+ throttle('operator:'+email, request)
+ with db.connect() as c:
+ account = c.execute('SELECT * FROM dtf_local.operators WHERE email=%s', (email,)).fetchone()
+ if not c.execute('SELECT 1 FROM dtf_local.operators WHERE active LIMIT 1').fetchone():
+ raise HTTPException(503, 'No Kanban operator account is configured')
+ # Comparable password work whether or not the account exists or is active.
+ stored = account['password_hash'] if account else DUMMY_PASSWORD_HASH
+ matches = password_matches(body.password, stored)
+ if not account or not account['active'] or not matches:
+ audit('operator_login_failed', ip=client_ip(request), operator=email)
+ raise HTTPException(401, 'Invalid operator login')
+ token = secrets.token_urlsafe(32)
+ with db.connect() as c:
+ previous = hashlib.sha256(request.cookies.get('dtf_operator','').encode()).hexdigest()
+ c.execute('DELETE FROM dtf_local.operator_sessions WHERE token_hash=%s', (previous,))
+ c.execute('INSERT INTO dtf_local.operator_sessions(token_hash,username) VALUES(%s,%s)',
+ (hashlib.sha256(token.encode()).hexdigest(), email))
+ c.execute('UPDATE dtf_local.operators SET last_login_at=now() WHERE id=%s', (account['id'],))
+ response.set_cookie('dtf_operator', token, httponly=True, secure=COOKIE_SECURE,
+ samesite='strict', path='/api/operator', max_age=28800)
+ audit('operator_login_success', operator=email)
+ return {'ok': True}
+
+@router.post('/api/operator/logout')
+def operator_logout(request: Request, response: Response):
+ with db.connect() as c:
+ digest = hashlib.sha256(request.cookies.get('dtf_operator','').encode()).hexdigest()
+ c.execute('DELETE FROM dtf_local.operator_sessions WHERE token_hash=%s', (digest,))
+ response.delete_cookie('dtf_operator', path='/api/operator', httponly=True,
+ secure=COOKIE_SECURE, samesite='strict')
+ audit('operator_logout')
+ return {'ok': True}
+
+@router.get('/api/operator/board')
+def board(user=Depends(operator)):
+ with db.connect() as c:
+ # Everything still in progress, however old: an operator must never lose a
+ # card they can act on. Finished orders are terminal and only accumulate,
+ # so the board carries a recent window of them and reports the true total.
+ active = c.execute("SELECT * FROM dtf_local.orders WHERE state<>'fin' ORDER BY created_at").fetchall()
+ 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()
+ 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],
+ 'events': c.execute('SELECT * FROM dtf_local.outbox ORDER BY id DESC LIMIT 100').fetchall()}
+
+@router.post('/api/operator/quotes/{uid}/approve')
+def approve(uid: UUID, body: Review, user=Depends(operator)):
+ with db.connect() as c:
+ row = c.execute('SELECT * FROM dtf_local.quotes WHERE id=%s FOR UPDATE', (uid,)).fetchone()
+ if not row:
+ raise HTTPException(404, 'Quote not found')
+ if row['approved']:
+ raise HTTPException(409, 'Approved quotes are immutable; request a new quote')
+ draft = row['draft']
+ 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')
+ 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']})
+ 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']}
+ c.execute('UPDATE dtf_local.quotes SET approved=%s, reviewed_by=%s, approved_at=now() WHERE id=%s', (Jsonb(approved),user,uid))
+ return approved
+
+@router.post('/api/operator/orders/{uid}/move')
+def move(uid: UUID, body: Move, user=Depends(operator)):
+ with db.connect() as c:
+ row = c.execute('SELECT * FROM dtf_local.orders WHERE id=%s FOR UPDATE', (uid,)).fetchone()
+ if not row:
+ raise HTTPException(404, 'Order not found')
+ if body.version != row['version']:
+ raise HTTPException(409, 'Order changed; refresh the board')
+ if body.state == row['state']:
+ return row
+ if body.state not in TRANSITIONS[row['state']]:
+ raise HTTPException(409, 'Move is not allowed from this state')
+ if body.state == 'cor' and not body.reason.strip():
+ raise HTTPException(422, 'Correction requires a reason')
+ if body.state in ('fil','imp'):
+ coverage = c.execute('SELECT DISTINCT f.item_index FROM dtf_local.order_files f JOIN dtf_local.uploads u ON u.id=f.upload_id WHERE f.order_id=%s AND f.kind=\'final\' AND f.active AND u.expires_at>now() AND u.purged_at IS NULL AND u.scan_state=\'clean\'', (uid,)).fetchall()
+ if {r['item_index'] for r in coverage} != set(range(len(row['snapshot']['items']))):
+ raise HTTPException(409, 'Approve a complete final-file set for every item before queueing')
+ if body.state == 'cor':
+ c.execute("UPDATE dtf_local.order_files SET active=false WHERE order_id=%s AND kind='final'", (uid,))
+ c.execute('INSERT INTO dtf_local.movements(order_id,from_state,to_state,operator,reason) VALUES(%s,%s,%s,%s,%s)',
+ (uid,row['state'],body.state,user,body.reason))
+ changed = c.execute('UPDATE dtf_local.orders SET state=%s, version=version+1, updated_at=now() WHERE id=%s RETURNING *', (body.state,uid)).fetchone()
+ events = {'imp':'production_started','cor':'correction_needed','fin':'ready'}
+ if body.state in events:
+ for provider in ('tiny','whatsapp'):
+ enqueue(c, f'{uid}:{changed["version"]}:{provider}', provider,
+ {'order_id':str(uid), 'number':row['number'], 'event':events[body.state], 'reason':body.reason,
+ 'customer_path': f'/portal.html?order={uid}'})
+ return changed
+
+@router.get('/api/operator/orders/{uid}/history')
+def history(uid: UUID, user=Depends(operator)):
+ with db.connect() as c:
+ return c.execute('SELECT * FROM dtf_local.movements WHERE order_id=%s ORDER BY id', (uid,)).fetchall()
+
+@router.get('/api/operator/uploads/{uid}/download')
+def download(uid: UUID, user=Depends(operator)):
+ with db.connect() as c:
+ row = c.execute('SELECT * FROM dtf_local.uploads WHERE id=%s AND complete', (uid,)).fetchone()
+ if not row:
+ raise HTTPException(404, 'Completed upload not found')
+ if row['expires_at'] <= datetime.now(timezone.utc):
+ raise HTTPException(410, 'Artwork retention expired')
+ require_clean(row)
+ return {'name':row['name'], 'url':storage.download(row['object_key'],row['name']), 'expires_in':300}
diff --git a/local/api/orders.py b/local/api/orders.py
new file mode 100644
index 0000000..9bb0185
--- /dev/null
+++ b/local/api/orders.py
@@ -0,0 +1,41 @@
+"""Paid orders. Local development payment only; no provider is wired yet."""
+from datetime import datetime, timedelta, timezone
+from uuid import UUID, uuid4
+
+from fastapi import APIRouter, Depends, HTTPException
+from psycopg.types.json import Jsonb
+
+from .. import db
+from ..auth import owner
+from ..models import Pay
+from ..runtime import ENVIRONMENT, enqueue, payment, upload_row
+from ..scanning import require_clean
+
+router = APIRouter()
+
+@router.post('/api/orders/dev-paid')
+def dev_paid(body: Pay, session_id=Depends(owner)):
+ if ENVIRONMENT != 'local':
+ raise HTTPException(503, 'Checkout is not configured yet')
+ with db.connect() as c:
+ row = c.execute('SELECT * FROM dtf_local.quotes WHERE id=%s AND owner=%s FOR UPDATE', (body.quote_id,session_id)).fetchone()
+ if not row:
+ raise HTTPException(404, 'Quote not found')
+ existing = c.execute('SELECT * FROM dtf_local.orders WHERE quote_id=%s', (body.quote_id,)).fetchone()
+ if existing:
+ return existing
+ if not row['approved']:
+ raise HTTPException(409, 'An operator must verify length and grade first')
+ if row['approved_at'] < datetime.now(timezone.utc)-timedelta(hours=24):
+ raise HTTPException(409, 'Quote expired; request a new quote')
+ approved = row['approved']
+ for item in approved['items']:
+ for upload_id in item['uploads']:
+ require_clean(upload_row(c, UUID(upload_id), session_id))
+ paid = payment.pay(str(body.quote_id), approved['total_cents'])
+ result = c.execute('INSERT INTO dtf_local.orders(id,quote_id,owner,snapshot,payment) VALUES(%s,%s,%s,%s,%s) RETURNING *',
+ (uuid4(),body.quote_id,session_id,Jsonb(approved),Jsonb(paid))).fetchone()
+ for provider in ('tiny','whatsapp'):
+ enqueue(c, f"{result['id']}:paid:{provider}", provider,
+ {'order_id': str(result['id']), 'number': result['number'], 'event': 'payment_approved', 'order': approved})
+ return result
diff --git a/local/api/quotes.py b/local/api/quotes.py
new file mode 100644
index 0000000..7121197
--- /dev/null
+++ b/local/api/quotes.py
@@ -0,0 +1,46 @@
+"""Quotes: the customer's cart, and the operator-reviewed version of it."""
+import hashlib
+import json
+from uuid import UUID, uuid4
+
+from fastapi import APIRouter, Depends, HTTPException
+from psycopg.types.json import Jsonb
+
+from .. import db
+from ..auth import owner
+from ..models import QuoteRequest
+from ..runtime import freight, quote_view, upload_row
+from ..scanning import require_clean
+
+router = APIRouter()
+
+@router.post('/api/quotes')
+def create_quote(body: QuoteRequest, session_id=Depends(owner)):
+ draft = body.model_dump(mode='json', exclude={'request_key'})
+ digest = hashlib.sha256(json.dumps(draft, sort_keys=True).encode()).hexdigest()
+ try:
+ freight.quote(body.freight.service, body.freight.postal_code)
+ except ValueError as exc:
+ raise HTTPException(422, str(exc))
+ with db.connect() as c:
+ for item in body.items:
+ for uid in item.uploads:
+ row = upload_row(c, uid, session_id)
+ if not row['complete']:
+ raise HTTPException(409, 'Complete every upload before requesting a quote')
+ require_clean(row)
+ uid = uuid4()
+ c.execute('INSERT INTO dtf_local.quotes(id,owner,request_key,request_hash,draft) VALUES(%s,%s,%s,%s,%s) ON CONFLICT(owner,request_key) DO NOTHING',
+ (uid, session_id, body.request_key, digest, Jsonb(draft)))
+ row = c.execute('SELECT * FROM dtf_local.quotes WHERE owner=%s AND request_key=%s', (session_id, body.request_key)).fetchone()
+ if row['request_hash'] != digest:
+ raise HTTPException(409, 'Request key already used for a different cart')
+ return {'id': row['id'], 'status': 'pending_review'}
+
+@router.get('/api/quotes/{uid}')
+def get_quote(uid: UUID, session_id=Depends(owner)):
+ with db.connect() as c:
+ row = c.execute('SELECT * FROM dtf_local.quotes WHERE id=%s AND owner=%s', (uid,session_id)).fetchone()
+ if not row:
+ raise HTTPException(404, 'Quote not found')
+ return quote_view(c, row)
diff --git a/local/api/uploads.py b/local/api/uploads.py
new file mode 100644
index 0000000..6d32831
--- /dev/null
+++ b/local/api/uploads.py
@@ -0,0 +1,85 @@
+"""Customer uploads: reservation, signed parts, completion.
+
+The operator artwork routes reuse these functions directly with a different
+identity, which is why they are plain functions with a session_id argument.
+"""
+import math
+import os
+from uuid import UUID, uuid4
+
+from botocore.exceptions import ClientError
+from fastapi import APIRouter, Depends, HTTPException
+
+from .. import db
+from ..auth import audit, owner, rate_limit
+from ..models import UploadStart
+from ..runtime import PART_BYTES, storage, upload_row
+
+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')
+ uid = uuid4()
+ key = f'originals/{uid}'
+ rate_limit('upload-start', str(session_id), 60, 900)
+ with db.connect() as c:
+ # Serialize reservations across API processes, including changing guest IDs.
+ c.execute('SELECT pg_advisory_xact_lock(804208)')
+ 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()
+ 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)',
+ (uid, session_id, body.name, body.size, key, multipart))
+ return {'id': uid, 'part_bytes': PART_BYTES}
+
+@router.get('/api/uploads/{uid}')
+def upload_status(uid: UUID, session_id=Depends(owner)):
+ with db.connect() as c:
+ row = upload_row(c, uid, session_id)
+ parts = [] if row['complete'] else storage.parts(row['object_key'], row['multipart_id'])
+ return {'id': uid, 'complete': row['complete'], 'part_bytes': PART_BYTES,
+ 'scan_state':row['scan_state'], 'scan_reason':row['scan_reason'],
+ 'parts': [p['PartNumber'] for p in parts]}
+
+@router.post('/api/uploads/{uid}/parts/{part}')
+def part_url(uid: UUID, part: int, session_id=Depends(owner)):
+ with db.connect() as c:
+ row = upload_row(c, uid, session_id)
+ if row['complete'] or not 1 <= part <= math.ceil(row['size'] / PART_BYTES):
+ raise HTTPException(409, 'Invalid part or completed upload')
+ size = min(PART_BYTES, row['size']-(part-1)*PART_BYTES)
+ return {'url': storage.part_url(row['object_key'], row['multipart_id'], part, size)}
+
+@router.post('/api/uploads/{uid}/complete')
+def complete_upload(uid: UUID, session_id=Depends(owner)):
+ with db.connect() as c:
+ row = upload_row(c, uid, session_id, lock=True)
+ if row['complete']:
+ return {'id': uid, 'complete': True}
+ try:
+ existing_size = storage.size(row['object_key'])
+ except ClientError as exc:
+ if exc.response['ResponseMetadata']['HTTPStatusCode'] != 404:
+ raise
+ existing_size = None
+ if existing_size is None:
+ parts = storage.parts(row['object_key'], row['multipart_id'])
+ expected = math.ceil(row['size'] / PART_BYTES)
+ if [p['PartNumber'] for p in parts] != list(range(1, expected+1)) or any(
+ p['Size'] != min(PART_BYTES, row['size'] - i*PART_BYTES) for i,p in enumerate(parts)):
+ raise HTTPException(409, 'Parts are missing or their sizes do not match')
+ storage.complete(row['object_key'], row['multipart_id'], parts)
+ 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,))
+ return {'id': uid, 'complete': True}
diff --git a/local/app.py b/local/app.py
index 13009a4..0fbfdb4 100644
--- a/local/app.py
+++ b/local/app.py
@@ -1,51 +1,20 @@
-import hashlib
-import json
-import math
-import os
-import secrets
-from contextlib import asynccontextmanager
-from datetime import datetime, timedelta, timezone
-from uuid import UUID, uuid4
+"""The DTF Portal/API service: assembly only.
-from botocore.exceptions import ClientError
-from fastapi import Depends, FastAPI, HTTPException, Request, Response
+Configuration and shared helpers are in local.runtime; every route lives in a
+router under local.api. This module creates the application, applies the
+cross-cutting middleware, and includes them.
+"""
+from contextlib import asynccontextmanager
+
+from fastapi import FastAPI
from fastapi.responses import JSONResponse
from starlette.middleware.trustedhost import TrustedHostMiddleware
-from psycopg.types.json import Jsonb
from . import db
-from .secrets import load as load_secret_files
-from .adapters import FakeFreight, FakePayment, LocalS3Storage, require_runtime
-from .models import Freight, Move, Pay, QuoteRequest, Review, UploadStart, OperatorLogin
-from .pricing import price
-from .auth import COOKIE_SECURE, DUMMY_PASSWORD_HASH, client_ip, owner, session_row, new_session, operator, password_matches, throttle, audit, rate_limit
-from .scanning import require_clean
+from .auth import audit, client_ip
+from .runtime import ALLOWED_HOSTS, ALLOWED_ORIGINS, storage
+from .api import artwork, customer, health, operator, orders, quotes, uploads
-load_secret_files()
-require_runtime()
-storage = LocalS3Storage()
-payment = FakePayment()
-freight = FakeFreight()
-ENVIRONMENT = os.environ.get('APP_ENV', 'local')
-PUBLIC_ORIGIN = os.environ.get('PUBLIC_ORIGIN', 'http://localhost')
-ALLOWED_HOSTS = [host for host in os.environ.get('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',') if host]
-ALLOWED_ORIGINS = [origin for origin in os.environ.get('ALLOWED_ORIGINS', PUBLIC_ORIGIN).split(',') if origin]
-# Per source and generous: a browser needs one session and keeps the cookie, but
-# offices and mobile carriers put many real customers behind one address, so a
-# tight per-IP ceiling would lock out the same people the old global one did.
-GUEST_SESSION_LIMIT = int(os.environ.get('GUEST_SESSION_LIMIT', '240'))
-# The board returned every order ever created. Finished ones are terminal, so
-# they were pure growth: at a few hundred a day the response and the page both
-# degrade with no operator benefit.
-BOARD_FINISHED_LIMIT = int(os.environ.get('BOARD_FINISHED_LIMIT', '50'))
-BOARD_QUOTE_LIMIT = int(os.environ.get('BOARD_QUOTE_LIMIT', '100'))
-PART_BYTES = int(os.environ.get('UPLOAD_PART_BYTES', '8388608'))
-if not 5242880 <= PART_BYTES <= 67108864:
- raise RuntimeError('UPLOAD_PART_BYTES must be between 5 and 64 MiB')
-STATES = {'rec': 'Arte recebida', 'tra': 'Arte tratada', 'fil': 'Fila de impressão',
- 'imp': 'Imprimindo', 'cor': 'Correção', 'fin': 'Finalizado'}
-TRANSITIONS = {'rec': ['tra','cor'], 'tra': ['fil','cor'], 'fil': ['imp','cor'],
- 'imp': ['fin','cor'], 'cor': ['rec','tra'], 'fin': []}
@asynccontextmanager
async def lifespan(app):
@@ -54,45 +23,10 @@ async def lifespan(app):
storage.health()
yield
+
app = FastAPI(title='DTF Portal/API', lifespan=lifespan, docs_url=None, redoc_url=None)
app.add_middleware(TrustedHostMiddleware, allowed_hosts=ALLOWED_HOSTS)
-@app.post('/api/operator/login')
-def operator_login(body: OperatorLogin, request: Request, response: Response):
- email = body.email
- throttle('operator:'+email, request)
- with db.connect() as c:
- account = c.execute('SELECT * FROM dtf_local.operators WHERE email=%s', (email,)).fetchone()
- if not c.execute('SELECT 1 FROM dtf_local.operators WHERE active LIMIT 1').fetchone():
- raise HTTPException(503, 'No Kanban operator account is configured')
- # Comparable password work whether or not the account exists or is active.
- stored = account['password_hash'] if account else DUMMY_PASSWORD_HASH
- matches = password_matches(body.password, stored)
- if not account or not account['active'] or not matches:
- audit('operator_login_failed', ip=client_ip(request), operator=email)
- raise HTTPException(401, 'Invalid operator login')
- token = secrets.token_urlsafe(32)
- with db.connect() as c:
- previous = hashlib.sha256(request.cookies.get('dtf_operator','').encode()).hexdigest()
- c.execute('DELETE FROM dtf_local.operator_sessions WHERE token_hash=%s', (previous,))
- c.execute('INSERT INTO dtf_local.operator_sessions(token_hash,username) VALUES(%s,%s)',
- (hashlib.sha256(token.encode()).hexdigest(), email))
- c.execute('UPDATE dtf_local.operators SET last_login_at=now() WHERE id=%s', (account['id'],))
- response.set_cookie('dtf_operator', token, httponly=True, secure=COOKIE_SECURE,
- samesite='strict', path='/api/operator', max_age=28800)
- audit('operator_login_success', operator=email)
- return {'ok': True}
-
-@app.post('/api/operator/logout')
-def operator_logout(request: Request, response: Response):
- with db.connect() as c:
- digest = hashlib.sha256(request.cookies.get('dtf_operator','').encode()).hexdigest()
- c.execute('DELETE FROM dtf_local.operator_sessions WHERE token_hash=%s', (digest,))
- response.delete_cookie('dtf_operator', path='/api/operator', httponly=True,
- secure=COOKIE_SECURE, samesite='strict')
- audit('operator_logout')
- return {'ok': True}
-
@app.middleware('http')
async def safe_headers(request, call_next):
if request.method not in ('GET','HEAD','OPTIONS'):
@@ -108,273 +42,7 @@ async def safe_headers(request, call_next):
response.headers['Referrer-Policy'] = 'no-referrer'
return response
-@app.get('/health')
-@app.get('/api/health')
-def health():
- try:
- with db.connect() as c:
- c.execute('SELECT 1')
- storage.health()
- except Exception:
- raise HTTPException(503, 'Database or storage unavailable')
- return {'status': 'ok', 'environment': ENVIRONMENT,
- 'storage': 'minio' if ENVIRONMENT == 'local' else 'r2', 'integrations': 'fake'}
-@app.get('/api/session')
-def session(request: Request, response: Response):
- try:
- session_id = owner(request)
- except HTTPException:
- # Per source, not per deployment: keyed on the environment name this was a
- # single global bucket, so ~8 new visitors a minute exhausted it site-wide.
- rate_limit('guest-sessions', client_ip(request), GUEST_SESSION_LIMIT, 900)
- 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'))}
-
-@app.post('/api/freight')
-def quote_freight(body: Freight):
- try:
- return freight.quote(body.service, body.postal_code)
- except ValueError as exc:
- raise HTTPException(422, str(exc))
-
-def upload_row(c, upload_id, session_id, lock=False):
- row = c.execute('SELECT * FROM dtf_local.uploads WHERE id=%s AND owner=%s' +
- (' FOR UPDATE' if lock else ''), (upload_id, session_id)).fetchone()
- if not row:
- raise HTTPException(404, 'Upload not found')
- days = 30 if row['complete'] else 1
- if row['purged_at'] or row['expires_at'] <= datetime.now(timezone.utc) or row['created_at'] < datetime.now(timezone.utc) - timedelta(days=days):
- raise HTTPException(410, 'Upload expired; select the file again')
- return row
-
-@app.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')
- uid = uuid4()
- key = f'originals/{uid}'
- rate_limit('upload-start', str(session_id), 60, 900)
- with db.connect() as c:
- # Serialize reservations across API processes, including changing guest IDs.
- c.execute('SELECT pg_advisory_xact_lock(804208)')
- 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()
- 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)',
- (uid, session_id, body.name, body.size, key, multipart))
- return {'id': uid, 'part_bytes': PART_BYTES}
-
-@app.get('/api/uploads/{uid}')
-def upload_status(uid: UUID, session_id=Depends(owner)):
- with db.connect() as c:
- row = upload_row(c, uid, session_id)
- parts = [] if row['complete'] else storage.parts(row['object_key'], row['multipart_id'])
- return {'id': uid, 'complete': row['complete'], 'part_bytes': PART_BYTES,
- 'scan_state':row['scan_state'], 'scan_reason':row['scan_reason'],
- 'parts': [p['PartNumber'] for p in parts]}
-
-@app.post('/api/uploads/{uid}/parts/{part}')
-def part_url(uid: UUID, part: int, session_id=Depends(owner)):
- with db.connect() as c:
- row = upload_row(c, uid, session_id)
- if row['complete'] or not 1 <= part <= math.ceil(row['size'] / PART_BYTES):
- raise HTTPException(409, 'Invalid part or completed upload')
- size = min(PART_BYTES, row['size']-(part-1)*PART_BYTES)
- return {'url': storage.part_url(row['object_key'], row['multipart_id'], part, size)}
-
-@app.post('/api/uploads/{uid}/complete')
-def complete_upload(uid: UUID, session_id=Depends(owner)):
- with db.connect() as c:
- row = upload_row(c, uid, session_id, lock=True)
- if row['complete']:
- return {'id': uid, 'complete': True}
- try:
- existing_size = storage.size(row['object_key'])
- except ClientError as exc:
- if exc.response['ResponseMetadata']['HTTPStatusCode'] != 404:
- raise
- existing_size = None
- if existing_size is None:
- parts = storage.parts(row['object_key'], row['multipart_id'])
- expected = math.ceil(row['size'] / PART_BYTES)
- if [p['PartNumber'] for p in parts] != list(range(1, expected+1)) or any(
- p['Size'] != min(PART_BYTES, row['size'] - i*PART_BYTES) for i,p in enumerate(parts)):
- raise HTTPException(409, 'Parts are missing or their sizes do not match')
- storage.complete(row['object_key'], row['multipart_id'], parts)
- 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,))
- return {'id': uid, 'complete': True}
-
-@app.post('/api/quotes')
-def create_quote(body: QuoteRequest, session_id=Depends(owner)):
- draft = body.model_dump(mode='json', exclude={'request_key'})
- digest = hashlib.sha256(json.dumps(draft, sort_keys=True).encode()).hexdigest()
- try:
- freight.quote(body.freight.service, body.freight.postal_code)
- except ValueError as exc:
- raise HTTPException(422, str(exc))
- with db.connect() as c:
- for item in body.items:
- for uid in item.uploads:
- row = upload_row(c, uid, session_id)
- if not row['complete']:
- raise HTTPException(409, 'Complete every upload before requesting a quote')
- require_clean(row)
- uid = uuid4()
- c.execute('INSERT INTO dtf_local.quotes(id,owner,request_key,request_hash,draft) VALUES(%s,%s,%s,%s,%s) ON CONFLICT(owner,request_key) DO NOTHING',
- (uid, session_id, body.request_key, digest, Jsonb(draft)))
- row = c.execute('SELECT * FROM dtf_local.quotes WHERE owner=%s AND request_key=%s', (session_id, body.request_key)).fetchone()
- if row['request_hash'] != digest:
- raise HTTPException(409, 'Request key already used for a different cart')
- return {'id': row['id'], 'status': 'pending_review'}
-
-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'],
- 'status': 'paid' if order else 'expired' if expired else 'approved' if row['approved'] else 'pending_review',
- 'order': order}
-
-@app.get('/api/quotes/{uid}')
-def get_quote(uid: UUID, session_id=Depends(owner)):
- with db.connect() as c:
- row = c.execute('SELECT * FROM dtf_local.quotes WHERE id=%s AND owner=%s', (uid,session_id)).fetchone()
- if not row:
- raise HTTPException(404, 'Quote not found')
- return quote_view(c, row)
-
-def enqueue(c, event_key, provider, payload):
- c.execute('INSERT INTO dtf_local.outbox(event_key,provider,payload) VALUES(%s,%s,%s) ON CONFLICT(event_key) DO NOTHING',
- (event_key, provider, Jsonb(payload)))
-
-@app.post('/api/orders/dev-paid')
-def dev_paid(body: Pay, session_id=Depends(owner)):
- if ENVIRONMENT != 'local':
- raise HTTPException(503, 'Checkout is not configured yet')
- with db.connect() as c:
- row = c.execute('SELECT * FROM dtf_local.quotes WHERE id=%s AND owner=%s FOR UPDATE', (body.quote_id,session_id)).fetchone()
- if not row:
- raise HTTPException(404, 'Quote not found')
- existing = c.execute('SELECT * FROM dtf_local.orders WHERE quote_id=%s', (body.quote_id,)).fetchone()
- if existing:
- return existing
- if not row['approved']:
- raise HTTPException(409, 'An operator must verify length and grade first')
- if row['approved_at'] < datetime.now(timezone.utc)-timedelta(hours=24):
- raise HTTPException(409, 'Quote expired; request a new quote')
- approved = row['approved']
- for item in approved['items']:
- for upload_id in item['uploads']:
- require_clean(upload_row(c, UUID(upload_id), session_id))
- paid = payment.pay(str(body.quote_id), approved['total_cents'])
- result = c.execute('INSERT INTO dtf_local.orders(id,quote_id,owner,snapshot,payment) VALUES(%s,%s,%s,%s,%s) RETURNING *',
- (uuid4(),body.quote_id,session_id,Jsonb(approved),Jsonb(paid))).fetchone()
- for provider in ('tiny','whatsapp'):
- enqueue(c, f"{result['id']}:paid:{provider}", provider,
- {'order_id': str(result['id']), 'number': result['number'], 'event': 'payment_approved', 'order': approved})
- return result
-
-@app.get('/api/operator/board')
-def board(user=Depends(operator)):
- with db.connect() as c:
- # Everything still in progress, however old: an operator must never lose a
- # card they can act on. Finished orders are terminal and only accumulate,
- # so the board carries a recent window of them and reports the true total.
- active = c.execute("SELECT * FROM dtf_local.orders WHERE state<>'fin' ORDER BY created_at").fetchall()
- 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()
- 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],
- 'events': c.execute('SELECT * FROM dtf_local.outbox ORDER BY id DESC LIMIT 100').fetchall()}
-
-@app.post('/api/operator/quotes/{uid}/approve')
-def approve(uid: UUID, body: Review, user=Depends(operator)):
- with db.connect() as c:
- row = c.execute('SELECT * FROM dtf_local.quotes WHERE id=%s FOR UPDATE', (uid,)).fetchone()
- if not row:
- raise HTTPException(404, 'Quote not found')
- if row['approved']:
- raise HTTPException(409, 'Approved quotes are immutable; request a new quote')
- draft = row['draft']
- 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')
- 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']})
- 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']}
- c.execute('UPDATE dtf_local.quotes SET approved=%s, reviewed_by=%s, approved_at=now() WHERE id=%s', (Jsonb(approved),user,uid))
- return approved
-
-@app.post('/api/operator/orders/{uid}/move')
-def move(uid: UUID, body: Move, user=Depends(operator)):
- with db.connect() as c:
- row = c.execute('SELECT * FROM dtf_local.orders WHERE id=%s FOR UPDATE', (uid,)).fetchone()
- if not row:
- raise HTTPException(404, 'Order not found')
- if body.version != row['version']:
- raise HTTPException(409, 'Order changed; refresh the board')
- if body.state == row['state']:
- return row
- if body.state not in TRANSITIONS[row['state']]:
- raise HTTPException(409, 'Move is not allowed from this state')
- if body.state == 'cor' and not body.reason.strip():
- raise HTTPException(422, 'Correction requires a reason')
- if body.state in ('fil','imp'):
- coverage = c.execute('SELECT DISTINCT f.item_index FROM dtf_local.order_files f JOIN dtf_local.uploads u ON u.id=f.upload_id WHERE f.order_id=%s AND f.kind=\'final\' AND f.active AND u.expires_at>now() AND u.purged_at IS NULL AND u.scan_state=\'clean\'', (uid,)).fetchall()
- if {r['item_index'] for r in coverage} != set(range(len(row['snapshot']['items']))):
- raise HTTPException(409, 'Approve a complete final-file set for every item before queueing')
- if body.state == 'cor':
- c.execute("UPDATE dtf_local.order_files SET active=false WHERE order_id=%s AND kind='final'", (uid,))
- c.execute('INSERT INTO dtf_local.movements(order_id,from_state,to_state,operator,reason) VALUES(%s,%s,%s,%s,%s)',
- (uid,row['state'],body.state,user,body.reason))
- changed = c.execute('UPDATE dtf_local.orders SET state=%s, version=version+1, updated_at=now() WHERE id=%s RETURNING *', (body.state,uid)).fetchone()
- events = {'imp':'production_started','cor':'correction_needed','fin':'ready'}
- if body.state in events:
- for provider in ('tiny','whatsapp'):
- enqueue(c, f'{uid}:{changed["version"]}:{provider}', provider,
- {'order_id':str(uid), 'number':row['number'], 'event':events[body.state], 'reason':body.reason,
- 'customer_path': f'/portal.html?order={uid}'})
- return changed
-
-@app.get('/api/operator/orders/{uid}/history')
-def history(uid: UUID, user=Depends(operator)):
- with db.connect() as c:
- return c.execute('SELECT * FROM dtf_local.movements WHERE order_id=%s ORDER BY id', (uid,)).fetchall()
-
-@app.get('/api/operator/uploads/{uid}/download')
-def download(uid: UUID, user=Depends(operator)):
- with db.connect() as c:
- row = c.execute('SELECT * FROM dtf_local.uploads WHERE id=%s AND complete', (uid,)).fetchone()
- if not row:
- raise HTTPException(404, 'Completed upload not found')
- if row['expires_at'] <= datetime.now(timezone.utc):
- raise HTTPException(410, 'Artwork retention expired')
- require_clean(row)
- return {'name':row['name'], 'url':storage.download(row['object_key'],row['name']), 'expires_in':300}
-
-from .customer import install_routes
-install_routes(app, operator, storage, begin_upload, upload_status, part_url, complete_upload, upload_row, STATES)
+# Order is not significant: no two routers declare the same path.
+for module in (health, uploads, quotes, orders, operator, customer, artwork):
+ app.include_router(module.router)
diff --git a/local/artwork.py b/local/artwork.py
new file mode 100644
index 0000000..2084177
--- /dev/null
+++ b/local/artwork.py
@@ -0,0 +1,50 @@
+"""Attaching artwork to an order: the rules shared by customers and operators.
+
+A customer submits a correction and an operator submits the final set; both go
+through the same checks, so the rule about what may be attached, when, and what
+it does to retention lives in one place.
+"""
+from datetime import datetime, timedelta, timezone
+from uuid import UUID, uuid4
+
+from fastapi import HTTPException
+
+from .runtime import upload_row
+from .scanning import require_clean
+
+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}
diff --git a/local/artwork_browser_test.mjs b/local/artwork_browser_test.mjs
index f772acc..fa6785e 100644
--- a/local/artwork_browser_test.mjs
+++ b/local/artwork_browser_test.mjs
@@ -9,7 +9,7 @@ import {mkdtemp, readFile, writeFile, mkdir} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import {resolve} from 'node:path';
-const html=await readFile('dtf-site.html','utf8');
+const html=await readFile('web/index.html','utf8');
const hashes=[...html.matchAll(/', html.read_text(), re.S | re.I):
if not re.search(r'\bsrc\s*=', attributes, re.I) and script.strip():
hashes.append("'sha256-" + base64.b64encode(hashlib.sha256(script.encode()).digest()).decode() + "'")
diff --git a/local/customer.py b/local/customer.py
deleted file mode 100644
index 3ace336..0000000
--- a/local/customer.py
+++ /dev/null
@@ -1,191 +0,0 @@
-"""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)
diff --git a/local/runtime.py b/local/runtime.py
new file mode 100644
index 0000000..ee1fe7f
--- /dev/null
+++ b/local/runtime.py
@@ -0,0 +1,85 @@
+"""Composition root, and the pieces every router needs.
+
+app.py held the adapters, the configuration, the shared query helpers and all
+nineteen of its routes, so customer.py could not import from it without a cycle
+and was wired instead by passing nine callables into install_routes. Everything
+shared lives here: routers import downwards, never from each other.
+"""
+import os
+from datetime import datetime, timedelta, timezone
+from uuid import uuid5, NAMESPACE_URL
+
+from fastapi import HTTPException
+from psycopg.types.json import Jsonb
+
+from .adapters import FakeFreight, FakePayment, LocalS3Storage, require_runtime
+from .secrets import load as load_secret_files
+
+# Secret files must resolve before any configuration below is read.
+load_secret_files()
+require_runtime()
+
+storage = LocalS3Storage()
+payment = FakePayment()
+freight = FakeFreight()
+ENVIRONMENT = os.environ.get('APP_ENV', 'local')
+PUBLIC_ORIGIN = os.environ.get('PUBLIC_ORIGIN', 'http://localhost')
+ALLOWED_HOSTS = [host for host in os.environ.get('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',') if host]
+ALLOWED_ORIGINS = [origin for origin in os.environ.get('ALLOWED_ORIGINS', PUBLIC_ORIGIN).split(',') if origin]
+# Per source and generous: a browser needs one session and keeps the cookie, but
+# offices and mobile carriers put many real customers behind one address, so a
+# tight per-IP ceiling would lock out the same people the old global one did.
+GUEST_SESSION_LIMIT = int(os.environ.get('GUEST_SESSION_LIMIT', '240'))
+# The board returned every order ever created. Finished ones are terminal, so
+# they were pure growth: at a few hundred a day the response and the page both
+# degrade with no operator benefit.
+BOARD_FINISHED_LIMIT = int(os.environ.get('BOARD_FINISHED_LIMIT', '50'))
+BOARD_QUOTE_LIMIT = int(os.environ.get('BOARD_QUOTE_LIMIT', '100'))
+PART_BYTES = int(os.environ.get('UPLOAD_PART_BYTES', '8388608'))
+if not 5242880 <= PART_BYTES <= 67108864:
+ raise RuntimeError('UPLOAD_PART_BYTES must be between 5 and 64 MiB')
+STATES = {'rec': 'Arte recebida', 'tra': 'Arte tratada', 'fil': 'Fila de impressão',
+ 'imp': 'Imprimindo', 'cor': 'Correção', 'fin': 'Finalizado'}
+TRANSITIONS = {'rec': ['tra','cor'], 'tra': ['fil','cor'], 'fil': ['imp','cor'],
+ 'imp': ['fin','cor'], 'cor': ['rec','tra'], 'fin': []}
+
+
+def upload_row(c, upload_id, session_id, lock=False):
+ row = c.execute('SELECT * FROM dtf_local.uploads WHERE id=%s AND owner=%s' +
+ (' FOR UPDATE' if lock else ''), (upload_id, session_id)).fetchone()
+ if not row:
+ raise HTTPException(404, 'Upload not found')
+ days = 30 if row['complete'] else 1
+ if row['purged_at'] or row['expires_at'] <= datetime.now(timezone.utc) or row['created_at'] < datetime.now(timezone.utc) - timedelta(days=days):
+ raise HTTPException(410, 'Upload expired; select the file again')
+ return row
+
+
+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'],
+ 'status': 'paid' if order else 'expired' if expired else 'approved' if row['approved'] else 'pending_review',
+ 'order': order}
+
+
+def enqueue(c, event_key, provider, payload):
+ c.execute('INSERT INTO dtf_local.outbox(event_key,provider,payload) VALUES(%s,%s,%s) ON CONFLICT(event_key) DO NOTHING',
+ (event_key, provider, Jsonb(payload)))
+
+
+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()
+
+
+def operator_identity(user):
+ return uuid5(NAMESPACE_URL, 'dtf-local-operator:'+user)
diff --git a/local/test_pricing.py b/local/test_pricing.py
index 8593ba3..69c8118 100644
--- a/local/test_pricing.py
+++ b/local/test_pricing.py
@@ -9,7 +9,7 @@ class PricingTests(unittest.TestCase):
def test_every_grade_against_actual_site_javascript(self):
# The ladders live with the rest of the Site's behaviour; this test exists
# to prove the server agrees with whatever the customer is shown.
- source = Path('local/static/site-config.js').read_text()
+ source = Path('web/site-config.js').read_text()
tiers = source.split('const FAIXAS=')[1].split('\n};',1)[0]+'\n}'
calculator = source.split('const cobrar=')[1].split(';',1)[0]
js = f'const FAIXAS={tiers};const MINIMO_M=1;const cobrar={calculator};'
diff --git a/local/static/cart.js b/web/cart.js
similarity index 100%
rename from local/static/cart.js
rename to web/cart.js
diff --git a/local/static/checkout.js b/web/checkout.js
similarity index 98%
rename from local/static/checkout.js
rename to web/checkout.js
index 59d9f7a..341adc8 100644
--- a/local/static/checkout.js
+++ b/web/checkout.js
@@ -1,4 +1,4 @@
-/* Checkout bridge only: approved commercial functions in dtf-site.html stay intact. */
+/* Checkout bridge only: approved commercial functions in web/index.html stay intact. */
(() => {
const status = document.getElementById('checkoutStatus');
const actions = document.getElementById('checkoutActions');
diff --git a/dtf-site.html b/web/index.html
similarity index 100%
rename from dtf-site.html
rename to web/index.html
diff --git a/local/static/kanban.html b/web/kanban.html
similarity index 100%
rename from local/static/kanban.html
rename to web/kanban.html
diff --git a/local/static/kanban.js b/web/kanban.js
similarity index 100%
rename from local/static/kanban.js
rename to web/kanban.js
diff --git a/local/static/portal.html b/web/portal.html
similarity index 100%
rename from local/static/portal.html
rename to web/portal.html
diff --git a/local/static/portal.js b/web/portal.js
similarity index 100%
rename from local/static/portal.js
rename to web/portal.js
diff --git a/local/static/privacy.js b/web/privacy.js
similarity index 100%
rename from local/static/privacy.js
rename to web/privacy.js
diff --git a/local/static/site-cart.js b/web/site-cart.js
similarity index 98%
rename from local/static/site-cart.js
rename to web/site-cart.js
index 7b9e9bf..878984b 100644
--- a/local/static/site-cart.js
+++ b/web/site-cart.js
@@ -1,5 +1,5 @@
/* Site DTF — Cart totals and the order summary.
- Extracted verbatim from the single inline script in dtf-site.html.
+ Extracted verbatim from the single inline script in web/index.html.
Loaded as classic scripts in the order listed there: they share one global
scope and run top to bottom, exactly as the original did. */
// ── carrinho
diff --git a/local/static/site-config.js b/web/site-config.js
similarity index 98%
rename from local/static/site-config.js
rename to web/site-config.js
index bcc9ec8..fa96cf8 100644
--- a/local/static/site-config.js
+++ b/web/site-config.js
@@ -1,5 +1,5 @@
/* Site DTF — Product tables, commercial ladders, shared state and field validation.
- Extracted verbatim from the single inline script in dtf-site.html.
+ Extracted verbatim from the single inline script in web/index.html.
Loaded as classic scripts in the order listed there: they share one global
scope and run top to bottom, exactly as the original did. */
diff --git a/local/static/site-flow.js b/web/site-flow.js
similarity index 99%
rename from local/static/site-flow.js
rename to web/site-flow.js
index bd9bd9a..03db2ff 100644
--- a/local/static/site-flow.js
+++ b/web/site-flow.js
@@ -1,5 +1,5 @@
/* Site DTF — Carousel, path selector, delivery choice and input masks.
- Extracted verbatim from the single inline script in dtf-site.html.
+ Extracted verbatim from the single inline script in web/index.html.
Loaded as classic scripts in the order listed there: they share one global
scope and run top to bottom, exactly as the original did. */
// ── decisões
diff --git a/local/static/site-modes.js b/web/site-modes.js
similarity index 98%
rename from local/static/site-modes.js
rename to web/site-modes.js
index d12fdca..cd556be 100644
--- a/local/static/site-modes.js
+++ b/web/site-modes.js
@@ -1,5 +1,5 @@
/* Site DTF — Choosing a product, and the declared sheet/loose-artwork switch.
- Extracted verbatim from the single inline script in dtf-site.html.
+ Extracted verbatim from the single inline script in web/index.html.
Loaded as classic scripts in the order listed there: they share one global
scope and run top to bottom, exactly as the original did. */
// ── escolha
diff --git a/local/static/site-packing.js b/web/site-packing.js
similarity index 99%
rename from local/static/site-packing.js
rename to web/site-packing.js
index 0bb5674..2fa2732 100644
--- a/local/static/site-packing.js
+++ b/web/site-packing.js
@@ -1,5 +1,5 @@
/* Site DTF — Masks, the nesting engine, and drawing the assembled film.
- Extracted verbatim from the single inline script in dtf-site.html.
+ Extracted verbatim from the single inline script in web/index.html.
Loaded as classic scripts in the order listed there: they share one global
scope and run top to bottom, exactly as the original did. */
// ── imagens e prévia
diff --git a/local/static/site-pdf.js b/web/site-pdf.js
similarity index 99%
rename from local/static/site-pdf.js
rename to web/site-pdf.js
index 4011ebd..c338d9d 100644
--- a/local/static/site-pdf.js
+++ b/web/site-pdf.js
@@ -1,12 +1,12 @@
/* Site DTF — PDF measurement and rasterisation, and the DPI of images inside one.
- Extracted verbatim from the single inline script in dtf-site.html.
+ Extracted verbatim from the single inline script in web/index.html.
Loaded as classic scripts in the order listed there: they share one global
scope and run top to bottom, exactly as the original did. */
// ── PDF também é conferido: rasteriza a página e mede o DPI das imagens de dentro.
// Arte vetorial não tem resolução — nesses casos a nota é máxima, e isso é correto.
// Served from this origin, not a CDN: the Site keeps working when a third party
// does not, and the CSP can name only 'self' for scripts and workers. Provenance
-// and hashes are recorded in local/static/vendor/README.md.
+// and hashes are recorded in web/vendor/README.md.
const PDFJS_URL='/vendor/pdf.min.js';
const PDFJS_WORKER=new URL('/vendor/pdf.worker.min.js', location.origin).href;
let pdfLibP=null, temWorker=null;
diff --git a/local/static/site-quality.js b/web/site-quality.js
similarity index 99%
rename from local/static/site-quality.js
rename to web/site-quality.js
index 208be0a..c1359e1 100644
--- a/local/static/site-quality.js
+++ b/web/site-quality.js
@@ -1,5 +1,5 @@
/* Site DTF — The grade shown to the customer, and the reservations attached to it.
- Extracted verbatim from the single inline script in dtf-site.html.
+ Extracted verbatim from the single inline script in web/index.html.
Loaded as classic scripts in the order listed there: they share one global
scope and run top to bottom, exactly as the original did. */
// ── enviar
diff --git a/local/static/site-sheet.js b/web/site-sheet.js
similarity index 98%
rename from local/static/site-sheet.js
rename to web/site-sheet.js
index 91a0186..1d2799e 100644
--- a/local/static/site-sheet.js
+++ b/web/site-sheet.js
@@ -1,5 +1,5 @@
/* Site DTF — Reading a finished sheet from its alpha channel: pieces, gaps, waste.
- Extracted verbatim from the single inline script in dtf-site.html.
+ Extracted verbatim from the single inline script in web/index.html.
Loaded as classic scripts in the order listed there: they share one global
scope and run top to bottom, exactly as the original did. */
// ── Análise real da folha: lê o canal alfa, separa cada ilha de arte e mede.
diff --git a/local/static/site-upload.js b/web/site-upload.js
similarity index 98%
rename from local/static/site-upload.js
rename to web/site-upload.js
index 353c3c2..8eb3187 100644
--- a/local/static/site-upload.js
+++ b/web/site-upload.js
@@ -1,5 +1,5 @@
/* Site DTF — Accepting files: the drop zone, the accept rules, first measurement.
- Extracted verbatim from the single inline script in dtf-site.html.
+ Extracted verbatim from the single inline script in web/index.html.
Loaded as classic scripts in the order listed there: they share one global
scope and run top to bottom, exactly as the original did. */
// ── upload
diff --git a/local/static/upload.js b/web/upload.js
similarity index 100%
rename from local/static/upload.js
rename to web/upload.js
diff --git a/local/static/vendor/README.md b/web/vendor/README.md
similarity index 94%
rename from local/static/vendor/README.md
rename to web/vendor/README.md
index 9f1ceee..454f16b 100644
--- a/local/static/vendor/README.md
+++ b/web/vendor/README.md
@@ -28,6 +28,6 @@ curl -s "https://api.cdnjs.com/libraries/pdf.js/?fields=sri"
```
**Version note.** 3.11.174 is old. It is affected by GHSA-wgrm-67xf-hhpq, whose
-documented workaround is `isEvalSupported: false`; `dtf-site.html` already passes
+documented workaround is `isEvalSupported: false`; `web/index.html` already passes
that, so the known path is closed. Upgrading is worthwhile but is an API change,
not a file swap, and belongs with its own browser testing — see `ROADMAP.md` 2.7.
diff --git a/local/static/vendor/pdf.min.js b/web/vendor/pdf.min.js
similarity index 100%
rename from local/static/vendor/pdf.min.js
rename to web/vendor/pdf.min.js
diff --git a/local/static/vendor/pdf.worker.min.js b/web/vendor/pdf.worker.min.js
similarity index 100%
rename from local/static/vendor/pdf.worker.min.js
rename to web/vendor/pdf.worker.min.js