first commit
This commit is contained in:
11
local/Dockerfile
Normal file
11
local/Dockerfile
Normal 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
13
local/Dockerfile.web
Normal 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
1
local/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
127
local/adapters.py
Normal file
127
local/adapters.py
Normal 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
341
local/app.py
Normal 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
93
local/auth.py
Normal 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
128
local/backup.py
Normal 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
25
local/bootstrap.py
Normal 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
140
local/browser_test.mjs
Normal 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
15
local/clamd.conf
Normal 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
15
local/compile_web.py
Normal 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
191
local/customer.py
Normal 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
12
local/db.py
Normal 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
24
local/dependency_audit.py
Normal 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)
|
||||
4
local/fixtures/local-test.cdr
Normal file
4
local/fixtures/local-test.cdr
Normal 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
13
local/lock_dependencies.sh
Executable 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
101
local/models.py
Normal 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
34
local/nginx.conf.template
Normal 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
27
local/pricing.py
Normal 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
316
local/requirements.lock
Normal 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
7
local/requirements.txt
Normal 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
34
local/retention_test.py
Normal 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()
|
||||
42
local/runtime_security_test.py
Normal file
42
local/runtime_security_test.py
Normal 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
81
local/scanning.py
Normal 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
17
local/scanning_test.py
Normal 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
81
local/schema.sql
Normal 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
31
local/security_status.py
Normal 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
66
local/security_test.py
Normal 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
147
local/smoke_test.py
Normal 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
100
local/staging_readiness.py
Normal 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
47
local/static/cart.js
Normal 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
126
local/static/checkout.js
Normal 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
21
local/static/kanban.html
Normal 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
107
local/static/kanban.js
Normal 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
11
local/static/portal.html
Normal 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
76
local/static/portal.js
Normal 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
15
local/static/privacy.js
Normal 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
30
local/static/upload.js
Normal 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
24
local/storage-init.sh
Normal 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.'
|
||||
1
local/storage-lifecycle.json
Normal file
1
local/storage-lifecycle.json
Normal file
@@ -0,0 +1 @@
|
||||
{"Rules":[{"ID":"local-artwork-retention","Status":"Enabled","Filter":{"Prefix":""},"Expiration":{"Days":30},"AbortIncompleteMultipartUpload":{"DaysAfterInitiation":1}}]}
|
||||
7
local/storage-policy.json
Normal file
7
local/storage-policy.json
Normal 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
192
local/storage_backup.py
Normal 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()
|
||||
46
local/test_dependency_lock.py
Normal file
46
local/test_dependency_lock.py
Normal 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
40
local/test_pricing.py
Normal 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()
|
||||
56
local/test_staging_readiness.py
Normal file
56
local/test_staging_readiness.py
Normal 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
77
local/worker.py
Normal 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
73
local/workflow_test.py
Normal 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()
|
||||
Reference in New Issue
Block a user