first commit
Some checks failed
Validate, publish and deploy / validate (push) Successful in 2m2s
Validate, publish and deploy / publish-and-deploy (push) Failing after 8s

This commit is contained in:
Cauê Faleiros
2026-09-15 16:42:34 -03:00
commit 98c951d374
170 changed files with 95988 additions and 0 deletions

11
local/Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM python:3.12-slim
RUN apt-get update \
&& apt-get upgrade -y \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY local/requirements.txt local/requirements.lock /app/local/
RUN pip install --no-cache-dir --require-hashes -r local/requirements.lock
COPY local /app/local
RUN useradd --uid 10001 --create-home dtf
USER dtf
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1

13
local/Dockerfile.web Normal file
View File

@@ -0,0 +1,13 @@
FROM python:3.12-slim AS policy
WORKDIR /build
COPY dtf-site.html /build/dtf-site.html
COPY local /build/local
RUN python local/compile_web.py
FROM nginx:1.28-alpine
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/

1
local/__init__.py Normal file
View File

@@ -0,0 +1 @@

127
local/adapters.py Normal file
View File

@@ -0,0 +1,127 @@
"""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_local():
if os.environ.get('APP_ENV') != 'local':
raise RuntimeError('This runtime only supports 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 fake adapter')
if os.environ.get('STORAGE_ADAPTER') != 's3-local':
raise RuntimeError('Only local S3 storage is supported')
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')
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)

341
local/app.py Normal file
View File

@@ -0,0 +1,341 @@
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
from botocore.exceptions import ClientError
from fastapi import Depends, FastAPI, HTTPException, Request, Response
from fastapi.responses import JSONResponse
from starlette.middleware.trustedhost import TrustedHostMiddleware
from psycopg.types.json import Jsonb
from . import db
from .adapters import FakeFreight, FakePayment, LocalS3Storage, require_local
from .models import Freight, Move, Pay, QuoteRequest, Review, UploadStart, OperatorLogin
from .pricing import price
from .auth import owner, session_row, new_session, operator, throttle, audit, rate_limit
from .scanning import require_clean
require_local()
storage = LocalS3Storage()
payment = FakePayment()
freight = FakeFreight()
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):
with db.connect() as c:
c.execute('SELECT 1 FROM dtf_local.operator_sessions LIMIT 1')
storage.health()
yield
app = FastAPI(title='DTF Local Portal/API', lifespan=lifespan, docs_url=None, redoc_url=None)
app.add_middleware(TrustedHostMiddleware, allowed_hosts=['localhost', '127.0.0.1'])
@app.post('/api/operator/login')
def operator_login(body: OperatorLogin, request: Request, response: Response):
throttle('operator:'+body.username, request)
valid_user = secrets.compare_digest(body.username.encode(), os.environ['OPERATOR_USER'].encode())
valid_password = secrets.compare_digest(body.password.encode(), os.environ['OPERATOR_PASSWORD'].encode())
if not (valid_user and valid_password):
audit('operator_login_failed')
raise HTTPException(401, 'Invalid local 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(), body.username))
response.set_cookie('dtf_operator', token, httponly=True, samesite='strict', path='/api/operator', max_age=28800)
audit('operator_login_success', operator=body.username)
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, 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'):
origin = request.headers.get('origin')
if request.headers.get('sec-fetch-site') == 'cross-site' or (origin and origin != 'http://'+request.headers.get('host','')):
audit('cross_origin_rejected')
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)
response.headers['Cache-Control'] = 'no-store'
response.headers['X-Content-Type-Options'] = 'nosniff'
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': 'local', 'storage': 'minio', 'integrations': 'fake'}
@app.get('/api/session')
def session(request: Request, response: Response):
try:
session_id = owner(request)
except HTTPException:
rate_limit('guest-sessions', 'local-stack', 120, 900)
with db.connect() as c:
session_id = new_session(c, response)
return {'environment': 'local', '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 local 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)):
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:
orders = c.execute('SELECT * FROM dtf_local.orders ORDER BY created_at').fetchall()
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').fetchall()
return {'states': STATES, 'transitions': TRANSITIONS, 'orders': orders,
'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)

93
local/auth.py Normal file
View File

@@ -0,0 +1,93 @@
"""Local customer passwords and revocable database sessions. No email service."""
import hashlib
import secrets
import logging
import json
from uuid import UUID, uuid4
from fastapi import HTTPException, Request
from .db import connect
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, 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', request.client.host if request.client else 'local', 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']

128
local/backup.py Normal file
View File

@@ -0,0 +1,128 @@
"""Local PostgreSQL and clean-object backup with isolated restore verification."""
import argparse
from datetime import datetime, timezone
import hashlib
import json
from pathlib import Path
import subprocess
from uuid import uuid4
ROOT = Path(__file__).resolve().parent.parent
BACKUPS = ROOT / 'backups'
def docker(script, *args, **kwargs):
return subprocess.run(['docker','compose','exec','-T','db','sh','-c',script,'sh',*args],
cwd=ROOT,check=True,**kwargs)
def checksum(path):
digest=hashlib.sha256()
with path.open('rb') as stream:
for block in iter(lambda:stream.read(1048576),b''):digest.update(block)
return digest.hexdigest()
def compose_exec(service, *command, **kwargs):
return subprocess.run(['docker','compose','exec','-T',service,*command],
cwd=ROOT,check=True,**kwargs)
def create():
BACKUPS.mkdir(mode=0o700,exist_ok=True)
prefix='dtf-'+datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')+'-'+uuid4().hex[:8]
database=BACKUPS/(prefix+'.dump')
objects=BACKUPS/(prefix+'.objects.tar.gz')
manifest_path=BACKUPS/(prefix+'.manifest.json')
sidecar=BACKUPS/(prefix+'.manifest.sha256')
created=[]
try:
with database.open('xb') as stream:
created.append(database);database.chmod(0o600)
docker('pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --format=custom',stdout=stream)
with objects.open('xb') as stream:
created.append(objects);objects.chmod(0o600)
result=compose_exec('api','python','-m','local.storage_backup','export',
stdout=stream,stderr=subprocess.PIPE)
summaries=[line.removeprefix('DTF_BACKUP_SUMMARY ') for line in
result.stderr.decode().splitlines() if line.startswith('DTF_BACKUP_SUMMARY ')]
if len(summaries)!=1:
raise RuntimeError('Object backup did not return a valid summary')
manifest={'format':'dtf-local-backup-v2',
'created_at':datetime.now(timezone.utc).isoformat(),
'database':{'file':database.name,'sha256':checksum(database)},
'objects':{'file':objects.name,'sha256':checksum(objects),
**json.loads(summaries[0])}}
manifest_path.write_text(json.dumps(manifest,indent=2,sort_keys=True)+'\n')
created.append(manifest_path);manifest_path.chmod(0o600)
sidecar.write_text(checksum(manifest_path)+'\n')
created.append(sidecar);sidecar.chmod(0o600)
except Exception:
for path in reversed(created):path.unlink(missing_ok=True)
raise
print(manifest_path.relative_to(ROOT))
return manifest_path
def verify_database(path):
if checksum(path)!=path.with_suffix('.sha256').read_text().strip():
raise ValueError('Backup checksum mismatch')
database='dtf_verify_'+uuid4().hex
docker('createdb -U "$POSTGRES_USER" "$1"',database)
try:
with path.open('rb') as stream:
docker('pg_restore -U "$POSTGRES_USER" -d "$1" --exit-on-error --no-owner --no-privileges',database,stdin=stream)
result=docker('psql -U "$POSTGRES_USER" -d "$1" -At -v ON_ERROR_STOP=1 -c "SELECT json_build_object(\'orders\',(SELECT count(*) FROM dtf_local.orders),\'uploads\',(SELECT count(*) FROM dtf_local.uploads),\'accounts\',(SELECT count(*) FROM dtf_local.accounts),\'history\',(SELECT count(*) FROM dtf_local.movements));"',database,capture_output=True,text=True)
print('PASS: backup restored to isolated database; restored counts '+result.stdout.strip())
finally:
# Only the freshly created UUID-named verification database is removed.
docker('dropdb -U "$POSTGRES_USER" "$1"',database)
print('Removed temporary verification database. Active database was untouched.')
def bundle_file(manifest_path, name):
if not isinstance(name,str) or Path(name).name!=name:
raise ValueError('Backup manifest contains an invalid filename')
path=(manifest_path.parent/name).resolve()
if path.parent!=BACKUPS.resolve():
raise ValueError('Backup manifest references a file outside backups/')
return path
def verify(path):
path=path.resolve()
if path.parent!=BACKUPS.resolve():
raise ValueError('Choose a backup generated in this repository backups/ directory')
if path.suffix=='.dump':
return verify_database(path)
if not path.name.endswith('.manifest.json'):
raise ValueError('Choose a v2 .manifest.json or legacy .dump backup')
sidecar=path.with_name(path.name.removesuffix('.json')+'.sha256')
if checksum(path)!=sidecar.read_text().strip():
raise ValueError('Backup manifest checksum mismatch')
manifest=json.loads(path.read_text())
if manifest.get('format')!='dtf-local-backup-v2':
raise ValueError('Unsupported backup format')
database=bundle_file(path,manifest.get('database',{}).get('file'))
objects=bundle_file(path,manifest.get('objects',{}).get('file'))
if checksum(database)!=manifest['database'].get('sha256') or checksum(objects)!=manifest['objects'].get('sha256'):
raise ValueError('Backup bundle checksum mismatch')
# Reuse the legacy database verifier with a temporary matching sidecar.
legacy_sidecar=database.with_suffix('.sha256')
had_legacy=legacy_sidecar.exists()
previous=legacy_sidecar.read_bytes() if had_legacy else None
legacy_sidecar.write_text(manifest['database']['sha256']+'\n');legacy_sidecar.chmod(0o600)
try:
verify_database(database)
finally:
if had_legacy:legacy_sidecar.write_bytes(previous)
else:legacy_sidecar.unlink(missing_ok=True)
with objects.open('rb') as stream:
compose_exec('api','python','-m','local.storage_backup','verify',stdin=stream)
print('PASS: combined database and clean-object backup verified. Active data was untouched.')
if __name__=='__main__':
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('command',choices=['create','verify','create-and-verify'])
parser.add_argument('path',nargs='?',type=Path,
help='v2 manifest printed by create, or a legacy .dump')
args=parser.parse_args()
if args.command=='verify':
if not args.path:parser.error('verify requires the path printed by create')
verify(args.path)
else:
path=create()
if args.command=='create-and-verify':verify(path)

25
local/bootstrap.py Normal file
View File

@@ -0,0 +1,25 @@
"""One-shot schema/role setup. Only this job receives database admin credentials."""
import os
from pathlib import Path
import psycopg
from psycopg import sql
def main():
role = os.environ['APP_DB_USER']
with psycopg.connect(os.environ['DATABASE_ADMIN_URL']) 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(os.environ['APP_DB_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)))
print('Local schema migrated; runtime role has DML only.')
if __name__ == '__main__': main()

140
local/browser_test.mjs Normal file
View File

@@ -0,0 +1,140 @@
// Dependency-free Chrome DevTools smoke test. Node 22+ and Chrome required.
import {spawn} from 'node:child_process';
import {mkdtemp,readFile,writeFile,mkdir} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import {resolve} from 'node:path';
import assert from 'node:assert/strict';
try {
for(const line of (await readFile('.env','utf8')).split('\n')) {
if(!line.startsWith('#') && line.includes('=')) {
const i=line.indexOf('=');process.env[line.slice(0,i)]??=line.slice(i+1);
}
}
}catch(e){if(e.code!=='ENOENT')throw e;}
const profile=await mkdtemp(tmpdir()+'/dtf-browser-');
const chrome=spawn(process.env.CHROME_BIN||'/usr/bin/google-chrome-stable',[
'--headless=new','--disable-gpu','--no-first-run','--no-default-browser-check',
'--remote-debugging-port=0','--user-data-dir='+profile,'about:blank'
],{stdio:['ignore','ignore','pipe']});
const pause=ms=>new Promise(r=>setTimeout(r,ms));
let stderr='';chrome.stderr.on('data',data=>stderr+=data.toString());
const clients=[];
async function waitFor(fn,description,timeout=30000){const end=Date.now()+timeout;while(Date.now()<end){if(await fn())return;await pause(200);}throw new Error('Timeout: '+description);}
class Page {
constructor(ws){this.ws=ws;this.next=0;this.pending=new Map();this.errors=[];
ws.onmessage=event=>{const d=JSON.parse(event.data);if(d.id){const p=this.pending.get(d.id);if(p){this.pending.delete(d.id);d.error?p.reject(d.error):p.resolve(d.result);}}else if(d.method==='Runtime.exceptionThrown')this.errors.push(d.params.exceptionDetails);};
}
call(method,params={}){return new Promise((resolve,reject)=>{const id=++this.next;this.pending.set(id,{resolve,reject});this.ws.send(JSON.stringify({id,method,params}));});}
async eval(expression){const result=await this.call('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true});if(result.exceptionDetails)throw new Error(JSON.stringify(result.exceptionDetails));return result.result.value;}
async click(selector){await this.eval(`document.querySelector(${JSON.stringify(selector)}).click()`);}
async fill(selector,value,event='input'){await this.eval(`(()=>{const el=document.querySelector(${JSON.stringify(selector)});el.value=${JSON.stringify(value)};el.dispatchEvent(new Event(${JSON.stringify(event)},{bubbles:true}));})()`);}
async text(){return this.eval('document.body.innerText');}
async screenshot(path){const result=await this.call('Page.captureScreenshot',{format:'png',captureBeyondViewport:false});await writeFile(path,Buffer.from(result.data,'base64'));}
}
try{
let port;
await waitFor(async()=>{try{port=Number((await readFile(profile+'/DevToolsActivePort','utf8')).split('\n')[0]);return true;}catch{return false;}},'Chrome startup');
async function page(url){
const tab=await (await fetch('http://localhost:'+port+'/json/new?'+encodeURIComponent(url),{method:'PUT'})).json();
const ws=new WebSocket(tab.webSocketDebuggerUrl);await new Promise((r,j)=>{ws.onopen=r;ws.onerror=j;});
const p=new Page(ws);clients.push(p);await p.call('Runtime.enable');await p.call('Page.enable');
await p.call('Emulation.setDeviceMetricsOverride',{width:1440,height:1000,deviceScaleFactor:1,mobile:false});
await waitFor(()=>p.eval('document.readyState === "complete"'),'page load');return p;
}
const site=await page('http://localhost:'+(process.env.SITE_PORT||8080));
await waitFor(()=>site.eval('typeof window.dtfCheckout === "function"'),'checkout bridge');
// Prove escaping itself, independently of the CSP's second line of defense.
await site.call('Page.setBypassCSP',{enabled:true});
await site.eval(`(()=>{window.xssProbe=0;abrir('file');sel([new File(['test'],'<img src=x onerror=window.xssProbe=1>.cdr')]);})()`);
await pause(500);
assert.equal(await site.eval('window.xssProbe'),0);
assert.equal(await site.eval('document.querySelectorAll("#lista img[onerror]").length'),0);
assert.ok((await site.text()).includes('<img src=x onerror=window.xssProbe=1>.cdr'));
await site.eval(`(()=>{recusa([new File(['test'],'bad.<img src=x onerror=window.xssProbe=1>')]);})()`);
await pause(200);assert.equal(await site.eval('window.xssProbe'),0);
await site.call('Page.setBypassCSP',{enabled:false});
await site.call('Page.reload');
await waitFor(()=>site.eval('typeof window.dtfCheckout === "function"'),'reload after security probe');
await site.click('[data-modo="file"]');
await site.click('[data-cam="tabela"]');
const root=await site.call('DOM.getDocument');
const input=await site.call('DOM.querySelector',{nodeId:root.root.nodeId,selector:'#inp'});
await site.call('DOM.setFileInputFiles',{nodeId:input.nodeId,files:[resolve('local/fixtures/local-test.cdr')]});
await waitFor(()=>site.eval('!!document.querySelector("[data-comp]")'),'manual length field');
await site.fill('[data-comp]','1.01','change');
await waitFor(()=>site.eval('!!itemAtual'),'cart calculation');
assert.equal(await site.eval('itemAtual.total'),21.89);
await site.fill('#fCnpj','11222333000181');
await site.fill('#fZap','11999999999');
await site.fill('#fMail','local-browser@example.test');
await pause(800);
await site.call('Page.reload');
await waitFor(()=>site.eval('typeof pedido!=="undefined" && pedido.length===1'),'persistent cart recovery');
assert.equal(await site.eval('pedido[0].localFiles[0].name'),'local-test.cdr');
assert.equal(await site.eval('pedido[0].total'),21.89);
assert.equal(await site.eval('document.getElementById("bPagar").disabled'),false);
await site.click('#bPagar');
await waitFor(async()=> (await site.text()).includes('Arquivos enviados.'),'browser upload and quote',45000);
const qid=await site.eval('localStorage.getItem("dtf-quote")');
const kanban=await page('http://localhost:'+(process.env.KANBAN_PORT||8081));
await kanban.fill('#user',process.env.OPERATOR_USER||'operator');
await kanban.fill('#password',process.env.OPERATOR_PASSWORD||'local-operator-only');
await kanban.eval('document.getElementById("login").requestSubmit()');
await waitFor(async()=> (await kanban.text()).includes(qid.slice(0,8)),'quote on Kanban');
assert.equal(await kanban.eval('sessionStorage.getItem("dtf-operator")'),null);
assert.equal(await kanban.eval('document.getElementById("password").value'),'');
await kanban.eval(`(()=>{const card=[...document.querySelectorAll('.review')].find(x=>x.textContent.includes(${JSON.stringify(qid.slice(0,8))}));card.querySelector('[type=checkbox]').click();card.querySelector('form').requestSubmit();})()`);
await waitFor(async()=> (await kanban.text()).includes('Aprovada:'),'quote approval');
await site.eval('[...document.querySelectorAll("button")].find(x=>x.textContent==="Atualizar pedido local").click()');
await waitFor(async()=> (await site.text()).includes('Total validado no servidor:'),'approved quote displayed');
await site.eval('[...document.querySelectorAll("button")].find(x=>x.textContent==="Criar pedido pago local").click()');
await waitFor(async()=> (await site.text()).includes('Nenhuma cobrança real.'),'local payment');
await kanban.click('#refresh');
await waitFor(()=>kanban.eval(`board.orders.some(o=>o.quote_id===${JSON.stringify(qid)})`),'paid card');
const oid=await kanban.eval(`board.orders.find(o=>o.quote_id===${JSON.stringify(qid)}).id`);
// Click real transition buttons, including rerender after each move.
for(const [title,state] of [['Arte tratada','tra'],['Fila de impressão','fil'],['Imprimindo','imp'],['Finalizado','fin']]){
if(state==='fil'){
await kanban.eval(`(()=>{const card=document.querySelector('[data-order="${oid}"]');[...card.querySelectorAll('button')].find(x=>x.textContent==='Arquivos de produção').click();})()`);
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-final-item]')`),'final upload controls');
const doc=await kanban.call('DOM.getDocument');
const input=await kanban.call('DOM.querySelector',{nodeId:doc.root.nodeId,selector:`[data-order="${oid}"] [data-final-item]`});
await kanban.call('DOM.setFileInputFiles',{nodeId:input.nodeId,files:[resolve('local/fixtures/local-test.cdr')]});
await kanban.fill(`[data-order="${oid}"] input[placeholder="Nota da revisão"]`,'Browser test final file');
await kanban.eval(`(()=>{const form=document.querySelector('[data-order="${oid}"] form');form.querySelector('[type=checkbox]').click();form.requestSubmit();})()`);
await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').version===2`),'final file approval');
}
await kanban.eval(`(()=>{const card=document.querySelector('[data-order="${oid}"]');[...card.querySelectorAll('button')].find(x=>x.textContent===${JSON.stringify('→ '+title)}).click();})()`);
await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').state==='${state}'`),'transition '+state);
}
// Reload proves the board is persisted on the backend.
await kanban.call('Page.reload');
await waitFor(()=>kanban.eval(`typeof board!=='undefined' && !!board && board.orders.some(o=>o.id==='${oid}'&&o.state==='fin')`),'persisted board');
await mkdir('output/local',{recursive:true});
await site.eval('window.scrollTo({top:0,behavior:"instant"})');
await waitFor(()=>site.eval('window.scrollY===0'),'screenshot scroll position');
await site.screenshot('output/local/site.png');
await kanban.screenshot('output/local/kanban.png');
const portal=await page('http://localhost:'+(process.env.SITE_PORT||8080)+'/portal.html?order='+oid);
await waitFor(async()=> (await portal.text()).includes('Finalizado'),'customer order tracking');
await portal.fill('#cnpj','11222333000181');await portal.fill('#phone','11999999999');
await portal.fill('#register-email','browser-'+Date.now()+'@example.test');
await portal.fill('#register-password','local-browser-password-123');
await portal.eval('document.getElementById("register").requestSubmit()');
await waitFor(()=>portal.eval('document.getElementById("auth").hidden'),'customer registration');
assert.ok((await portal.text()).includes('Finalizado'));
await portal.screenshot('output/local/portal.png');
// A logout must clear draft file blobs and metadata, including other open Site tabs.
await portal.eval(`(async()=>{const db=await new Promise((resolve,reject)=>{const r=indexedDB.open('dtf-local-cart',1);r.onsuccess=()=>resolve(r.result);r.onerror=reject;});await new Promise((resolve,reject)=>{const tx=db.transaction('cart','readwrite');tx.objectStore('cart').put({items:[new File(['private'],'private.cdr')],expires:Date.now()+86400000},'security-fixture');tx.oncomplete=resolve;tx.onerror=reject;});db.close();})()`);
await portal.click('#logout');
await waitFor(()=>portal.eval('document.getElementById("logout").hidden'),'customer logout');
let stored;
await waitFor(async()=>{stored=await portal.eval(`(async()=>{try{const db=await new Promise((resolve,reject)=>{const r=indexedDB.open('dtf-local-cart',1);r.onupgradeneeded=()=>r.result.createObjectStore('cart');r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);});const n=await new Promise((resolve,reject)=>{const r=db.transaction('cart').objectStore('cart').count();r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);});db.close();return n;}catch{return -1;}})()`);return stored>=0;},'IndexedDB available after Clear-Site-Data');
assert.equal(stored,0);
assert.deepEqual(portal.errors,[]);
assert.deepEqual(site.errors,[]);assert.deepEqual(kanban.errors,[]);
console.log('PASS: browser Site upload → operator quote → local paid order → all main Kanban states → reload persistence. Order '+oid);
console.log('Screenshots: output/local/site.png and output/local/kanban.png');
console.log('PASS: filename XSS escaping with CSP bypassed, no stored operator password, logout clears browser file blobs.');
}catch(error){console.error(error);if(stderr)console.error(stderr.slice(-1500));process.exitCode=1;}
finally{for(const p of clients)p.ws.close();chrome.kill();}

15
local/clamd.conf Normal file
View File

@@ -0,0 +1,15 @@
Foreground yes
LogTime yes
DatabaseDirectory /var/lib/clamav
TCPSocket 3310
TCPAddr 0.0.0.0
MaxThreads 2
MaxQueue 8
StreamMaxLength 128M
MaxFileSize 128M
MaxScanSize 256M
MaxScanTime 120000
AlertExceedsMax yes
AlertEncrypted yes
ScanPDF yes
ScanArchive yes

15
local/compile_web.py Normal file
View File

@@ -0,0 +1,15 @@
"""Hash only trusted source inline scripts at image build time for strict CSP."""
import base64
import hashlib
import os
from pathlib import Path
import re
root=Path('/build')
hashes=[]
for html in [root/'dtf-site.html',*(root/'local/static').glob('*.html')]:
for attributes,body in re.findall(r'<script\b([^>]*)>(.*?)</script>',html.read_text(),re.S|re.I):
if not re.search(r'\bsrc\s*=',attributes,re.I):
hashes.append("'sha256-"+base64.b64encode(hashlib.sha256(body.encode()).digest()).decode()+"'")
template=Path(os.environ.get('NGINX_TEMPLATE', root/'local/nginx.conf.template')).read_text()
(root/'default.conf.template').write_text(template.replace('@SCRIPT_HASHES@',' '.join(hashes)))

191
local/customer.py Normal file
View File

@@ -0,0 +1,191 @@
"""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
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')
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)

12
local/db.py Normal file
View File

@@ -0,0 +1,12 @@
import os
from pathlib import Path
import psycopg
from psycopg.rows import dict_row
def connect():
return psycopg.connect(os.environ['DATABASE_URL'], row_factory=dict_row)
def initialize():
with connect() as c:
c.execute(Path(__file__).with_name('schema.sql').read_text())

24
local/dependency_audit.py Normal file
View File

@@ -0,0 +1,24 @@
"""Audit exact installed API packages, not a host-dependent re-resolution.
Install pip-audit in an isolated environment; pass its executable as argv[1].
Only package names/versions are sent to the public vulnerability database.
"""
import json
from pathlib import Path
import subprocess
import sys
if __name__=='__main__':
packages=json.loads(subprocess.check_output(['docker','compose','exec','-T','api','pip','list','--format=json'],text=True))
requirements='\n'.join(p['name']+'=='+p['version'] for p in packages)+'\n'
destination=Path('output/security');destination.mkdir(parents=True,exist_ok=True)
(destination/'runtime-requirements.txt').write_text(requirements)
result=subprocess.run([sys.argv[1] if len(sys.argv)>1 else 'pip-audit','--no-deps','--disable-pip',
'--progress-spinner','off','-r',str(destination/'runtime-requirements.txt'),
'-f','json','-o',str(destination/'python-audit.json')])
if (destination/'python-audit.json').exists():
data=json.loads((destination/'python-audit.json').read_text())
for package in data['dependencies']:
for vulnerability in package.get('vulns',[]):
print(package['name'],package['version'],vulnerability['id'],'fix:',','.join(vulnerability['fix_versions']))
sys.exit(result.returncode)

View File

@@ -0,0 +1,4 @@
DTF LOCAL UPLOAD TEST ONLY
This is a harmless text fixture with a .cdr extension, not printable artwork.
Use the manual-length path: 1.01 metres, grade 0, pickup = BRL 21.89.
It intentionally requires no artwork parsing or automatic pre-flight.

13
local/lock_dependencies.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/sh
set -eu
root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
docker run --rm \
--user "$(id -u):$(id -g)" \
--env HOME=/tmp \
--env CUSTOM_COMPILE_COMMAND=./local/lock_dependencies.sh \
--volume "$root:/src" \
--workdir /src \
python:3.12-slim \
sh -c 'python -m pip install --no-cache-dir --target /tmp/piptools pip==25.3 pip-tools==7.5.2 && PYTHONPATH=/tmp/piptools python -m piptools compile --generate-hashes --allow-unsafe --strip-extras --no-emit-index-url --output-file local/requirements.lock local/requirements.txt'

101
local/models.py Normal file
View File

@@ -0,0 +1,101 @@
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):
username: str = Field(min_length=1, max_length=100)
password: str = Field(min_length=1, max_length=128)
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)

34
local/nginx.conf.template Normal file
View File

@@ -0,0 +1,34 @@
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=20r/s;
server {
listen 80;
server_name localhost;
if ($host !~ ^(localhost|127\.0\.0\.1)$) { return 400; }
root /usr/share/nginx/html;
index ${WEB_INDEX};
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy no-referrer always;
add_header X-Frame-Options DENY always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' @SCRIPT_HASHES@ https://cdnjs.cloudflare.com; script-src-attr 'none'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://cdn.vnda.com.br; connect-src 'self' ${S3_PUBLIC_ENDPOINT} https://cdn.vnda.com.br https://cdnjs.cloudflare.com; worker-src 'self' blob: https://cdnjs.cloudflare.com; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always;
location = /health { access_log off; return 200 'ok'; }
location /api/ {
limit_req zone=api_limit burst=100 nodelay;
limit_req_status 429;
proxy_pass http://api:8000;
proxy_set_header Host $http_host;
client_max_body_size 2m;
}
location / { try_files $uri $uri/ =404; }
}
server {
listen 81;
server_name localhost;
if ($host !~ ^(localhost|127\.0\.0\.1)$) { return 400; }
client_max_body_size 2m;
location / {
limit_req zone=api_limit burst=100 nodelay;
limit_req_status 429;
proxy_pass http://api:8000;
proxy_set_header Host $http_host;
}
}

27
local/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))}

316
local/requirements.lock Normal file
View File

@@ -0,0 +1,316 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# ./local/lock_dependencies.sh
#
annotated-doc==0.0.5 \
--hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101 \
--hash=sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb
# via fastapi
annotated-types==0.8.0 \
--hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \
--hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0
# via pydantic
anyio==4.15.1 \
--hash=sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101 \
--hash=sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94
# via
# httpx
# starlette
boto3==1.38.23 \
--hash=sha256:70ab8364f1f6f0a7e0eaf97f62fbdacf9c1e4cc1de330faf1c146ef9ab01e7d0 \
--hash=sha256:bcf73aca469add09e165b8793be18e7578db8d2604d82505ab13dc2495bad982
# via -r local/requirements.txt
botocore==1.38.46 \
--hash=sha256:8798e5a418c27cf93195b077153644aea44cb171fcd56edc1ecebaa1e49e226e \
--hash=sha256:89ca782ffbf2e8769ca9c89234cfa5ca577f1987d07d913ee3c68c4776b1eb5b
# via
# boto3
# s3transfer
certifi==2026.7.22 \
--hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \
--hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55
# via
# httpcore
# httpx
click==8.5.0 \
--hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360 \
--hash=sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34
# via uvicorn
fastapi==0.141.1 \
--hash=sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3 \
--hash=sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1
# via -r local/requirements.txt
h11==0.16.0 \
--hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \
--hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
# via
# httpcore
# uvicorn
httpcore==1.0.9 \
--hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \
--hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8
# via httpx
httpx==0.28.1 \
--hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \
--hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad
# via -r local/requirements.txt
idna==3.19 \
--hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \
--hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4
# via
# anyio
# httpx
jmespath==1.1.0 \
--hash=sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d \
--hash=sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64
# via
# boto3
# botocore
psycopg==3.2.9 \
--hash=sha256:01a8dadccdaac2123c916208c96e06631641c0566b22005493f09663c7a8d3b6 \
--hash=sha256:2fbb46fcd17bc81f993f28c47f1ebea38d66ae97cc2dbc3cad73b37cefbff700
# via -r local/requirements.txt
psycopg-binary==3.2.9 \
--hash=sha256:001e986656f7e06c273dd4104e27f4b4e0614092e544d950c7c938d822b1a894 \
--hash=sha256:08bf9d5eabba160dd4f6ad247cf12f229cc19d2458511cab2eb9647f42fa6795 \
--hash=sha256:093a0c079dd6228a7f3c3d82b906b41964eaa062a9a8c19f45ab4984bf4e872b \
--hash=sha256:0e8aeefebe752f46e3c4b769e53f1d4ad71208fe1150975ef7662c22cca80fab \
--hash=sha256:14f64d1ac6942ff089fc7e926440f7a5ced062e2ed0949d7d2d680dc5c00e2d4 \
--hash=sha256:166acc57af5d2ff0c0c342aed02e69a0cd5ff216cae8820c1059a6f3b7cf5f78 \
--hash=sha256:18ac08475c9b971237fcc395b0a6ee4e8580bb5cf6247bc9b8461644bef5d9f4 \
--hash=sha256:1b2cf018168cad87580e67bdde38ff5e51511112f1ce6ce9a8336871f465c19a \
--hash=sha256:1ed2bab85b505d13e66a914d0f8cdfa9475c16d3491cf81394e0748b77729af2 \
--hash=sha256:1f1736d5b21f69feefeef8a75e8d3bf1f0a1e17c165a7488c3111af9d6936e91 \
--hash=sha256:2290bc146a1b6a9730350f695e8b670e1d1feb8446597bed0bbe7c3c30e0abcb \
--hash=sha256:24ddb03c1ccfe12d000d950c9aba93a7297993c4e3905d9f2c9795bb0764d523 \
--hash=sha256:2504e9fd94eabe545d20cddcc2ff0da86ee55d76329e1ab92ecfcc6c0a8156c4 \
--hash=sha256:25ab464bfba8c401f5536d5aa95f0ca1dd8257b5202eede04019b4415f491351 \
--hash=sha256:354dea21137a316b6868ee41c2ae7cce001e104760cf4eab3ec85627aed9b6cd \
--hash=sha256:387c87b51d72442708e7a853e7e7642717e704d59571da2f3b29e748be58c78a \
--hash=sha256:39a127e0cf9b55bd4734a8008adf3e01d1fd1cb36339c6a9e2b2cbb6007c50ee \
--hash=sha256:3db3ba3c470801e94836ad78bf11fd5fab22e71b0c77343a1ee95d693879937a \
--hash=sha256:413f9e46259fe26d99461af8e1a2b4795a4e27cc8ac6f7919ec19bcee8945074 \
--hash=sha256:418f52b77b715b42e8ec43ee61ca74abc6765a20db11e8576e7f6586488a266f \
--hash=sha256:4bfec4a73e8447d8fe8854886ffa78df2b1c279a7592241c2eb393d4499a17e2 \
--hash=sha256:4c1ab25e3134774f1e476d4bb9050cdec25f10802e63e92153906ae934578734 \
--hash=sha256:4df22ec17390ec5ccb38d211fb251d138d37a43344492858cea24de8efa15003 \
--hash=sha256:528239bbf55728ba0eacbd20632342867590273a9bacedac7538ebff890f1093 \
--hash=sha256:52e239cd66c4158e412318fbe028cd94b0ef21b0707f56dcb4bdc250ee58fd40 \
--hash=sha256:587a3f19954d687a14e0c8202628844db692dbf00bba0e6d006659bf1ca91cbe \
--hash=sha256:5918c0fab50df764812f3ca287f0d716c5c10bedde93d4da2cefc9d40d03f3aa \
--hash=sha256:5be8292d07a3ab828dc95b5ee6b69ca0a5b2e579a577b39671f4f5b47116dfd2 \
--hash=sha256:5d2c9fe14fe42b3575a0b4e09b081713e83b762c8dc38a3771dd3265f8f110e7 \
--hash=sha256:61d0a6ceed8f08c75a395bc28cb648a81cf8dee75ba4650093ad1a24a51c8724 \
--hash=sha256:6a76b4722a529390683c0304501f238b365a46b1e5fb6b7249dbc0ad6fea51a0 \
--hash=sha256:6afb3e62f2a3456f2180a4eef6b03177788df7ce938036ff7f09b696d418d186 \
--hash=sha256:72691a1615ebb42da8b636c5ca9f2b71f266be9e172f66209a361c175b7842c5 \
--hash=sha256:72fdbda5b4c2a6a72320857ef503a6589f56d46821592d4377c8c8604810342b \
--hash=sha256:76eddaf7fef1d0994e3d536ad48aa75034663d3a07f6f7e3e601105ae73aeff6 \
--hash=sha256:778588ca9897b6c6bab39b0d3034efff4c5438f5e3bd52fda3914175498202f9 \
--hash=sha256:791759138380df21d356ff991265fde7fe5997b0c924a502847a9f9141e68786 \
--hash=sha256:799fa1179ab8a58d1557a95df28b492874c8f4135101b55133ec9c55fc9ae9d7 \
--hash=sha256:7a838852e5afb6b4126f93eb409516a8c02a49b788f4df8b6469a40c2157fa21 \
--hash=sha256:7b617b81f08ad8def5edd110de44fd6d326f969240cc940c6f6b3ef21fe9c59f \
--hash=sha256:7e4660fad2807612bb200de7262c88773c3483e85d981324b3c647176e41fdc8 \
--hash=sha256:7fc2915949e5c1ea27a851f7a472a7da7d0a40d679f0a31e42f1022f3c562e87 \
--hash=sha256:95315b8c8ddfa2fdcb7fe3ddea8a595c1364524f512160c604e3be368be9dd07 \
--hash=sha256:96a551e4683f1c307cfc3d9a05fec62c00a7264f320c9962a67a543e3ce0d8ff \
--hash=sha256:98bbe35b5ad24a782c7bf267596638d78aa0e87abc7837bdac5b2a2ab954179e \
--hash=sha256:a1fa38a4687b14f517f049477178093c39c2a10fdcced21116f47c017516498f \
--hash=sha256:a3e0f89fe35cb03ff1646ab663dabf496477bab2a072315192dbaa6928862891 \
--hash=sha256:a4d76e28df27ce25dc19583407f5c6c6c2ba33b443329331ab29b6ef94c8736d \
--hash=sha256:ac2c04b6345e215e65ca6aef5c05cc689a960b16674eaa1f90a8f86dfaee8c04 \
--hash=sha256:ad280bbd409bf598683dda82232f5215cfc5f2b1bf0854e409b4d0c44a113b1d \
--hash=sha256:b2d7a6646d41228e9049978be1f3f838b557a1bde500b919906d54c4390f5086 \
--hash=sha256:b7e4e4dd177a8665c9ce86bc9caae2ab3aa9360b7ce7ec01827ea1baea9ff748 \
--hash=sha256:bb37ac3955d19e4996c3534abfa4f23181333974963826db9e0f00731274b695 \
--hash=sha256:bc75f63653ce4ec764c8f8c8b0ad9423e23021e1c34a84eb5f4ecac8538a4a4a \
--hash=sha256:be7d650a434921a6b1ebe3fff324dbc2364393eb29d7672e638ce3e21076974e \
--hash=sha256:cc19ed5c7afca3f6b298bfc35a6baa27adb2019670d15c32d0bb8f780f7d560d \
--hash=sha256:cf789be42aea5752ee396d58de0538d5fcb76795c85fb03ab23620293fb81b6f \
--hash=sha256:d9ac10a2ebe93a102a326415b330fff7512f01a9401406896e78a81d75d6eddc \
--hash=sha256:e0f05b9dafa5670a7503abc715af081dbbb176a8e6770de77bccaeb9024206c5 \
--hash=sha256:e4978c01ca4c208c9d6376bd585e2c0771986b76ff7ea518f6d2b51faece75e8 \
--hash=sha256:eac3a6e926421e976c1c2653624e1294f162dc67ac55f9addbe8f7b8d08ce603 \
--hash=sha256:f0d5b3af045a187aedbd7ed5fc513bd933a97aaff78e61c3745b330792c4345b \
--hash=sha256:f34e88940833d46108f949fdc1fcfb74d6b5ae076550cd67ab59ef47555dba95 \
--hash=sha256:fa5c80d8b4cbf23f338db88a7251cef8bb4b68e0f91cf8b6ddfa93884fdbb0c1 \
--hash=sha256:fb7599e436b586e265bea956751453ad32eb98be6a6e694252f4691c31b16edb
# via psycopg
pydantic==2.13.5 \
--hash=sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73 \
--hash=sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08
# via fastapi
pydantic-core==2.46.5 \
--hash=sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942 \
--hash=sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821 \
--hash=sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5 \
--hash=sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c \
--hash=sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184 \
--hash=sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2 \
--hash=sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc \
--hash=sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5 \
--hash=sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0 \
--hash=sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931 \
--hash=sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e \
--hash=sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25 \
--hash=sha256:1e5aad1220a1192c42341c8fd4a8686657e73ab2a920c970bdc4de334fe3193d \
--hash=sha256:200aa3dc9f8d54f0754f43247c0bad0999fdcfbfd2488384dd44f37279271fe6 \
--hash=sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47 \
--hash=sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038 \
--hash=sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8 \
--hash=sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b \
--hash=sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a \
--hash=sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e \
--hash=sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074 \
--hash=sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0 \
--hash=sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433 \
--hash=sha256:356c8368cbc321050b169595683a2e1d63413b1e0e2868b330af9fc14c616d3f \
--hash=sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034 \
--hash=sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21 \
--hash=sha256:3a3e26b6a8274211bddee2d0e4d0d42778f17a34510f49d2ec44b58abfc41736 \
--hash=sha256:3aa166e99c4f2985407fb8714aebede877ecb5455cf321b606adca926d30d5a0 \
--hash=sha256:3d2652072b2d774947ba5cf78a9e59644ac62ee572daf6dd2e1dfe905e15b2b7 \
--hash=sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c \
--hash=sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4 \
--hash=sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c \
--hash=sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c \
--hash=sha256:4d44cf99ddebf875f9b68cc267aa684c99b7b44fe63ee1cac4ec163807290069 \
--hash=sha256:4dedce55295becb61921e386b99d4f2706045306e7fa52249a33004c837379fb \
--hash=sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061 \
--hash=sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29 \
--hash=sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3 \
--hash=sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa \
--hash=sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e \
--hash=sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38 \
--hash=sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f \
--hash=sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b \
--hash=sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8 \
--hash=sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e \
--hash=sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c \
--hash=sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be \
--hash=sha256:657b40d6240c0a7b6a64b30f22d1e3aa631c7e846c621b0c0f6d1d75e2e15ea6 \
--hash=sha256:6d30e1a4f138b8951063e9a394752a9179b51da288ffa507b1e659222f4c1793 \
--hash=sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1 \
--hash=sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b \
--hash=sha256:771cf63ae0b1b50dd22e5f3e3549fab5f3f4ff1635d352a9e1a97fe01c7b2e64 \
--hash=sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f \
--hash=sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761 \
--hash=sha256:7b0fc826b16c55e561e5d2a0c5c77b051ba1d92808118c4e4b5390f5e0cf191d \
--hash=sha256:7c6be839a5a8312626b32029a415644a0846b420bc8b52b95b28cd92da162168 \
--hash=sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869 \
--hash=sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111 \
--hash=sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5 \
--hash=sha256:850a08d167dde16db8702c274f320c7be9d7da6f6dff2b58b18f9e815bd94f5b \
--hash=sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7 \
--hash=sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129 \
--hash=sha256:895395f8918627b04efb1ad2a4cf605387143300ba03304cd1dfa6d03f5e095e \
--hash=sha256:8b10e3e8fd7ddc2bd915848a2768e44c15b22936f1cc54c462ad1164deb02655 \
--hash=sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c \
--hash=sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec \
--hash=sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689 \
--hash=sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f \
--hash=sha256:978e7b97d4824b5be09c69fb70507cbde3b0323fc147332ca40a94d9a6a0ebbf \
--hash=sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea \
--hash=sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9 \
--hash=sha256:9b68938dd5b0c783d88ff8e2dcc69451b5eb936fe212d516b21b9d5567f6d464 \
--hash=sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519 \
--hash=sha256:9f47b8a949e60f027f0aa0a6f6c7b7e9c55cbf4380d10b344e282fa4e7ab1e1b \
--hash=sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f \
--hash=sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee \
--hash=sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a \
--hash=sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e \
--hash=sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6 \
--hash=sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2 \
--hash=sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f \
--hash=sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a \
--hash=sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f \
--hash=sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a \
--hash=sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47 \
--hash=sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda \
--hash=sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0 \
--hash=sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4 \
--hash=sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d \
--hash=sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3 \
--hash=sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2 \
--hash=sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355 \
--hash=sha256:c3471e5c4a949c26ec00a77f01df59096aa9495877de76fd60a980f8ee6be461 \
--hash=sha256:c583b927a8838dab890706a6fa7573fbb8b70e24000ef9f7238e2d6f6435a5ed \
--hash=sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f \
--hash=sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5 \
--hash=sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2 \
--hash=sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829 \
--hash=sha256:cdc8b74ecc48c0cb1e9607a05ec4e9e88db60a19ffcc9a1d5f9088ede40c8dc0 \
--hash=sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266 \
--hash=sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575 \
--hash=sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290 \
--hash=sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f \
--hash=sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62 \
--hash=sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f \
--hash=sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a \
--hash=sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7 \
--hash=sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed \
--hash=sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f \
--hash=sha256:eb7d8d0e5886a89a55d2eef490e272fa965a9d57c6b29a5b5088a7997ec2cad1 \
--hash=sha256:ecb42011e12ee19cafbc312887cbf3546959fe02fbad44f272d4be5baa997615 \
--hash=sha256:ef3fbbf161dc9351a2fe0422e51b129f9e97e42385bd0320b309c15f7d287dd8 \
--hash=sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3 \
--hash=sha256:f077d0b97ab11fa7dcc633fca53515f290bca8a8a633e966d5b6d1879d9ed01a \
--hash=sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff \
--hash=sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084 \
--hash=sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0 \
--hash=sha256:fc5d783bd4a2387e97b8a2d5ec781cfb92b3d893bf82370548e99db5915935d3 \
--hash=sha256:fc8515076c11f3cfdf4fb142dcca0fe384b1230a3b5415458ac84f3e0903ec13 \
--hash=sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9
# via pydantic
python-dateutil==2.9.0.post0 \
--hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
--hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
# via botocore
s3transfer==0.13.1 \
--hash=sha256:a981aa7429be23fe6dfc13e80e4020057cbab622b08c0315288758d67cabc724 \
--hash=sha256:c3fdba22ba1bd367922f27ec8032d6a1cf5f10c934fb5d68cf60fd5a23d936cf
# via boto3
six==1.17.0 \
--hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \
--hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
# via python-dateutil
starlette==1.6.0 \
--hash=sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c \
--hash=sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b
# via
# -r local/requirements.txt
# fastapi
typing-extensions==4.16.0 \
--hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \
--hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5
# via
# anyio
# fastapi
# psycopg
# pydantic
# pydantic-core
# starlette
# typing-inspection
typing-inspection==0.4.4 \
--hash=sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47 \
--hash=sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147
# via
# fastapi
# pydantic
urllib3==2.7.0 \
--hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \
--hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897
# via botocore
uvicorn==0.34.2 \
--hash=sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328 \
--hash=sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403
# via -r local/requirements.txt
# The following packages are considered to be unsafe in a requirements file:
pip==26.2.1 \
--hash=sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e \
--hash=sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f
# via -r local/requirements.txt

7
local/requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
fastapi==0.141.1
starlette==1.6.0
pip==26.2.1
uvicorn==0.34.2
psycopg[binary]==3.2.9
boto3==1.38.23
httpx==0.28.1

34
local/retention_test.py Normal file
View File

@@ -0,0 +1,34 @@
"""Run inside the API container. Creates only synthetic retention fixtures."""
from datetime import datetime, timedelta, timezone
from uuid import uuid4
from botocore.exceptions import ClientError
from .adapters import LocalS3Storage
from .db import connect
from .worker import cleanup
def run():
storage=LocalS3Storage()
ids=[]
for expired in (True,False):
uid=uuid4();ids.append(uid);key=f'originals/{uid}'
upload=storage.begin(key)
result=storage.client.upload_part(Bucket=storage.bucket,Key=key,UploadId=upload,PartNumber=1,Body=b'LOCAL RETENTION TEST')
storage.complete(key,upload,[{'PartNumber':1,'ETag':result['ETag']}])
expiry=datetime.now(timezone.utc)+timedelta(days=-1 if expired else 1)
with connect() as c:
c.execute('INSERT INTO dtf_local.uploads(id,owner,name,size,object_key,multipart_id,complete,expires_at) VALUES(%s,%s,%s,%s,%s,%s,true,%s)',
(uid,uuid4(),'LOCAL-RETENTION-TEST.txt',len(b'LOCAL RETENTION TEST'),key,upload,expiry))
cleanup()
with connect() as c:
assert c.execute('SELECT purged_at FROM dtf_local.uploads WHERE id=%s',(ids[0],)).fetchone()['purged_at']
assert not c.execute('SELECT purged_at FROM dtf_local.uploads WHERE id=%s',(ids[1],)).fetchone()['purged_at']
try:storage.size(f'originals/{ids[0]}');raise AssertionError('Expired bytes still exist')
except ClientError as exc:assert exc.response['ResponseMetadata']['HTTPStatusCode']==404
assert storage.size(f'originals/{ids[1]}')==len(b'LOCAL RETENTION TEST')
# Clean only the other fixture just created above, leaving metadata intact.
with connect() as c:
c.execute("UPDATE dtf_local.uploads SET expires_at=now()-interval '1 second' WHERE id=%s",(ids[1],))
cleanup()
print('PASS: expired bytes removed, unexpired bytes preserved, upload metadata retained. Synthetic fixture bytes cleaned up.')
if __name__=='__main__':run()

View File

@@ -0,0 +1,42 @@
"""Run inside API container: permissions, hashing and fail-closed scanner unit checks."""
import hashlib
from unittest.mock import patch
from botocore.exceptions import ClientError
from .db import connect
from .adapters import LocalS3Storage
from .auth import password_hash, password_matches
from .scanning import ClamAV, require_clean
from fastapi import HTTPException
def run():
with connect() as c:
role=c.execute('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user').fetchone()
assert not any(role.values()),role
assert not c.execute("SELECT has_schema_privilege(current_user,'dtf_local','CREATE') AS allowed").fetchone()['allowed']
storage=LocalS3Storage()
storage.health()
visible={bucket['Name'] for bucket in storage.client.list_buckets()['Buckets']}
assert visible=={storage.bucket},visible
for call in (lambda:storage.client.get_bucket_policy(Bucket=storage.bucket),
lambda:storage.client.get_object(Bucket=storage.bucket,Key='outside-runtime-prefix/test.cdr')):
try:call();raise AssertionError('Runtime storage credentials have excessive privilege')
except ClientError as error:assert error.response['ResponseMetadata']['HTTPStatusCode']==403
password='test-password-for-hash'
salt='00'*16
old='scrypt$'+salt+'$'+hashlib.scrypt(password.encode(),salt=bytes.fromhex(salt),n=16384,r=8,p=1).hex()
assert password_matches(password,old)
new=password_hash(password)
assert new.startswith('scrypt-v2$') and password_matches(password,new)
assert not password_matches('wrong',new)
for state in ('pending','error','rejected'):
try:require_clean({'complete':True,'scan_state':state});raise AssertionError('Unscanned file released')
except HTTPException as error:assert error.status_code==409
require_clean({'complete':True,'scan_state':'clean'})
assert ClamAV().ping() and ClamAV().version().startswith('ClamAV ')
assert ClamAV().scan(None,134217729)[0]=='rejected'
with patch('local.scanning.socket.create_connection',side_effect=OSError('offline')):
try:ClamAV().scan(None,1);raise AssertionError('Offline scanner returned success')
except OSError:pass
print('PASS: runtime DB/S3 least privilege, legacy/current password hashes, quarantine states and scanner size/offline behavior')
if __name__=='__main__':run()

81
local/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 .auth import audit
from .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 local 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','Local 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)

17
local/scanning_test.py Normal file
View File

@@ -0,0 +1,17 @@
"""Harmless EICAR anti-malware test and blocked download/quote regressions."""
from uuid import uuid4
from .smoke_test import Client, upload_bytes
def run():
customer=Client();customer.call('/session')
# Standard antivirus test string, not executable malware.
marker=b'X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*'
uid=upload_bytes(customer,marker,name='SECURITY-EICAR.cdr',expected_scan='rejected')
customer.call('/operator/uploads/'+uid+'/download',operator=True,expected=409)
customer.call('/quotes',{'request_key':str(uuid4()),'customer':{'cnpj':'11222333000181','zap':'11999999999','mail':'security@example.test'},
'items':[{'mode':'file','metres':'1','grade':0,'uploads':[uid]}],'freight':{'service':'pickup'}},expected=409)
clean=upload_bytes(customer,b'Harmless local artwork fixture',name='SECURITY-CLEAN.cdr')
customer.call('/operator/uploads/'+clean+'/download',operator=True)
print('PASS: real ClamAV detects EICAR; rejected artwork cannot be downloaded or quoted; clean artwork is released.')
if __name__=='__main__':run()

81
local/schema.sql Normal file
View File

@@ -0,0 +1,81 @@
-- 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.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()
);
CREATE INDEX IF NOT EXISTS uploads_owner ON dtf_local.uploads(owner);
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)
);

31
local/security_status.py Normal file
View File

@@ -0,0 +1,31 @@
"""Local alert triage: prints redacted counts; exits 1 when attention is required."""
import json
from datetime import datetime, timezone
from .db import connect
from .scanning import ClamAV
def scanner_status():
try:
version=ClamAV().version()
signature_text=version.split('/',2)[2]
signature_at=datetime.strptime(signature_text,'%a %b %d %H:%M:%S %Y').replace(tzinfo=timezone.utc)
age_hours=max(0,(datetime.now(timezone.utc)-signature_at).total_seconds()/3600)
return {'available':True,'version':version,'signature_age_hours':round(age_hours,1),
'signatures_stale':age_hours>24*7}
except Exception:
return {'available':False,'version':None,'signature_age_hours':None,'signatures_stale':True}
def status():
with connect() as c:
rows=c.execute("""SELECT event,count(*) AS count FROM dtf_local.security_events
WHERE created_at>now()-interval '24 hours' GROUP BY event ORDER BY event""").fetchall()
blocked=c.execute("SELECT scan_state,count(*) AS count FROM dtf_local.uploads WHERE purged_at IS NULL AND scan_state IN ('error','rejected') GROUP BY scan_state").fetchall()
counts={r['event']:r['count'] for r in rows}
scanner=scanner_status()
attention=bool(blocked or not scanner['available'] or scanner['signatures_stale'] or
counts.get('rate_limit',0) or counts.get('scanner_worker_error',0) or
counts.get('operator_login_failed',0)+counts.get('customer_login_failed',0)>=10)
return {'attention_required':attention,'scanner':scanner,'last_24h':counts,'blocked_artwork':blocked}
if __name__=='__main__':
result=status();print(json.dumps(result,indent=2));raise SystemExit(1 if result['attention_required'] else 0)

66
local/security_test.py Normal file
View File

@@ -0,0 +1,66 @@
"""Non-destructive localhost security regressions. Leaves tiny test upload reservations."""
import base64
import os
from urllib.error import HTTPError
from urllib.request import Request, urlopen
from urllib.parse import urlparse, parse_qs
from uuid import uuid4
from .smoke_test import Client, BASE
def raw(path, expected, headers=None, body=None):
request=Request(BASE+path, data=body, headers=headers or {})
try:
with urlopen(request,timeout=10) as response:
assert response.status==expected
return response.headers
except HTTPError as error:
assert error.code==expected,(path,error.code,expected)
return error.headers
def run():
headers=raw('/',200)
policy=headers['Content-Security-Policy']
assert "script-src-attr 'none'" in policy and "frame-ancestors 'none'" in policy
assert "'sha256-" in policy and "object-src 'none'" in policy
raw('/api/health',400,{'Host':'attacker.invalid'})
raw('/api/account/logout',403,{'Content-Type':'application/json','Origin':'https://attacker.invalid'},b'{}')
raw('/api/account/logout',403,{'Content-Type':'application/json','Origin':'http://localhost:9999'},b'{}')
print('PASS: CSP, frame protection, Host and cross-origin rejection')
operator=Client()
credentials={'username':os.environ.get('OPERATOR_USER','operator'),'password':os.environ.get('OPERATOR_PASSWORD','local-operator-only')}
encoded=base64.b64encode((credentials['username']+':'+credentials['password']).encode()).decode()
raw('/api/operator/board',401,{'Authorization':'Basic '+encoded})
operator.call('/operator/login',credentials)
token=next(c for c in operator.jar if c.name=='dtf_operator')
assert token.has_nonstandard_attr('HttpOnly') and token.get_nonstandard_attr('SameSite')=='strict'
assert token.path=='/api/operator'
operator.call('/operator/board')
replay=Client();replay.jar.set_cookie(token)
operator.call('/operator/logout',{})
replay.call('/operator/board',expected=401)
print('PASS: Basic rejected; HttpOnly scoped operator session; server-side logout revocation')
client=Client();client.call('/session')
client.call('/uploads',{'name':'payload.html','size':1},expected=422)
uid=client.call('/uploads',{'name':'SECURITY-PART.cdr','size':3})['id']
url=client.call('/uploads/'+uid+'/parts/1',{})['url']
assert 'content-length' in parse_qs(urlparse(url).query)['X-Amz-SignedHeaders'][0]
try:
urlopen(Request(url,data=b'toolong',method='PUT'),timeout=10)
raise AssertionError('Signed part accepted wrong length')
except HTTPError as error:assert error.code==403,error.code
with urlopen(Request(url,data=b'abc',method='PUT'),timeout=10) as response:assert response.status==200
client.call('/uploads/'+uid+'/complete',{})
count=int(os.environ.get('MAX_PENDING_UPLOADS','10'))
for i in range(count):client.call('/uploads',{'name':'SECURITY-PENDING.cdr','size':1})
client.call('/uploads',{'name':'SECURITY-OVER-LIMIT.cdr','size':1},expected=429)
print('PASS: extension allowlist, exact multipart Content-Length signature, pending upload quota')
# Unique identity avoids locking out the real local operator.
attacker=Client();username='test-'+uuid4().hex
for _ in range(10):attacker.call('/operator/login',{'username':username,'password':'invalid'},expected=401)
attacker.call('/operator/login',{'username':username,'password':'invalid'},expected=429)
print('PASS: operator login throttling (only synthetic account bucket exhausted)')
if __name__=='__main__':run()

147
local/smoke_test.py Normal file
View File

@@ -0,0 +1,147 @@
"""Local stack integration checks; creates and retains clearly named test orders.
Run: python3 -m local.smoke_test. Standard library only. Honors .env/environment.
"""
from concurrent.futures import ThreadPoolExecutor
import hashlib
import http.cookiejar
import json
import os
from pathlib import Path
import time
from urllib.error import HTTPError
from urllib.request import build_opener, HTTPCookieProcessor, Request, urlopen
from uuid import uuid4
if Path('.env').exists():
for line in Path('.env').read_text().splitlines():
if line.strip() and not line.startswith('#') and '=' in line:
key,value=line.split('=',1)
os.environ.setdefault(key,value)
BASE='http://localhost:'+os.environ.get('SITE_PORT','8080')
class Client:
def __init__(self):
self.jar=http.cookiejar.CookieJar()
self.opener=build_opener(HTTPCookieProcessor(self.jar))
self.operator_client=None
def call(self,path,body=None,operator=False,expected=200):
headers={'Content-Type':'application/json'}
if operator:
if self.operator_client is None:
self.operator_client=Client()
self.operator_client.call('/operator/login',{'username':os.environ.get('OPERATOR_USER','operator'),'password':os.environ.get('OPERATOR_PASSWORD','local-operator-only')})
return self.operator_client.call(path,body,expected=expected)
request=Request(BASE+'/api'+path,data=None if body is None else json.dumps(body).encode(),headers=headers)
try:
with self.opener.open(request,timeout=30) as response:
assert response.status==expected,(path,response.status,expected)
return json.load(response)
except HTTPError as exc:
if exc.code!=expected:raise AssertionError((path,exc.code,exc.read().decode())) from exc
return json.load(exc)
def wait_scan(client, uid, operator=False, expected='clean'):
prefix='/operator/uploads' if operator else '/uploads'
deadline=time.monotonic()+150
while time.monotonic()<deadline:
state=client.call(prefix+'/'+uid,operator=operator)
if state['scan_state'] in ('clean','rejected','error'):
assert state['scan_state']==expected,state
return state
time.sleep(0.5)
raise AssertionError('Malware scan did not finish')
def upload_bytes(client, content, name='LOCAL-TEST.cdr', order_id=None, expected_scan='clean'):
operator=order_id is not None
prefix='/operator/uploads' if operator else '/uploads'
start='/operator/orders/'+order_id+'/uploads' if operator else prefix
data=client.call(start,{'name':name,'size':len(content)},operator=operator)
uid=data['id'];size=data['part_bytes']
for offset in range(0,len(content),size):
url=client.call(prefix+'/'+uid+'/parts/'+str(offset//size+1),{},operator=operator)['url']
with urlopen(Request(url,data=content[offset:offset+size],method='PUT'),timeout=30) as response:
assert response.status==200
client.call(prefix+'/'+uid+'/complete',{},operator=operator)
wait_scan(client,uid,operator,expected_scan)
return uid
def run():
client=Client();other=Client()
config=client.call('/session');other.call('/session')
assert client.call('/health')['integrations']=='fake'
client.call('/operator/board',expected=401)
block=config['part_bytes'];content=b'DTF local multipart test\n'+b'x'*block
uid=client.call('/uploads',{'name':'LOCAL-SMOKE-ONLY.cdr','size':len(content)})['id']
other.call('/uploads/'+uid,expected=404)
other.call('/uploads/'+uid+'/parts/1',{},expected=404)
signed=client.call('/uploads/'+uid+'/parts/1',{})['url']
with urlopen(Request(signed,data=content[:block],method='PUT'),timeout=30) as response:assert response.status==200
assert client.call('/uploads/'+uid)['parts']==[1]
client.call('/uploads/'+uid+'/complete',{},expected=409)
signed=client.call('/uploads/'+uid+'/parts/2',{})['url']
with urlopen(Request(signed,data=content[block:],method='PUT'),timeout=30) as response:assert response.status==200
client.call('/uploads/'+uid+'/complete',{})
client.call('/uploads/'+uid+'/complete',{})
wait_scan(client,uid)
client.call('/uploads/'+uid+'/parts/1',{},expected=409)
download=client.call('/operator/uploads/'+uid+'/download',operator=True)
with urlopen(download['url'],timeout=30) as response:assert hashlib.sha256(response.read()).digest()==hashlib.sha256(content).digest()
unsigned=download['url'].split('?')[0]
try:urlopen(unsigned,timeout=10);raise AssertionError('Bucket must be private')
except HTTPError as exc:assert exc.code==403
print('PASS: multipart resume, incomplete rejection, immutable completion, ownership, private/downloaded bytes')
items=[{'mode':m,'metres':'2.75','grade':90,'uploads':[uid]} for m in ('file','avulsa','uvfile','uv')]
draft={'request_key':str(uuid4()),'customer':{'cnpj':'11222333000181','zap':'11999999999','mail':'local-smoke@example.test'},
'items':items,'freight':{'service':'mock-standard','postal_code':'14400000'}}
client.call('/quotes',{**draft,'total_cents':1},expected=422)
client.call('/quotes',{**draft,'customer':{**draft['customer'],'cnpj':'11111111111111'}},expected=422)
quote=client.call('/quotes',draft)
assert client.call('/quotes',draft)['id']==quote['id']
client.call('/quotes',{**draft,'freight':{'service':'pickup'}},expected=409)
qid=quote['id']
other.call('/quotes/'+qid,expected=404)
client.call('/orders/dev-paid',{'quote_id':qid},expected=409)
client.call('/operator/quotes/'+qid+'/approve',{'items':items},expected=401)
# Reviewer corrects a browser-supplied grade and length. Browser values are proposals.
corrected=[{**items[0],'metres':'1.01','grade':0},*items[1:]]
approved=client.call('/operator/quotes/'+qid+'/approve',{'items':corrected},operator=True)
assert approved['items'][0]['total_cents']==2189
assert approved['total_cents']==2189+6972+19572+23492+int(os.environ.get('MOCK_FREIGHT_CENTS','1500'))
client.call('/operator/quotes/'+qid+'/approve',{'items':items},operator=True,expected=409)
client.call('/orders/dev-paid',{'quote_id':qid,'total_cents':1},expected=422)
other.call('/orders/dev-paid',{'quote_id':qid},expected=404)
# Concurrent retries must produce precisely one payment/order/outbox pair.
with ThreadPoolExecutor(max_workers=4) as executor:
paid=list(executor.map(lambda _:client.call('/orders/dev-paid',{'quote_id':qid}),range(4)))
assert len({p['id'] for p in paid})==1
order=paid[0];oid=order['id']
assert order['payment']['status']=='paid' and order['snapshot']==approved
board=client.call('/operator/board',operator=True)
assert len([o for o in board['orders'] if o['quote_id']==qid])==1
client.call('/operator/orders/'+oid+'/move',{'state':'fin','version':0},operator=True,expected=409)
client.call('/operator/orders/'+oid+'/move',{'state':'cor','version':0},operator=True,expected=422)
version=0
for state in ('cor','rec','tra','fil','imp','fin'):
if state=='fil':
client.call('/operator/orders/'+oid+'/move',{'state':'fil','version':version},operator=True,expected=409)
files=[{'item_index':i,'upload_id':upload_bytes(client,b'LOCAL FINAL FIXTURE '+str(i).encode(),order_id=oid)} for i in range(4)]
result=client.call('/operator/orders/'+oid+'/final-files',{'version':version,'files':files,'note':'Local test manual final-file approval'},operator=True)
version=result['version']
moved=client.call('/operator/orders/'+oid+'/move',{'state':state,'version':version,'reason':'Local test correction' if state=='cor' else ''},operator=True)
version+=1;assert moved['version']==version
client.call('/operator/orders/'+oid+'/move',{'state':'rec','version':0},operator=True,expected=409)
assert len(client.call('/operator/orders/'+oid+'/history',operator=True))==6
print('PASS: all modes, authoritative review/prices/freight, tamper rejection, concurrent payment idempotency, transitions and history')
deadline=time.monotonic()+30
while time.monotonic()<deadline:
events=[e for e in client.call('/operator/board',operator=True)['events'] if e['payload']['order_id']==oid]
if len(events)==8 and all(e['delivered_at'] and e['receipt'] for e in events):break
time.sleep(1)
else:raise AssertionError('Mock outbox did not drain')
assert len({e['event_key'] for e in events})==8
print(f"PASS: 8 durable fake receipts. Local test order #{order['number']} retained in Finalizado.")
return oid
if __name__=='__main__':run()

100
local/staging_readiness.py Normal file
View File

@@ -0,0 +1,100 @@
"""Validate non-secret staging decisions without contacting external services."""
from pathlib import Path
import re
import sys
from urllib.parse import urlparse
REQUIRED = (
'APP_ENV',
'STAGING_APPROVED_BY',
'STAGING_PUBLIC_ORIGIN',
'STAGING_S3_ENDPOINT',
'STAGING_S3_BUCKET',
'STAGING_DATABASE_MODE',
'STAGING_SECRET_SOURCE',
'STAGING_BACKUP_DESTINATION',
'STAGING_FREIGHT_PROVIDER',
'STAGING_PAYMENT_PROVIDER',
'STAGING_ERP_PROVIDER',
'STAGING_WHATSAPP_PROVIDER',
'STAGING_ALERT_OWNER',
'STAGING_ROLLBACK_OWNER',
)
FORBIDDEN_NAME = re.compile(
r'(PASSWORD|TOKEN|SECRET|ACCESS_KEY|PRIVATE_KEY|CREDENTIAL)', re.IGNORECASE)
PLACEHOLDERS = {'', 'todo', 'tbd', 'replace-me', 'changeme', 'unconfirmed'}
LOCAL_HOSTS = {'localhost', '127.0.0.1', '::1', 'storage', 'db'}
def read_config(path: Path) -> dict[str, str]:
values = {}
for number, raw in enumerate(path.read_text().splitlines(), 1):
line = raw.strip()
if not line or line.startswith('#'):
continue
if '=' not in line:
raise ValueError(f'{path}:{number}: expected NAME=value')
name, value = line.split('=', 1)
name = name.strip()
if not re.fullmatch(r'[A-Z][A-Z0-9_]*', name):
raise ValueError(f'{path}:{number}: invalid setting name')
if name in values:
raise ValueError(f'{path}:{number}: duplicate setting {name}')
if name != 'STAGING_SECRET_SOURCE' and FORBIDDEN_NAME.search(name):
raise ValueError(f'{path}:{number}: secrets must not be stored in this file ({name})')
values[name] = value.strip()
return values
def validate(values: dict[str, str]) -> list[str]:
errors = []
for name in REQUIRED:
if values.get(name, '').lower() in PLACEHOLDERS:
errors.append(f'{name} is not decided')
if values.get('APP_ENV') != 'staging':
errors.append('APP_ENV must be staging')
for name in ('STAGING_PUBLIC_ORIGIN', 'STAGING_S3_ENDPOINT'):
endpoint = urlparse(values.get(name, ''))
if endpoint.scheme != 'https' or not endpoint.hostname:
errors.append(f'{name} must be an absolute HTTPS URL')
elif endpoint.hostname.lower() in LOCAL_HOSTS:
errors.append(f'{name} must not point to localhost or a Compose service')
if endpoint.path not in ('', '/') or endpoint.params or endpoint.query or endpoint.fragment:
errors.append(f'{name} must not include a path, query, or fragment')
storage_host = urlparse(values.get('STAGING_S3_ENDPOINT', '')).hostname or ''
if storage_host and not storage_host.endswith('.r2.cloudflarestorage.com'):
errors.append('STAGING_S3_ENDPOINT must be a Cloudflare R2 S3 API endpoint')
bucket = values.get('STAGING_S3_BUCKET', '')
if bucket and not re.fullmatch(r'[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]', bucket):
errors.append('STAGING_S3_BUCKET is not a valid S3 bucket name')
for name in ('STAGING_FREIGHT_PROVIDER', 'STAGING_PAYMENT_PROVIDER',
'STAGING_ERP_PROVIDER', 'STAGING_WHATSAPP_PROVIDER'):
if values.get(name, '').lower() in {'fake', 'local', 'mock'}:
errors.append(f'{name} cannot select a local fake provider')
if values.get('STAGING_DATABASE_MODE') not in {'managed', 'dedicated-container'}:
errors.append('STAGING_DATABASE_MODE must be managed or dedicated-container')
if values.get('STAGING_SECRET_SOURCE') in {'env-file', 'repository', '.env'}:
errors.append('STAGING_SECRET_SOURCE must be an external secret-injection mechanism')
return errors
def main(path: Path) -> int:
try:
errors = validate(read_config(path))
except (OSError, ValueError) as exc:
print(f'BLOCKED: {exc}')
return 2
if errors:
print('BLOCKED: staging inputs are incomplete or unsafe:')
for error in errors:
print(f'- {error}')
return 2
print('PASS: non-secret staging inputs are complete and structurally safe.')
print('No provider was contacted. This check does not authorize deployment.')
return 0
if __name__ == '__main__':
if len(sys.argv) != 2:
raise SystemExit('usage: python -m local.staging_readiness PATH')
raise SystemExit(main(Path(sys.argv[1])))

47
local/static/cart.js Normal file
View File

@@ -0,0 +1,47 @@
/* Browser-local cart recovery. Files are stored only for an unfinished cart, for 24h. */
(() => {
let database,scope,timer,restoring=true;
let signedOut=false;
window.addEventListener('dtf-private-data-cleared',()=>{signedOut=true;clearTimeout(timer);pedido=[];itemAtual=null;folhas=[];artes=[];});
const notice=document.createElement('p');notice.style.cssText='font:13px system-ui;color:#56616d;padding:8px 20px';
document.getElementById('carr').prepend(notice);
function transaction(mode,fn){return new Promise((resolve,reject)=>{const tx=database.transaction('cart',mode);const request=fn(tx.objectStore('cart'));let result;request.onsuccess=()=>result=request.result;tx.oncomplete=()=>resolve(result);tx.onerror=()=>reject(tx.error);tx.onabort=()=>reject(tx.error);});}
async function save(){
if(signedOut||restoring||!database||!scope)return;
const items=[...pedido,...(itemAtual?[itemAtual]:[])];
try{
if(!items.length){await transaction('readwrite',store=>store.delete(scope));return;}
await transaction('readwrite',store=>store.put({items,customer:{...cliente},delivery:{...entrega},expires:Date.now()+86400000},scope));
notice.textContent='Carrinho salvo neste navegador por 24 horas. Você pode remover itens e adicionar outros após recarregar.';
}catch(error){notice.textContent='Não foi possível salvar o carrinho neste navegador. Mantenha esta página aberta até enviar o pedido.';}
}
window.dtfClearCart=async()=>{clearTimeout(timer);if(database&&scope)await transaction('readwrite',store=>store.delete(scope));notice.textContent='';};
window.addEventListener('dtf-cart-changed',()=>{clearTimeout(timer);timer=setTimeout(save,400);});
window.addEventListener('pagehide',()=>{clearTimeout(timer);save();});
setInterval(async()=>{
if(!database||signedOut)return;
try{const keys=await transaction('readonly',s=>s.getAllKeys());for(const key of keys){const value=await transaction('readonly',s=>s.get(key));if(value&&value.expires<Date.now())await transaction('readwrite',s=>s.delete(key));}}catch{/* Browser may close storage during navigation. */}
},60000);
(async()=>{
try{
scope=(await window.dtfSessionReady).cart_scope;
database=await new Promise((resolve,reject)=>{const req=indexedDB.open('dtf-local-cart',1);req.onupgradeneeded=()=>req.result.createObjectStore('cart');req.onsuccess=()=>resolve(req.result);req.onerror=()=>reject(req.error);});
const records=await transaction('readonly',store=>store.getAllKeys());
for(const key of records){const value=await transaction('readonly',store=>store.get(key));if(value.expires<Date.now())await transaction('readwrite',store=>store.delete(key));}
const saved=await transaction('readonly',store=>store.get(scope));
if(saved && !pedido.length && !itemAtual){
pedido=saved.items;cliente=saved.customer;entrega=saved.delivery;
// Freight must be quoted again; restored browser values are never final.
if(entrega.tipo==='frete'){entrega.cotado=false;entrega.valor=0;}
for(const [id,key] of [['fCnpj','cnpj'],['fZap','zap'],['fMail','mail']])$(id).value=cliente[key];
$('cepIn').value=entrega.cep;
$('atual').style.display='none';pintaEntrega();
notice.textContent='Carrinho recuperado. Remova e adicione novamente um item se precisar alterar sua montagem.';
}else{
const account=await window.dtfApi('/account/me');
if(account.customer && !cliente.mail){cliente={...account.customer};for(const [id,key] of [['fCnpj','cnpj'],['fZap','zap'],['fMail','mail']])$(id).value=cliente[key];pintaEntrega();}
}
}catch(error){notice.textContent='Recuperação de carrinho indisponível; o pedido ainda pode ser feito nesta sessão.';}
finally{restoring=false;}
})();
})();

126
local/static/checkout.js Normal file
View File

@@ -0,0 +1,126 @@
/* Checkout bridge only: approved commercial functions in dtf-site.html stay intact. */
(() => {
const box = document.createElement('section');
box.style.cssText = 'position:relative;z-index:20;background:#152026;color:#e6f4f7;padding:12px 24px;font:14px system-ui;border-bottom:2px solid #00b8da';
box.innerHTML = '<b>Ambiente local · pagamento simulado</b> <span id="local-status" role="status"></span><div id="local-actions"></div>';
document.body.prepend(box);
const status = document.getElementById('local-status');
const actions = document.getElementById('local-actions');
let busy = false;
let draftId = localStorage.getItem('dtf-quote');
let requestKey = localStorage.getItem('dtf-request-key');
let requestBody = localStorage.getItem('dtf-request-body');
const api = async (path, body) => {
const response = await fetch('/api'+path, {
credentials: 'same-origin', headers: {'Content-Type':'application/json'},
...(body === undefined ? {} : {method:'POST', body:JSON.stringify(body)})
});
const data = await response.json();
if (!response.ok) { const error=new Error(typeof data.detail === 'string' ? data.detail : 'Confira os dados do pedido ('+response.status+').');error.status=response.status;throw error; }
return data;
};
const ready = api('/session');
window.dtfSessionReady=ready;
window.dtfApi=api;
ready.catch(error => { status.textContent = error.message; });
function message(text) { status.textContent = ' · '+text; }
function button(label, handler) {
const el = document.createElement('button');
el.textContent = label;
el.style.cssText = 'margin:8px 8px 0 0;padding:8px 14px;cursor:pointer';
el.onclick = handler;
actions.append(el);
return el;
}
async function upload(file) {
const session=await ready;
return window.dtfUpload(file,{api,progress:message,scope:session.cart_scope});
}
window.dtfFreight = async () => {
const cep = entrega.cep;
try {
const result = await api('/freight',{service:'mock-standard',postal_code:cep});
if (entrega.cep !== cep || entrega.tipo !== 'frete') return;
entrega.valor = result.total_cents/100;
entrega.cotado = true;
$('cepMsg').textContent = 'Frete simulado local: '+rs(entrega.valor)+'. Nenhuma transportadora foi consultada.';
pintaEntrega();
} catch(error) { $('cepMsg').textContent = error.message; }
};
window.dtfCheckout = async () => {
if (busy) return;
if (draftId) { await refresh(); box.scrollIntoView(); return; }
if (!clienteOk() || !entrega.cotado) return;
const cart = [...pedido,...(itemAtual?[itemAtual]:[])];
if (!cart.length) return message('Adicione um item ao pedido.');
busy = true;
$('bPagar').disabled = true;
try {
await ready;
if((await api('/session')).cart_scope !== (await ready).cart_scope) throw new Error('Sua conta ou sessão mudou. Recarregue a página antes de enviar o carrinho.');
const items=[];
for (const item of cart) {
if (!item.localFiles?.length) throw new Error('Selecione novamente os arquivos deste item.');
const uploads=[];
for (const file of item.localFiles) uploads.push(await upload(file));
items.push({mode:item.modo, metres:String(item.metros), grade:item.nota, uploads});
}
const content = {customer:{...cliente},items,freight:{service:entrega.tipo==='retira'?'pickup':'mock-standard',postal_code:entrega.tipo==='retira'?'':entrega.cep}};
const serialized = JSON.stringify(content);
if (!requestKey || serialized !== requestBody) {
requestKey = crypto.randomUUID(); requestBody = serialized;
localStorage.setItem('dtf-request-key',requestKey);
localStorage.setItem('dtf-request-body',requestBody);
}
const quote = await api('/quotes',{request_key:requestKey,...content});
draftId=quote.id; localStorage.setItem('dtf-quote',draftId);
await refresh();
box.scrollIntoView({behavior:'smooth'});
} catch(error) { message(error.message); }
finally { busy=false; pintaEntrega(); }
};
async function refresh() {
if (!draftId) return;
try {
await ready;
const quote=await api('/quotes/'+draftId);
actions.replaceChildren();
if (quote.status==='pending_review') {
message('Arquivos enviados. No Kanban, confira metragem e nota e aprove a cotação '+draftId.slice(0,8)+'.');
} else if (quote.status==='approved') {
message('Total validado no servidor: '+rs(quote.approved.total_cents/100)+' · inclui frete. Cotação válida por 24 horas.');
button('Criar pedido pago local',async event=>{
event.target.disabled=true;
try {
const order=await api('/orders/dev-paid',{quote_id:draftId});
pedido=[]; itemAtual=null; limpaPaineis();
await window.dtfClearCart?.();
message('Pedido local #'+order.number+' pago e disponível no Kanban.');
await refresh();
} catch(error) { message(error.message); event.target.disabled=false; }
});
} else if (quote.status==='paid') {
message('Pedido local #'+quote.order.number+' pago · etapa: '+quote.order.state+'. Nenhuma cobrança real.');
button('Novo pedido local',()=>{localStorage.removeItem('dtf-quote');localStorage.removeItem('dtf-request-key');localStorage.removeItem('dtf-request-body');location.reload();});
} else {
message('Cotação expirada. Envie o carrinho para uma nova revisão.');
button('Nova cotação',()=>{draftId=null;requestKey=null;localStorage.removeItem('dtf-quote');localStorage.removeItem('dtf-request-key');actions.replaceChildren();});
}
} catch(error) {
message(error.message);
actions.replaceChildren();
button('Limpar referência local e tentar de novo',()=>{draftId=null;requestKey=null;localStorage.removeItem('dtf-quote');localStorage.removeItem('dtf-request-key');actions.replaceChildren();});
}
}
// Explicit refresh avoids replacing focused payment controls during interaction.
const refreshButton = document.createElement('button');
refreshButton.textContent='Atualizar pedido local';
refreshButton.style.cssText='margin-left:12px;padding:6px;cursor:pointer';
refreshButton.onclick=refresh;
box.append(refreshButton);
const portalLink=document.createElement('a');portalLink.href='/portal.html';portalLink.textContent='Minha conta e pedidos';
portalLink.style.cssText='color:#e6f4f7;margin-left:16px;text-decoration:underline';box.append(portalLink);
const quoteFromPortal=new URLSearchParams(location.search).get('quote');
if(quoteFromPortal && /^[0-9a-f-]{36}$/.test(quoteFromPortal)){draftId=quoteFromPortal;localStorage.setItem('dtf-quote',draftId);}
refresh();
})();

21
local/static/kanban.html Normal file
View File

@@ -0,0 +1,21 @@
<!doctype html>
<html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>DTF · Kanban local</title>
<style>
:root{--bg:#0B0D10;--card:#15181D;--card2:#1C2026;--linha:#282C34;--tx:#EDEBE6;--fraco:#a1a6af;--ciano:#00B8DA}
*{box-sizing:border-box}body{background:var(--bg);color:var(--tx);font:14px/1.5 system-ui,sans-serif;padding:20px;margin:0}
h1{font-size:24px;margin:0}h2{font-size:18px}header{display:flex;align-items:center;gap:18px;flex-wrap:wrap;margin-bottom:20px}
.aviso{background:#152026;border-left:3px solid var(--ciano);padding:12px;color:#b9cbd2;margin:16px 0}
button,input,select{font:inherit;border:1px solid #56616d;border-radius:5px;padding:8px;background:var(--card2);color:var(--tx)}
button{cursor:pointer}button:hover{border-color:var(--ciano)}button:disabled{opacity:.5}input[type=number]{width:100px}
label{display:inline-flex;gap:8px;align-items:center;margin:5px}#kan{display:grid;grid-template-columns:repeat(6,minmax(220px,1fr));gap:10px;overflow-x:auto;padding-bottom:16px}
.col{background:var(--card);border:1px solid var(--linha);border-radius:9px;min-height:250px;padding:10px}.col h2{font-size:14px;border-bottom:2px solid var(--cc);padding-bottom:10px}.col.alvo{border-color:var(--ciano)}
.cd,.review{background:var(--card2);border:1px solid var(--linha);border-left:3px solid var(--cc,var(--ciano));border-radius:6px;padding:12px;margin-bottom:10px}.cd{cursor:grab}.cd p{overflow-wrap:anywhere}.meta{color:var(--fraco);font-size:12px}.actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}.error{color:#ffad83}#status{min-height:24px}details{margin:16px 0}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:var(--ciano)}
</style></head><body>
<header><h1>Kanban DTF</h1><span class="meta">Desenvolvimento local</span><button id="refresh">Atualizar</button><button id="logout">Sair</button></header>
<div class="aviso">Pagamentos, Tiny/Olist, WhatsApp e frete são simulados. Confira a cotação manualmente. Em Arquivos de produção, envie e aprove os arquivos finais de todos os itens antes de colocar o pedido na fila. Não há validação automática de arte.</div>
<form id="login"><label>Usuário <input id="user" autocomplete="username" required></label><label>Senha local <input id="password" type="password" autocomplete="current-password" required></label><button>Entrar</button></form>
<p id="status" role="status"></p>
<section id="reviews"></section><div id="kan"></div>
<details><summary>Eventos locais de integração</summary><pre id="events"></pre></details>
<script src="/upload.js"></script><script src="/kanban.js"></script></body></html>

107
local/static/kanban.js Normal file
View File

@@ -0,0 +1,107 @@
/* Reuses the prototype palette, column names and drag/drop interaction; no machine controls. */
const $ = id=>document.getElementById(id);
// Remove credentials saved by older local builds. Only HttpOnly sessions now.
sessionStorage.removeItem('dtf-operator');
let board;
const money=cents=>(cents/100).toLocaleString('pt-BR',{style:'currency',currency:'BRL'});
function node(tag,text,className){const e=document.createElement(tag);if(text!==undefined)e.textContent=text;if(className)e.className=className;return e;}
function action(text,fn){const b=node('button',text);b.onclick=async()=>{b.disabled=true;try{await fn();}catch(e){$('status').textContent=e.message;}finally{b.disabled=false;}};return b;}
async function api(path,body){
const r=await fetch('/api/operator'+path,{headers:{'Content-Type':'application/json'},...(body===undefined?{}:{method:'POST',body:JSON.stringify(body)})});
const d=await r.json();if(!r.ok){const error=new Error(typeof d.detail==='string'?d.detail:'Confira os campos ('+r.status+').');error.status=r.status;throw error;}return d;
}
function files(container,items){
for(const uid of [...new Set(items.flatMap(i=>i.uploads))])container.append(action('Baixar original '+uid.slice(0,6),async()=>{
const result=await api('/uploads/'+uid+'/download');
const link=node('a');link.href=result.url;link.referrerPolicy='no-referrer';link.download=result.name;link.click();
}));
}
async function load(){
try{
board=await api('/board');$('login').hidden=true;
$('status').textContent='Atualizado às '+new Date().toLocaleTimeString();
render();
}catch(e){$('status').textContent=e.message;$('login').hidden=false;}
}
function render(){
$('reviews').replaceChildren();
if(board.quotes.length)$('reviews').append(node('h2','Cotações · conferência comercial'));
for(const quote of board.quotes){
const card=node('article',undefined,'review');
card.append(node('b',quote.id.slice(0,8)+' · '+quote.draft.customer.mail));
if(quote.status!=='pending_review'){
card.append(node('p',quote.status==='expired'?'Cotação expirada. O cliente precisa solicitar outra.':'Aprovada: '+money(quote.approved.total_cents)+'. Volte ao Site, atualize o pedido e confirme o pagamento local.'));
}else{
const form=node('form');
const edits=quote.draft.items.map((item,index)=>{
const row=node('div');row.append(node('strong',(index+1)+'. '+item.mode+' '));
const metres=node('input');metres.type='number';metres.min='0.001';metres.max='12000';metres.step='any';metres.value=item.metres;metres.required=true;
const grade=node('input');grade.type='number';grade.min='0';grade.max='100';grade.step='1';grade.value=item.grade;grade.required=true;
const ml=node('label','Metros conferidos');ml.append(metres);const gl=node('label','Nota conferida');gl.append(grade);row.append(ml,gl);form.append(row);
return ()=>({...item,metres:metres.value,grade:Number(grade.value)});
});
const check=node('input');check.type='checkbox';check.required=true;const label=node('label','Conferi os arquivos, a metragem total (incluindo repetições/montagem) e a nota.');label.prepend(check);form.append(label);
const submit=node('button','Aprovar cotação');submit.type='submit';form.append(submit);
form.onsubmit=async e=>{e.preventDefault();submit.disabled=true;try{await api('/quotes/'+quote.id+'/approve',{items:edits.map(fn=>fn())});await load();}catch(error){$('status').textContent=error.message;submit.disabled=false;}};
card.append(form);
}
const downloads=node('div',undefined,'actions');files(downloads,quote.draft.items);card.append(downloads);$('reviews').append(card);
}
$('kan').replaceChildren();
const colors=['#00b8da','#e0357c','#efb700','#edebe6','#e0642a','#48b072'];
Object.entries(board.states).forEach(([state,title],index)=>{
const column=node('section',undefined,'col');column.dataset.state=state;column.style.setProperty('--cc',colors[index]);
const orders=board.orders.filter(o=>o.state===state);column.append(node('h2',title+' · '+orders.length));
for(const order of orders){
const card=node('article',undefined,'cd');card.draggable=true;card.dataset.order=order.id;
card.append(node('b','#'+order.number+' · Pago local'),node('p',order.snapshot.customer.mail,'meta'),node('p',money(order.snapshot.total_cents)));
for(const item of order.snapshot.items)card.append(node('p',item.mode+' · '+item.billed_metres+' m · nota '+item.grade,'meta'));
const actions=node('div',undefined,'actions');files(actions,order.snapshot.items);
const artwork=node('div');
actions.append(action('Arquivos de produção',()=>artworkPanel(order,artwork)));
actions.append(action('Histórico',async()=>{const rows=await api('/orders/'+order.id+'/history');alert(rows.map(r=>board.states[r.from_state]+' → '+board.states[r.to_state]+' · '+r.operator+(r.reason?' · '+r.reason:'')).join('\n')||'Pedido recebido.');}));
for(const next of board.transitions[state])actions.append(action('→ '+board.states[next],()=>move(order,next)));
card.append(actions,artwork);card.ondragstart=e=>e.dataTransfer.setData('text/plain',order.id);column.append(card);
}
column.ondragover=e=>{e.preventDefault();column.classList.add('alvo');};column.ondragleave=()=>column.classList.remove('alvo');
column.ondrop=async e=>{e.preventDefault();column.classList.remove('alvo');const order=board.orders.find(o=>o.id===e.dataTransfer.getData('text/plain'));if(order)try{await move(order,state);}catch(error){$('status').textContent=error.message;}};
$('kan').append(column);
});
$('events').textContent=board.events.map(e=>e.provider+' · '+e.payload.event+' · pedido #'+e.payload.number+' · '+(e.delivered_at?'registrado localmente':'pendente')+' · tentativas '+e.attempts).join('\n')||'Nenhum evento.';
}
async function move(order,state){
let reason='';if(state==='cor'){reason=prompt('Motivo da correção:');if(!reason)return;}
await api('/orders/'+order.id+'/move',{state,version:order.version,reason});await load();
}
$('login').onsubmit=async e=>{e.preventDefault();try{await api('/login',{username:$('user').value,password:$('password').value});await load();}catch(error){$('status').textContent=error.message;}finally{$('password').value='';}};
$('logout').onclick=async()=>{await api('/logout',{});for(const key of Object.keys(localStorage))if(key.startsWith('dtf-'))localStorage.removeItem(key);location.reload();};
$('refresh').onclick=load;
load();
async function artworkPanel(order,container){
container.replaceChildren();
const revisions=await api('/orders/'+order.id+'/files');
for(const file of revisions){
const row=node('p',(file.kind==='final'?'Final':'Correção do cliente')+' · item '+(file.item_index+1)+' · '+file.name+' · '+(file.expired?'expirado':file.active?'atual':'substituído'),'meta');
row.append(node('p',file.note));
if(!file.expired)row.append(action('Baixar '+file.name,async()=>{const result=await api('/uploads/'+file.upload_id+'/download');const link=node('a');link.href=result.url;link.download=result.name;link.referrerPolicy='no-referrer';link.click();}));
container.append(row);
}
if(!['rec','tra','cor'].includes(order.state))return;
const form=node('form');
form.append(node('p','Enviar um conjunto final completo. Pode haver várias partes por item. Um novo conjunto substitui o anterior.'));
const inputs=order.snapshot.items.map((item,index)=>{const label=node('label','Item '+(index+1)+' · '+item.mode);label.style.display='block';const input=node('input');input.type='file';input.multiple=true;input.required=true;input.dataset.finalItem=index;input.style.width='100%';label.append(input);form.append(label);return input;});
const note=node('input');note.placeholder='Nota da revisão';note.required=true;note.maxLength=1000;note.style.width='100%';form.append(note);
const check=node('input');check.type='checkbox';check.required=true;const label=node('label','Conferi estes arquivos finais para impressão manual.');label.prepend(check);form.append(label);
const submit=node('button','Aprovar arquivos finais');submit.type='submit';form.append(submit);
form.onsubmit=async event=>{event.preventDefault();submit.disabled=true;try{
const refs=[];
for(const [index,input] of inputs.entries())for(const file of input.files){
const uid=await dtfUpload(file,{api,startPath:'/orders/'+order.id+'/uploads',scope:'operator:'+order.id+':'+order.version,progress:text=>$('status').textContent=text});
refs.push({item_index:index,upload_id:uid});
}
await api('/orders/'+order.id+'/final-files',{version:order.version,files:refs,note:note.value});
await load();
}catch(error){$('status').textContent=error.message;submit.disabled=false;}};
container.append(form);
}

11
local/static/portal.html Normal file
View File

@@ -0,0 +1,11 @@
<!doctype html><html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Dropstar · Meus pedidos DTF</title>
<style>
*{box-sizing:border-box}body{margin:0;background:#f6f7f9;color:#20252d;font:15px/1.5 system-ui,sans-serif}header{background:white;border-bottom:1px solid #ddd;padding:20px max(20px,calc((100% - 1100px)/2));display:flex;gap:24px;align-items:center;flex-wrap:wrap}.brand{color:#ef8500;font-size:24px;font-style:italic;font-weight:900}a{color:#8a4e00}main{max-width:1100px;margin:24px auto;padding:0 20px}h1{font-size:28px}h2{font-size:21px}h3{font-size:17px}.notice{background:#fff1d4;border-left:4px solid #ffa900;padding:12px 16px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:20px}.card{background:white;border:1px solid #ddd;border-radius:10px;padding:20px;margin:16px 0}.card p{overflow-wrap:anywhere}.muted{color:#596471}label{display:block;margin:10px 0}input,textarea,button{font:inherit;border:1px solid #9aa0a8;border-radius:6px;padding:10px}input:not([type=file]),textarea{width:100%}button{background:#ffa900;border-color:#ffa900;cursor:pointer}button:disabled{opacity:.6}form{margin:10px 0}.actions{display:flex;gap:10px;flex-wrap:wrap;align-items:center}#message{min-height:24px;color:#9b3800}li{margin:8px 0}.tag{background:#eef2f6;padding:5px 10px;border-radius:6px}details{margin:12px 0}[hidden]{display:none!important}
</style></head><body>
<header><a class="brand" href="/">DROPSTAR</a><a href="/">Enviar arte</a><span>Minha conta · DTF</span><button id="logout" hidden>Sair</button></header>
<main><h1>Meus pedidos DTF</h1><p class="notice">Ambiente de desenvolvimento local. Pagamentos e notificações são simulados. Use apenas dados de teste.</p><p id="message" role="status"></p>
<div id="account"></div><section id="auth" class="grid">
<form id="login" class="card"><h2>Entrar</h2><label>E-mail <input id="email" type="email" autocomplete="username" required></label><label>Senha <input id="password" type="password" autocomplete="current-password" required></label><button>Entrar</button></form>
<form id="register" class="card"><h2>Criar conta local</h2><label>CNPJ <input id="cnpj" required></label><label>WhatsApp <input id="phone" required></label><label>E-mail <input id="register-email" type="email" autocomplete="email" required></label><label>Senha (12 caracteres ou mais) <input id="register-password" type="password" minlength="12" maxlength="128" autocomplete="new-password" required></label><button>Criar conta</button><p class="muted">Pedidos desta sessão serão associados à sua conta. Não há envio de e-mail nem recuperação de senha nesta versão local.</p></form>
</section><div class="actions"><h2>Acompanhamento</h2><button id="refresh">Atualizar pedidos</button></div><p id="guest" class="muted"></p><section id="quotes"></section><section id="orders"></section></main>
<script src="/privacy.js"></script><script src="/upload.js"></script><script src="/portal.js"></script></body></html>

76
local/static/portal.js Normal file
View File

@@ -0,0 +1,76 @@
const $=id=>document.getElementById(id);
const node=(tag,text)=>{const el=document.createElement(tag);if(text!==undefined)el.textContent=text;return el;};
const money=c=>(c/100).toLocaleString('pt-BR',{style:'currency',currency:'BRL'});
let scope,states={};
async function api(path,body){const r=await fetch('/api'+path,{headers:{'Content-Type':'application/json'},...(body===undefined?{}:{method:'POST',body:JSON.stringify(body)})});const d=await r.json();if(!r.ok){const error=new Error(typeof d.detail==='string'?d.detail:'Confira os campos informados.');error.status=r.status;throw error;}return d;}
function button(text,fn){const b=node('button',text);b.onclick=async()=>{b.disabled=true;try{await fn();}catch(e){$('message').textContent=e.message;}finally{b.disabled=false;}};return b;}
function forgetCheckout(){for(const k of ['dtf-quote','dtf-request-key','dtf-request-body'])localStorage.removeItem(k);}
async function load(){
try{
scope=(await api('/session')).cart_scope;
const account=await api('/account/me');
$('auth').hidden=!!account.customer;$('logout').hidden=!account.customer;
$('account').textContent=account.customer?'Conta: '+account.customer.mail:'';
$('guest').textContent=account.customer?'':'Você está vendo apenas os pedidos desta sessão. Crie uma conta para acessar seus pedidos em outro navegador.';
const data=await api('/customer/orders');states=data.states;
$('quotes').replaceChildren();
for(const quote of data.quotes){
const card=node('article');card.className='card';card.append(node('h3','Cotação '+quote.id.slice(0,8)));
card.append(node('p',quote.approved?'Total aprovado: '+money(quote.approved.total_cents):'Aguardando conferência de metragem e nota.'));
const link=node('a','Abrir cotação no Site');link.href='/?quote='+quote.id;card.append(link);$('quotes').append(card);
}
$('orders').replaceChildren();
if(!data.orders.length)$('orders').append(node('p','Nenhum pedido pago nesta conta ou sessão.'));
for(const order of data.orders){
const card=node('article');card.className='card';card.id='order-'+order.id;
card.append(node('h3','Pedido #'+order.number),node('p',states[order.state]+' · '+money(order.snapshot.total_cents)+' · pagamento local'));
card.append(node('p',new Date(order.created_at).toLocaleString('pt-BR')));
const content=node('div');
card.append(button('Ver detalhes e arquivos',()=>details(order.id,content)),content);$('orders').append(card);
if(new URLSearchParams(location.search).get('order')===order.id)await details(order.id,content);
}
}catch(error){$('message').textContent=error.message;}
}
async function details(id,container){
const order=await api('/customer/orders/'+id);container.replaceChildren();
for(const [i,item] of order.snapshot.items.entries())container.append(node('p',(i+1)+'. '+item.mode+' · '+item.billed_metres+' m · nota '+item.grade+' · '+money(item.total_cents)));
const history=node('ol');
for(const h of order.history)history.append(node('li',new Date(h.created_at).toLocaleString('pt-BR')+' · '+states[h.to_state]+(h.reason?' — '+h.reason:'')));
container.append(history);
for(const file of order.files){
const row=node('p',(file.kind==='final'?'Arquivo final':'Correção')+' · item '+(file.item_index+1)+' · '+file.name+' · '+(file.expired?'expirado':!file.active?'substituído':'disponível até '+new Date(file.expires_at).toLocaleDateString('pt-BR')));
if(file.active&&!file.expired)row.append(button('Baixar',async()=>{const d=await api('/customer/orders/'+id+'/files/'+file.id+'/download');const link=node('a');link.href=d.url;link.download=d.name;link.referrerPolicy='no-referrer';link.click();}));
container.append(row);
}
if(order.state==='cor'){
const form=node('form');form.append(node('h3','Enviar arte corrigida'));
const inputs=order.snapshot.items.map((item,index)=>{const label=node('label','Item '+(index+1)+' · '+item.mode);const input=node('input');input.type='file';input.multiple=true;input.dataset.item=index;label.append(input);form.append(label);return input;});
const note=node('textarea');note.placeholder='Descreva a correção';note.required=true;note.maxLength=1000;form.append(note);
const submit=node('button','Enviar correção');submit.type='submit';form.append(submit);
form.onsubmit=async event=>{event.preventDefault();submit.disabled=true;try{
const files=[];for(const [index,input] of inputs.entries())for(const file of input.files)files.push({item_index:index,upload_id:await dtfUpload(file,{api,scope:scope+':correction:'+id+':'+order.version,progress:text=>$('message').textContent=text})});
if(!files.length)throw new Error('Selecione pelo menos um arquivo corrigido.');
await api('/customer/orders/'+id+'/corrections',{version:order.version,files,note:note.value});
$('message').textContent='Correção enviada. Aguarde a conferência da equipe.';await details(id,container);
}catch(error){$('message').textContent=error.message;}finally{submit.disabled=false;}};
container.append(form);
}
}
async function migrateGuestCart(previous,next){
if(previous===next)return;
const database=await new Promise((resolve,reject)=>{const r=indexedDB.open('dtf-local-cart',1);r.onupgradeneeded=()=>r.result.createObjectStore('cart');r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);});
await new Promise((resolve,reject)=>{const tx=database.transaction('cart','readwrite'),store=tx.objectStore('cart'),req=store.get(previous);req.onsuccess=()=>{if(req.result){store.put(req.result,next);store.delete(previous);}};tx.oncomplete=resolve;tx.onerror=()=>reject(tx.error);});
database.close();
}
async function authenticate(path,body){
const previous=scope,guest=!(await api('/account/me')).customer;
await api(path,body);forgetCheckout();$('message').textContent='Acesso confirmado.';
const next=(await api('/session')).cart_scope;
if(guest)try{await migrateGuestCart(previous,next);}catch{$('message').textContent='Acesso confirmado. Não foi possível transferir o carrinho salvo neste navegador.';}
await load();$('password').value='';$('register-password').value='';
}
$('login').onsubmit=async event=>{event.preventDefault();try{await authenticate('/account/login',{email:$('email').value,password:$('password').value});}catch(e){$('message').textContent=e.message;}};
$('register').onsubmit=async event=>{event.preventDefault();try{await authenticate('/account/register',{customer:{cnpj:$('cnpj').value,zap:$('phone').value,mail:$('register-email').value},password:$('register-password').value});}catch(e){$('message').textContent=e.message;}};
$('logout').onclick=async()=>{try{await window.dtfForgetPrivateData();await api('/account/logout',{});location.reload();}catch(error){$('message').textContent='Não foi possível concluir a limpeza local. Feche as abas do Site e limpe os dados deste site no navegador.';}};
$('refresh').onclick=load;
load();

15
local/static/privacy.js Normal file
View File

@@ -0,0 +1,15 @@
/* Clear draft files and metadata across Site tabs when signing out. */
(() => {
const signal='dtf-privacy-reset';
function notify(){window.dispatchEvent(new Event('dtf-private-data-cleared'));}
window.dtfForgetPrivateData=async()=>{
notify();
for(const key of Object.keys(localStorage))if(key.startsWith('dtf-'))localStorage.removeItem(key);
localStorage.setItem(signal,crypto.randomUUID());
const db=await new Promise((resolve,reject)=>{const r=indexedDB.open('dtf-local-cart',1);r.onupgradeneeded=()=>r.result.createObjectStore('cart');r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);});
try{await new Promise((resolve,reject)=>{const tx=db.transaction('cart','readwrite');tx.objectStore('cart').clear();tx.oncomplete=resolve;tx.onerror=()=>reject(tx.error);});}finally{db.close();}
};
window.addEventListener('storage',event=>{
if(event.key===signal && event.newValue){notify();location.reload();}
});
})();

30
local/static/upload.js Normal file
View File

@@ -0,0 +1,30 @@
/* Shared direct multipart transport for customer originals/corrections and operator finals. */
window.dtfUpload = async (file, {api, prefix='/uploads', startPath=prefix, progress=()=>{}, scope='guest', resume=true}) => {
const samples=new Blob([file.slice(0,65536),file.slice(Math.max(0,file.size-65536))]);
const hash=Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',await samples.arrayBuffer())),b=>b.toString(16).padStart(2,'0')).join('');
const key='dtf-upload:'+JSON.stringify([scope,file.name,file.size,file.lastModified,hash]);
let id=resume?localStorage.getItem(key):null,state;
if(id){try{state=await api(prefix+'/'+id);}catch(error){if(![404,410].includes(error.status))throw error;id=null;}}
if(!id){state=await api(startPath,{name:file.name,size:file.size});id=state.id;if(resume)localStorage.setItem(key,id);}
async function waitForScan(){
for(let attempt=0;attempt<150;attempt++){
const checked=await api(prefix+'/'+id);
if(checked.scan_state==='clean')return id;
if(['rejected','error'].includes(checked.scan_state))throw new Error(checked.scan_reason||'Arquivo bloqueado pela verificação de segurança.');
progress('Verificando segurança de '+file.name+'…');
await new Promise(resolve=>setTimeout(resolve,1000));
}
throw new Error('Verificação de segurança pendente. Tente novamente para consultar o resultado.');
}
if(state.complete)return waitForScan();
const done=new Set(state.parts||[]),size=state.part_bytes;
for(let offset=0,part=1;offset<file.size;offset+=size,part++){
if(done.has(part))continue;
progress('Enviando '+file.name+' · parte '+part+'/'+Math.ceil(file.size/size));
const signed=await api(prefix+'/'+id+'/parts/'+part,{});
const response=await fetch(signed.url,{method:'PUT',body:file.slice(offset,offset+size)});
if(!response.ok)throw new Error('Upload interrompido. Tente novamente para retomar.');
}
await api(prefix+'/'+id+'/complete',{});
return waitForScan();
};

24
local/storage-init.sh Normal file
View File

@@ -0,0 +1,24 @@
#!/bin/sh
set -eu
case "$S3_BUCKET" in *[!a-z0-9.-]*|'') echo 'Invalid local bucket name' >&2; exit 1;; esac
if [ "$MINIO_ROOT_USER" = "$S3_APP_USER" ]; then echo 'Runtime storage user must not be root' >&2; exit 1; fi
# Disposable local secrets are passed via environment, never traced/logged.
mc alias set local http://storage:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null
mc mb --ignore-existing "local/$S3_BUCKET" >/dev/null
mc anonymous set none "local/$S3_BUCKET" >/dev/null
mc admin user add local "$S3_APP_USER" "$S3_APP_PASSWORD" >/dev/null
# Use shell builtins only: the minimal MinIO image does not ship sed/cat.
policy=''
while IFS= read -r line || [ -n "$line" ]; do
while [ "${line#*dtf-local-artwork}" != "$line" ]; do
policy="$policy${line%%dtf-local-artwork*}$S3_BUCKET"
line=${line#*dtf-local-artwork}
done
policy="$policy$line
"
done < /policy.json
printf '%s' "$policy" > /tmp/policy.json
mc admin policy create local dtf-artwork /tmp/policy.json >/dev/null
mc admin policy attach local dtf-artwork --user "$S3_APP_USER" >/dev/null
mc ilm import "local/$S3_BUCKET" < /lifecycle.json
echo 'Local storage runtime account provisioned.'

View File

@@ -0,0 +1 @@
{"Rules":[{"ID":"local-artwork-retention","Status":"Enabled","Filter":{"Prefix":""},"Expiration":{"Days":30},"AbortIncompleteMultipartUpload":{"DaysAfterInitiation":1}}]}

View File

@@ -0,0 +1,7 @@
{
"Version": "2012-10-17",
"Statement": [
{"Effect":"Allow","Action":["s3:ListBucket","s3:ListBucketMultipartUploads","s3:GetBucketLocation"],"Resource":["arn:aws:s3:::dtf-local-artwork"]},
{"Effect":"Allow","Action":["s3:GetObject","s3:PutObject","s3:DeleteObject","s3:AbortMultipartUpload","s3:ListMultipartUploadParts"],"Resource":["arn:aws:s3:::dtf-local-artwork/originals/*"]}
]
}

192
local/storage_backup.py Normal file
View File

@@ -0,0 +1,192 @@
"""Stream clean MinIO artwork to an archive and verify it via an isolated prefix."""
import hashlib
import io
import json
import os
import sys
import tarfile
from datetime import datetime, timezone
from uuid import uuid4
from .adapters import LocalS3Storage, require_local
from .db import connect
CHUNK = 8 * 1024 * 1024
MAX_OBJECT = min(128 * 1024 * 1024, int(os.environ.get('SCAN_MAX_BYTES', '134217728')))
MAX_TOTAL = int(os.environ.get('STORAGE_QUOTA_BYTES', '53687091200'))
MAX_OBJECTS = 100000
class DigestReader:
def __init__(self, stream):
self.stream = stream
self.digest = hashlib.sha256()
self.size = 0
def read(self, size=-1):
block = self.stream.read(size)
if block:
self.digest.update(block)
self.size += len(block)
return block
def add_bytes(archive, name, content):
info = tarfile.TarInfo(name)
info.size = len(content)
info.mtime = int(datetime.now(timezone.utc).timestamp())
info.mode = 0o600
archive.addfile(info, io.BytesIO(content))
def export_archive():
require_local()
storage = LocalS3Storage()
with connect() as c:
rows = c.execute("""SELECT id,object_key,size,created_at,expires_at
FROM dtf_local.uploads WHERE complete AND purged_at IS NULL
AND expires_at>now() AND scan_state='clean' ORDER BY object_key""").fetchall()
if len(rows) > MAX_OBJECTS:
raise RuntimeError('Object count exceeds the backup safety limit')
expected_total = sum(row['size'] for row in rows)
if expected_total > MAX_TOTAL:
raise RuntimeError('Object bytes exceed the backup safety limit')
objects = []
with tarfile.open(fileobj=sys.stdout.buffer, mode='w|gz', compresslevel=6) as archive:
for index, row in enumerate(rows):
if row['size'] <= 0 or row['size'] > MAX_OBJECT:
raise RuntimeError('A clean object has an invalid backup size')
response = storage.client.get_object(Bucket=storage.bucket, Key=row['object_key'])
if response['ContentLength'] != row['size']:
response['Body'].close()
raise RuntimeError('Stored object size differs from database metadata')
reader = DigestReader(response['Body'])
name = f'objects/{index:08d}'
info = tarfile.TarInfo(name)
info.size = row['size']
info.mtime = int(row['created_at'].timestamp())
info.mode = 0o600
try:
archive.addfile(info, reader)
finally:
response['Body'].close()
if reader.size != row['size']:
raise RuntimeError('Object changed while the backup was streaming')
objects.append({'archive_name': name, 'upload_id': str(row['id']),
'object_key': row['object_key'], 'size': row['size'],
'sha256': reader.digest.hexdigest(),
'expires_at': row['expires_at'].isoformat()})
manifest = {'format': 'dtf-object-backup-v1',
'created_at': datetime.now(timezone.utc).isoformat(),
'source_bucket': storage.bucket, 'objects': objects,
'object_count': len(objects), 'total_bytes': expected_total}
add_bytes(archive, 'manifest.json', json.dumps(manifest, sort_keys=True).encode())
print('DTF_BACKUP_SUMMARY '+json.dumps({'object_count': len(objects),
'total_bytes': expected_total}), file=sys.stderr)
def upload_member(storage, source, size, key):
upload = storage.client.create_multipart_upload(
Bucket=storage.bucket, Key=key, ContentType='application/octet-stream')['UploadId']
parts = []
digest = hashlib.sha256()
remaining = size
try:
number = 1
while remaining:
block = source.read(min(CHUNK, remaining))
if not block:
raise ValueError('Archive object ended before its declared size')
digest.update(block)
result = storage.client.upload_part(Bucket=storage.bucket, Key=key,
UploadId=upload, PartNumber=number, Body=block, ContentLength=len(block))
parts.append({'PartNumber': number, 'ETag': result['ETag']})
remaining -= len(block)
number += 1
storage.client.complete_multipart_upload(Bucket=storage.bucket, Key=key,
UploadId=upload, MultipartUpload={'Parts': parts})
except Exception:
try:
storage.client.abort_multipart_upload(
Bucket=storage.bucket, Key=key, UploadId=upload)
except Exception:
# Preserve the original upload/read error; the verification-prefix
# cleanup below still removes any completed temporary object.
pass
raise
return digest.hexdigest()
def object_digest(storage, key):
response = storage.client.get_object(Bucket=storage.bucket, Key=key)
digest = hashlib.sha256()
size = 0
try:
for block in response['Body'].iter_chunks(chunk_size=1024 * 1024):
digest.update(block)
size += len(block)
finally:
response['Body'].close()
return size, digest.hexdigest()
def verify_archive():
require_local()
storage = LocalS3Storage()
verification = uuid4().hex
restored = []
observed = []
manifest = None
total = 0
try:
with tarfile.open(fileobj=sys.stdin.buffer, mode='r|gz') as archive:
for member in archive:
if not member.isfile():
raise ValueError('Backup archive contains a non-file member')
source = archive.extractfile(member)
if source is None:
raise ValueError('Backup archive member cannot be read')
if member.name == 'manifest.json':
if manifest is not None or member.size > 1024 * 1024:
raise ValueError('Invalid object-backup manifest')
manifest = json.loads(source.read().decode())
continue
expected_name = f'objects/{len(observed):08d}'
if member.name != expected_name or len(observed) >= MAX_OBJECTS:
raise ValueError('Unexpected object-backup member')
if member.size <= 0 or member.size > MAX_OBJECT:
raise ValueError('Object-backup member exceeds safety limits')
total += member.size
if total > MAX_TOTAL:
raise ValueError('Object-backup total exceeds safety limits')
key = f'originals/restore-verification/{verification}/{len(observed):08d}'
digest = upload_member(storage, source, member.size, key)
restored.append(key)
observed.append({'archive_name': member.name, 'size': member.size,
'sha256': digest})
if not manifest or manifest.get('format') != 'dtf-object-backup-v1':
raise ValueError('Object-backup manifest is missing or unsupported')
expected = manifest.get('objects')
if manifest.get('object_count') != len(observed) or manifest.get('total_bytes') != total:
raise ValueError('Object-backup summary does not match archive contents')
if not isinstance(expected, list) or len(expected) != len(observed):
raise ValueError('Object-backup manifest count does not match')
for actual, recorded, key in zip(observed, expected, restored):
for field in ('archive_name', 'size', 'sha256'):
if actual[field] != recorded.get(field):
raise ValueError('Object-backup checksum manifest does not match')
restored_size, restored_hash = object_digest(storage, key)
if restored_size != actual['size'] or restored_hash != actual['sha256']:
raise ValueError('Restored verification object differs from archive')
print(f'PASS: {len(observed)} clean objects ({total} bytes) restored and hashed in an isolated prefix.')
finally:
for key in restored:
storage.client.delete_object(Bucket=storage.bucket, Key=key)
print('Removed temporary verification objects. Active artwork was untouched.')
if __name__ == '__main__':
if len(sys.argv) != 2 or sys.argv[1] not in ('export', 'verify'):
raise SystemExit('usage: python -m local.storage_backup export|verify')
export_archive() if sys.argv[1] == 'export' else verify_archive()

View File

@@ -0,0 +1,46 @@
import re
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
NAME_VERSION = re.compile(r'^([A-Za-z0-9_.-]+)==([^\s\\]+)', re.MULTILINE)
def normalized(name):
return re.sub(r'[-_.]+', '-', name).lower()
class DependencyLockTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.direct_text = (ROOT / 'local/requirements.txt').read_text()
cls.lock_text = (ROOT / 'local/requirements.lock').read_text()
def test_every_direct_pin_matches_lock(self):
direct = {normalized(name): version for name, version in
NAME_VERSION.findall(self.direct_text)}
locked = {normalized(name): version for name, version in
NAME_VERSION.findall(self.lock_text)}
self.assertTrue(direct)
self.assertEqual({name: locked.get(name) for name in direct}, direct)
def test_every_locked_package_has_sha256_hash(self):
matches = list(NAME_VERSION.finditer(self.lock_text))
self.assertTrue(matches)
for index, match in enumerate(matches):
end = matches[index + 1].start() if index + 1 < len(matches) else len(self.lock_text)
block = self.lock_text[match.start():end]
hashes = re.findall(r'--hash=sha256:([0-9a-f]{64})(?:\s|\\)', block)
self.assertTrue(hashes, f'{match.group(1)} has no SHA-256 artifact hash')
def test_image_build_requires_the_lock_and_hashes(self):
dockerfile = (ROOT / 'local/Dockerfile').read_text()
self.assertIn('requirements.lock', dockerfile)
self.assertIn('--require-hashes -r local/requirements.lock', dockerfile)
def test_reproducible_generator_is_recorded(self):
self.assertIn('./local/lock_dependencies.sh', self.lock_text[:300])
if __name__ == '__main__':
unittest.main()

40
local/test_pricing.py Normal file
View File

@@ -0,0 +1,40 @@
"""Run from repository root: python3 -m unittest local.test_pricing -v."""
import json
from pathlib import Path
import subprocess
import unittest
from .pricing import price, TIERS
class PricingTests(unittest.TestCase):
def test_every_grade_against_actual_site_javascript(self):
source = Path('dtf-site.html').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};'
js += '''const results=[];for(const mode of Object.keys(FAIXAS))for(let grade=0;grade<=100;grade++)
for(const metres of [0.01,0.99,1,1.01,1.1,2.75,2.8,2.8000000000000003,19.99,60,12000]){
const unit=FAIXAS[mode].find(x=>grade>=x[0])[1];
results.push([mode,String(metres),grade,cobrar(metres),Math.round(unit*100),Math.round(cobrar(metres)*unit*100)]);
}process.stdout.write(JSON.stringify(results));'''
cases = json.loads(subprocess.check_output(['node','-e',js],text=True))
for mode,metres,grade,billed,unit,total in cases:
with self.subTest(mode=mode,metres=metres,grade=grade):
result=price(mode,metres,grade)
self.assertEqual(float(result['billed_metres']),billed)
self.assertEqual(result['unit_cents'],unit)
self.assertEqual(result['total_cents'],total)
def test_assembly_is_included_at_every_tier(self):
for grade in range(101):
self.assertEqual(price('avulsa','1',grade)['total_cents']-price('file','1',grade)['total_cents'],1000)
self.assertEqual(price('uv','1',grade)['total_cents']-price('uvfile','1',grade)['total_cents'],1400)
def test_invalid_inputs(self):
for value in ('0','-1','NaN','Infinity','12001'):
with self.assertRaises(ValueError):price('file',value,90)
for grade in (-1,101,True,89.5):
with self.assertRaises(ValueError):price('file','1',grade)
with self.assertRaises(ValueError):price('other','1',90)
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,56 @@
import tempfile
import unittest
from pathlib import Path
from .staging_readiness import read_config, validate
VALID = '''
APP_ENV=staging
STAGING_APPROVED_BY=business-and-technical-owners
STAGING_PUBLIC_ORIGIN=https://staging.example.invalid
STAGING_S3_ENDPOINT=https://example.r2.cloudflarestorage.com
STAGING_S3_BUCKET=dtf-staging-artwork
STAGING_DATABASE_MODE=dedicated-container
STAGING_SECRET_SOURCE=portainer-secrets
STAGING_BACKUP_DESTINATION=separate-encrypted-r2-bucket
STAGING_FREIGHT_PROVIDER=provider-sandbox
STAGING_PAYMENT_PROVIDER=mercado-pago-sandbox
STAGING_ERP_PROVIDER=tiny-olist-sandbox
STAGING_WHATSAPP_PROVIDER=provider-sandbox
STAGING_ALERT_OWNER=operations-team
STAGING_ROLLBACK_OWNER=technical-team
'''
class StagingReadinessTests(unittest.TestCase):
def parse(self, text):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'staging.env'
path.write_text(text)
return read_config(path)
def test_complete_non_secret_metadata_passes(self):
self.assertEqual(validate(self.parse(VALID)), [])
def test_local_endpoint_and_fake_provider_are_blocked(self):
values = self.parse(VALID.replace(
'https://example.r2.cloudflarestorage.com', 'http://localhost:9000'
).replace('provider-sandbox', 'fake', 1))
errors = validate(values)
self.assertTrue(any('STAGING_S3_ENDPOINT' in error for error in errors))
self.assertTrue(any('STAGING_FREIGHT_PROVIDER' in error for error in errors))
def test_secret_named_setting_is_rejected(self):
with self.assertRaisesRegex(ValueError, 'secrets must not be stored'):
self.parse(VALID + 'MERCADO_PAGO_ACCESS_TOKEN=do-not-store-this\n')
def test_undecided_values_are_blocked(self):
errors = validate(self.parse(VALID.replace(
'STAGING_APPROVED_BY=business-and-technical-owners',
'STAGING_APPROVED_BY=TBD')))
self.assertIn('STAGING_APPROVED_BY is not decided', errors)
if __name__ == '__main__':
unittest.main()

77
local/worker.py Normal file
View File

@@ -0,0 +1,77 @@
"""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_local
from .db import connect
from .scanning import ClamAV, scan_loop
require_local()
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()

73
local/workflow_test.py Normal file
View File

@@ -0,0 +1,73 @@
"""Customer identity, correction and final-file trust boundaries against local stack."""
from uuid import uuid4
from urllib.request import urlopen
from .smoke_test import Client, upload_bytes
def run():
customer=Client();other=Client();customer.call('/session');other.call('/session')
uid=upload_bytes(customer,b'LOCAL ORIGINAL ONLY')
item={'mode':'file','metres':'1.01','grade':0,'uploads':[uid]}
profile={'cnpj':'11222333000181','zap':'11999999999','mail':'workflow-'+uuid4().hex[:8]+'@example.test'}
q=customer.call('/quotes',{'request_key':str(uuid4()),'customer':profile,'items':[item],'freight':{'service':'pickup'}})
customer.call('/operator/quotes/'+q['id']+'/approve',{'items':[item]},operator=True)
order=customer.call('/orders/dev-paid',{'quote_id':q['id']});oid=order['id']
before=list(customer.jar)[0].value
password='local-test-password-'+uuid4().hex
customer.call('/account/register',{'customer':profile,'password':password})
assert customer.call('/account/me')['customer']['mail']==profile['mail']
assert customer.call('/customer/orders')['orders'][0]['id']==oid
# Email/CNPJ do not grant ownership; only current guest session is migrated.
other.call('/customer/orders/'+oid,expected=404)
other.call('/account/login',{'email':profile['mail'],'password':'wrong-password'},expected=401)
revoked=Client()
import http.cookiejar
cookie=http.cookiejar.Cookie(0,'dtf_session',before,None,False,'localhost.local',False,False,'/',True,False,None,True,None,None,{},False)
revoked.jar.set_cookie(cookie)
revoked.call('/customer/orders',expected=401)
account_scope=customer.call('/session')['cart_scope']
cookie.value=account_scope;revoked.jar.set_cookie(cookie)
revoked.call('/customer/orders',expected=401)
other.call('/account/login',{'email':profile['mail'],'password':password})
assert other.call('/customer/orders/'+oid)['id']==oid
print('PASS: registration claims only current guest records, cross-session account login, revoked sessions, owner UUID is not a credential')
def move(state,version):
return customer.call('/operator/orders/'+oid+'/move',{'state':state,'version':version,'reason':'Please replace the artwork' if state=='cor' else ''},operator=True)['version']
version=move('tra',0)
customer.call('/operator/orders/'+oid+'/move',{'state':'fil','version':version},operator=True,expected=409)
final_id=upload_bytes(customer,b'LOCAL FINAL VERSION ONE',order_id=oid)
customer.call('/uploads/'+final_id,expected=404)
body={'version':version,'files':[{'item_index':0,'upload_id':final_id}],'note':'Manually checked final'}
customer.call('/operator/orders/'+oid+'/final-files',body,expected=401)
version=customer.call('/operator/orders/'+oid+'/final-files',body,operator=True)['version']
detail=customer.call('/customer/orders/'+oid)
final=detail['files'][0]
link=customer.call('/customer/orders/'+oid+'/files/'+final['id']+'/download')
assert urlopen(link['url']).read()==b'LOCAL FINAL VERSION ONE'
guest=Client();guest.call('/session');guest.call('/customer/orders/'+oid+'/files/'+final['id']+'/download',expected=404)
version=move('fil',version);version=move('imp',version);version=move('cor',version)
customer.call('/customer/orders/'+oid+'/files/'+final['id']+'/download',expected=404)
correction_id=upload_bytes(customer,b'LOCAL CORRECTED ORIGINAL')
payload={'version':version,'files':[{'item_index':0,'upload_id':correction_id}],'note':'Replaced the artwork as requested'}
guest.call('/customer/orders/'+oid+'/corrections',payload,expected=404)
customer.call('/customer/orders/'+oid+'/corrections',{**payload,'version':0},expected=409)
version=customer.call('/customer/orders/'+oid+'/corrections',payload)['version']
files=customer.call('/operator/orders/'+oid+'/files',operator=True)
assert any(f['kind']=='correction' and f['active'] and f['upload_id']==correction_id for f in files)
version=move('tra',version)
customer.call('/operator/orders/'+oid+'/move',{'state':'fil','version':version},operator=True,expected=409)
new_final=upload_bytes(customer,b'LOCAL FINAL VERSION TWO',order_id=oid)
version=customer.call('/operator/orders/'+oid+'/final-files',{'version':version,'files':[{'item_index':0,'upload_id':new_final}],'note':'Checked corrected final'},operator=True)['version']
for state in ('fil','imp','fin'):version=move(state,version)
detail=other.call('/customer/orders/'+oid)
assert detail['state']=='fin'
assert sum(f['active'] and f['kind']=='final' for f in detail['files'])==1
assert any(h['reason']=='Please replace the artwork' for h in detail['history'])
print('PASS: final-file gate, final revisions, secure customer downloads, correction history/uploads, old final invalidation and reapproval')
old_cookie=list(other.jar)[0].value
other.call('/account/logout',{})
cookie.value=old_cookie;revoked.jar.set_cookie(cookie);revoked.call('/customer/orders',expected=401)
other.call('/session');assert other.call('/customer/orders')['orders']==[]
print('PASS: logout revokes server session and signed-out visitors cannot see account orders')
if __name__=='__main__':run()