refactor: lay the repository out by role
All checks were successful
Build and deploy / Validate source (push) Successful in 7s
Build and deploy / Integration suite on a real stack (push) Successful in 1m25s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images and notify Portainer (push) Successful in 1m31s

local/ held six unrelated things under a name that stopped being true once it
became the production runtime: the service, the frontend, the tests, the ops
commands, the container definitions and the dependency lock, 65 files with
nothing to tell them apart.

  app/      the service: api/ routers, core/ for identity, database, models,
            prices and secret loading, and the worker, bootstrap and schema
  tests/    the twelve suites, no longer inside the shipped package
  ops/      backup, readiness, dependency audit, security summary
  infra/    Dockerfiles, gateway templates, ClamAV and storage configuration,
            the requirements and their hash lock
  web/      the Site, Kanban and portal pages with their scripts

deploy/Dockerfile.api now copies app/ alone, so the tests stop shipping to
production; the local image still carries them, because the suites run inside
the stack's network.

Five kinds of reference had to follow, and each was found by something different
rather than by reading. Imports of the form "from . import db" survived a rewrite
that only matched "from .db import". Tests kept relative imports of modules that
had left the package. A mock.patch target names its module in a string, where no
import rewriting can see it. The browser test resolves a fixture by path. And the
release gate's markers pointed at local/runtime.py and local/worker.py, which is
the decay its new marker test exists to catch — it caught it.

Verified from docker compose down -v: the stack starts, all six integration
suites, both browser suites and the twenty-nine unit tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Cauê Faleiros
2026-09-21 17:40:57 -03:00
parent c9f8122600
commit b329f76378
69 changed files with 146 additions and 139 deletions

1
app/__init__.py Normal file
View File

@@ -0,0 +1 @@

149
app/adapters.py Normal file
View File

@@ -0,0 +1,149 @@
"""Local-only composition root. No production provider implementations/imports."""
import os
from typing import Protocol
from urllib.parse import urlparse
import boto3
from botocore.config import Config
from botocore.exceptions import ClientError
def require_runtime():
"""Validate the supported local and R2-backed deployment modes.
Real payment, freight, ERP, and WhatsApp providers are deliberately not
enabled yet. A non-local deployment keeps those adapters fake and disables
checkout until their audited implementations are added.
"""
environment = os.environ.get('APP_ENV', 'local')
for name in ('PAYMENT', 'FREIGHT', 'TINY', 'WHATSAPP'):
if os.environ.get(f'{name}_ADAPTER') != 'fake':
raise RuntimeError(f'{name} must use the currently supported fake adapter')
if environment == 'local':
if os.environ.get('STORAGE_ADAPTER') != 's3-local':
raise RuntimeError('Local runtime requires local S3 storage')
for name in ('S3_ENDPOINT', 'S3_PUBLIC_ENDPOINT'):
endpoint = urlparse(os.environ[name])
if endpoint.scheme != 'http' or endpoint.hostname not in ('storage', 'localhost', '127.0.0.1'):
raise RuntimeError(f'{name} must point to local MinIO')
return
if environment != 'production':
raise RuntimeError('APP_ENV must be local or production')
if os.environ.get('STORAGE_ADAPTER') != 's3-r2':
raise RuntimeError('Production runtime requires R2 storage')
for name in ('S3_ENDPOINT', 'S3_PUBLIC_ENDPOINT'):
endpoint = urlparse(os.environ[name])
if endpoint.scheme != 'https' or not (endpoint.hostname or '').endswith('.r2.cloudflarestorage.com'):
raise RuntimeError(f'{name} must be a Cloudflare R2 S3 API endpoint')
for name in ('AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY'):
if not os.environ.get(name):
raise RuntimeError(f'{name} is required for R2')
# Compatibility alias for local-only callers outside the active runtime.
require_local = require_runtime
class PaymentAdapter(Protocol):
def pay(self, quote_id: str, total_cents: int) -> dict: ...
class FakePayment:
def pay(self, quote_id: str, total_cents: int) -> dict:
return {'provider': 'fake', 'id': f'local-{quote_id}',
'status': 'paid', 'total_cents': total_cents}
class FreightAdapter(Protocol):
def quote(self, service: str, postal_code: str) -> dict: ...
class FakeFreight:
def quote(self, service: str, postal_code: str) -> dict:
if service == 'pickup':
return {'provider': 'fake', 'service': 'pickup', 'total_cents': 0, 'postal_code': ''}
if service != 'mock-standard' or len(postal_code) != 8 or not postal_code.isascii() or not postal_code.isdigit():
raise ValueError('Select pickup or a mock quote with an eight-digit CEP')
cents = int(os.environ.get('MOCK_FREIGHT_CENTS', '1500'))
if cents < 0:
raise ValueError('Invalid mock freight configuration')
return {'provider': 'fake', 'service': service, 'postal_code': postal_code,
'total_cents': cents, 'description': 'Local simulated freight'}
class EventAdapter(Protocol):
def deliver(self, event_key: str, payload: dict) -> dict: ...
class FakeTiny:
def deliver(self, event_key: str, payload: dict) -> dict:
return {'provider': 'fake-tiny', 'reference': f"LOCAL-{payload['number']}",
'event_key': event_key, 'status': 'recorded-locally'}
class FakeWhatsApp:
def deliver(self, event_key: str, payload: dict) -> dict:
return {'provider': 'fake-whatsapp', 'event_key': event_key,
'event': payload['event'], 'status': 'recorded-locally'}
class ObjectStorage(Protocol):
def begin(self, key: str) -> str: ...
def parts(self, key: str, upload_id: str) -> list: ...
def part_url(self, key: str, upload_id: str, part: int, size: int) -> str: ...
def complete(self, key: str, upload_id: str, parts: list): ...
def size(self, key: str) -> int: ...
def download(self, key: str, name: str) -> str: ...
def health(self): ...
def discard(self, key: str, upload_id: str, complete: bool): ...
class LocalS3Storage:
def __init__(self):
config = Config(signature_version='s3v4', s3={'addressing_style': 'path'},
connect_timeout=3, read_timeout=10, retries={'max_attempts': 2})
self.client = boto3.client('s3', endpoint_url=os.environ['S3_ENDPOINT'], config=config)
self.public = boto3.client('s3', endpoint_url=os.environ['S3_PUBLIC_ENDPOINT'], config=config)
self.bucket = os.environ['S3_BUCKET']
def initialize(self):
try:
self.client.head_bucket(Bucket=self.bucket)
except ClientError as exc:
if exc.response['ResponseMetadata']['HTTPStatusCode'] != 404:
raise
self.client.create_bucket(Bucket=self.bucket)
self.client.put_bucket_lifecycle_configuration(Bucket=self.bucket, LifecycleConfiguration={
'Rules': [{'ID': 'local-artwork-retention', 'Status': 'Enabled', 'Filter': {'Prefix': ''},
'Expiration': {'Days': 30}, 'AbortIncompleteMultipartUpload': {'DaysAfterInitiation': 1}}]})
def health(self):
self.client.head_bucket(Bucket=self.bucket)
def begin(self, key):
return self.client.create_multipart_upload(Bucket=self.bucket, Key=key,
ContentType='application/octet-stream')['UploadId']
def parts(self, key, upload_id):
result = []
for page in self.client.get_paginator('list_parts').paginate(
Bucket=self.bucket, Key=key, UploadId=upload_id):
result.extend(page.get('Parts', []))
return result
def part_url(self, key, upload_id, part, size):
return self.public.generate_presigned_url('upload_part', Params={
'Bucket': self.bucket, 'Key': key, 'UploadId': upload_id, 'PartNumber': part,
'ContentLength': size}, ExpiresIn=900)
def complete(self, key, upload_id, parts):
self.client.complete_multipart_upload(Bucket=self.bucket, Key=key, UploadId=upload_id,
MultipartUpload={'Parts': [{'PartNumber': p['PartNumber'], 'ETag': p['ETag']} for p in parts]})
def size(self, key):
return self.client.head_object(Bucket=self.bucket, Key=key)['ContentLength']
def download(self, key, name):
from urllib.parse import quote
return self.public.generate_presigned_url('get_object', Params={
'Bucket': self.bucket, 'Key': key,
'ResponseContentDisposition': "attachment; filename*=UTF-8''" + quote(name, safe=''),
'ResponseContentType': 'application/octet-stream'}, ExpiresIn=300)
def discard(self, key, upload_id, complete):
if not complete:
try:
self.client.abort_multipart_upload(Bucket=self.bucket, Key=key, UploadId=upload_id)
except ClientError as exc:
if exc.response['ResponseMetadata']['HTTPStatusCode'] != 404:
raise
self.client.delete_object(Bucket=self.bucket, Key=key)

2
app/api/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
"""HTTP routers, one per resource. They import from local.runtime, never
from each other or from local.app."""

49
app/api/artwork.py Normal file
View File

@@ -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 ..core import db
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
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)

116
app/api/customer.py Normal file
View File

@@ -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 ..core import db
from ..artwork import submit_files
from ..core.auth import (DUMMY_PASSWORD_HASH, audit, client_ip, new_session, owner,
password_hash, password_matches, session_row, throttle, transfer_guest)
from ..core.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']}

43
app/api/health.py Normal file
View File

@@ -0,0 +1,43 @@
"""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.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))

146
app/api/operator.py Normal file
View File

@@ -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 ..core import db
from ..core.auth import (COOKIE_SECURE, DUMMY_PASSWORD_HASH, audit, client_ip, operator,
password_matches, throttle)
from ..core.models import Move, OperatorLogin, Review
from ..core.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}

41
app/api/orders.py Normal file
View File

@@ -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 ..core import db
from ..core.auth import owner
from ..core.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

46
app/api/quotes.py Normal file
View File

@@ -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 ..core import db
from ..core.auth import owner
from ..core.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)

85
app/api/uploads.py Normal file
View File

@@ -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 ..core import db
from ..core.auth import audit, owner, rate_limit
from ..core.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}

48
app/app.py Normal file
View File

@@ -0,0 +1,48 @@
"""The DTF Portal/API service: assembly only.
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 .core import db
from .core.auth import audit, client_ip
from .runtime import ALLOWED_HOSTS, ALLOWED_ORIGINS, storage
from .api import artwork, customer, health, operator, orders, quotes, uploads
@asynccontextmanager
async def lifespan(app):
with db.connect() as c:
c.execute('SELECT 1 FROM dtf_local.operator_sessions LIMIT 1')
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.middleware('http')
async def safe_headers(request, call_next):
if request.method not in ('GET','HEAD','OPTIONS'):
origin = request.headers.get('origin')
if request.headers.get('sec-fetch-site') == 'cross-site' or (origin and origin not in ALLOWED_ORIGINS):
audit('cross_origin_rejected', ip=client_ip(request))
return JSONResponse({'detail':'Cross-origin request rejected'}, status_code=403)
response = await call_next(request)
if response.status_code in (401,403,429) or response.status_code>=500:
audit('http_security_event', method=request.method, status=response.status_code, ip=client_ip(request))
response.headers['Cache-Control'] = 'no-store'
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['Referrer-Policy'] = 'no-referrer'
return response
# 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)

50
app/artwork.py Normal file
View File

@@ -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}

58
app/bootstrap.py Normal file
View File

@@ -0,0 +1,58 @@
"""One-shot schema/role setup. Only this job receives database admin credentials."""
import os
from pathlib import Path
from urllib.parse import urlparse
import psycopg
from psycopg import sql
from .core.secrets import load as load_secret_files
from .operators import seed_from_environment
def admin_connect():
if os.environ.get('DATABASE_ADMIN_HOST'):
return psycopg.connect(
host=os.environ['DATABASE_ADMIN_HOST'],
dbname=os.environ['DATABASE_ADMIN_NAME'],
user=os.environ['DATABASE_ADMIN_USER'],
password=os.environ['DATABASE_ADMIN_PASSWORD'],
)
return psycopg.connect(os.environ['DATABASE_ADMIN_URL'])
def admin_password():
if os.environ.get('DATABASE_ADMIN_HOST'):
return os.environ.get('DATABASE_ADMIN_PASSWORD')
url = os.environ.get('DATABASE_ADMIN_URL', '')
return urlparse(url).password
def main():
load_secret_files()
role = os.environ['APP_DB_USER']
password = os.environ['APP_DB_PASSWORD']
if password == admin_password():
raise RuntimeError('Application and database administrator passwords must differ')
with admin_connect() as c:
admin, database = c.execute('SELECT current_user,current_database()').fetchone()
if role == admin:
raise RuntimeError('Application and database administrator must differ')
if not c.execute('SELECT 1 FROM pg_roles WHERE rolname=%s', (role,)).fetchone():
c.execute(sql.SQL('CREATE ROLE {} LOGIN').format(sql.Identifier(role)))
c.execute(sql.SQL('ALTER ROLE {} NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS PASSWORD {}').format(
sql.Identifier(role), sql.Literal(password)))
c.execute(Path(__file__).with_name('schema.sql').read_text())
c.execute(sql.SQL('REVOKE CREATE ON SCHEMA public FROM PUBLIC'))
c.execute(sql.SQL('GRANT CONNECT ON DATABASE {} TO {}').format(sql.Identifier(database),sql.Identifier(role)))
c.execute(sql.SQL('GRANT USAGE ON SCHEMA dtf_local TO {}').format(sql.Identifier(role)))
c.execute(sql.SQL('GRANT SELECT,INSERT,UPDATE,DELETE ON ALL TABLES IN SCHEMA dtf_local TO {}').format(sql.Identifier(role)))
c.execute(sql.SQL('GRANT USAGE,SELECT ON ALL SEQUENCES IN SCHEMA dtf_local TO {}').format(sql.Identifier(role)))
# Turn the configured credential into a real account so an existing
# deployment keeps logging in exactly as before. Inserts only when that
# email is absent, so a password changed with local.operators is never
# reverted by a stale environment variable on the next deploy.
seeded = seed_from_environment(c)
print('Local schema migrated; runtime role has DML only.')
if seeded:
print(f'Seeded operator account {seeded} from OPERATOR_EMAIL.')
if __name__ == '__main__': main()

1
app/core/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Shared foundations: identity, database, models, prices, secret loading."""

111
app/core/auth.py Normal file
View File

@@ -0,0 +1,111 @@
"""Local customer passwords and revocable database sessions. No email service."""
import hashlib
import os
import secrets
import logging
import json
from uuid import UUID, uuid4
from fastapi import HTTPException, Request
from ..core.db import connect
COOKIE_SECURE = os.environ.get('COOKIE_SECURE', 'false').lower() == 'true'
def client_ip(request: Request):
"""The requester's address, for rate-limit buckets and security events.
The API is only reachable through the web gateway, which replaces
X-Forwarded-For with the peer address it observed, so the header carries
exactly one value the client could not choose. Without this every request
looks like the gateway, which collapses per-source limits into one global
bucket and strips the address from the audit trail.
"""
forwarded = request.headers.get('x-forwarded-for', '').split(',')[0].strip()
if forwarded:
return forwarded[:64]
return request.client.host if request.client else 'unknown'
def password_hash(password, salt=None):
salt = salt or secrets.token_hex(16)
digest = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=5).hex()
return f'scrypt-v2${salt}${digest}'
def password_matches(password, stored):
try:
version, salt, digest = stored.split('$')
if version not in ('scrypt', 'scrypt-v2'): return False
actual = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt), n=16384, r=8,
p=1 if version == 'scrypt' else 5).hex()
return secrets.compare_digest(actual, digest)
except (ValueError, TypeError):
return False
DUMMY_PASSWORD_HASH = password_hash('invalid-account-password', '00'*16)
def session_row(request):
try:
sid = UUID(request.cookies.get('dtf_session', ''))
except ValueError:
raise HTTPException(401, 'Start a local session first')
with connect() as c:
row = c.execute('SELECT * FROM dtf_local.sessions WHERE id=%s AND expires_at>now()', (sid,)).fetchone()
if not row:
raise HTTPException(401, 'Session expired; sign in or start a new session')
return row
def owner(request: Request):
return session_row(request)['owner']
def new_session(c, response, identity=None):
sid = uuid4()
identity = identity or uuid4()
c.execute('INSERT INTO dtf_local.sessions(id,owner) VALUES(%s,%s)', (sid,identity))
response.set_cookie('dtf_session', str(sid), httponly=True, secure=COOKIE_SECURE,
samesite='strict', max_age=86400*7)
return identity
def transfer_guest(c, previous, identity):
# Successful authentication can claim only the current guest browser's records.
if c.execute('SELECT id FROM dtf_local.accounts WHERE id=%s', (previous['owner'],)).fetchone():
return
for table in ('uploads','quotes','orders'):
c.execute(f'UPDATE dtf_local.{table} SET owner=%s WHERE owner=%s', (identity,previous['owner']))
c.execute('DELETE FROM dtf_local.sessions WHERE owner=%s', (previous['owner'],))
def audit(event, **fields):
# Only explicit metadata: never cookies, passwords, signed URLs or request bodies.
logging.getLogger('dtf.security').warning(json.dumps({'event':event, **fields}, sort_keys=True))
try:
from psycopg.types.json import Jsonb
with connect() as c:
c.execute('INSERT INTO dtf_local.security_events(event,details) VALUES(%s,%s)',(event,Jsonb(fields)))
except Exception:
# Logging must still work during a DB outage without recursively auditing itself.
logging.getLogger('dtf.security').error('Security event persistence unavailable')
def rate_limit(scope, identity, limit, seconds=900):
key = hashlib.sha256((scope+'|'+identity).encode()).hexdigest()
with connect() as c:
row = c.execute("""INSERT INTO dtf_local.login_attempts(key,attempts) VALUES(%s,1)
ON CONFLICT(key) DO UPDATE SET
attempts=CASE WHEN dtf_local.login_attempts.started_at < now()-%s*interval '1 second' THEN 1 ELSE LEAST(dtf_local.login_attempts.attempts+1,1000000) END,
started_at=CASE WHEN dtf_local.login_attempts.started_at < now()-%s*interval '1 second' THEN now() ELSE dtf_local.login_attempts.started_at END
RETURNING attempts""", (key,seconds,seconds)).fetchone()
if row['attempts'] > limit:
audit('rate_limit', scope=scope)
raise HTTPException(429, 'Too many requests. Try again later.', headers={'Retry-After':str(seconds)})
def throttle(email, request):
# Independent account and source buckets prevent bypass by rotating emails.
rate_limit('auth-source', client_ip(request), 60)
rate_limit('auth-account', email, 10)
def operator(request: Request):
token = request.cookies.get('dtf_operator', '')
if not token or len(token)>128:
raise HTTPException(401, 'Sign in to the local Kanban')
digest = hashlib.sha256(token.encode()).hexdigest()
with connect() as c:
row = c.execute('SELECT username FROM dtf_local.operator_sessions WHERE token_hash=%s AND expires_at>now()', (digest,)).fetchone()
if not row:
raise HTTPException(401, 'Operator session expired')
return row['username']

23
app/core/db.py Normal file
View File

@@ -0,0 +1,23 @@
import os
from pathlib import Path
import psycopg
from psycopg.rows import dict_row
def connect():
# Production passes credentials as discrete libpq fields. This avoids
# treating characters in a generated password as URL syntax. Local and
# test environments retain DATABASE_URL compatibility.
if os.environ.get('DATABASE_HOST'):
return psycopg.connect(
host=os.environ['DATABASE_HOST'],
dbname=os.environ['DATABASE_NAME'],
user=os.environ['DATABASE_USER'],
password=os.environ['DATABASE_PASSWORD'],
row_factory=dict_row,
)
return psycopg.connect(os.environ['DATABASE_URL'], row_factory=dict_row)
def initialize():
with connect() as c:
# The schema lives at the package root, one level up from core/.
c.execute((Path(__file__).resolve().parent.parent / 'schema.sql').read_text())

109
app/core/models.py Normal file
View File

@@ -0,0 +1,109 @@
import re
from decimal import Decimal
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_validator
class StrictModel(BaseModel):
model_config = ConfigDict(extra='forbid', allow_inf_nan=False)
class Customer(StrictModel):
cnpj: str
zap: str
mail: str = Field(max_length=254)
@field_validator('cnpj')
@classmethod
def cnpj_valid(cls, value):
digits = re.sub(r'\D', '', value)
if len(digits) != 14 or len(set(digits)) == 1 or not digits.isascii():
raise ValueError('Invalid CNPJ')
def check(base, weights):
rem = sum(int(n) * w for n,w in zip(base, weights)) % 11
return 0 if rem < 2 else 11-rem
if check(digits[:12], [5,4,3,2,9,8,7,6,5,4,3,2]) != int(digits[12]) or check(digits[:13], [6,5,4,3,2,9,8,7,6,5,4,3,2]) != int(digits[13]):
raise ValueError('Invalid CNPJ')
return digits
@field_validator('zap')
@classmethod
def phone_valid(cls, value):
digits = re.sub(r'\D', '', value)
if len(digits) not in (10,11) or not digits.isascii():
raise ValueError('Invalid phone')
return digits
@field_validator('mail')
@classmethod
def email_valid(cls, value):
if not re.fullmatch(r'[^\s@]+@[^\s@]+\.[a-zA-Z]{2,}', value.strip()):
raise ValueError('Invalid email')
return value.strip()
class Freight(StrictModel):
service: Literal['pickup','mock-standard'] = 'pickup'
postal_code: str = Field(default='', max_length=8)
class UploadStart(StrictModel):
name: str = Field(min_length=1, max_length=200, pattern=r'^[^/\\\x00-\x1f]+$')
size: int = Field(gt=0, strict=True)
@field_validator('name')
@classmethod
def artwork_extension(cls, value):
# Union of existing Site product formats; this is NOT malware/pre-flight validation.
if not re.search(r'\.(png|jpe?g|webp|tiff?|pdf|psd|psb|ai|cdr)$', value, re.I):
raise ValueError('Unsupported artwork file extension')
return value
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)
class QuoteRequest(StrictModel):
request_key: UUID
customer: Customer
items: list[Item] = Field(min_length=1, max_length=30)
freight: Freight
class Review(StrictModel):
items: list[Item] = Field(min_length=1, max_length=30)
class Pay(StrictModel):
quote_id: UUID
class Move(StrictModel):
state: Literal['rec','tra','fil','imp','cor','fin']
version: int = Field(ge=0)
reason: str = Field(default='', max_length=1000)
class Register(StrictModel):
customer: Customer
password: str = Field(min_length=12, max_length=128)
class Login(StrictModel):
email: str = Field(min_length=3, max_length=254)
password: str = Field(min_length=1, max_length=128)
class OperatorLogin(StrictModel):
email: str = Field(min_length=3, max_length=254)
password: str = Field(min_length=1, max_length=128)
@field_validator('email')
@classmethod
def operator_email_valid(cls, value):
value = value.strip().lower()
if not re.fullmatch(r'[^\s@]+@[^\s@]+\.[a-zA-Z]{2,}', value):
raise ValueError('Invalid email')
return value
class FileReference(StrictModel):
upload_id: UUID
item_index: int = Field(ge=0, strict=True)
class ArtworkSubmission(StrictModel):
version: int = Field(ge=0, strict=True)
files: list[FileReference] = Field(min_length=1, max_length=60)
note: str = Field(min_length=1, max_length=1000)

27
app/core/pricing.py Normal file
View File

@@ -0,0 +1,27 @@
"""Exact approved Site DTF price ladders; integer BRL cents at the boundary.
Quantity/grade must be supplied by an authenticated commercial reviewer.
This module does not inspect artwork or perform pre-flight.
"""
from decimal import Decimal, ROUND_CEILING
TIERS = {
'file': [(90,1490),(75,1620),(60,1750),(40,1870),(0,1990)],
'avulsa': [(90,2490),(75,2620),(60,2750),(40,2870),(0,2990)],
'uvfile': [(90,6990),(75,7390),(60,7790),(40,8190),(0,8590)],
'uv': [(90,8390),(75,8790),(60,9190),(40,9590),(0,9990)],
}
def price(mode: str, metres: str, grade: int) -> dict:
length = Decimal(str(metres))
if mode not in TIERS or not length.is_finite() or not 0 < length <= 12000:
raise ValueError('Invalid product or length')
if isinstance(grade, bool) or not isinstance(grade, int) or not 0 <= grade <= 100:
raise ValueError('Grade must be an integer between 0 and 100')
billed = max(Decimal('1'), (length * 10).to_integral_value(rounding=ROUND_CEILING) / 10)
unit = next(cents for minimum, cents in TIERS[mode] if grade >= minimum)
return {'mode': mode, 'metres': str(length), 'grade': grade,
'billed_metres': str(billed), 'unit_cents': unit,
'total_cents': int(billed * unit),
'discount_cents': int(billed * (TIERS[mode][-1][1] - unit))}

76
app/core/secrets.py Normal file
View File

@@ -0,0 +1,76 @@
"""Resolve Docker secret files into the environment before configuration is read.
Swarm mounts each secret as a file and the stack passes its path as `<NAME>_FILE`.
The deployed `docker-compose.yml` passes credentials as plain environment
variables, so this module is inert there. It exists so a stack can supply them as
Docker secrets instead without any code change; see `ROADMAP.md` 2.12.
Call `load()` in every entrypoint before any configuration is read.
Note for readers: this module is `local.secrets`. Python 3 resolves `import
secrets` elsewhere in the package to the standard library, not to this file.
"""
import os
# The settings a stack may supply as secret files. Any other `*_FILE` variable is
# resolved the same way; this list documents the contract and is what the release
# gate checks against.
SECRET_FILE_SETTINGS = (
'DATABASE_URL',
'DATABASE_ADMIN_URL',
'DATABASE_PASSWORD',
'DATABASE_ADMIN_PASSWORD',
'APP_DB_PASSWORD',
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'OPERATOR_PASSWORD',
'PAYMENT_TOKEN',
'PAYMENT_WEBHOOK_SECRET',
'TINY_TOKEN',
'WHATSAPP_TOKEN',
)
SUFFIX = '_FILE'
def read_secret(path):
"""One secret's value, without the newline an editor or `docker secret` adds.
Only a single trailing newline is removed: everything else is part of the
value, because a generated password may legitimately end in whitespace.
"""
with open(path, 'r', encoding='utf-8') as handle:
value = handle.read()
if value.endswith('\r\n'):
return value[:-2]
if value.endswith('\n'):
return value[:-1]
return value
def load(environ=None):
"""Replace every `<NAME>_FILE` path with `<NAME>` holding the file's contents.
Fails closed. An unreadable secret, an empty one, or a name supplied both
directly and as a file is a configuration error, and starting anyway would
mean running with a credential nobody intended. Never logs a value.
"""
environ = os.environ if environ is None else environ
resolved = []
for key in sorted(k for k in environ if k.endswith(SUFFIX) and len(k) > len(SUFFIX)):
name = key[:-len(SUFFIX)]
path = environ[key].strip()
if not path:
raise RuntimeError(f'{key} is set but empty; point it at a secret file')
if environ.get(name):
raise RuntimeError(
f'{name} and {key} are both set; supply the value or the file, not both')
try:
value = read_secret(path)
except OSError as exc:
raise RuntimeError(f'{key} could not be read: {exc.strerror}') from None
if not value:
raise RuntimeError(f'{key} points at an empty secret file')
environ[name] = value
resolved.append(name)
return resolved

130
app/operators.py Normal file
View File

@@ -0,0 +1,130 @@
"""Kanban operator accounts.
One shared login meant every card movement was attributed to the same name, so
the movement history could not answer who did what. Accounts live in the
database; `OPERATOR_EMAIL` and `OPERATOR_PASSWORD` seed the first one so an
existing deployment keeps working unchanged.
Administered from the API container, the same way the schema is:
python -m local.operators list
python -m local.operators add maria@example.com --name "Maria"
python -m local.operators password maria@example.com
python -m local.operators disable maria@example.com
python -m local.operators enable maria@example.com
Passwords are read from the terminal, never from an argument, so they do not
reach shell history or the process list.
"""
import getpass
import os
import sys
from uuid import uuid4
from .core.auth import password_hash
from .core.db import connect
MIN_PASSWORD = 12
def seed_from_environment(cursor):
"""Make the configured credential a real account, once.
Only inserts when that email is absent, so a password changed here is never
reverted by a stale environment variable on the next deploy.
"""
email = os.environ.get('OPERATOR_EMAIL', '').strip().lower()
password = os.environ.get('OPERATOR_PASSWORD', '')
if not email or not password:
return None
existing = cursor.execute(
'SELECT id FROM dtf_local.operators WHERE email=%s', (email,)).fetchone()
if existing:
return None
cursor.execute(
'INSERT INTO dtf_local.operators(id,email,name,password_hash) VALUES(%s,%s,%s,%s)',
(uuid4(), email, 'Operador', password_hash(password)))
return email
def _ask_password(email):
first = getpass.getpass(f'New password for {email}: ')
if len(first) < MIN_PASSWORD:
raise SystemExit(f'Password must be at least {MIN_PASSWORD} characters.')
if first != getpass.getpass('Repeat: '):
raise SystemExit('Passwords did not match.')
return first
def add(email, name=''):
email = email.strip().lower()
with connect() as c:
if c.execute('SELECT id FROM dtf_local.operators WHERE email=%s', (email,)).fetchone():
raise SystemExit(f'{email} already exists. Use "password" or "enable".')
c.execute('INSERT INTO dtf_local.operators(id,email,name,password_hash) VALUES(%s,%s,%s,%s)',
(uuid4(), email, name, password_hash(_ask_password(email))))
print(f'Added {email}.')
def password(email):
email = email.strip().lower()
with connect() as c:
if not c.execute('SELECT id FROM dtf_local.operators WHERE email=%s', (email,)).fetchone():
raise SystemExit(f'{email} does not exist.')
c.execute('UPDATE dtf_local.operators SET password_hash=%s WHERE email=%s',
(password_hash(_ask_password(email)), email))
print(f'Password changed for {email}. Existing sessions stay valid until they expire;'
' run "disable" first if the account is compromised.')
def set_active(email, active):
email = email.strip().lower()
with connect() as c:
updated = c.execute(
'UPDATE dtf_local.operators SET active=%s WHERE email=%s RETURNING email',
(active, email)).fetchone()
if not updated:
raise SystemExit(f'{email} does not exist.')
if not active:
# Revoke immediately: disabling must end access now, not in eight hours.
c.execute('DELETE FROM dtf_local.operator_sessions WHERE username=%s', (email,))
print(f'{email} {"enabled" if active else "disabled; open sessions revoked"}.')
def listing():
with connect() as c:
rows = c.execute('''SELECT email,name,active,last_login_at FROM dtf_local.operators
ORDER BY email''').fetchall()
if not rows:
print('No operator accounts. Set OPERATOR_EMAIL and OPERATOR_PASSWORD and run'
' "python -m local.bootstrap", or add one here.')
return
for row in rows:
seen = row['last_login_at'].strftime('%Y-%m-%d %H:%M') if row['last_login_at'] else 'never'
state = 'active ' if row['active'] else 'DISABLED'
print(f"{state} {row['email']:<34} {row['name'][:20]:<20} last login {seen}")
def main(argv):
if not argv:
raise SystemExit(__doc__)
command, rest = argv[0], argv[1:]
if command == 'list':
return listing()
if not rest:
raise SystemExit(f'usage: python -m local.operators {command} <email>')
email = rest[0]
if command == 'add':
name = rest[rest.index('--name') + 1] if '--name' in rest else ''
return add(email, name)
if command == 'password':
return password(email)
if command == 'disable':
return set_active(email, False)
if command == 'enable':
return set_active(email, True)
raise SystemExit(__doc__)
if __name__ == '__main__':
main(sys.argv[1:])

85
app/runtime.py Normal file
View File

@@ -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 .core.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)

81
app/scanning.py Normal file
View File

@@ -0,0 +1,81 @@
"""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
def require_clean(row):
if not row['complete'] or row['scan_state'] != 'clean':
raise HTTPException(409, 'Artwork is quarantined until the malware scan succeeds')
class ClamAV:
def command(self, command, timeout=2):
with socket.create_connection(('scanner',3310),timeout=timeout) as sock:
sock.settimeout(timeout)
sock.sendall(b'z'+command+b'\0')
reply=b''
while b'\0' not in reply and len(reply)<4096:
block=sock.recv(4096)
if not block:break
reply+=block
return reply.rstrip(b'\0\n')
def ping(self):
return self.command(b'PING') == b'PONG'
def version(self):
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'))):
return 'rejected', 'File exceeds the malware scan limit'
with socket.create_connection(('scanner',3310),timeout=10) as sock:
sock.settimeout(150)
sock.sendall(b'zINSTREAM\0')
sent=0
for chunk in stream.iter_chunks(chunk_size=65536):
sent+=len(chunk)
if sent>size: return 'rejected','Stored file size changed'
sock.sendall(struct.pack('!I',len(chunk))+chunk)
if sent!=size:return 'rejected','Stored file size changed'
sock.sendall(b'\0\0\0\0')
reply=b''
while b'\0' not in reply and len(reply)<4096:
block=sock.recv(4096)
if not block:break
reply+=block
result=reply.rstrip(b'\0\n')
if result==b'stream: OK':return 'clean',None
if result.endswith(b' FOUND'):return 'rejected','Malware or unsafe scan condition detected'
return 'error','Scanner could not verify this file'
def scan_one(storage, scanner=None):
scanner=scanner or ClamAV()
with connect() as c:
row=c.execute('''SELECT * FROM dtf_local.uploads WHERE complete AND purged_at IS NULL
AND expires_at>now() AND scan_state IN ('pending','error') AND scan_after<=now()
ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1''').fetchone()
if not row:return False
try:
stream=storage.client.get_object(Bucket=storage.bucket,Key=row['object_key'])['Body']
try:state,reason=scanner.scan(stream,row['size'])
finally:stream.close()
except Exception:
state,reason='error','Malware scanner unavailable; file remains blocked'
c.execute("""UPDATE dtf_local.uploads SET scan_state=%s,scan_reason=%s,scanned_at=now(),
scan_after=now()+interval '1 minute',
expires_at=CASE WHEN %s IN ('rejected','error') THEN LEAST(expires_at,now()+interval '3 days') ELSE expires_at END
WHERE id=%s""",(state,reason,state,row['id']))
audit('artwork_scan', upload=str(row['id']), result=state)
return True
def scan_loop(storage):
while True:
try:
if scan_one(storage):continue
except Exception:
audit('scanner_worker_error')
time.sleep(1)

120
app/schema.sql Normal file
View File

@@ -0,0 +1,120 @@
-- Separate schema: never imports/migrates the historical schema.sql or SQLite.
CREATE SCHEMA IF NOT EXISTS dtf_local;
CREATE TABLE IF NOT EXISTS dtf_local.uploads (
id uuid PRIMARY KEY, owner uuid NOT NULL, name text NOT NULL,
size bigint NOT NULL, object_key text UNIQUE NOT NULL, multipart_id text NOT NULL,
complete boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS dtf_local.quotes (
id uuid PRIMARY KEY, owner uuid NOT NULL, request_key uuid NOT NULL,
request_hash text NOT NULL, draft jsonb NOT NULL, approved jsonb,
reviewed_by text, approved_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(), UNIQUE(owner, request_key)
);
CREATE TABLE IF NOT EXISTS dtf_local.orders (
id uuid PRIMARY KEY, number bigint GENERATED ALWAYS AS IDENTITY UNIQUE,
quote_id uuid NOT NULL UNIQUE REFERENCES dtf_local.quotes(id), owner uuid NOT NULL,
snapshot jsonb NOT NULL, payment jsonb NOT NULL, state text NOT NULL DEFAULT 'rec',
version integer NOT NULL DEFAULT 0, created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS dtf_local.movements (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id uuid NOT NULL REFERENCES dtf_local.orders(id),
from_state text NOT NULL, to_state text NOT NULL, operator text NOT NULL,
reason text NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS dtf_local.outbox (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, event_key text NOT NULL UNIQUE,
provider text NOT NULL, payload jsonb NOT NULL, attempts integer NOT NULL DEFAULT 0,
available_at timestamptz NOT NULL DEFAULT now(), delivered_at timestamptz,
last_error text, receipt jsonb
);
CREATE TABLE IF NOT EXISTS dtf_local.accounts (
id uuid PRIMARY KEY, email text UNIQUE NOT NULL, password_hash text NOT NULL,
profile jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS dtf_local.sessions (
id uuid PRIMARY KEY, owner uuid NOT NULL,
expires_at timestamptz NOT NULL DEFAULT now() + interval '7 days'
);
CREATE TABLE IF NOT EXISTS dtf_local.migrations (name text PRIMARY KEY);
-- Preserve pre-account guest sessions once, without resurrecting logged-out sessions.
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM dtf_local.migrations WHERE name='customer-sessions-v1') THEN
INSERT INTO dtf_local.sessions(id,owner)
SELECT owner,owner FROM (
SELECT owner FROM dtf_local.orders UNION SELECT owner FROM dtf_local.quotes
UNION SELECT owner FROM dtf_local.uploads
) legacy ON CONFLICT DO NOTHING;
INSERT INTO dtf_local.migrations VALUES('customer-sessions-v1');
END IF;
END $$;
CREATE TABLE IF NOT EXISTS dtf_local.login_attempts (
key text PRIMARY KEY, attempts integer NOT NULL DEFAULT 0,
started_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS dtf_local.operator_sessions (
token_hash text PRIMARY KEY, username text NOT NULL,
expires_at timestamptz NOT NULL DEFAULT now() + interval '8 hours'
);
CREATE TABLE IF NOT EXISTS dtf_local.operators (
id uuid PRIMARY KEY, email text UNIQUE NOT NULL, name text NOT NULL DEFAULT '',
password_hash text NOT NULL, active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(), last_login_at timestamptz
);
CREATE TABLE IF NOT EXISTS dtf_local.security_events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
event text NOT NULL, details jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE dtf_local.uploads ADD COLUMN IF NOT EXISTS expires_at timestamptz;
ALTER TABLE dtf_local.uploads ADD COLUMN IF NOT EXISTS purged_at timestamptz;
ALTER TABLE dtf_local.uploads ADD COLUMN IF NOT EXISTS scan_state text NOT NULL DEFAULT 'pending';
ALTER TABLE dtf_local.uploads ADD COLUMN IF NOT EXISTS scan_reason text;
ALTER TABLE dtf_local.uploads ADD COLUMN IF NOT EXISTS scanned_at timestamptz;
ALTER TABLE dtf_local.uploads ADD COLUMN IF NOT EXISTS scan_after timestamptz NOT NULL DEFAULT now();
UPDATE dtf_local.uploads SET expires_at=created_at + interval '30 days' WHERE expires_at IS NULL;
ALTER TABLE dtf_local.uploads ALTER COLUMN expires_at SET DEFAULT now() + interval '30 days';
CREATE TABLE IF NOT EXISTS dtf_local.order_files (
id uuid PRIMARY KEY, order_id uuid NOT NULL REFERENCES dtf_local.orders(id),
upload_id uuid NOT NULL REFERENCES dtf_local.uploads(id), item_index integer NOT NULL,
kind text NOT NULL CHECK(kind IN ('final','correction')), active boolean NOT NULL DEFAULT true,
note text NOT NULL, created_by text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(order_id,upload_id,kind)
);
CREATE INDEX IF NOT EXISTS uploads_owner ON dtf_local.uploads(owner);
-- Indexes follow the queries the application actually issues. Only these; every
-- extra index is paid for on each write.
-- The customer portal lists a person's orders and open quotes, newest first.
CREATE INDEX IF NOT EXISTS orders_owner ON dtf_local.orders(owner, created_at DESC);
CREATE INDEX IF NOT EXISTS quotes_owner ON dtf_local.quotes(owner, created_at DESC);
-- Order detail and the movement history read by order.
CREATE INDEX IF NOT EXISTS movements_order ON dtf_local.movements(order_id, id);
CREATE INDEX IF NOT EXISTS order_files_order ON dtf_local.order_files(order_id);
-- submit_files checks whether an upload is already attached, by upload.
CREATE INDEX IF NOT EXISTS order_files_upload ON dtf_local.order_files(upload_id);
-- The worker polls this once a second, and the table only grows. Partial, so the
-- index stays the size of the backlog rather than of all history.
CREATE INDEX IF NOT EXISTS outbox_pending ON dtf_local.outbox(available_at, id)
WHERE delivered_at IS NULL;
-- The scanner and the retention sweep both walk live uploads in arrival order.
-- now() cannot appear in a partial index predicate, so the time comparisons stay
-- in the query and the index narrows to rows still worth looking at.
CREATE INDEX IF NOT EXISTS uploads_live ON dtf_local.uploads(created_at)
WHERE purged_at IS NULL;
-- Sign-in transfers and logout delete a person's sessions by owner.
CREATE INDEX IF NOT EXISTS sessions_owner ON dtf_local.sessions(owner);
-- Disabling an operator revokes their open sessions by name.
CREATE INDEX IF NOT EXISTS operator_sessions_username ON dtf_local.operator_sessions(username);
-- security_status reads recent events; the worker prunes old ones by age.
CREATE INDEX IF NOT EXISTS security_events_created ON dtf_local.security_events(created_at);

79
app/worker.py Normal file
View File

@@ -0,0 +1,79 @@
"""Transactional outbox worker. Fake receipts persist; no messages leave the stack."""
import json
import logging
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
from psycopg.types.json import Jsonb
from .adapters import FakeTiny, FakeWhatsApp, LocalS3Storage, require_runtime
from .core.secrets import load as load_secret_files
from .core.db import connect
from .scanning import ClamAV, scan_loop
load_secret_files()
require_runtime()
adapters = {'tiny': FakeTiny(), 'whatsapp': FakeWhatsApp()}
last_tick = 0.0
last_cleanup = 0.0
storage = LocalS3Storage()
scan_thread = None
def cleanup():
"""Delete only expired object bytes; retain order/file metadata and history."""
with connect() as c:
rows = c.execute("""SELECT * FROM dtf_local.uploads WHERE purged_at IS NULL
AND (expires_at<=now() OR (NOT complete AND created_at<now()-interval '1 day'))
ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 50""").fetchall()
for row in rows:
storage.discard(row['object_key'],row['multipart_id'],row['complete'])
c.execute('UPDATE dtf_local.uploads SET purged_at=now() WHERE id=%s', (row['id'],))
c.execute('DELETE FROM dtf_local.sessions WHERE expires_at<=now()')
c.execute('DELETE FROM dtf_local.operator_sessions WHERE expires_at<=now()')
c.execute("DELETE FROM dtf_local.login_attempts WHERE started_at<now()-interval '1 day'")
c.execute("DELETE FROM dtf_local.security_events WHERE created_at<now()-interval '30 days'")
def tick():
global last_tick
with connect() as c:
job = c.execute('SELECT * FROM dtf_local.outbox WHERE delivered_at IS NULL AND available_at <= now() ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 1').fetchone()
if job:
try:
receipt = adapters[job['provider']].deliver(job['event_key'], job['payload'])
c.execute('UPDATE dtf_local.outbox SET delivered_at=now(), receipt=%s, attempts=attempts+1, last_error=NULL WHERE id=%s', (Jsonb(receipt),job['id']))
except Exception as exc:
delay = min(1800, 2**min(job['attempts']+1, 10))
c.execute("UPDATE dtf_local.outbox SET attempts=attempts+1, last_error=%s, available_at=now() + %s * interval '1 second' WHERE id=%s", (str(exc),delay,job['id']))
last_tick = time.monotonic()
def loop():
global last_cleanup
while True:
try:
tick()
if time.monotonic()-last_cleanup > 60:
cleanup()
last_cleanup = time.monotonic()
except Exception:
logging.exception('Local worker tick failed')
time.sleep(1)
class Health(BaseHTTPRequestHandler):
def do_GET(self):
scanner = False
try:
scanner = bool(scan_thread and scan_thread.is_alive() and ClamAV().ping())
except Exception:
pass
healthy = time.monotonic()-last_tick < 15 and scanner
self.send_response(200 if self.path == '/health' and healthy else 503)
self.end_headers()
self.wfile.write(json.dumps({'worker': 'ok' if healthy else 'unavailable',
'scanner': 'ok' if scanner else 'unavailable'}).encode())
def log_message(self, *args):
pass
if __name__ == '__main__':
scan_thread = threading.Thread(target=scan_loop,args=(storage,),daemon=True)
scan_thread.start()
threading.Thread(target=loop, daemon=True).start()
HTTPServer(('0.0.0.0',8002),Health).serve_forever()