Compare commits
3 Commits
7386469404
...
cfcbe545f1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfcbe545f1 | ||
|
|
24013458c9 | ||
|
|
ccc25a2d5d |
@@ -48,6 +48,9 @@ jobs:
|
||||
# by whoever follows them. The suites run inside the network, so it has to
|
||||
# be the service name, not a published port on the host.
|
||||
S3_PUBLIC_ENDPOINT: http://storage:9000
|
||||
PUBLIC_ORIGIN: http://site
|
||||
ALLOWED_HOSTS: localhost,127.0.0.1,site,kanban
|
||||
ALLOWED_ORIGINS: http://site,http://kanban,http://localhost:28080,http://localhost:28081
|
||||
COMPOSE: docker compose -f compose.local.yaml
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -70,7 +73,7 @@ jobs:
|
||||
# one a developer exercises on localhost.
|
||||
- name: API and workflow regressions
|
||||
run: |
|
||||
for suite in smoke_test workflow_test security_test scanning_test; do
|
||||
for suite in smoke_test workflow_test security_test scanning_test payment_test quote_pagination_test; do
|
||||
echo "--- $suite"
|
||||
$COMPOSE exec -T \
|
||||
-e SITE_BASE_URL=http://site \
|
||||
@@ -83,35 +86,13 @@ jobs:
|
||||
$COMPOSE exec -T api python -m tests.retention_test
|
||||
$COMPOSE exec -T api python -m tests.runtime_security_test
|
||||
|
||||
# These need a real Chrome. They are the only coverage for the artwork
|
||||
# editor and the full customer journey, so install google-chrome-stable
|
||||
# (or set CHROME_BIN) on the runner to make them gate deployments. The
|
||||
# suites above stay hard gates either way.
|
||||
# Run Chrome on the Compose network. It must resolve the same storage:9000
|
||||
# hostname used in presigned URLs, and absence of Chrome must fail CI.
|
||||
- name: Browser regressions
|
||||
run: |
|
||||
for candidate in "$CHROME_BIN" /usr/bin/google-chrome-stable \
|
||||
/usr/bin/google-chrome /usr/bin/chromium /usr/bin/chromium-browser; do
|
||||
if [ -n "$candidate" ] && [ -x "$candidate" ]; then
|
||||
export CHROME_BIN="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ ! -x "${CHROME_BIN:-}" ]; then
|
||||
echo "::warning::No Chrome on this runner; browser regressions were NOT run."
|
||||
echo "Install google-chrome-stable or set CHROME_BIN to gate on them."
|
||||
exit 0
|
||||
fi
|
||||
# Chrome runs here, in the runner container, and reaches the stack only
|
||||
# through ports published on the host. When the runner is itself a
|
||||
# container those are in another namespace, so check before running
|
||||
# rather than failing with a bare connection error. See ROADMAP 5.10.
|
||||
if ! wget -q -T 5 -O /dev/null "http://localhost:${SITE_PORT}/health"; then
|
||||
echo "::warning::Stack not reachable from the runner; browser regressions were NOT run."
|
||||
exit 0
|
||||
fi
|
||||
echo "Using $CHROME_BIN"
|
||||
node tests/artwork_browser_test.mjs
|
||||
node tests/browser_test.mjs
|
||||
$COMPOSE build browser-tests
|
||||
$COMPOSE run --rm --no-deps browser-tests sh -ec \
|
||||
'node tests/artwork_browser_test.mjs && node tests/browser_test.mjs'
|
||||
|
||||
- name: Diagnostics on failure
|
||||
if: failure()
|
||||
@@ -145,13 +126,9 @@ jobs:
|
||||
fs --scanners secret --exit-code 1 --severity HIGH,CRITICAL \
|
||||
--no-progress /src
|
||||
|
||||
# docs/PORTAINER.md described this as blocking publication. It never ran at
|
||||
# all, and turning it on unconditionally would block every deploy: the
|
||||
# source preflight refuses a release while the payment and messaging
|
||||
# adapters are fake, which is the deliberate state the stack runs in
|
||||
# today. So its verdict is always printed, and enforcement is opt-in.
|
||||
# Set the repository variable ENFORCE_PRODUCTION_PREFLIGHT to "true" once
|
||||
# real adapters land, and this becomes the gate the documentation claims.
|
||||
# Keep push feedback advisory while the provider adapters are fake.
|
||||
# The manual release job enforces the source preflight unconditionally.
|
||||
# ENFORCE_PRODUCTION_PREFLIGHT can make push checks fail on blockers too.
|
||||
- name: Production source preflight
|
||||
run: |
|
||||
set +e
|
||||
@@ -171,7 +148,7 @@ jobs:
|
||||
publish-and-deploy:
|
||||
name: Publish images and notify Portainer
|
||||
needs: [validate, integration, scan]
|
||||
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main'
|
||||
if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
@@ -181,6 +158,12 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
- name: Require production readiness
|
||||
env:
|
||||
PORTAINER_WEBHOOK: ${{ secrets.PORTAINER_WEBHOOK }}
|
||||
run: |
|
||||
python3 deploy/production_preflight.py --source-only
|
||||
test -n "$PORTAINER_WEBHOOK"
|
||||
- name: Sign in to the Gitea Container Registry
|
||||
env:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
@@ -190,7 +173,7 @@ jobs:
|
||||
test -n "$REGISTRY_TOKEN"
|
||||
echo "$REGISTRY_TOKEN" | docker login gitea.blyzer.com.br \
|
||||
--username "$REGISTRY_USERNAME" --password-stdin
|
||||
- name: Build and publish API
|
||||
- name: Build API
|
||||
run: |
|
||||
image="gitea.blyzer.com.br/blyzer/dtf-api"
|
||||
# The Dockerfiles pin digests themselves; these variables let a base be
|
||||
@@ -201,9 +184,7 @@ jobs:
|
||||
docker build --file deploy/Dockerfile.api "$@" \
|
||||
--build-arg VCS_REF="${{ gitea.sha }}" \
|
||||
--tag "$image:latest" --tag "$image:${{ gitea.sha }}" .
|
||||
docker push "$image:latest"
|
||||
docker push "$image:${{ gitea.sha }}"
|
||||
- name: Build and publish web
|
||||
- name: Build web
|
||||
run: |
|
||||
image="gitea.blyzer.com.br/blyzer/dtf-web"
|
||||
set --
|
||||
@@ -212,8 +193,6 @@ jobs:
|
||||
docker build --file deploy/Dockerfile.web "$@" \
|
||||
--build-arg VCS_REF="${{ gitea.sha }}" \
|
||||
--tag "$image:latest" --tag "$image:${{ gitea.sha }}" .
|
||||
docker push "$image:latest"
|
||||
docker push "$image:${{ gitea.sha }}"
|
||||
# CRITICAL blocks, HIGH is reported. Both images carry zero CRITICAL after
|
||||
# the base pinning and OS upgrades, so this gate holds the line already
|
||||
# reached. The remaining HIGH findings have no upstream fix, so failing on
|
||||
@@ -227,25 +206,29 @@ jobs:
|
||||
"gitea.blyzer.com.br/blyzer/dtf-web:${{ gitea.sha }}"; do
|
||||
echo "--- $target (HIGH, reported)"
|
||||
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock "$image" \
|
||||
image --scanners vuln --severity HIGH --no-progress \
|
||||
image --image-src docker --scanners vuln --severity HIGH --no-progress \
|
||||
--format table --exit-code 0 "$target" ||
|
||||
echo "::warning::Could not scan $target for HIGH findings"
|
||||
echo "--- $target (CRITICAL, blocking)"
|
||||
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock "$image" \
|
||||
image --scanners vuln --severity CRITICAL --no-progress \
|
||||
image --image-src docker --scanners vuln --severity CRITICAL --no-progress \
|
||||
--format table --exit-code 1 "$target" || failed=1
|
||||
done
|
||||
if [ "$failed" -ne 0 ]; then
|
||||
echo "::error::A CRITICAL vulnerability was found in a published image."
|
||||
echo "::error::A CRITICAL vulnerability was found in a release image."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Publish validated images
|
||||
run: |
|
||||
for name in dtf-api dtf-web; do
|
||||
image="gitea.blyzer.com.br/blyzer/$name"
|
||||
docker push "$image:${{ gitea.sha }}"
|
||||
docker push "$image:latest"
|
||||
done
|
||||
|
||||
- name: Trigger Portainer redeployment
|
||||
env:
|
||||
PORTAINER_WEBHOOK: ${{ secrets.PORTAINER_WEBHOOK }}
|
||||
run: |
|
||||
if [ -z "$PORTAINER_WEBHOOK" ]; then
|
||||
echo "PORTAINER_WEBHOOK is not configured; images were published but deployment was skipped."
|
||||
exit 0
|
||||
fi
|
||||
curl --fail --silent --show-error --max-time 30 --request POST "$PORTAINER_WEBHOOK"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Sistema DTF 24h — Altus Group
|
||||
|
||||
> **Active local milestone (2026-09-11):** Read [docs/CONTEXT.md](docs/CONTEXT.md) first.
|
||||
> Start with `docker compose up --build`, then open the [Site](http://localhost:8080)
|
||||
> **Active local milestone (2026-09-23):** Read [docs/CONTEXT.md](docs/CONTEXT.md) first.
|
||||
> Start with `docker compose -f compose.local.yaml up --build`, then open the [Site](http://localhost:8080)
|
||||
> and [Kanban](http://localhost:8081). Optional configuration: copy `.env.example`
|
||||
> to `.env`. Follow [docs/LOCAL_SETUP.md](docs/LOCAL_SETUP.md) for the complete test flow,
|
||||
> local login, health checks, and troubleshooting. See
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"""Local-only composition root. No production provider implementations/imports."""
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from typing import Protocol
|
||||
from typing import Mapping, NamedTuple, Protocol
|
||||
from urllib.parse import urlparse
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
@@ -41,10 +44,81 @@ def require_runtime():
|
||||
# Compatibility alias for local-only callers outside the active runtime.
|
||||
require_local = require_runtime
|
||||
|
||||
class PaymentEvent(NamedTuple):
|
||||
"""One provider notification, normalised.
|
||||
|
||||
`event_id` identifies the delivery and makes it idempotent. `reference` is
|
||||
our quote id, echoed back by the provider. `amount_cents` is what the
|
||||
provider says was actually paid, which the service compares against the
|
||||
approved total before it will create an order.
|
||||
"""
|
||||
event_id: str
|
||||
reference: str
|
||||
status: str # 'approved' | 'rejected' | 'pending' | 'refunded'
|
||||
amount_cents: int | None
|
||||
raw: dict
|
||||
|
||||
|
||||
class PaymentAdapter(Protocol):
|
||||
def pay(self, quote_id: str, total_cents: int) -> dict: ...
|
||||
def create(self, quote_id: str, total_cents: int, customer: dict) -> dict:
|
||||
"""Start a payment. Must be idempotent on quote_id: a retry after a
|
||||
timeout has to return the existing payment, never charge twice."""
|
||||
|
||||
def verify(self, headers: Mapping[str, str], body: bytes) -> bool:
|
||||
"""Whether this delivery genuinely came from the provider."""
|
||||
|
||||
def parse(self, body: bytes) -> PaymentEvent | None:
|
||||
"""Normalise a verified delivery, or None if it is not about a payment."""
|
||||
|
||||
|
||||
class FakePayment:
|
||||
"""Local stand-in with a real signature scheme, so the webhook path is
|
||||
exercised end to end rather than waiting for a provider account.
|
||||
|
||||
Signs the body with HMAC-SHA256 under PAYMENT_WEBHOOK_SECRET. A real adapter
|
||||
replaces verify() and parse() with the provider's own scheme; nothing else in
|
||||
the service changes.
|
||||
"""
|
||||
|
||||
header = 'x-payment-signature'
|
||||
|
||||
def _secret(self) -> bytes | None:
|
||||
secret = os.environ.get('PAYMENT_WEBHOOK_SECRET', '')
|
||||
return secret.encode() if secret else None
|
||||
|
||||
def create(self, quote_id: str, total_cents: int, customer: dict) -> dict:
|
||||
return {'provider': 'fake', 'id': f'local-{quote_id}',
|
||||
'status': 'pending', 'total_cents': total_cents}
|
||||
|
||||
def sign(self, body: bytes) -> str:
|
||||
secret = self._secret()
|
||||
if secret is None:
|
||||
raise RuntimeError('PAYMENT_WEBHOOK_SECRET is not configured')
|
||||
return hmac.new(secret, body, hashlib.sha256).hexdigest()
|
||||
|
||||
def verify(self, headers, body: bytes) -> bool:
|
||||
# No configured secret means nothing can be verified, so nothing is
|
||||
# accepted. A guessable default would let anyone forge an approval and
|
||||
# create an order that was never paid for.
|
||||
if self._secret() is None:
|
||||
return False
|
||||
supplied = headers.get(self.header) or headers.get(self.header.title()) or ''
|
||||
return hmac.compare_digest(supplied, self.sign(body))
|
||||
|
||||
def parse(self, body: bytes):
|
||||
try:
|
||||
data = json.loads(body)
|
||||
except ValueError:
|
||||
return None
|
||||
if not isinstance(data, dict) or 'event_id' not in data:
|
||||
return None
|
||||
return PaymentEvent(event_id=str(data['event_id']),
|
||||
reference=str(data.get('reference', '')),
|
||||
status=str(data.get('status', 'pending')),
|
||||
amount_cents=data.get('amount_cents'),
|
||||
raw=data)
|
||||
|
||||
# The local development checkout still needs a direct "it is paid" path.
|
||||
def pay(self, quote_id: str, total_cents: int) -> dict:
|
||||
return {'provider': 'fake', 'id': f'local-{quote_id}',
|
||||
'status': 'paid', 'total_cents': total_cents}
|
||||
|
||||
@@ -12,7 +12,7 @@ from ..artwork import submit_files
|
||||
from ..core.auth import operator
|
||||
from ..core.models import ArtworkSubmission, UploadStart
|
||||
from ..runtime import file_rows, operator_identity
|
||||
from .uploads import begin_upload, complete_upload, part_url, upload_status
|
||||
from .uploads import begin_upload, cancel_upload, complete_upload, part_url, upload_status
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -36,6 +36,10 @@ def final_part(uid: UUID, number: int, user=Depends(operator)):
|
||||
def final_complete(uid: UUID, user=Depends(operator)):
|
||||
return complete_upload(uid,session_id=operator_identity(user))
|
||||
|
||||
@router.delete('/api/operator/uploads/{uid}')
|
||||
def final_cancel(uid: UUID, user=Depends(operator)):
|
||||
return cancel_upload(uid,session_id=operator_identity(user))
|
||||
|
||||
@router.get('/api/operator/orders/{oid}/files')
|
||||
def operator_files(oid: UUID, user=Depends(operator)):
|
||||
with db.connect() as c:
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"""Health, session bootstrap and freight quoting."""
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
|
||||
from ..core import db
|
||||
from ..core.auth import client_ip, owner, new_session, rate_limit
|
||||
from ..core.limits import upload_limit_bytes
|
||||
from ..core.models import Freight
|
||||
from ..runtime import ENVIRONMENT, GUEST_SESSION_LIMIT, PART_BYTES, freight, storage
|
||||
|
||||
@@ -33,7 +32,7 @@ def session(request: Request, response: Response):
|
||||
with db.connect() as c:
|
||||
session_id = new_session(c, response)
|
||||
return {'environment': ENVIRONMENT, 'cart_scope': str(session_id), 'part_bytes': PART_BYTES,
|
||||
'max_upload_bytes': int(os.environ.get('MAX_UPLOAD_BYTES', '5368709120'))}
|
||||
'max_upload_bytes': upload_limit_bytes()}
|
||||
|
||||
@router.post('/api/freight')
|
||||
def quote_freight(body: Freight):
|
||||
|
||||
@@ -3,9 +3,10 @@ import hashlib
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from ..core import db
|
||||
@@ -65,15 +66,44 @@ def board(user=Depends(operator)):
|
||||
finished = c.execute("SELECT * FROM dtf_local.orders WHERE state='fin' ORDER BY created_at DESC LIMIT %s",
|
||||
(BOARD_FINISHED_LIMIT,)).fetchall()
|
||||
finished_total = c.execute("SELECT count(*) AS n FROM dtf_local.orders WHERE state='fin'").fetchone()['n']
|
||||
quotes = c.execute('''SELECT q.* FROM dtf_local.quotes q
|
||||
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id WHERE o.id IS NULL
|
||||
ORDER BY q.created_at LIMIT %s''', (BOARD_QUOTE_LIMIT,)).fetchall()
|
||||
pending = 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 AND q.approved IS NULL
|
||||
ORDER BY q.created_at DESC,q.id DESC LIMIT %s''', (BOARD_QUOTE_LIMIT,)).fetchall()
|
||||
approved = 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 AND q.approved IS NOT NULL
|
||||
ORDER BY q.created_at DESC,q.id DESC LIMIT 20''').fetchall()
|
||||
pending_total = c.execute('''SELECT count(*) AS n FROM dtf_local.quotes q
|
||||
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
|
||||
WHERE o.id IS NULL AND q.approved IS NULL''').fetchone()['n']
|
||||
approved_total = c.execute('''SELECT count(*) AS n FROM dtf_local.quotes q
|
||||
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
|
||||
WHERE o.id IS NULL AND q.approved IS NOT NULL''').fetchone()['n']
|
||||
return {'states': STATES, 'transitions': TRANSITIONS,
|
||||
'orders': active + list(reversed(finished)),
|
||||
'finished_shown': len(finished), 'finished_total': finished_total,
|
||||
'quotes': [quote_view(c, q) for q in quotes],
|
||||
'quotes': [quote_view(c, q) for q in pending + approved],
|
||||
'pending_total': pending_total, 'approved_total': approved_total,
|
||||
'events': c.execute('SELECT * FROM dtf_local.outbox ORDER BY id DESC LIMIT 100').fetchall()}
|
||||
|
||||
@router.get('/api/operator/quotes')
|
||||
def quote_page(kind: Literal['pending','approved'], before_created_at: datetime | None = None,
|
||||
before_id: UUID | None = None, limit: int = Query(default=50, ge=1, le=100),
|
||||
user=Depends(operator)):
|
||||
if (before_created_at is None) != (before_id is None):
|
||||
raise HTTPException(422, 'Both quote cursor fields are required')
|
||||
approved_filter = 'q.approved IS NULL' if kind == 'pending' else 'q.approved IS NOT NULL'
|
||||
cursor = 'AND (q.created_at,q.id)<(%s,%s)' if before_created_at else ''
|
||||
params = ((before_created_at,before_id) if before_created_at else ()) + (limit+1,)
|
||||
with db.connect() as c:
|
||||
rows = c.execute(f'''SELECT q.* FROM dtf_local.quotes q
|
||||
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
|
||||
WHERE o.id IS NULL AND {approved_filter} {cursor}
|
||||
ORDER BY q.created_at DESC,q.id DESC LIMIT %s''', params).fetchall()
|
||||
return {'quotes':[quote_view(c,row) for row in rows[:limit]],
|
||||
'has_more':len(rows)>limit}
|
||||
|
||||
@router.post('/api/operator/quotes/{uid}/approve')
|
||||
def approve(uid: UUID, body: Review, user=Depends(operator)):
|
||||
with db.connect() as c:
|
||||
@@ -83,15 +113,22 @@ def approve(uid: UUID, body: Review, user=Depends(operator)):
|
||||
if row['approved']:
|
||||
raise HTTPException(409, 'Approved quotes are immutable; request a new quote')
|
||||
draft = row['draft']
|
||||
if any(item.get('production', {}).get('version') != 2 for item in draft['items']):
|
||||
raise HTTPException(409, 'Quote uses an obsolete production layout; customer must request a new quote')
|
||||
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')
|
||||
if item.production.model_dump(mode='json') != original['production'] or item.quality_status != original['quality_status'] or item.quality_acknowledged != original['quality_acknowledged']:
|
||||
raise HTTPException(422, 'Production instructions and customer acknowledgement cannot change during commercial 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']})
|
||||
items.append({**price(item.mode, str(item.metres), item.grade),
|
||||
'uploads': original['uploads'], 'production': original['production'],
|
||||
'quality_status': original['quality_status'],
|
||||
'quality_acknowledged': original['quality_acknowledged']})
|
||||
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']}
|
||||
|
||||
@@ -1,41 +1,38 @@
|
||||
"""Paid orders. Local development payment only; no provider is wired yet."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID, uuid4
|
||||
"""Local development checkout.
|
||||
|
||||
The real path is the provider webhook. This exists so the local stack can reach
|
||||
a paid order without a provider account, and it goes through the same service so
|
||||
the two cannot drift apart.
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from .. import payments
|
||||
from ..core import db
|
||||
from ..core.auth import owner
|
||||
from ..core.models import Pay
|
||||
from ..runtime import ENVIRONMENT, enqueue, payment, upload_row
|
||||
from ..scanning import require_clean
|
||||
from ..runtime import ENVIRONMENT, payment
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post('/api/orders/dev-paid')
|
||||
def dev_paid(body: Pay, session_id=Depends(owner)):
|
||||
if ENVIRONMENT != 'local':
|
||||
raise HTTPException(503, 'Checkout is not configured yet')
|
||||
with db.connect() as c:
|
||||
row = c.execute('SELECT * FROM dtf_local.quotes WHERE id=%s AND owner=%s FOR UPDATE', (body.quote_id,session_id)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, 'Quote not found')
|
||||
existing = c.execute('SELECT * FROM dtf_local.orders WHERE quote_id=%s', (body.quote_id,)).fetchone()
|
||||
if existing:
|
||||
return existing
|
||||
if not row['approved']:
|
||||
raise HTTPException(409, 'An operator must verify length and grade first')
|
||||
if row['approved_at'] < datetime.now(timezone.utc)-timedelta(hours=24):
|
||||
raise HTTPException(409, 'Quote expired; request a new quote')
|
||||
approved = row['approved']
|
||||
for item in approved['items']:
|
||||
for upload_id in item['uploads']:
|
||||
require_clean(upload_row(c, UUID(upload_id), session_id))
|
||||
paid = payment.pay(str(body.quote_id), approved['total_cents'])
|
||||
result = c.execute('INSERT INTO dtf_local.orders(id,quote_id,owner,snapshot,payment) VALUES(%s,%s,%s,%s,%s) RETURNING *',
|
||||
(uuid4(),body.quote_id,session_id,Jsonb(approved),Jsonb(paid))).fetchone()
|
||||
for provider in ('tiny','whatsapp'):
|
||||
enqueue(c, f"{result['id']}:paid:{provider}", provider,
|
||||
{'order_id': str(result['id']), 'number': result['number'], 'event': 'payment_approved', 'order': approved})
|
||||
return result
|
||||
try:
|
||||
quote = payments.approved_quote(c, body.quote_id, session_id)
|
||||
except payments.PaymentRefused as refusal:
|
||||
# The quote may already be paid; that is not a refusal.
|
||||
existing = c.execute('SELECT * FROM dtf_local.orders WHERE quote_id=%s AND owner=%s',
|
||||
(body.quote_id, session_id)).fetchone()
|
||||
if existing:
|
||||
return existing
|
||||
raise HTTPException(404 if 'not found' in str(refusal) else 409, str(refusal))
|
||||
# The charge happens inside the transaction that persists the order, so a
|
||||
# failure to record it cannot leave a customer charged without an order.
|
||||
receipt = payment.pay(str(body.quote_id), quote['approved']['total_cents'])
|
||||
order, _ = payments.create_order(c, quote, receipt)
|
||||
return order
|
||||
|
||||
53
app/api/payments.py
Normal file
53
app/api/payments.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""The provider's callback.
|
||||
|
||||
Unauthenticated by necessity — a payment provider has no session — so the
|
||||
signature is the only thing standing between this endpoint and an attacker
|
||||
creating orders. It is verified before the body is parsed, let alone acted on,
|
||||
and an unverified delivery is recorded and refused rather than retried.
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from .. import payments
|
||||
from ..core import db
|
||||
from ..core.auth import audit, client_ip, rate_limit
|
||||
from ..runtime import payment
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Generous: a provider legitimately retries, and a signature check is cheap.
|
||||
# This exists so an unsigned flood cannot keep the database busy.
|
||||
WEBHOOK_LIMIT = 600
|
||||
|
||||
|
||||
@router.post('/api/payments/webhook')
|
||||
async def webhook(request: Request):
|
||||
rate_limit('payment-webhook', client_ip(request), WEBHOOK_LIMIT, 900)
|
||||
body = await request.body()
|
||||
|
||||
if not payment.verify(request.headers, body):
|
||||
audit('payment_webhook_rejected', ip=client_ip(request), reason='signature')
|
||||
raise HTTPException(403, 'Invalid signature')
|
||||
|
||||
event = payment.parse(body)
|
||||
if event is None:
|
||||
# Verified, so genuinely from the provider, but not about a payment.
|
||||
# Acknowledge it: refusing would make the provider retry for ever.
|
||||
return {'status': 'ignored'}
|
||||
|
||||
with db.connect() as c:
|
||||
stored = payments.record(c, event_provider(), event)
|
||||
if stored is None:
|
||||
# Already delivered. Acknowledge without acting again.
|
||||
return {'status': 'duplicate'}
|
||||
outcome = payments.apply(c, event)
|
||||
c.execute('UPDATE dtf_local.payment_events SET processed_at=now(), outcome=%s WHERE id=%s',
|
||||
(outcome, stored['id']))
|
||||
|
||||
# audit()'s own first parameter is named `event`, so the id goes under another key.
|
||||
audit('payment_webhook_applied', payment_event=event.event_id,
|
||||
status=event.status, outcome=outcome)
|
||||
return {'status': 'applied', 'outcome': outcome}
|
||||
|
||||
|
||||
def event_provider():
|
||||
return getattr(payment, 'name', payment.__class__.__name__.replace('Payment', '').lower() or 'fake')
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Quotes: the customer's cart, and the operator-reviewed version of it."""
|
||||
import hashlib
|
||||
import json
|
||||
from decimal import Decimal
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
@@ -16,6 +17,9 @@ router = APIRouter()
|
||||
|
||||
@router.post('/api/quotes')
|
||||
def create_quote(body: QuoteRequest, session_id=Depends(owner)):
|
||||
for item in body.items:
|
||||
if abs(item.metres*100-item.production.height_cm) > Decimal('0.02'):
|
||||
raise HTTPException(422, 'Quoted metres do not match the submitted layout height')
|
||||
draft = body.model_dump(mode='json', exclude={'request_key'})
|
||||
digest = hashlib.sha256(json.dumps(draft, sort_keys=True).encode()).hexdigest()
|
||||
try:
|
||||
|
||||
@@ -12,6 +12,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from ..core import db
|
||||
from ..core.auth import audit, owner, rate_limit
|
||||
from ..core.limits import upload_limit_bytes
|
||||
from ..core.models import UploadStart
|
||||
from ..runtime import PART_BYTES, storage, upload_row
|
||||
|
||||
@@ -19,8 +20,8 @@ router = APIRouter()
|
||||
|
||||
@router.post('/api/uploads')
|
||||
def begin_upload(body: UploadStart, session_id=Depends(owner)):
|
||||
if body.size > int(os.environ.get('MAX_UPLOAD_BYTES', '5368709120')):
|
||||
raise HTTPException(413, 'File exceeds the upload limit')
|
||||
if body.size > upload_limit_bytes():
|
||||
raise HTTPException(413, 'File exceeds the malware scan limit; select a smaller file')
|
||||
uid = uuid4()
|
||||
key = f'originals/{uid}'
|
||||
rate_limit('upload-start', str(session_id), 60, 900)
|
||||
@@ -30,14 +31,16 @@ def begin_upload(body: UploadStart, session_id=Depends(owner)):
|
||||
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()
|
||||
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)',
|
||||
c.execute('''INSERT INTO dtf_local.uploads(id,owner,name,size,object_key,multipart_id,expires_at)
|
||||
VALUES(%s,%s,%s,%s,%s,%s,now()+interval '1 hour')''',
|
||||
(uid, session_id, body.name, body.size, key, multipart))
|
||||
return {'id': uid, 'part_bytes': PART_BYTES}
|
||||
|
||||
@@ -81,5 +84,16 @@ def complete_upload(uid: UUID, session_id=Depends(owner)):
|
||||
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,))
|
||||
c.execute("UPDATE dtf_local.uploads SET complete=true,expires_at=now()+interval '30 days' WHERE id=%s", (uid,))
|
||||
return {'id': uid, 'complete': True}
|
||||
|
||||
@router.delete('/api/uploads/{uid}')
|
||||
def cancel_upload(uid: UUID, session_id=Depends(owner)):
|
||||
with db.connect() as c:
|
||||
row=upload_row(c,uid,session_id,lock=True)
|
||||
if row['complete']:
|
||||
raise HTTPException(409, 'Completed upload cannot be cancelled')
|
||||
storage.discard(row['object_key'],row['multipart_id'],False)
|
||||
c.execute('UPDATE dtf_local.uploads SET purged_at=now() WHERE id=%s',(uid,))
|
||||
audit('upload_cancelled', upload=str(uid))
|
||||
return {'id':uid,'cancelled':True}
|
||||
|
||||
@@ -13,7 +13,7 @@ from starlette.middleware.trustedhost import TrustedHostMiddleware
|
||||
from .core import db
|
||||
from .core.auth import audit, client_ip
|
||||
from .runtime import ALLOWED_HOSTS, ALLOWED_ORIGINS, storage
|
||||
from .api import artwork, customer, health, operator, orders, quotes, uploads
|
||||
from .api import artwork, customer, health, operator, orders, payments, quotes, uploads
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -44,5 +44,5 @@ async def safe_headers(request, call_next):
|
||||
|
||||
|
||||
# Order is not significant: no two routers declare the same path.
|
||||
for module in (health, uploads, quotes, orders, operator, customer, artwork):
|
||||
for module in (health, uploads, quotes, orders, payments, operator, customer, artwork):
|
||||
app.include_router(module.router)
|
||||
|
||||
@@ -38,7 +38,12 @@ def submit_files(c, order, body, identity, kind, actor):
|
||||
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))
|
||||
# A new customer correction supersedes every final prepared from earlier
|
||||
# artwork, including finals uploaded while this order was in correction.
|
||||
if kind == 'correction':
|
||||
c.execute("UPDATE dtf_local.order_files SET active=false WHERE order_id=%s AND kind IN ('correction','final')", (order['id'],))
|
||||
else:
|
||||
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))
|
||||
|
||||
13
app/core/limits.py
Normal file
13
app/core/limits.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Limits shared by upload admission and the malware scanner."""
|
||||
import os
|
||||
|
||||
CLAMAV_STREAM_MAX_BYTES = 128 * 1024 * 1024 # infra/clamd.conf
|
||||
|
||||
|
||||
def scan_limit_bytes():
|
||||
return min(CLAMAV_STREAM_MAX_BYTES, int(os.environ.get('SCAN_MAX_BYTES', '134217728')))
|
||||
|
||||
|
||||
def upload_limit_bytes():
|
||||
transport = int(os.environ.get('MAX_UPLOAD_BYTES', '5368709120'))
|
||||
return min(transport, scan_limit_bytes())
|
||||
@@ -2,7 +2,7 @@ import re
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
class StrictModel(BaseModel):
|
||||
model_config = ConfigDict(extra='forbid', allow_inf_nan=False)
|
||||
@@ -56,11 +56,80 @@ class UploadStart(StrictModel):
|
||||
raise ValueError('Unsupported artwork file extension')
|
||||
return value
|
||||
|
||||
class ProductionSource(StrictModel):
|
||||
upload_id: UUID
|
||||
kind: Literal['sheet', 'artwork']
|
||||
width_cm: Decimal = Field(gt=0, le=57)
|
||||
length_cm: Decimal = Field(gt=0, le=6000)
|
||||
copies: int = Field(ge=1, le=200, strict=True)
|
||||
rotation_degrees: Literal[0, 90] = 0
|
||||
mirrored: bool = False
|
||||
measurement: Literal['file', 'customer']
|
||||
|
||||
class ProductionPlacement(StrictModel):
|
||||
source_index: int = Field(ge=0, le=19, strict=True)
|
||||
copy_index: int = Field(ge=0, le=199, strict=True)
|
||||
x_cm: Decimal = Field(ge=0, le=57)
|
||||
y_cm: Decimal = Field(ge=0, le=1200000)
|
||||
width_cm: Decimal = Field(gt=0, le=57)
|
||||
length_cm: Decimal = Field(gt=0, le=6000)
|
||||
rotation_degrees: Literal[0, 90, 180, 270]
|
||||
mirrored: bool
|
||||
|
||||
class ProductionSpec(StrictModel):
|
||||
version: Literal[2]
|
||||
film_width_cm: Decimal
|
||||
height_cm: Decimal = Field(gt=0, le=1200000)
|
||||
sources: list[ProductionSource] = Field(min_length=1, max_length=20)
|
||||
placements: list[ProductionPlacement] = Field(min_length=1, max_length=4000)
|
||||
|
||||
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)
|
||||
production: ProductionSpec
|
||||
quality_status: Literal['ok', 'warning', 'unverified']
|
||||
quality_acknowledged: bool
|
||||
|
||||
@model_validator(mode='after')
|
||||
def production_matches_uploads(self):
|
||||
if [source.upload_id for source in self.production.sources] != self.uploads:
|
||||
raise ValueError('Production sources must match uploaded files in order')
|
||||
is_sheet = self.mode in ('file', 'uvfile')
|
||||
film_width = Decimal('28.5') if self.mode in ('uvfile', 'uv') else Decimal('57')
|
||||
if self.production.film_width_cm != film_width:
|
||||
raise ValueError('Production film width does not match the product')
|
||||
for source in self.production.sources:
|
||||
if (source.kind == 'sheet') != is_sheet or source.width_cm > film_width:
|
||||
raise ValueError('Production source does not fit the selected product')
|
||||
if is_sheet and (source.rotation_degrees or source.mirrored):
|
||||
raise ValueError('Finished sheets cannot be rotated or mirrored by the layout')
|
||||
expected={(index,copy) for index,source in enumerate(self.production.sources)
|
||||
for copy in range(source.copies)}
|
||||
placed=set()
|
||||
tolerance=Decimal('0.02')
|
||||
for placement in self.production.placements:
|
||||
key=(placement.source_index,placement.copy_index)
|
||||
if key not in expected or key in placed:
|
||||
raise ValueError('Production placement has a missing or duplicate source copy')
|
||||
placed.add(key)
|
||||
source=self.production.sources[placement.source_index]
|
||||
auto_rotation=(placement.rotation_degrees-source.rotation_degrees)%360
|
||||
if auto_rotation not in (0,90) or placement.mirrored != source.mirrored:
|
||||
raise ValueError('Production placement changes the source transform')
|
||||
width,length=(source.width_cm,source.length_cm) if auto_rotation==0 else (source.length_cm,source.width_cm)
|
||||
if abs(placement.width_cm-width)>tolerance or abs(placement.length_cm-length)>tolerance:
|
||||
raise ValueError('Production placement changes the source size')
|
||||
if placement.x_cm+placement.width_cm>film_width+tolerance or placement.y_cm+placement.length_cm>self.production.height_cm+tolerance:
|
||||
raise ValueError('Production placement is outside the film')
|
||||
if is_sheet and (placement.x_cm or auto_rotation):
|
||||
raise ValueError('Finished sheets must retain their original orientation')
|
||||
if placed != expected:
|
||||
raise ValueError('Production layout does not cover every source copy')
|
||||
if self.quality_status == 'warning' and not self.quality_acknowledged:
|
||||
raise ValueError('Resolution warning must be acknowledged')
|
||||
return self
|
||||
|
||||
class QuoteRequest(StrictModel):
|
||||
request_key: UUID
|
||||
|
||||
105
app/payments.py
Normal file
105
app/payments.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Turning a payment into an order, once.
|
||||
|
||||
A provider may deliver the same notification several times, out of order, or
|
||||
long after the fact. None of that may produce a second order, a second charge,
|
||||
or a second WhatsApp message. Every delivery is recorded under the provider's
|
||||
own event id and applied inside one transaction, so a duplicate is a no-op and a
|
||||
crash mid-way is retried rather than half-applied.
|
||||
|
||||
Order creation lives here rather than in a route because two paths reach it: the
|
||||
webhook, and the local development checkout. They must agree.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from .core.auth import audit
|
||||
from .runtime import enqueue, upload_row
|
||||
from .scanning import require_clean
|
||||
|
||||
QUOTE_VALID_HOURS = 24
|
||||
|
||||
|
||||
class PaymentRefused(Exception):
|
||||
"""The payment cannot become an order, with a reason worth recording."""
|
||||
|
||||
|
||||
def approved_quote(c, quote_id, owner=None):
|
||||
"""The reviewed quote behind a payment, or a refusal explaining why not."""
|
||||
sql = 'SELECT * FROM dtf_local.quotes WHERE id=%s' + (' AND owner=%s' if owner else '')
|
||||
row = c.execute(sql + ' FOR UPDATE', (quote_id, owner) if owner else (quote_id,)).fetchone()
|
||||
if not row:
|
||||
raise PaymentRefused('quote not found')
|
||||
if not row['approved']:
|
||||
raise PaymentRefused('quote was never reviewed')
|
||||
if any(item.get('production', {}).get('version') != 2 for item in row['approved']['items']):
|
||||
raise PaymentRefused('quote uses an obsolete production layout; request a new quote')
|
||||
if row['approved_at'] < datetime.now(timezone.utc) - timedelta(hours=QUOTE_VALID_HOURS):
|
||||
raise PaymentRefused('quote expired before payment')
|
||||
return row
|
||||
|
||||
|
||||
def create_order(c, quote, payment):
|
||||
"""Create the order for a reviewed quote, or return the one already there.
|
||||
|
||||
Returns (order, created). The caller decides what to do about a duplicate;
|
||||
the important part is that asking twice cannot produce two orders, because
|
||||
orders.quote_id is unique and this runs inside the caller's transaction.
|
||||
"""
|
||||
existing = c.execute('SELECT * FROM dtf_local.orders WHERE quote_id=%s', (quote['id'],)).fetchone()
|
||||
if existing:
|
||||
return existing, False
|
||||
|
||||
approved = quote['approved']
|
||||
for item in approved['items']:
|
||||
for upload_id in item['uploads']:
|
||||
require_clean(upload_row(c, UUID(upload_id), quote['owner']))
|
||||
|
||||
order = c.execute(
|
||||
'INSERT INTO dtf_local.orders(id,quote_id,owner,snapshot,payment) VALUES(%s,%s,%s,%s,%s) RETURNING *',
|
||||
(uuid4(), quote['id'], quote['owner'], Jsonb(approved), Jsonb(payment))).fetchone()
|
||||
for provider in ('tiny', 'whatsapp'):
|
||||
enqueue(c, f"{order['id']}:paid:{provider}", provider,
|
||||
{'order_id': str(order['id']), 'number': order['number'],
|
||||
'event': 'payment_approved', 'order': approved})
|
||||
return order, True
|
||||
|
||||
|
||||
def record(c, provider, event):
|
||||
"""Store a delivery. Returns None if this exact event was already seen."""
|
||||
inserted = c.execute(
|
||||
'''INSERT INTO dtf_local.payment_events(id,provider,event_id,reference,status,amount_cents,payload)
|
||||
VALUES(%s,%s,%s,%s,%s,%s,%s) ON CONFLICT(provider,event_id) DO NOTHING RETURNING *''',
|
||||
(uuid4(), provider, event.event_id, event.reference, event.status,
|
||||
event.amount_cents, Jsonb(event.raw))).fetchone()
|
||||
return inserted
|
||||
|
||||
|
||||
def apply(c, event):
|
||||
"""Act on a payment notification. Returns the outcome recorded against it."""
|
||||
if event.status != 'approved':
|
||||
return f'ignored: {event.status}'
|
||||
|
||||
try:
|
||||
quote_id = UUID(event.reference)
|
||||
except (ValueError, AttributeError):
|
||||
return 'refused: reference is not a quote id'
|
||||
|
||||
try:
|
||||
quote = approved_quote(c, quote_id)
|
||||
except PaymentRefused as refusal:
|
||||
return f'refused: {refusal}'
|
||||
|
||||
# The provider is the authority on what was paid, and the reviewed quote is
|
||||
# the authority on what was owed. If they disagree, no order is created:
|
||||
# underpayment would ship artwork that was not paid for, and overpayment
|
||||
# means something is wrong that a person should look at.
|
||||
expected = quote['approved']['total_cents']
|
||||
if type(event.amount_cents) is not int or event.amount_cents != expected:
|
||||
audit('payment_amount_mismatch', quote=str(quote_id),
|
||||
expected_cents=expected, paid_cents=event.amount_cents)
|
||||
return f'refused: paid {event.amount_cents} but quote total is {expected}'
|
||||
|
||||
order, created = create_order(c, quote, {'provider': 'webhook', **event.raw})
|
||||
return f"order {order['number']}" + ('' if created else ' (already existed)')
|
||||
@@ -58,7 +58,8 @@ def upload_row(c, upload_id, session_id, lock=False):
|
||||
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'],
|
||||
return {'id': row['id'], 'created_at': row['created_at'],
|
||||
'draft': row['draft'], 'approved': row['approved'],
|
||||
'status': 'paid' if order else 'expired' if expired else 'approved' if row['approved'] else 'pending_review',
|
||||
'order': order}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Local ClamAV boundary. Unknown/error/over-limit results NEVER release artwork."""
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
import time
|
||||
from fastapi import HTTPException
|
||||
from .core.auth import audit
|
||||
from .core.db import connect
|
||||
from .core.limits import scan_limit_bytes
|
||||
|
||||
def require_clean(row):
|
||||
if not row['complete'] or row['scan_state'] != 'clean':
|
||||
@@ -30,7 +30,7 @@ class ClamAV:
|
||||
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'))):
|
||||
if size > scan_limit_bytes():
|
||||
return 'rejected', 'File exceeds the malware scan limit'
|
||||
with socket.create_connection(('scanner',3310),timeout=10) as sock:
|
||||
sock.settimeout(150)
|
||||
|
||||
@@ -64,6 +64,13 @@ CREATE TABLE IF NOT EXISTS dtf_local.operators (
|
||||
password_hash text NOT NULL, active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(), last_login_at timestamptz
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS dtf_local.payment_events (
|
||||
id uuid PRIMARY KEY, provider text NOT NULL, event_id text NOT NULL,
|
||||
reference text, status text NOT NULL, amount_cents bigint,
|
||||
payload jsonb NOT NULL, received_at timestamptz NOT NULL DEFAULT now(),
|
||||
processed_at timestamptz, outcome text,
|
||||
UNIQUE(provider, event_id)
|
||||
);
|
||||
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()
|
||||
@@ -118,3 +125,9 @@ CREATE INDEX IF NOT EXISTS operator_sessions_username ON dtf_local.operator_sess
|
||||
|
||||
-- security_status reads recent events; the worker prunes old ones by age.
|
||||
CREATE INDEX IF NOT EXISTS security_events_created ON dtf_local.security_events(created_at);
|
||||
|
||||
-- The webhook looks an event up by provider and id on every delivery, and the
|
||||
-- unique constraint already indexes that pair. Only the unprocessed sweep needs
|
||||
-- its own index, and it stays the size of the backlog.
|
||||
CREATE INDEX IF NOT EXISTS payment_events_unprocessed ON dtf_local.payment_events(received_at)
|
||||
WHERE processed_at IS NULL;
|
||||
|
||||
@@ -23,11 +23,12 @@ x-app: &app
|
||||
OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:-local-operator-only}
|
||||
# The browser reaches the API through the Site gateway, so the published
|
||||
# Site/Kanban origins must be accepted or every write is rejected 403.
|
||||
PUBLIC_ORIGIN: http://localhost:${SITE_PORT:-8080}
|
||||
ALLOWED_HOSTS: localhost,127.0.0.1
|
||||
ALLOWED_ORIGINS: http://localhost:${SITE_PORT:-8080},http://localhost:${KANBAN_PORT:-8081},http://127.0.0.1:${SITE_PORT:-8080},http://127.0.0.1:${KANBAN_PORT:-8081}
|
||||
PUBLIC_ORIGIN: ${PUBLIC_ORIGIN:-http://localhost:${SITE_PORT:-8080}}
|
||||
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1}
|
||||
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:${SITE_PORT:-8080},http://localhost:${KANBAN_PORT:-8081},http://127.0.0.1:${SITE_PORT:-8080},http://127.0.0.1:${KANBAN_PORT:-8081}}
|
||||
COOKIE_SECURE: "false"
|
||||
PAYMENT_ADAPTER: fake
|
||||
PAYMENT_WEBHOOK_SECRET: ${PAYMENT_WEBHOOK_SECRET:-local-webhook-secret}
|
||||
FREIGHT_ADAPTER: fake
|
||||
TINY_ADAPTER: fake
|
||||
WHATSAPP_ADAPTER: fake
|
||||
@@ -207,6 +208,28 @@ services:
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
|
||||
browser-tests:
|
||||
profiles: [ci]
|
||||
build:
|
||||
context: .
|
||||
dockerfile: infra/Dockerfile.browser-tests
|
||||
environment:
|
||||
CHROME_BIN: /usr/bin/chromium
|
||||
CHROME_NO_SANDBOX: "1"
|
||||
CHROME_TRUST_TEST_ORIGINS: "1"
|
||||
SITE_BROWSER_ORIGIN: http://site
|
||||
KANBAN_BROWSER_ORIGIN: http://kanban
|
||||
OPERATOR_EMAIL: ${OPERATOR_EMAIL:-operator@example.test}
|
||||
OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:-local-operator-only}
|
||||
shm_size: 1gb
|
||||
networks: [local]
|
||||
depends_on:
|
||||
site: {condition: service_healthy}
|
||||
kanban: {condition: service_healthy}
|
||||
storage: {condition: service_healthy}
|
||||
security_opt: [no-new-privileges:true]
|
||||
cap_drop: [ALL]
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
storage-data:
|
||||
|
||||
@@ -8,7 +8,7 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: infra/Dockerfile
|
||||
command: python -m app.staging_readiness /config/staging.env
|
||||
command: python -m ops.staging_readiness /config/staging.env
|
||||
volumes:
|
||||
- ./staging/staging.env:/config/staging.env:ro
|
||||
network_mode: none
|
||||
|
||||
@@ -21,6 +21,7 @@ WORKDIR /app
|
||||
COPY infra/requirements.txt infra/requirements.lock /app/infra/
|
||||
RUN python -m pip install --no-cache-dir --require-hashes -r infra/requirements.lock
|
||||
COPY app /app/app
|
||||
COPY ops /app/ops
|
||||
RUN useradd --uid 10001 --create-home --shell /usr/sbin/nologin dtf
|
||||
USER 10001:10001
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PYTHONPATH=/app
|
||||
|
||||
@@ -176,6 +176,8 @@ def config_errors(values):
|
||||
errors.append('OWNER_UPLOAD_QUOTA_BYTES cannot exceed STORAGE_QUOTA_BYTES')
|
||||
if numeric.get('SCAN_MAX_BYTES', 0) > numeric.get('MAX_UPLOAD_BYTES', 0):
|
||||
errors.append('SCAN_MAX_BYTES cannot exceed MAX_UPLOAD_BYTES')
|
||||
if numeric.get('SCAN_MAX_BYTES', 0) > 128 * 1024 * 1024:
|
||||
errors.append('SCAN_MAX_BYTES cannot exceed the configured ClamAV 128 MiB stream limit')
|
||||
ports = {}
|
||||
for name in ('SITE_PORT', 'KANBAN_PORT'):
|
||||
try:
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from .production_preflight import config_errors, source_errors
|
||||
|
||||
|
||||
class ReleaseWorkflowTests(unittest.TestCase):
|
||||
def test_main_push_cannot_publish_and_manual_release_is_gated(self):
|
||||
workflow = (Path(__file__).resolve().parents[1] /
|
||||
'.gitea/workflows/deploy.yml').read_text()
|
||||
release = workflow.split(' publish-and-deploy:\n', 1)[1]
|
||||
self.assertIn("if: gitea.event_name == 'workflow_dispatch' && "
|
||||
"gitea.ref == 'refs/heads/main'", release)
|
||||
preflight = release.index('python3 deploy/production_preflight.py --source-only')
|
||||
webhook = release.index('test -n "$PORTAINER_WEBHOOK"')
|
||||
scan = release.index('- name: Image vulnerabilities')
|
||||
publish = release.index('- name: Publish validated images')
|
||||
redeploy = release.index('- name: Trigger Portainer redeployment')
|
||||
self.assertLess(preflight, scan)
|
||||
self.assertLess(webhook, scan)
|
||||
self.assertLess(scan, publish)
|
||||
self.assertLess(publish, redeploy)
|
||||
|
||||
|
||||
def valid_config():
|
||||
digest = '1' * 64
|
||||
values = {
|
||||
@@ -112,6 +131,7 @@ class ProductionPreflightTests(unittest.TestCase):
|
||||
self.assertTrue(any('distinct external Swarm secret' in error for error in errors))
|
||||
self.assertIn('OWNER_UPLOAD_QUOTA_BYTES cannot exceed STORAGE_QUOTA_BYTES', errors)
|
||||
self.assertIn('SCAN_MAX_BYTES cannot exceed MAX_UPLOAD_BYTES', errors)
|
||||
self.assertTrue(any('configured ClamAV 128 MiB stream limit' in error for error in errors))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -20,6 +20,10 @@ x-app-environment: &app-environment
|
||||
OPERATOR_EMAIL: ${OPERATOR_EMAIL:-}
|
||||
OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:?set OPERATOR_PASSWORD}
|
||||
PAYMENT_ADAPTER: fake
|
||||
# Optional: without it the webhook verifies nothing and therefore accepts
|
||||
# nothing, which is the correct state until a provider is connected. Set it
|
||||
# when the provider is configured, never to a value anyone could guess.
|
||||
PAYMENT_WEBHOOK_SECRET: ${PAYMENT_WEBHOOK_SECRET:-}
|
||||
FREIGHT_ADAPTER: fake
|
||||
TINY_ADAPTER: fake
|
||||
WHATSAPP_ADAPTER: fake
|
||||
|
||||
@@ -297,11 +297,12 @@ as references and are not imported or started by Compose.
|
||||
files can be quoted, commercially approved, paid, downloaded, attached as final
|
||||
files, or admitted to the print queue. Rejected/error files remain blocked and
|
||||
expire within three days. The isolated scanner uses signatures bundled in its
|
||||
pinned image and has no external network route. The transport accepts files up
|
||||
to 5 GiB, but the local scan/release limit is 128 MiB; larger files remain
|
||||
blocked. This malware gate is not print pre-flight or artwork validation.
|
||||
pinned image and has no external network route. The multipart transport could
|
||||
carry 5 GiB, but API and customer admission stop at the effective 128 MiB
|
||||
scan/release limit by default. Larger files require a new scan/release design.
|
||||
This malware gate is not print pre-flight or artwork validation.
|
||||
- A retention worker removes expired object bytes but keeps order/file metadata:
|
||||
incomplete uploads after one day, originals within seven days of manual final
|
||||
incomplete uploads after a one-hour reservation lease, originals within seven days of manual final
|
||||
artwork approval, and attached final/correction files within 30 days of the
|
||||
order's first upload. Storage lifecycle is also a 30-day backstop.
|
||||
- Structured security events are written to logs and PostgreSQL. The local
|
||||
@@ -327,7 +328,7 @@ as references and are not imported or started by Compose.
|
||||
not been deployed. Its fail-closed preflight intentionally rejects the current
|
||||
source until production adapters, Docker-secret file loading, approved inputs,
|
||||
restore rehearsal, image scans, and human security approval are complete.
|
||||
- `python3 -m app.backup create-and-verify` creates a private, Git-ignored bundle
|
||||
- `python3 -m ops.backup create-and-verify` creates a private, Git-ignored bundle
|
||||
containing a PostgreSQL dump plus every complete, unexpired object already marked
|
||||
`clean`. SHA-256 manifests protect both parts. Verification restores the database
|
||||
under a UUID name and the object bytes under a UUID MinIO prefix, hashes the
|
||||
|
||||
@@ -161,8 +161,8 @@ test is unmistakable:
|
||||
```bash
|
||||
python3 -m tests.security_test
|
||||
python3 -m tests.scanning_test
|
||||
docker compose exec -T api python3 -m tests.runtime_security_test
|
||||
docker compose exec -T api python3 -m tests.retention_test
|
||||
docker compose -f compose.local.yaml exec -T api python3 -m tests.runtime_security_test
|
||||
docker compose -f compose.local.yaml exec -T api python3 -m tests.retention_test
|
||||
```
|
||||
|
||||
`local.scanning_test` stores an EICAR fixture as `SECURITY-EICAR.cdr`; ClamAV
|
||||
@@ -183,14 +183,15 @@ test order for inspection. Both integration scripts read `.env` automatically.
|
||||
## Configuration and storage
|
||||
|
||||
`.env.example` lists local ports, database/MinIO values, operator login, adapter
|
||||
selection, mock freight amount, maximum file size (5 GiB), and multipart size
|
||||
(8 MiB by default). The malware scanner releases only files up to 128 MiB by
|
||||
default (`SCAN_MAX_BYTES`); larger uploads remain blocked even though the
|
||||
multipart transport supports 5 GiB. `S3_ENDPOINT=http://storage:9000`, database
|
||||
selection, mock freight amount, transport ceiling (5 GiB), and multipart size
|
||||
(8 MiB by default). The API and customer picker admit only files within the
|
||||
malware scanner's effective 128 MiB limit by default (`SCAN_MAX_BYTES`); the
|
||||
larger-file path remains a Week 2 decision. `S3_ENDPOINT=http://storage:9000`, database
|
||||
hostname `db`,
|
||||
and the internal service ports are fixed Compose wiring. The public S3 endpoint
|
||||
must resolve from the browser; keep `http://localhost:9000` for this stack.
|
||||
Parts use 15-minute presigned URLs and uploads must finish within one day.
|
||||
Parts use 15-minute presigned URLs and unfinished reservations expire after
|
||||
one hour. Clients can cancel a reservation through the upload DELETE endpoint.
|
||||
|
||||
Only fake integration adapters and `s3-local` storage are accepted. Startup fails
|
||||
if a production adapter/environment or nonlocal S3 endpoint is selected.
|
||||
@@ -199,7 +200,8 @@ and MinIO also join a network that permits loopback port publishing.
|
||||
|
||||
Objects are private, use UUID keys rather than filenames, and persist in a named
|
||||
volume. MinIO lifecycle rules expire objects after 30 days and abandon incomplete
|
||||
multipart uploads after one day; the API also blocks expired downloads. Order
|
||||
multipart uploads after one day as a backstop; the API lease and worker release
|
||||
unfinished reservations after one hour. The API also blocks expired downloads. Order
|
||||
history remains in PostgreSQL. Completed files start in `pending`; unknown,
|
||||
scanner-error, over-limit, encrypted/unsafe, and malware results fail closed.
|
||||
Only `clean` files can cross quote, payment, download, final-approval, and queue
|
||||
@@ -217,7 +219,7 @@ again. The operator can still inspect order records.
|
||||
## Local backup and restore check
|
||||
|
||||
```bash
|
||||
python3 -m app.backup create-and-verify
|
||||
python3 -m ops.backup create-and-verify
|
||||
```
|
||||
|
||||
This writes a private four-file bundle in `backups/` (ignored by Git and Docker
|
||||
@@ -234,7 +236,7 @@ checks database counts, bundle hashes, every archived object's hash, and the byt
|
||||
downloaded after restore, then removes only the temporary database and objects. It
|
||||
never restores over active data. Keep every bundle file private: it contains
|
||||
customer data, password hashes, and customer artwork. To verify it again, run
|
||||
`python3 -m app.backup verify` followed by the printed
|
||||
`python3 -m ops.backup verify` followed by the printed
|
||||
`backups/...manifest.json` path. Legacy database-only `.dump` backups remain
|
||||
verifiable. Scheduling, offsite copies, and a production restore runbook remain
|
||||
unfinished.
|
||||
@@ -242,7 +244,7 @@ unfinished.
|
||||
After building the current image, test retention with synthetic files:
|
||||
|
||||
```bash
|
||||
docker compose exec -T api python3 -m tests.retention_test
|
||||
docker compose -f compose.local.yaml exec -T api python3 -m tests.retention_test
|
||||
```
|
||||
|
||||
This checks that expired bytes are removed while unexpired files survive. It
|
||||
@@ -251,14 +253,14 @@ cleans up its own synthetic object bytes and retains their metadata.
|
||||
## Operations and troubleshooting
|
||||
|
||||
```bash
|
||||
docker compose logs --tail=100 api worker scanner
|
||||
docker compose restart api worker
|
||||
docker compose ps
|
||||
docker compose down
|
||||
docker compose -f compose.local.yaml logs --tail=100 api worker scanner
|
||||
docker compose -f compose.local.yaml restart api worker
|
||||
docker compose -f compose.local.yaml ps
|
||||
docker compose -f compose.local.yaml down
|
||||
```
|
||||
|
||||
`down` stops the stack and preserves named database/storage volumes. Restart
|
||||
with `docker compose up -d --wait`. Do not add `--volumes` unless you intend to
|
||||
with `docker compose -f compose.local.yaml up -d --wait`. Do not add `--volumes` unless you intend to
|
||||
permanently erase all local orders and artwork. No reset is required for tests.
|
||||
|
||||
Seven long-running services have Docker health checks; the database and storage
|
||||
@@ -270,7 +272,7 @@ exposes `/minio/health/ready`.
|
||||
Run the redacted local alert summary inside the API network namespace:
|
||||
|
||||
```bash
|
||||
docker compose exec -T api python3 -m app.security_status
|
||||
docker compose -f compose.local.yaml exec -T api python3 -m ops.security_status
|
||||
```
|
||||
|
||||
Exit status 1 means attention is required. Review blocked artwork, rate limits,
|
||||
@@ -312,7 +314,7 @@ a direct pin, regenerate the lock in the same Python 3.12 environment and rebuil
|
||||
|
||||
```bash
|
||||
./infra/lock_dependencies.sh
|
||||
docker compose up --build -d --wait
|
||||
docker compose -f compose.local.yaml up --build -d --wait
|
||||
```
|
||||
|
||||
The generator downloads public package metadata in a disposable container and
|
||||
|
||||
@@ -17,8 +17,9 @@ Cloudflare R2, so MinIO is not part of this stack.
|
||||
|
||||
The single workflow is `.gitea/workflows/deploy.yml`. Every push and pull request
|
||||
runs static validation, the integration suite against a real stack, and a secret
|
||||
scan. A push to `main` then builds the production images, publishes both `latest`
|
||||
and the full commit SHA, reports their vulnerabilities, and calls Portainer.
|
||||
scan. A push to `main` does not publish or deploy. A manual workflow run on
|
||||
`main` repeats those checks, builds and scans the images, then publishes both
|
||||
`latest` and the full commit SHA and calls Portainer.
|
||||
|
||||
What actually gates a deployment:
|
||||
|
||||
@@ -26,17 +27,19 @@ What actually gates a deployment:
|
||||
|---|---|
|
||||
| `py_compile` and the unit tests | yes |
|
||||
| Integration suite on a live stack (smoke, workflow, security, scanning, retention, runtime) | yes |
|
||||
| Browser suites | only when the runner has Chrome; otherwise warns and continues |
|
||||
| Browser suites | yes; Chrome runs in the Compose test container |
|
||||
| Trivy secret scan (HIGH/CRITICAL) | yes |
|
||||
| Source preflight (`deploy/production_preflight.py --source-only`) | only when `ENFORCE_PRODUCTION_PREFLIGHT` is `true` |
|
||||
| Source preflight (`deploy/production_preflight.py --source-only`) | yes for manual release; advisory on pushes unless `ENFORCE_PRODUCTION_PREFLIGHT` is `true` |
|
||||
| Trivy image vulnerabilities, CRITICAL | yes |
|
||||
| Trivy image vulnerabilities, HIGH | no — reported after the build |
|
||||
| Trivy image vulnerabilities, HIGH | no — reported before publication |
|
||||
| Configured Portainer webhook | yes for manual release |
|
||||
|
||||
The source preflight is advisory by default because it refuses a release while
|
||||
the payment and messaging adapters are fake, which is the deliberate state the
|
||||
stack runs in today. Enforcing it now would block every deployment. Set the
|
||||
repository variable `ENFORCE_PRODUCTION_PREFLIGHT` to `true` once real adapters
|
||||
land, and it becomes a hard gate.
|
||||
The source preflight refuses a release while the payment and messaging adapters
|
||||
are fake. It remains advisory on push checks so development can continue, but a
|
||||
manual release is blocked until those adapters are replaced. Set the repository
|
||||
variable `ENFORCE_PRODUCTION_PREFLIGHT` to `true` when all pushes should also
|
||||
fail on those blockers. Before a manual release, run the full configuration
|
||||
preflight below against the actual Portainer values; CI checks source only.
|
||||
|
||||
CRITICAL image findings block. Both images carry none: the bases are pinned by
|
||||
digest, both Dockerfiles upgrade their OS packages, and the web image moved off
|
||||
@@ -60,10 +63,10 @@ Repository secrets:
|
||||
- `REGISTRY_USERNAME` and `REGISTRY_TOKEN` — package write credentials.
|
||||
- `PORTAINER_WEBHOOK` — webhook generated by the `dtf-cloud` Portainer stack.
|
||||
|
||||
The webhook is called only after the gating checks in the table above pass.
|
||||
The webhook is called only after the release gates above pass.
|
||||
`ENFORCE_PRODUCTION_PREFLIGHT` and `TRIVY_IMAGE` are optional repository
|
||||
variables; without them the preflight is advisory and a pinned default scanner
|
||||
image is used.
|
||||
variables; the former affects push checks and the latter defaults to a pinned
|
||||
scanner image.
|
||||
|
||||
## 2. One-time Portainer resources
|
||||
|
||||
@@ -124,10 +127,13 @@ The `db-init` service completing and stopping is expected. The other six
|
||||
services must be healthy. A failed `db-init` task or an unhealthy service blocks
|
||||
acceptance.
|
||||
|
||||
## 4. Normal deployment
|
||||
## 4. Manual deployment
|
||||
|
||||
Push to `main`. Gitea validates, tests, scans, publishes these images, and calls
|
||||
the webhook:
|
||||
Push the reviewed commit to `main` and wait for its validation workflow to pass.
|
||||
After validating the actual Portainer configuration with the full preflight in
|
||||
section 3, use Gitea Actions to manually run **Build and deploy** on `main` at
|
||||
that commit. The workflow repeats validation, tests and scans, then publishes
|
||||
these images and calls the webhook:
|
||||
|
||||
```text
|
||||
gitea.blyzer.com.br/blyzer/dtf-api:latest
|
||||
|
||||
85
docs/REMEDIATION-2026-09-22.md
Normal file
85
docs/REMEDIATION-2026-09-22.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# DTF remediation register — 2026-09-22
|
||||
|
||||
This is the action list for all 37 findings in [the September 21 review](REVIEW-2026-09-21.md). That review contains the evidence and severity for each ID. This register includes the September 22 payment review. It is a plan, not evidence that a finding has been closed in production.
|
||||
|
||||
**Current position:** We are in Week 2. The local fixes for foreign-quote order disclosure and approval without a verified amount are implemented and tested, but are not committed or deployed. The first order-correctness slice now covers parts of findings 2, 5–8, and 11; see the progress note below. The remaining work and production verification stay open. Payment webhook work is partial progress on finding 31, not completion of real payments.
|
||||
|
||||
**Local progress, 2026-09-22:** Browser and API regressions covered stale editor items, DPI refusal and warning acknowledgement, changed-cart quote actions, oversized width, and finals invalidated by a later correction. A versioned per-file source specification survived quote review into the order snapshot. At that point exact placement coordinates and a generated print file were still missing. None of these changes is a production release.
|
||||
|
||||
**Local progress, 2026-09-23:** Specification v2 adds per-copy film coordinates, validates every copy and the quote height, and keeps a downloadable layout manifest in the approved order. The board now pages pending and approved unpaid quotes; a local regression reached all 105 pending and 22 approved fixture quotes. Final print-file generation, completed-order search, and quote cancellation/expiry lifecycle remain open.
|
||||
|
||||
**Operational progress, 2026-09-23:** Finding 23's entrypoints are repaired locally. The staging gate passed in a network-disabled image with non-secret fixture data; `ops.security_status` ran in the API image and correctly reported stale local signatures; `ops.backup create-and-verify` restored database counts and 78 clean objects in isolated temporary targets; the production API image built and imported `ops`. This verifies the commands, not production offsite recovery (finding 30) or a fresh scanner (finding 17).
|
||||
|
||||
**Quality progress, 2026-09-23:** Findings 9 and 10 are partly repaired: failed image decoding blocks checkout, mixed analyzed/manual sheets stay at table price, and rotated DPI uses the correct pixel axis. PDF measurement now uses the PDF.js page model with effective crop, rotation, UserUnit and page count; invalid or multi-page files block quoting. The same-origin PDF worker and isolated browser checks pass. Unsupported image operators, representative print-file evidence and final printability remain open.
|
||||
|
||||
**Upload progress, 2026-09-23:** The API and customer picker now reject files above the effective ClamAV stream limit before transfer, and the session advertises that limit. Unfinished reservations have a one-hour lease and a customer/operator cancel endpoint; owner-scoped cancellation has an integration check. Quota remains reserved until the object is actually purged, so a failed cleanup cannot admit unaccounted storage. PDF rendering now destroys the parser job when its timeout fires, covered by a browser check. This closes the misleading upload-then-quarantine path locally but does not satisfy the agreed large-file capability in finding 3. A tested large-file scan/release design, stronger anonymous admission controls (finding 16), and remaining browser resource bounds (finding 12) are still required.
|
||||
|
||||
## Gates
|
||||
|
||||
| Gate | Meaning |
|
||||
|---|---|
|
||||
| **W2** | Fix during Week 2 before calling the corresponding client workflow complete. These defects can be worked on while provider contracts are clarified. |
|
||||
| **Upload** | Resolve before inviting the public to upload customer artwork. |
|
||||
| **Paid** | Resolve before enabling live checkout or accepting a real paid order. |
|
||||
| **Release** | Resolve before declaring the deployed production system ready. |
|
||||
| **Incremental** | Improve alongside feature work; it does not justify a standalone rewrite. |
|
||||
|
||||
The gates are cumulative: a live release must pass W2, Upload, Paid, and Release checks. Decisions labelled **business** require an agreed product rule; engineering can build and test the surrounding flow in parallel. Where a deployment risk is conditional, verify the actual topology and document the result before closing it.
|
||||
|
||||
## Complete finding-to-action map
|
||||
|
||||
| Review ID | Gate | Required action and closure evidence |
|
||||
|---|---|---|
|
||||
| 1 | Paid | Implement real payment, freight, ERP, and notification adapters with sandbox acceptance and reconciliation; remove fake adapters from the live path. |
|
||||
| 2 | W2 | Store a versioned per-file production specification and approved layout on quote and order; prove the factory can reproduce the purchased job. |
|
||||
| 3 | Upload; business | Agree the advertised maximum and implement a scan/release path that actually supports it; reject unsupported sizes before transfer. |
|
||||
| 4 | W2 | Give quotes an explicit lifecycle and paginated/searchable operator view; verify the 101st actionable quote remains visible. |
|
||||
| 5 | W2 | Invalidate or revision-bind finals when a new correction arrives; test a correction submitted after a final was uploaded. |
|
||||
| 6 | W2 | Make quality eligibility a checkout gate and store any required acknowledgement against the artwork revision. |
|
||||
| 7 | W2 | Clear the current cart item immediately when artwork is removed or becomes invalid; test the submitted payload. |
|
||||
| 8 | W2 | Bind checkout to an immutable quoted cart snapshot; require re-quote after any material edit, including same-price edits. |
|
||||
| 9 | W2 | Measure PDF pages through the parser's page model; handle every supported page or reject multi-page/unsupported geometry explicitly. |
|
||||
| 10 | W2 | Require quality evidence per billable source; make undecodable/unknown sources explicit and correct rotation-sensitive DPI calculations. |
|
||||
| 11 | W2 | Validate physical dimensions before packing; never silently scale a requested print size. |
|
||||
| 12 | Upload | Bound browser decoding, copy count, PDF work, and preview size; cancel obsolete work and test representative large inputs. |
|
||||
| 13 | Paid | Capture and validate a full delivery-address snapshot, then connect it to freight quote and order fulfilment. |
|
||||
| 14 | Paid; business | Set written auto-approval rules and manual-exception criteria; prove eligible orders can complete after hours without an operator. |
|
||||
| 15 | W2; business | Define accepted print output, generate it from the approved versioned layout, and compare produced geometry/metres with the quote. If scope changes, update the client commitment and site claims explicitly. |
|
||||
| 16 | Upload | Limit anonymous reservation capacity and lifetime; add cancellation and cleanup, then test quota-exhaustion behavior. |
|
||||
| 17 | Upload | Update ClamAV signatures on a controlled schedule; surface signature age and fail the intake gate when stale. |
|
||||
| 18 | Release | Trust only the actual proxy hop, restrict origin access, and test real client IP/rate limits through the deployed Swarm topology. |
|
||||
| 19 | Release | Wire file-backed secrets into the active stack; give each service only necessary credentials and remove unused bootstrap secrets. |
|
||||
| 20 | Release | Recheck operator `active` atomically when issuing and using sessions; test disable-versus-login concurrency and document password-change session policy. |
|
||||
| 21 | Paid | Provide email verification and customer recovery/guest continuity, and make checkout's account-creation claim match reality. |
|
||||
| 22 | Release; business | Agree retention/export/deletion rules for profiles, quotes, orders, payloads, artwork, and backups; implement and verify them. |
|
||||
| 23 | W2 | Repair staging, backup, and security commands after the directory move; smoke-test them in the images and Compose files actually shipped. |
|
||||
| 24 | Release | Set and test PostgreSQL node placement/persistence for the intended Swarm size, plus recovery after host failure. |
|
||||
| 25 | Release | Scan before promotion, publish immutable paired API/web image identities, and deploy exactly the scanned release. |
|
||||
| 26 | Release | Make preflight enforce the active stack contract and provider behavior; verify Portainer/deployment convergence after promotion. |
|
||||
| 27 | Release | Run browser tests in a network where signed storage URLs work; fail CI when Chrome or the test endpoint is unavailable. |
|
||||
| 28 | Release | Isolate each CI Compose project, ports, networks, and volumes; serialize release promotion and test overlapping runs. |
|
||||
| 29 | Release | Separate liveness/readiness, monitor provider backlog, cleanup, scanner freshness and backup age; test alert routing and rollback acceptance. |
|
||||
| 30 | Release; business | Set recovery objectives, make consistent encrypted offsite backups, and rehearse restore of database plus required live artwork. |
|
||||
| 31 | Paid | Finish durable payment intent, idempotent webhook handling, status/refund rules, reconciliation, and ordered outbox/dead-letter recovery; test provider-success/database-failure cases. Signed event work is only partial progress. |
|
||||
| 32 | Upload | Bound upload concurrency, decouple upload from scan waiting, measure queue latency, and distinguish transient scan errors from rejected content. |
|
||||
| 33 | Release | Introduce ordered schema migrations and core constraints/relationships; test both clean install and upgrade from the existing schema. |
|
||||
| 34 | Incremental | Replace shared mutable browser cart state as part of IDs 2/7/8; then extract reusable business operations from routes and add bounded DB connection management where load measurements warrant it. |
|
||||
| 35 | Release | Add representative artwork, real PDF, failure/retry, migration, operational-command, and exact-release acceptance tests. |
|
||||
| 36 | W2 | Correct executable setup/Portainer/security instructions and PDF generator paths; check generated output against current scope. |
|
||||
| 37 | Release | Inventory and scan every deployed image and vendored asset, pin release dependencies, and set a controlled refresh process. Do not describe the existing PDF.js advisory as a proven exploit. |
|
||||
|
||||
## Execution order
|
||||
|
||||
1. **Correct the customer/order model now:** IDs 2, 4–11, 23, and 36. Keep an immutable quote revision through payment, production output, and correction approval. Close each defect with a focused regression test and a real artwork example where geometry matters.
|
||||
2. **Set the missing product rules while coding continues:** IDs 3, 14, 15, 22, and 30. Obtain representative files, accepted print format, auto-approval thresholds, retention rules, recovery objectives, and provider sandbox access. Do not collect credentials in this document.
|
||||
3. **Make public intake safe:** IDs 3, 12, 16, 17, and 32. Test the declared upload size end to end, including scan, release, quota, browser memory, and timeout behavior.
|
||||
4. **Complete live commerce:** IDs 1, 13, 14, 15, 21, and 31. Build freight/address, payment/reconciliation, ERP, and notification flows; test duplicates, outages, refunds, and human exceptions in provider sandboxes.
|
||||
5. **Prove the deployed system:** IDs 18–20, 22, 24–30, 33, 35, and 37. Run the exact images and stack, exercise migration, backup/restore, monitoring, secrets, CI and release rollback. Improve ID 34 as the affected areas are changed.
|
||||
|
||||
## Who supplies what
|
||||
|
||||
- **Engineering:** implement and test the code, schema, operational commands, CI gates, provider adapters, and recovery runbooks; gather evidence for each closure. This work can start with the order/cart defects without waiting for provider access.
|
||||
- **Business/client:** approve unattended-pricing exceptions, final print-file format and samples, the real maximum file size, shipping services and policy, privacy retention, and recovery objectives. The detailed worksheet is [production inputs](PRODUCTION_INPUTS.md).
|
||||
- **Provider/operations owners:** supply sandbox accounts and configuration through the approved secret channel, plus the real deployment topology, backup destination, alert recipients, and release/rollback ownership. No credentials belong in this register or the repository.
|
||||
|
||||
**Closure rule:** A checkbox or passing mocked flow is insufficient. For each ID, keep the original evidence, record the implemented change and test, then verify in the environment that carries the risk. The [working roadmap](ROADMAP.md) tracks Week 2 delivery status; this register tracks the full defect disposition.
|
||||
144
docs/REVIEW-2026-09-21.md
Normal file
144
docs/REVIEW-2026-09-21.md
Normal file
@@ -0,0 +1,144 @@
|
||||
**DTF project review — September 21, 2026**
|
||||
|
||||
Reviewed revision: `7386469`. Commit window: September 21, 00:00–24:00, America/Sao_Paulo; 26 commits. This is a review, not an implementation change or production approval.
|
||||
|
||||
**Assessment**
|
||||
|
||||
The project has a useful local workflow and several sound controls, but it is not yet the automated ordering and production system described in the meeting. The largest risks are incomplete commercial integrations, loss of production instructions between the editor and API, incorrect cart/quote behavior, an upload limit that the scanner cannot support, and operational controls that are documented more strongly than they are implemented.
|
||||
|
||||
Today's restructuring improved navigation through the repository, but introduced broken operational entrypoints. Today's board limit also introduced a starvation bug. Passing the existing tests does not establish that the requested artwork can be reproduced correctly or that the production deployment is recoverable.
|
||||
|
||||
**Business baseline and scope**
|
||||
|
||||
I read [the September 9 meeting notes](/home/farelos/compor/dtf-sistema/docs/reuniao-2026-09-09-anotacoes.pdf) first, then compared the implementation with [the later project context](/home/farelos/compor/dtf-sistema/docs/CONTEXT.md:20) and [the client roadmap](/home/farelos/compor/dtf-sistema/docs/roadmap-cliente.pdf).
|
||||
|
||||
The meeting's core outcome is unattended order intake and payment, fewer designer handoffs, reliable large-file handling, a traceable queue, and separation of ready artwork from artwork needing assistance. Later decisions explicitly defer automatic print preflight, factory agents, FlexiPRINT integration, machine dashboards, and advanced reports. Their absence is not reported here as an accidental regression. The approved site pricing also supersedes the meeting's simplified 20% discount description; changing those prices would require a separate business decision.
|
||||
|
||||
The active layout is `web/` for browser code; `app/api/` for HTTP routes; `app/core/` for shared foundations; the remaining `app/` modules for storage, artwork, scanning, initialization, and background work; `infra/` for local images and configuration; `deploy/`, the root Swarm composition, and `.gitea/` for delivery; `ops/` for operational commands; and `tests/` for checks. Historical documents describe earlier models and should not define current acceptance criteria.
|
||||
|
||||
**Evidence and limits**
|
||||
|
||||
- Inspected the active first-party application, browser logic, schema, deployment definitions, operational scripts, tests, project documents, and today's commit history and relevant diffs. Vendored PDF.js was checked as a third-party dependency, not subjected to a line-by-line audit of its minified implementation. Historical material was used as background.
|
||||
- Ran the current fast suite: 29 tests executed, 28 passed, one skipped because it still looks for the deleted `deploy/stack.yaml`.
|
||||
- Parsed all first-party Python files and checked the syntax of all first-party browser JavaScript files successfully.
|
||||
- Ran the isolated artwork browser suite with additional review probes against synthetic files. The existing checks passed; the probes reproduced findings 6, 7, and 10 below.
|
||||
- Ran the production source preflight: it correctly reported the fake payment and messaging adapters as blockers. CI does not enforce that result by default.
|
||||
- Used read-only checks in the running local API container: `ops` is absent, `app.staging_readiness` is absent, a 128 MiB + 1 byte scan is rejected, and ClamAV reports `1.5.4/28122/Sun Sep 13 06:26:25 2026`.
|
||||
- Executed the actual `submit_files` function body against an in-memory connection double to confirm which file kinds it invalidates. Also reproduced the board query's 101st-quote omission against synthetic SQL data. These are targeted logic checks, not production database tests.
|
||||
- Did not change application code, production services, credentials, orders, or stored artwork. Did not rerun the full container integration suite, perform a current Trivy audit, test provider sandboxes, or inspect production firewall/Portainer settings. Deployment-dependent risks below are explicitly qualified.
|
||||
|
||||
P1 means a delivery blocker or a material correctness, security, or recovery risk that should be resolved before accepting real customer work. P2 means an important correctness, reliability, or maintenance issue. A finding labelled a gap or design risk is not presented as an observed production incident.
|
||||
|
||||
**Commercial flow and artwork correctness**
|
||||
|
||||
1. **P1 — Production cannot complete an order. Confirmed delivery gap.** `dev-paid` returns 503 outside local mode, while runtime configuration requires fake payment, freight, Tiny, and WhatsApp adapters. There is no real payment creation/webhook/reconciliation flow, carrier quote, ERP order, or notification delivery. R2 connectivity and a healthy public page therefore do not establish a usable sales system. Complete the provider contracts and implement their sandbox-tested flows before treating the deployment as live commerce. Evidence: [orders.py:17](/home/farelos/compor/dtf-sistema/app/api/orders.py:17), [adapters.py:9](/home/farelos/compor/dtf-sistema/app/adapters.py:9), [runtime.py:24](/home/farelos/compor/dtf-sistema/app/runtime.py:24).
|
||||
|
||||
2. **P1 — The customer's production instructions disappear at checkout. Confirmed defect.** The editor records width, copies, rotation, mirroring, and ready-sheet repetitions, but `itemAtual` retains only totals and original Files. The API receives only mode, aggregate metres, grade, and upload IDs. It cannot tell the factory whether one artwork should be printed six times at 20 cm or with a different combination producing the same length. Neither the saved cart nor the order contains the full reproducible layout. Persist a versioned per-file production specification and the approved layout/revision. Evidence: [site-cart.js:65](/home/farelos/compor/dtf-sistema/web/site-cart.js:65), [checkout.js:65](/home/farelos/compor/dtf-sistema/web/checkout.js:65), [models.py:59](/home/farelos/compor/dtf-sistema/app/core/models.py:59).
|
||||
|
||||
3. **P1 — The 5 GiB upload path cannot deliver files above 128 MiB. Reproduced.** The browser/API accept 5 GiB, but `ClamAV.scan` hard-caps acceptance at `min(128 MiB, SCAN_MAX_BYTES)`, and ClamAV has matching limits. Raising the environment variable alone cannot fix it. Oversized files are rejected, blocked from quote/download/production, and scheduled for deletion; the customer may upload gigabytes before discovering this. Expose the usable limit before transfer and implement a deliberate large-file scan/release design. Evidence: [scanning.py:32](/home/farelos/compor/dtf-sistema/app/scanning.py:32), [clamd.conf:8](/home/farelos/compor/dtf-sistema/infra/clamd.conf:8), [docker-compose.yml:31](/home/farelos/compor/dtf-sistema/docker-compose.yml:31).
|
||||
|
||||
4. **P1 — The oldest 100 unpaid quotes can permanently hide new work. Introduced today in `543a9a9`.** The board selects the oldest unpaid quotes with a limit, including approved, expired, and abandoned quotes. There is no pagination, archive/cancel action, or pending-quote expiry that frees this window. Approved quotes are immutable, and expired ones cannot be paid, so old entries can remain indefinitely. Quote 101 is invisible even while needing review. Finished-order trimming also has no operator search/archive view for older completed work. Paginate/search history and give quotes an explicit lifecycle; do not silently cap actionable work. Evidence: [operator.py:59](/home/farelos/compor/dtf-sistema/app/api/operator.py:59), [kanban.js:24](/home/farelos/compor/dtf-sistema/web/kanban.js:24).
|
||||
|
||||
5. **P1 — A new correction can leave an obsolete final approved. Targeted logic reproduction.** An operator may submit finals while the order is in `cor`. The customer may then submit another correction using the latest version. `submit_files` deactivates only files of the incoming kind, so the earlier final remains active. Moving `cor → tra → fil` checks final coverage, not whether those finals were approved after the latest correction, and can accept the stale set. Invalidate finals on every accepted customer correction, or bind final approval to the specific correction revision. Evidence: [artwork.py:15](/home/farelos/compor/dtf-sistema/app/artwork.py:15), [artwork.py:40](/home/farelos/compor/dtf-sistema/app/artwork.py:40), [operator.py:118](/home/farelos/compor/dtf-sistema/app/api/operator.py:118).
|
||||
|
||||
6. **P2 — The stated DPI rejection and customer acknowledgement do not gate checkout. Browser-reproduced.** With 25.4-DPI artwork, the quality button was disabled but the payment button remained enabled and a cart item existed. `pintaEntrega` checks only customer data and freight; `dtfCheckout` does not check the resolution gate. The warning acknowledgement is also never recorded in the order. Manual operator review currently provides a later checkpoint, but the UI's claim that these files cannot proceed is false. Use one eligibility state for cart submission and retain any required acknowledgement against the artwork revision. Evidence: [site-quality.js:158](/home/farelos/compor/dtf-sistema/web/site-quality.js:158), [site-flow.js:90](/home/farelos/compor/dtf-sistema/web/site-flow.js:90), [checkout.js:49](/home/farelos/compor/dtf-sistema/web/checkout.js:49).
|
||||
|
||||
7. **P1 — Removing artwork can leave it in the submitted cart. Browser-reproduced.** After deleting the only artwork, `artes.length` became zero while `itemAtual.localFiles` still contained the removed file and checkout stayed enabled. An incomplete evaluation hides panels without clearing `itemAtual`; other invalid edits can similarly leave old totals and Files alive. Clear or invalidate the current cart item immediately whenever its underlying artwork becomes incomplete. Evidence: [site-cart.js:7](/home/farelos/compor/dtf-sistema/web/site-cart.js:7), [site-quality.js:79](/home/farelos/compor/dtf-sistema/web/site-quality.js:79), [site-pdf.js:348](/home/farelos/compor/dtf-sistema/web/site-pdf.js:348).
|
||||
|
||||
8. **P1 — Editing the cart after requesting a quote can still purchase the old quote. Confirmed control-flow defect.** Once `draftId` exists, checkout returns early through `refresh()` and never compares the current cart with the stored quote. The editor remains usable. The confirmation displays a server total but no full immutable item comparison, so quantity/artwork edits can be silently ignored, especially when the one-metre minimum keeps the total unchanged. Bind the UI to the quoted snapshot and explicitly replace the quote on edits. Evidence: [checkout.js:51](/home/farelos/compor/dtf-sistema/web/checkout.js:51), [checkout.js:89](/home/farelos/compor/dtf-sistema/web/checkout.js:89).
|
||||
|
||||
9. **P2 — PDF quantity and geometry are not reliably measured. Confirmed algorithm limitation.** Measurement searches the first/last 4 MiB for the first textual `MediaBox` and an unrelated first `UserUnit`; rendering/quality checks only page 1. There is no multi-page rejection or sum across pages. A multi-page print file can therefore be quoted as one page. Compressed/inherited page dictionaries, rotation, and page-specific units are not handled by this textual search. Use the PDF parser's page model, and either support every page or explicitly reject unsupported documents. Evidence: [site-pdf.js:101](/home/farelos/compor/dtf-sistema/web/site-pdf.js:101), [site-pdf.js:132](/home/farelos/compor/dtf-sistema/web/site-pdf.js:132).
|
||||
|
||||
10. **P2 — Quality grades can be based on missing or incorrect evidence. Partly browser-reproduced.** A CDR with no analysis plus an analysed PNG received grade 50 and R$18.70/m for the entire item rather than the CDR's stated table rate of R$19.90/m. `filter(Boolean)` removes unanalysed files from grading while their metres remain billable. For loose artwork, failed image decoding leaves a pixel estimate derived from compressed file size. Rotation retains the original width for DPI even when the physical width now corresponds to image height. The PDF image walker also treats no recognized images as vector artwork, although unsupported image operators or failed object lookup can yield the same result. Track quality per source and fail explicitly when measurement is unknown. Evidence: [site-quality.js:19](/home/farelos/compor/dtf-sistema/web/site-quality.js:19), [site-config.js:107](/home/farelos/compor/dtf-sistema/web/site-config.js:107), [site-upload.js:96](/home/farelos/compor/dtf-sistema/web/site-upload.js:96), [site-pdf.js:46](/home/farelos/compor/dtf-sistema/web/site-pdf.js:46).
|
||||
|
||||
11. **P2 — Oversized artwork is silently shrunk by the packer. Confirmed code path.** Width inputs advertise a maximum, but their JavaScript handlers accept larger values without validating form constraints. When no orientation fits, `encaixar` scales the artwork down to film width. The requested dimension and actual preview geometry then disagree, without an explicit approval to resize. Reject impossible dimensions or offer a visible, accepted resize. Evidence: [site-pdf.js:349](/home/farelos/compor/dtf-sistema/web/site-pdf.js:349), [site-packing.js:175](/home/farelos/compor/dtf-sistema/web/site-packing.js:175).
|
||||
|
||||
12. **P2 — The browser processing path is not bounded for large artwork. Confirmed design risk.** Images are loaded as full data URLs and decoded repeatedly; PDFs use full-file `arrayBuffer`; copy count has no effective upper bound before `Array.from`; preview canvas height grows with the whole layout. PDF timeouts race a promise without cancelling parsing/rendering or destroying the document. Large files or copy counts can exhaust memory or freeze the main thread before resumable upload helps. Bound work, avoid repeated full decodes, tile previews, cancel obsolete parsing, and provide a path that does not require browser rasterization of huge files. Evidence: [site-packing.js:6](/home/farelos/compor/dtf-sistema/web/site-packing.js:6), [site-packing.js:204](/home/farelos/compor/dtf-sistema/web/site-packing.js:204), [site-pdf.js:97](/home/farelos/compor/dtf-sistema/web/site-pdf.js:97).
|
||||
|
||||
13. **P1 — Home-delivery orders cannot capture a deliverable address. Confirmed MVP gap.** Customer data contains CNPJ, phone, and email; freight contains only service and CEP. There is no recipient name, street, number, complement, city/state, or validated delivery snapshot. A CEP can support an estimate, but it does not identify the destination needed to fulfil the order. Add the delivery contract together with freight integration; automated label purchase can remain out of scope. Evidence: [models.py:10](/home/farelos/compor/dtf-sistema/app/core/models.py:10), [models.py:43](/home/farelos/compor/dtf-sistema/app/core/models.py:43), [index.html:1209](/home/farelos/compor/dtf-sistema/web/index.html:1209).
|
||||
|
||||
14. **P1 — Unattended sales still depend on a human commercial review. Product decision outstanding.** Server pricing safely recalculates prices, but metres and grade become trusted only when an operator approves every quote. This intentionally protects the local prototype; it does not solve the meeting's after-hours bottleneck. Decide which orders can be accepted automatically, what evidence supports their price, and what exceptions require a person. Automatic print preflight being deferred does not itself settle the pricing-authority question. Evidence: [orders.py:28](/home/farelos/compor/dtf-sistema/app/api/orders.py:28), [operator.py:78](/home/farelos/compor/dtf-sistema/app/api/operator.py:78), [CONTEXT.md:262](/home/farelos/compor/dtf-sistema/docs/CONTEXT.md:262).
|
||||
|
||||
15. **P1 — Final print-file generation remains missing from the promised delivery. Confirmed gap.** The server stores originals and manually uploaded finals; it never generates the layout shown in the browser, applies repetitions, splits output, or adds the promised order identification. Consequently the factory must reconstruct the job, and billed metres have no machine-verifiable connection to produced metres. The client roadmap includes final-file generation in week 2 even while deferring automatic preflight. Implement a reproducible output path and print acceptance checks, or explicitly renegotiate that deliverable and the site's promises. Evidence: [api/artwork.py:45](/home/farelos/compor/dtf-sistema/app/api/artwork.py:45), [site-packing.js:342](/home/farelos/compor/dtf-sistema/web/site-packing.js:342), [ROADMAP.md:171](/home/farelos/compor/dtf-sistema/docs/ROADMAP.md:171).
|
||||
|
||||
**Security and access**
|
||||
|
||||
16. **P1 — Anonymous reservations can exhaust the entire storage quota without uploading bytes. Confirmed arithmetic/control-flow risk; no attack run.** Quota accounting immediately reserves the declared file size. With current defaults, five guest identities can reserve two 5 GiB uploads each and occupy the global 50 GiB allowance. Guest sessions and per-owner limits do not prevent this; all reservations remain until incomplete-upload cleanup after one day, and no customer abort endpoint releases them. Add global admission safeguards, short idle reservation leases, explicit cancellation, and a suitable identity/abuse policy. Evidence: [uploads.py:21](/home/farelos/compor/dtf-sistema/app/api/uploads.py:21), [health.py:26](/home/farelos/compor/dtf-sistema/app/api/health.py:26), [worker.py:21](/home/farelos/compor/dtf-sistema/app/worker.py:21).
|
||||
|
||||
17. **P1 — Malware signatures are already stale in the inspected local runtime. Reproduced operational gap.** The scanner runs `clamd` directly from a pinned image on an internal network, with no updater or scheduled replacement. It reports September 13 signatures on September 21, exceeding the repository's own seven-day threshold. Worker health only requires a live thread and PING. A running scanner is therefore reported healthy despite stale detection data. Establish controlled signature updates and make signature freshness visible to operations. [ClamAV's signature-management documentation](https://docs.clamav.net/manual/Usage/SignatureManagement.html) describes the update mechanism. Evidence: [docker-compose.yml:79](/home/farelos/compor/dtf-sistema/docker-compose.yml:79), [worker.py:60](/home/farelos/compor/dtf-sistema/app/worker.py:60), [security_status.py:7](/home/farelos/compor/dtf-sistema/ops/security_status.py:7).
|
||||
|
||||
18. **P1 — The reverse-proxy trust boundary is broader than the claimed protection. Deployment-dependent security risk introduced in `3b92813`.** Nginx accepts forwarded client addresses from every RFC1918 network, rather than the actual trusted proxy. Any reachable private peer can supply that header. The web ports use Swarm ingress, so the assumption that a direct internet request necessarily arrives with a public socket peer also needs topology testing. If untrusted traffic arrives through a trusted internal peer, it can spoof audit addresses and rate-limit buckets. Restrict trusted proxy hops and firewall the origin ports; verify through the actual Swarm/reverse-proxy chain. I did not verify production exposure or demonstrate a public exploit. Evidence: [nginx.conf.template:17](/home/farelos/compor/dtf-sistema/deploy/nginx.conf.template:17), [docker-compose.yml:143](/home/farelos/compor/dtf-sistema/docker-compose.yml:143). References: [Nginx real-IP trust](https://nginx.org/en/docs/http/ngx_http_realip_module.html), [Docker ingress routing](https://docs.docker.com/engine/swarm/ingress/).
|
||||
|
||||
19. **P2 — Production credentials remain plain service environment values. Confirmed configuration risk.** The actual stack supplies DB, R2, and operator secrets directly, despite the new secret-file loader and documentation describing external secrets. API and worker both receive the bootstrap operator password even though runtime authentication now uses the database. Removing the unused second stack reduced drift, but did not remove this exposure from the active one. Use the implemented file loader in the real stack and restrict each service to the secrets it needs. This is metadata/operational exposure, not evidence of a public credential leak. Evidence: [docker-compose.yml:3](/home/farelos/compor/dtf-sistema/docker-compose.yml:3), [core/secrets.py:1](/home/farelos/compor/dtf-sistema/app/core/secrets.py:1).
|
||||
|
||||
20. **P2 — Operator disable can race with login. Introduced with accounts in `a874033`; static concurrency finding.** Login reads the active account, performs expensive password verification, then inserts a session in a separate transaction without rechecking `active`. Disabling between those steps deletes existing sessions, but the in-flight login can create a new one afterwards. The request guard checks only session existence/expiry, so that session can authorize a disabled account for eight hours. Recheck active status atomically when issuing sessions and in authorization. Password changes also intentionally retain current sessions; document or revise that recovery policy. Evidence: [operator.py:23](/home/farelos/compor/dtf-sistema/app/api/operator.py:23), [auth.py:102](/home/farelos/compor/dtf-sistema/app/core/auth.py:102), [operators.py:80](/home/farelos/compor/dtf-sistema/app/operators.py:80).
|
||||
|
||||
21. **P2 — Account recovery and guest continuity are incomplete. Confirmed gap.** There is no email verification, password recovery/change flow for customers, or durable guest recovery mechanism. A guest who loses the cookie or passes its seven-day expiry cannot prove ownership merely by knowing the order/CNPJ, correctly, but also has no supported way to regain access. The site incorrectly promises account creation at payment; the order route creates no account. Complete the identity/recovery journey without weakening the existing ownership checks. Evidence: [customer.py:25](/home/farelos/compor/dtf-sistema/app/api/customer.py:25), [auth.py:58](/home/farelos/compor/dtf-sistema/app/core/auth.py:58), [index.html:1190](/home/farelos/compor/dtf-sistema/web/index.html:1190).
|
||||
|
||||
22. **P2 — Personal-data lifecycle is undefined beyond artwork cleanup. Confirmed governance gap, not a legal conclusion.** Profiles, quote drafts, immutable order snapshots, and integration payloads duplicate contact data without an implemented deletion/anonymization/export policy. Artwork expiry does not cover those records or retained backups. The site does link an external privacy/terms page, so claiming there is no privacy link would be inaccurate; this review did not establish whether that notice covers this processing. Define retention and access requirements for each data class, then implement them consistently. Evidence: [schema.sql:9](/home/farelos/compor/dtf-sistema/app/schema.sql:9), [schema.sql:28](/home/farelos/compor/dtf-sistema/app/schema.sql:28), [index.html:1358](/home/farelos/compor/dtf-sistema/web/index.html:1358).
|
||||
|
||||
**Operations and release engineering**
|
||||
|
||||
23. **P1 — Today's directory move broke staging, backup, and security operations. Reproduced packaging failure in `b329f76`.** Staging runs `app.staging_readiness`, but the module now lives under `ops`. Neither API Dockerfile copies `ops`. Backup still calls `local.storage_backup` inside the API container and selects plain `docker compose`, which now means the production-oriented root file rather than `compose.local.yaml`. Documentation calls nonexistent `app.backup` and `app.security_status`. These are broken operational commands, not merely stale comments. Package a deliberate operational runtime, update every executable entrypoint, and smoke-test the shipped commands. Evidence: [compose.staging.yaml:11](/home/farelos/compor/dtf-sistema/compose.staging.yaml:11), [infra/Dockerfile:10](/home/farelos/compor/dtf-sistema/infra/Dockerfile:10), [backup.py:13](/home/farelos/compor/dtf-sistema/ops/backup.py:13), [backup.py:41](/home/farelos/compor/dtf-sistema/ops/backup.py:41).
|
||||
|
||||
24. **P1 — PostgreSQL has no Swarm data-placement contract. Conditional recovery risk.** The active stack uses a normal local named volume with no node constraint. On a multi-node Swarm, rescheduling can attach a same-named empty local volume on another node rather than the existing database. The documented `POSTGRES_VOLUME` and `dtf_database=true` placement contract is not implemented. A single-node VPS is also a single failure domain. Specify and test persistence, placement, and recovery instead of relying on the volume name alone. Evidence: [docker-compose.yml:40](/home/farelos/compor/dtf-sistema/docker-compose.yml:40), [docker-compose.yml:180](/home/farelos/compor/dtf-sistema/docker-compose.yml:180), [PORTAINER.md:73](/home/farelos/compor/dtf-sistema/docs/PORTAINER.md:73).
|
||||
|
||||
25. **P1 — `latest` is published before the release scan, and deployment is not tied to the scanned pair. Confirmed pipeline design defect.** API and web `latest` tags are pushed independently before vulnerability checks. A failed scan leaves the mutable deployment tags pointing at rejected images; a manual redeploy or another run can consume them. Concurrent runs can also overwrite each other's API/web tags, while the webhook carries no exact release identity. Build and scan first, then promote one immutable API/web release and deploy that release. Evidence: [deploy.yml:194](/home/farelos/compor/dtf-sistema/.gitea/workflows/deploy.yml:194), [deploy.yml:217](/home/farelos/compor/dtf-sistema/.gitea/workflows/deploy.yml:217), [deploy.yml:243](/home/farelos/compor/dtf-sistema/.gitea/workflows/deploy.yml:243).
|
||||
|
||||
26. **P1 — The production gate does not validate the deployed contract. Confirmed gap.** CI runs only `--source-only`, treats failure as advisory by default, and never validates the actual Portainer metadata or observes deployment convergence. Full config validation expects names/secrets/volumes from the removed stack, while the active stack uses different variables. Positive secret-loader checks also derive their cases from that deleted file, leaving the set empty; the associated unit test skips. The two remaining provider checks match literal source strings, not working provider behavior. Retarget validation to the actual deployment, retain positive loader tests independently of a file's existence, and add release acceptance against real capabilities. Evidence: [deploy.yml:155](/home/farelos/compor/dtf-sistema/.gitea/workflows/deploy.yml:155), [production_preflight.py:17](/home/farelos/compor/dtf-sistema/deploy/production_preflight.py:17), [production_preflight.py:81](/home/farelos/compor/dtf-sistema/deploy/production_preflight.py:81), [test_secrets.py:75](/home/farelos/compor/dtf-sistema/tests/test_secrets.py:75).
|
||||
|
||||
27. **P1 — Browser tests can be skipped on a green release, and their upload endpoint is incompatible with the runner topology. Confirmed CI gap.** Missing Chrome or inaccessible host loopback returns success. Meanwhile CI signs browser storage URLs for `http://storage:9000`, a Compose-only hostname; installing Chrome or making host ports reachable does not by itself give that browser access to storage. These are the only broad tests of the artwork/cart journey. Run Chrome and the tests in a compatible network, then make omission fail the release. Evidence: [deploy.yml:45](/home/farelos/compor/dtf-sistema/.gitea/workflows/deploy.yml:45), [deploy.yml:90](/home/farelos/compor/dtf-sistema/.gitea/workflows/deploy.yml:90), [browser_test.mjs:44](/home/farelos/compor/dtf-sistema/tests/browser_test.mjs:44).
|
||||
|
||||
28. **P2 — CI runs share fixed ports and lack enforced project isolation. Conditional concurrency risk.** The integration job uses the same five host ports, relies on the default Compose project name, and ends with `down -v`. Overlapping runs on the shared Docker daemon can collide; if their project names coincide, they can also operate on each other's containers and volumes. Selecting currently unused ports fixed one collision but not concurrency. Allocate a unique project/network per run, avoid unnecessary published ports, and serialize release promotion. Evidence: [deploy.yml:27](/home/farelos/compor/dtf-sistema/.gitea/workflows/deploy.yml:27), [deploy.yml:121](/home/farelos/compor/dtf-sistema/.gitea/workflows/deploy.yml:121).
|
||||
|
||||
29. **P1 — Health checks do not establish operational readiness or successful delivery. Confirmed monitoring gap.** Public `/health` always returns 200 from Nginx. API health checks DB/storage, while worker health can remain green during repeated provider failures, stale signatures, or failed cleanup. `last_tick` advances even after a delivery retry is scheduled. Cleanup handles a batch in one transaction; one consistently failing object can roll back progress and repeatedly block later expired objects. There is no implemented external alert routing or post-webhook application verification, and the stack lacks explicit rollback/update policies described in the docs. Separate liveness from readiness and alert on backlog age, cleanup progress, signatures, backup age, and deployment acceptance. Evidence: [nginx.conf.template:32](/home/farelos/compor/dtf-sistema/deploy/nginx.conf.template:32), [worker.py:21](/home/farelos/compor/dtf-sistema/app/worker.py:21), [worker.py:35](/home/farelos/compor/dtf-sistema/app/worker.py:35), [deploy.yml:243](/home/farelos/compor/dtf-sistema/.gitea/workflows/deploy.yml:243).
|
||||
|
||||
30. **P1 — Recoverability is not implemented as a production service. Confirmed gap beyond the broken commands.** There is no scheduled offsite encrypted backup, production restoration procedure, or tested recovery objective. The local backup dumps PostgreSQL and selects object bytes afterwards, without a shared snapshot or enforced pause; concurrent uploads/retention can produce mismatched state. Its restore check validates database counts and archived bytes separately, not that every required live order file is restorable. Define a consistent recovery boundary, required data classes, backup retention, and a rehearsed restoration procedure. Evidence: [backup.py:27](/home/farelos/compor/dtf-sistema/ops/backup.py:27), [storage_backup.py:46](/home/farelos/compor/dtf-sistema/ops/storage_backup.py:46), [LOCAL_SETUP.md:216](/home/farelos/compor/dtf-sistema/docs/LOCAL_SETUP.md:216).
|
||||
|
||||
31. **P1 — Real payment and integration failure semantics are not yet represented. Design gap before enabling providers.** The mock payment is called before the order transaction commits. A future provider success followed by a DB failure needs a durable payment intent, provider idempotency, and reconciliation. The outbox has a useful unique event key, but retries can let a later event for the same order overtake an earlier failed event; permanent failures retry forever without a dead-letter/operator resolution path. Provider-side success followed by a crash can also repeat delivery. There are no real pending/failed/refunded/cancelled payment states or corresponding production rules. Implement those contracts before substituting real network calls for fakes. Evidence: [orders.py:34](/home/farelos/compor/dtf-sistema/app/api/orders.py:34), [worker.py:35](/home/farelos/compor/dtf-sistema/app/worker.py:35), [runtime.py:43](/home/farelos/compor/dtf-sistema/app/runtime.py:43).
|
||||
|
||||
32. **P2 — Upload/scanning throughput and timeout behavior are poorly matched. Confirmed design limitation.** Files and their 8 MiB parts are uploaded sequentially, with one API presign round-trip per part; the browser then waits for scanning before uploading the next file. One scan thread handles all work, holds a DB transaction during the remote read/scan, and the browser gives up after roughly 150 polls. Scanner errors shorten retention to three days even if a later retry succeeds; success does not restore that deadline. Add bounded transfer concurrency, separate upload completion from scan waiting, measure queue latency, and distinguish transient scanner faults from rejected content. Evidence: [upload.js:9](/home/farelos/compor/dtf-sistema/web/upload.js:9), [checkout.js:60](/home/farelos/compor/dtf-sistema/web/checkout.js:60), [scanning.py:55](/home/farelos/compor/dtf-sistema/app/scanning.py:55).
|
||||
|
||||
**Architecture, verification, and maintenance**
|
||||
|
||||
33. **P2 — Database integrity and schema evolution rely too heavily on application convention. Confirmed design weakness.** Startup replays one growing SQL script without a general ordered migration/version contract. Orders reference artwork inside JSON instead of relational order-item/upload references; states and scan states lack checks, and upload expiry remains nullable. The database cannot enforce several invariants that the application assumes. Today's index-before-table regression, fixed in `86b8199`, demonstrates why clean initialization and upgrade paths both need testing. Use versioned migrations, explicit core relationships/constraints, and verify supported upgrades as well as empty installations. Evidence: [bootstrap.py:29](/home/farelos/compor/dtf-sistema/app/bootstrap.py:29), [schema.sql:14](/home/farelos/compor/dtf-sistema/app/schema.sql:14), [schema.sql:71](/home/farelos/compor/dtf-sistema/app/schema.sql:71).
|
||||
|
||||
34. **P2 — File separation has not yet produced clear internal boundaries. Confirmed maintenance risk.** The browser scripts share mutable globals and a required evaluation order; cart and editor state can diverge, as reproduced above. API routes still implement SQL/business transactions, and the operator artwork router imports and calls the customer upload route functions directly. `runtime.py` constructs environment-bound adapters at import time, making isolated business tests harder. Every database operation creates a new connection, and synchronous audit DB writes also run directly in async middleware. Keep the small deployment, but move reusable use cases behind explicit interfaces, use one cart state model, and introduce bounded connection management as load requires. Evidence: [index.html:1376](/home/farelos/compor/dtf-sistema/web/index.html:1376), [api/artwork.py:15](/home/farelos/compor/dtf-sistema/app/api/artwork.py:15), [runtime.py:21](/home/farelos/compor/dtf-sistema/app/runtime.py:21), [db.py:6](/home/farelos/compor/dtf-sistema/app/core/db.py:6), [app.py:30](/home/farelos/compor/dtf-sistema/app/app.py:30).
|
||||
|
||||
35. **P2 — Existing tests verify the mock workflow more strongly than the delivered product. Confirmed coverage gap.** Pricing parity is valuable, but uses only 11 lengths and shared tables; it cannot validate print geometry. Integration fixtures deliberately use plain text with a `.cdr` extension, which tests transport but proves no printability. The current isolated browser server does not serve `/vendor/...`, and its tests do not exercise real PDF parsing. Missing operational entrypoints and stale-cart behavior passed the suite. CI tests local images/configuration, not the final production images and Swarm proxy topology. Add targeted acceptance for the defects above, real representative artwork, fault/retry cases, clean/upgrade schema paths, operational command packaging, and the exact release artifacts. Evidence: [test_pricing.py:10](/home/farelos/compor/dtf-sistema/tests/test_pricing.py:10), [fixtures/local-test.cdr](/home/farelos/compor/dtf-sistema/tests/fixtures/local-test.cdr), [artwork_browser_test.mjs:26](/home/farelos/compor/dtf-sistema/tests/artwork_browser_test.mjs:26), [deploy.yml:59](/home/farelos/compor/dtf-sistema/.gitea/workflows/deploy.yml:59).
|
||||
|
||||
36. **P2 — Documentation and generated deliverables can send operators to the wrong system. Partly introduced today.** README's active quick start still selects the production Compose file. Portainer instructions tell users to populate secret/volume/host variables the actual stack does not consume. Security/context documents still claim absent rollback, secrets, blocked deployment, or obsolete CDN behavior. The historical index calls the local milestone report a different prototype. The client PDF's diagram still labels a worker preflight while its scope defers it. Moving the PDF generator in `66ddb17` left `parents[2]`, which now resolves to `/home/farelos/compor`, outside this repository, and its output path no longer matches the documented PDF. Correct executable instructions and current claims, and validate generated output paths. Evidence: [README.md:3](/home/farelos/compor/dtf-sistema/README.md:3), [PORTAINER.md:73](/home/farelos/compor/dtf-sistema/docs/PORTAINER.md:73), [SECURITY_REPORT.md:40](/home/farelos/compor/dtf-sistema/docs/SECURITY_REPORT.md:40), [generate_dtf_report.py:17](/home/farelos/compor/dtf-sistema/tools/generate_dtf_report.py:17).
|
||||
|
||||
37. **P2 — Dependency/rebuild claims exceed the enforced supply-chain contract. Confirmed maintenance gap.** Vendoring PDF.js removed the CDN dependency, but version 3.11.174 remains old and has no automated inventory/update check. Its known eval advisory is mitigated by the existing `isEvalSupported:false` and restrictive CSP; this is not reported as demonstrated arbitrary code execution. PostgreSQL remains a mutable tag; CI scans only the application images. Unversioned OS upgrades and optional base-image overrides also mean a pinned base alone does not guarantee identical rebuilt images. The pricing tables are still separately maintained in JS/Python, though parity tests help. Record complete dependency provenance, scan all deployed images and vendored assets, and maintain a controlled refresh process. Evidence: [vendor/README.md:7](/home/farelos/compor/dtf-sistema/web/vendor/README.md:7), [site-pdf.js:97](/home/farelos/compor/dtf-sistema/web/site-pdf.js:97), [docker-compose.yml:41](/home/farelos/compor/dtf-sistema/docker-compose.yml:41), [deploy/Dockerfile.api:16](/home/farelos/compor/dtf-sistema/deploy/Dockerfile.api:16). Reference: [Mozilla's advisory and workaround](https://github.com/mozilla/pdf.js/security/advisories/GHSA-wgrm-67xf-hhpq). No fresh vulnerability counts are asserted here.
|
||||
|
||||
**Today's commit assessment**
|
||||
|
||||
| Commits | Result at reviewed HEAD |
|
||||
|---|---|
|
||||
| `341f154`, `d2f7b2c` | Secret-file loader and tests are useful; active stack still does not use them. |
|
||||
| `9da2a7d`, `bbcc8ab` | Added real gates, but source readiness remains advisory and publication precedes image acceptance. |
|
||||
| `4c9fa24`, `010c2a1` | Pinned application bases and added CRITICAL scanning. Do not infer current zero findings for every deployed service. |
|
||||
| `6c52ad6`, `6a50e6d` | Fixed CI image retrieval/configuration packaging. |
|
||||
| `24cb52d`, `7cab210`, `c1a07a7`, `dbc9ba4` | Improved integration execution after port/network failures; browser/network coverage and run isolation remain incomplete. |
|
||||
| `3b92813` | Recovered proxy client headers but trusts overly broad private ranges; needs real-topology verification. |
|
||||
| `e95a42d`, `9926d3a`, `91269ce` | Removed duplicate stack definition; active hardening and documentation were not reconciled completely. |
|
||||
| `da903db` | Removed PDF.js CDN dependency; old dependency and PDF logic remain. |
|
||||
| `a874033` | Added useful per-operator attribution; session issuance has a disable/login race. |
|
||||
| `543a9a9`, `86b8199` | Added useful indexes and bounded finished history; fixed table/index ordering. Quote truncation creates starvation. |
|
||||
| `ca69843` | Removed inactive prototypes; this correctly reduces ambiguity and should not be counted as lost active functionality. |
|
||||
| `96f1d27`, `c9f8122` | Improved code navigation and router assembly. Global state and business boundaries remain coupled. |
|
||||
| `66ddb17` | Removed accidentally tracked deliverables from HEAD and relocated artifacts; generator root/output was not updated. Untracking does not remove historical Git objects. |
|
||||
| `b329f76` | Improved directory roles; broke staging/backup/security command packaging. |
|
||||
| `7386469` | Centralized engineering docs; several executable instructions and current-state claims still disagree with code. |
|
||||
|
||||
The overall problem with today's work is incomplete acceptance around operational entrypoints and domain behavior. The reorganizations themselves are reasonable. Neither a microservice rewrite nor returning to the deleted prototypes would address the findings above.
|
||||
|
||||
**Recommended order of work**
|
||||
|
||||
1. Fix confirmed data/work-loss defects: preserve production instructions; clear stale cart state; bind quotes to the displayed cart; remove quote starvation; invalidate finals after corrections.
|
||||
2. Repair operational command packaging and establish a real backup/restore path. Address stale signatures, anonymous quota exhaustion, and the actual proxy trust boundary before accepting public uploads.
|
||||
3. Resolve the two central product decisions: unattended price authority and supported large-file processing. Align the site promises with the chosen interim behavior.
|
||||
4. Complete the MVP contracts: destination address/freight, durable payment intents and webhooks, Tiny, final-file generation, and the four WhatsApp events. Test their failure/reconciliation paths in staging.
|
||||
5. Make release identity, exact-artifact testing, browser tests, production configuration checks, and post-deployment verification enforceable. Reconcile the documentation with that one implementation.
|
||||
|
||||
The current controls worth preserving are server-calculated prices, immutable approved quotes, owner-scoped access, parameterized SQL, password hashing, revocable HttpOnly sessions, strict script CSP, private signed storage access, malware quarantine, state/version checks, transactional outbox insertion, and dependency hashes. This review identifies material defects and gaps supported by the inspected code; it does not establish that every possible flaw has been found.
|
||||
224
docs/ROADMAP.md
224
docs/ROADMAP.md
@@ -7,18 +7,40 @@
|
||||
> Update the **Current step** line and the item status every time something moves.
|
||||
> Add new findings at the bottom of the relevant block rather than rewriting history.
|
||||
|
||||
**Current step:** Block 0 closed; Block 2 closed except 2.9–2.11; 4.1, 4.2, 4.5,
|
||||
5.1, 5.3, 5.4 and 5.8 done. Remaining work needs decisions (3.1, 3.2, 3.3) or
|
||||
client inputs (1.1, 1.2, 2.10). Block 1 still waits on client inputs for
|
||||
1.1/1.2.
|
||||
**Current step (2026-09-23, Week 2):** Payment safety fixes 2.13 and 2.14 and
|
||||
the local order-correctness work in 3.6/3.9 have passed integration checks.
|
||||
Production specification v2 now records each copy's film coordinates and is
|
||||
kept through the approved order; 4.6 now pages pending and approved unpaid
|
||||
quotes, including a tested 101st pending quote. Operational entrypoints in
|
||||
5.12 are repaired and locally exercised. Image decoding, mixed-sheet grading,
|
||||
rotation-sensitive DPI, and PDF page geometry are corrected in 3.9/4.4.
|
||||
`main` pushes now validate without publishing; manual release requires a passing
|
||||
source preflight. The containerized browser gate passes locally, pending a Gitea
|
||||
runner run.
|
||||
Next address the upload/scanner safety gate and unsupported PDF image evidence.
|
||||
The customer/API upload admission now stops above the scanner's effective limit
|
||||
before transfer; the 5 GiB large-file product path still needs agreement and
|
||||
implementation.
|
||||
Unfinished upload reservations now expire after one hour or can be cancelled
|
||||
explicitly; anonymous admission and browser resource bounds stay open.
|
||||
Generated print output, lifecycle/recovery, and provider work remain open.
|
||||
Obtain decisions for unattended pricing, print-file acceptance and large files,
|
||||
plus sandbox inputs for freight and Mercado Pago. Week 2 delivery items 1.1–1.5
|
||||
remain open; 1.6 is complete.
|
||||
|
||||
> Paths in closed items are written as they were when the finding was made.
|
||||
> The repository was laid out by role on 2026-09-21 (`local/` became `app/`,
|
||||
> with `tests/`, `ops/`, `infra/` and `web/` beside it); the history is left
|
||||
> as recorded rather than rewritten.
|
||||
|
||||
**Last audit:** 2026-09-18, full read of `app/`, `dtf-site.html`, `deploy/`,
|
||||
`.gitea/`, docs and legacy prototypes. Findings below carry their audit IDs.
|
||||
**Last audit:** 2026-09-18, full read of the then-current tree. The 2026-09-21
|
||||
full review is `docs/REVIEW-2026-09-21.md`; the 2026-09-22 review and payment
|
||||
probes added new findings to Blocks 2–5 below. Historical paths in closed items
|
||||
remain as recorded.
|
||||
|
||||
**Full remediation register:** `docs/REMEDIATION-2026-09-22.md` maps every one
|
||||
of the 37 review findings to an action and a release gate. Use it alongside
|
||||
this Week 2 tracker; a green milestone here does not close the production gate.
|
||||
|
||||
| Status | Meaning |
|
||||
|---|---|
|
||||
@@ -29,6 +51,29 @@ client inputs (1.1, 1.2, 2.10). Block 1 still waits on client inputs for
|
||||
|
||||
---
|
||||
|
||||
## Week 2 execution sequence
|
||||
|
||||
This sequence keeps the client commitments in Block 1 visible while correcting
|
||||
defects that would make those commitments unsafe or impossible to operate.
|
||||
Do not mark a provider item complete from a fake-adapter test or a healthy page.
|
||||
|
||||
| Order | Work | Exit evidence |
|
||||
|---|---|---|
|
||||
| 1. Immediate safety — done 2026-09-22 | Close 2.13 and 2.14; cover foreign quote IDs, missing/invalid amounts, duplicates and valid approvals. | Local integration checks pass and no other customer's order is returned. |
|
||||
| 2. Order correctness | Fix 3.6 and 4.6: one current cart/quote snapshot, versioned per-file production instructions, correction/final revision binding, visible actionable quotes. | The approved quote, order and final file can be traced back to the same reviewed layout; edits cannot buy an old cart. |
|
||||
| 3. Resolve product contracts | Decide 3.1–3.3: which quotes may auto-approve, what generates the print file, and which sizes the upload and scanner can release. | Written acceptance rules and representative artwork/large-file cases before enabling unattended payment. |
|
||||
| 4. Week 2 integrations | Add destination data and real freight first, then Mercado Pago payment intents/webhooks/reconciliation, then Tiny/Olist order creation. Keep the four agreed WhatsApp events in the same delivery contract. | Sandbox flows and failure/retry cases pass; no fake provider is presented as production ready. |
|
||||
| 5. Operability and release | Repair 5.12–5.14, signatures, proxy trust and backup/restore; gate browser tests and the exact deployed images. | Fresh install, upgrade, recovery and deployed release checks pass with alert ownership recorded. |
|
||||
|
||||
Client inputs needed for steps 3–4 are listed in `docs/PRODUCTION_INPUTS.md`.
|
||||
Engineering can complete steps 1–2 and repair local operational commands while
|
||||
those inputs are gathered. The full disposition of architecture, security,
|
||||
quality, operational, and delivery findings is in
|
||||
`docs/REMEDIATION-2026-09-22.md`; all release gates there must be met before
|
||||
accepting real customer work.
|
||||
|
||||
---
|
||||
|
||||
## Block 0 · Broken right now
|
||||
|
||||
Nothing in this block is optional. Until it is closed, the system cannot be
|
||||
@@ -157,21 +202,29 @@ Ports 8090/8091/8010 were used; 8080 was held by an unrelated preview server.
|
||||
|
||||
From the report already sent. These are dated promises, not backlog.
|
||||
|
||||
- `[ ]` 1.1 — Mercado Pago transparent checkout, signed and idempotent webhooks.
|
||||
Requires production credentials + webhook access. Payment must never be created
|
||||
before the freight amount is final.
|
||||
- `[~]` 1.1 — Mercado Pago transparent checkout, signed and idempotent webhooks.
|
||||
**Current foundation (2026-09-22):** a fake signer exercises signature rejection,
|
||||
event-ID deduplication, amount comparison and transactional order creation.
|
||||
This is not a Mercado Pago integration. Complete a durable payment intent,
|
||||
provider payment ID and currency binding, real verification and status lookup,
|
||||
delayed/duplicate event handling, refund/cancellation rules and reconciliation.
|
||||
A refused paid event must be visible for operator resolution rather than silently
|
||||
treated as finished. See 2.14 and 3.7. Requires sandbox access, webhook
|
||||
administration, event mapping and an approved refund policy.
|
||||
- `[ ]` 1.2 — Real freight quotation. **Blocked on client inputs** (see
|
||||
`PRODUCTION_INPUTS.md`): source platform, credentials, origin CEP, services,
|
||||
packaging weight/dimensions per length, subsidy policy.
|
||||
- `[ ]` 1.3 — Idempotent Tiny/Olist order creation with order-number traceability.
|
||||
Confirm endpoints, tag behaviour and rate limits first.
|
||||
- `[ ]` 1.4 — Final print-file generation (see 3.2 — this is the same problem).
|
||||
- `[ ]` 1.4 — Final print-file generation (see 3.2 and 3.6: production instructions
|
||||
must survive checkout before an output engine can reproduce the approved job).
|
||||
- `[ ]` 1.5 — Main Kanban production states consolidated.
|
||||
- `[ ]` 1.6 — **Block 0.2 + 0.3**, promised as "início da próxima semana".
|
||||
- `[x]` 1.6 — **Block 0.2 + 0.3** were completed and verified on 2026-09-18.
|
||||
|
||||
`[!]` The production compose currently blocks `dev_paid` (`ENVIRONMENT != 'local'`)
|
||||
and ships only fake adapters, so the deployed system cannot take an order at all.
|
||||
1.1 is what unblocks it.
|
||||
Real freight, payment initiation and verified provider events are required to
|
||||
unblock it; the fake webhook alone does not.
|
||||
|
||||
---
|
||||
|
||||
@@ -326,8 +379,36 @@ proving control of the e-mail. Needs a transactional mail provider — **client
|
||||
### `[ ]` 2.11 — LGPD `(F15)`
|
||||
|
||||
CNPJ, phone and e-mail are kept indefinitely in `accounts.profile` and
|
||||
`orders.snapshot`. Artwork has a 30-day policy; personal data has none, and there is
|
||||
no privacy notice, consent record or deletion path.
|
||||
`orders.snapshot`. Artwork has a 30-day policy; personal data has no defined
|
||||
retention, export or deletion path. The Site links an external privacy notice;
|
||||
confirm that it covers this processing and define the required records and
|
||||
customer rights flow before production activation.
|
||||
|
||||
### `[x]` 2.13 — A foreign paid quote ID exposes an order (2026-09-22 review)
|
||||
|
||||
The `dev-paid` refusal fallback fetched `orders` by `quote_id` without `owner`.
|
||||
A separate local customer session received the full paid order when supplied
|
||||
another customer's quote ID. `app/api/orders.py` now includes the owner in the
|
||||
fallback query. The local payment integration test confirms a foreign ID returns
|
||||
404 while the owner can still retrieve the already-paid order.
|
||||
|
||||
### `[x]` 2.14 — A signed approval without a paid amount creates an order
|
||||
|
||||
`app/payments.py` compared amounts only when the event contained one. A local
|
||||
signed `approved` event without `amount_cents` created an order. The service now
|
||||
requires an actual integer amount equal to the approved total; local integration
|
||||
tests cover missing, non-integer, underpaid and correct values. Currency and
|
||||
provider payment identity belong to the wider contract in 3.7; this gate does
|
||||
not complete 1.1.
|
||||
|
||||
### `[~]` 2.15 — Public intake controls need operational proof
|
||||
|
||||
Unfinished reservations now have a one-hour lease and owner-scoped cancellation;
|
||||
anonymous admission capacity still needs a firm bound. Keep ClamAV signatures
|
||||
current and alert on stale data; scope reverse-proxy IP trust
|
||||
to the actual hop and verify it through Swarm ingress. These are separate
|
||||
controls, but all must work before public large-file intake is considered safe.
|
||||
The production topology has not been verified by the repository review.
|
||||
|
||||
---
|
||||
|
||||
@@ -363,8 +444,10 @@ what was promised in the meeting and what exists.
|
||||
### `[?]` 3.3 — The 5 GB problem is unsolved `(F19)`
|
||||
|
||||
Transport accepts 5 GiB; `SCAN_MAX_BYTES` / ClamAV `StreamMaxLength` release only
|
||||
≤ 128 MiB. Files above that are quarantined permanently with no path forward. This
|
||||
is exactly the risk Jorge raised in the meeting.
|
||||
≤ 128 MiB. As of 2026-09-23, customer selection and API reservation reject files
|
||||
above the effective scan limit before transfer, and the Site displays the current
|
||||
limit. This prevents a doomed upload; it does not deliver the promised 5 GiB path.
|
||||
This is exactly the risk Jorge raised in the meeting.
|
||||
|
||||
**Decide:** raise the scan ceiling with a resource/timeout design, or define an
|
||||
explicit large-file path (staged scan, sampled scan, operator override with audit).
|
||||
@@ -375,11 +458,69 @@ explicit large-file path (staged scan, sampled scan, operator override with audi
|
||||
round-trip per part → ~640 sequential API calls for a 5 GB file, through an nginx
|
||||
`limit_req` of 20r/s. Add parallelism (4–6 in flight) and batch presigning.
|
||||
|
||||
### `[ ]` 3.5 — Payment ordering `(F27)`
|
||||
### `[~]` 3.5 — Payment ordering `(F27)`
|
||||
|
||||
`dev_paid` charges before persisting the order and passes no idempotency key.
|
||||
Harmless with `FakePayment`; with Mercado Pago that ordering is how you get double
|
||||
charges. Fix as part of 1.1.
|
||||
The local fake `pay` call now runs inside the order transaction, and the inbound
|
||||
webhook records and applies a delivery transactionally. This does not make an
|
||||
external charge atomic with PostgreSQL: a provider can succeed while the database
|
||||
write fails, or deliver the approval later. Add a durable payment intent,
|
||||
provider idempotency key and reconciliation as part of 1.1 and 3.7.
|
||||
|
||||
### `[~]` 3.6 — Preserve and bind the order the customer actually reviewed
|
||||
|
||||
The browser's width, copies, rotation, mirroring and repetitions are absent from
|
||||
the API item, so the factory cannot reproduce the priced layout. Removing an
|
||||
artwork can leave a stale cart item; editing after quote creation can leave the
|
||||
old quote payable; a new correction can leave an obsolete final active. Persist
|
||||
a versioned per-file production specification, tie the displayed cart to its
|
||||
immutable quote, and tie final approval to the latest correction revision.
|
||||
Cover the real editor-to-quote-to-final journey, not only the pricing table.
|
||||
|
||||
**Local progress 2026-09-23:** The Site includes per-upload width, length,
|
||||
copies, rotation, mirroring, measurement source, and the exact placement of
|
||||
each copy in production specification v2. The API checks coverage, dimensions,
|
||||
film bounds, and quote height; commercial review cannot replace the layout.
|
||||
The order snapshot and downloadable Kanban manifest retain it. Browser quote actions are disabled when
|
||||
the cart differs, including same-price changes. Editor changes invalidate the
|
||||
current cart item immediately; a new customer correction deactivates prior
|
||||
finals. Browser and local API regressions pass. **Still open:** generate and
|
||||
validate the final print file from the approved source revision, and
|
||||
make quote/cart continuity work across devices through a server-authoritative
|
||||
confirmation flow. Current quote binding is a browser guard.
|
||||
|
||||
### `[?]` 3.7 — Complete payment state and reconciliation rules
|
||||
|
||||
Event-ID deduplication does not establish which provider payment settled which
|
||||
quote. Define intent creation, provider transaction ID, currency, paid-at time,
|
||||
pending/rejected/refunded/cancelled states, late approval after quote expiry,
|
||||
overpayment and provider success followed by database failure. Record refused
|
||||
paid events for resolution. Decide who reconciles them and when production must
|
||||
stop or refund. Implement with 1.1 after the checkout/refund policy is approved.
|
||||
|
||||
### `[ ]` 3.8 — Collect a deliverable destination before charging freight
|
||||
|
||||
The quote has a shipping service and CEP but no recipient, street, number,
|
||||
city/state or delivery snapshot. Add and validate these fields with 1.2, then
|
||||
bind the chosen service and final freight amount to the payment intent.
|
||||
|
||||
### `[~]` 3.9 — Make artwork quality and geometry evidence explicit
|
||||
|
||||
Reject or route for review when PDF page count/geometry, image decoding or DPI
|
||||
cannot be established. Do not infer pixels from compressed file size, grade a
|
||||
mixed item from only the readable files, silently shrink oversized artwork, or
|
||||
allow a displayed DPI rejection to proceed through checkout. Use representative
|
||||
real artwork in acceptance checks.
|
||||
|
||||
**Local progress 2026-09-22:** DPI refusal and warning acknowledgement now gate
|
||||
the cart and quote API records the acknowledgement. Oversized loose-art width
|
||||
is rejected in the UI, API production contract, and packer. On 2026-09-23,
|
||||
unreadable loose images are blocked, mixed analyzed/manual sheets receive no
|
||||
automatic grade or discount, and rotated DPI uses the pixel dimension that
|
||||
corresponds to printed width. PDF dimensions now come from the parsed page
|
||||
model, with page count, crop, rotation, and UserUnit checks; multi-page and
|
||||
malformed PDFs block quoting. Isolated browser checks cover those cases and
|
||||
same-origin PDF rendering. Unsupported PDF image operators and representative
|
||||
print-file evidence still need correction before this item can close.
|
||||
|
||||
---
|
||||
|
||||
@@ -394,15 +535,22 @@ charges. Fix as part of 1.1.
|
||||
window of recent finished ones (`BOARD_FINISHED_LIMIT`, default 50) and the true
|
||||
finished total. An operator can never lose a card they could act on; only terminal
|
||||
ones are trimmed. The Kanban column reads "Finalizado · 50 de 213" when truncated,
|
||||
so the count is not mistaken for an all-time total. Pending quotes are capped too.
|
||||
so the count is not mistaken for an all-time total. Pending quotes now have
|
||||
a paginated view; older completed orders still need search in 4.6.
|
||||
- `[ ]` 4.3 — Scan throughput `(F21)`: one `scan_loop` thread, `worker` at
|
||||
`replicas: 1`, ClamAV `MaxThreads 2`, browser gives up after 150s.
|
||||
- `[ ]` 4.4 — Quality grade fallback `(F22)`: when `carregarImagem` fails,
|
||||
`px(f)=Math.sqrt(f.size/1024)*95` stands — a DPI inferred from **file size in
|
||||
bytes** — and it drives up to a 25% discount. Fail closed instead.
|
||||
- `[x]` 4.4 — Failed image decoding no longer infers pixels from compressed
|
||||
file size. Unreadable loose images cannot enter the cart or receive a grade;
|
||||
an isolated browser regression covers the failure path (2026-09-23).
|
||||
- `[x]` 4.5 — Dead config `(F28)`: resolved by deleting `deploy/stack.yaml` in 2.12.
|
||||
`CLAMD_HOST` no longer appears anywhere; `scanning.py` reaching `'scanner'`
|
||||
directly is now simply how it works, not a contradiction.
|
||||
- `[~]` 4.6 — Older unpaid quotes can disappear behind the board limit.
|
||||
On 2026-09-23 the board began showing newest pending and approved unpaid
|
||||
quotes separately, with cursor pagination and counts; a local regression
|
||||
retrieved all 105 pending and 22 approved fixture quotes and cleaned them up.
|
||||
**Still open:** an explicit terminal state for abandoned/expired quotes and
|
||||
search/history for older completed orders.
|
||||
|
||||
---
|
||||
|
||||
@@ -453,14 +601,9 @@ charges. Fix as part of 1.1.
|
||||
integration job should run `down -v` before `up` — or a dedicated step should
|
||||
apply `schema.sql` twice to a fresh database, proving both a first install and
|
||||
a re-run.
|
||||
- `[ ]` 5.10 — The browser suites do not run in CI. Chrome runs in the runner
|
||||
container and can only reach the stack through ports published on the host, which
|
||||
is a different network namespace when the runner is itself a container. The API,
|
||||
workflow, security, scanning, retention and runtime suites were moved inside the
|
||||
stack's network and do gate. The browser suites are the only coverage for the
|
||||
artwork editor and the full customer journey, so they need either Chrome in a
|
||||
container on that network, or a runner with host networking. Until then they gate
|
||||
locally only, and CI warns when it skips them.
|
||||
- `[ ]` 5.10 — The browser suites were moved into a Chrome container on the
|
||||
Compose network and made required in CI. Confirm the complete checkout journey
|
||||
passes in that topology and on the actual Gitea runner before closing this item.
|
||||
- `[ ]` 5.9 — `local/browser_test.mjs` failed once and passed on an immediate
|
||||
re-run, with no code change in between (2026-09-21). It is a deploy gate when the
|
||||
runner has Chrome, so an intermittent failure there blocks releases for no reason.
|
||||
@@ -471,6 +614,23 @@ charges. Fix as part of 1.1.
|
||||
days. The copy now states 30 days, says a later order needs the file again, and
|
||||
keeps only the true part: order history remains in the account. Policy unchanged;
|
||||
the promise was corrected to match it.
|
||||
- `[x]` 5.12 — Repair operational entrypoints after the `local/` split.
|
||||
On 2026-09-23, staging and both API images package `ops/`; staging calls
|
||||
`ops.staging_readiness`; local backup calls `ops.storage_backup` through
|
||||
`compose.local.yaml`; documented security and backup commands use the real
|
||||
modules. Verified a network-disabled staging pass with non-secret fixture
|
||||
data, a production API image import, local security status, and a local
|
||||
backup/restore of the database plus 78 clean objects. Production offsite
|
||||
recovery and signature freshness remain separate open items.
|
||||
- `[ ]` 5.13 — Define production recovery: scheduled encrypted offsite database
|
||||
and object backups, a consistent snapshot boundary, Swarm data placement and
|
||||
a restore rehearsal that opens every required live order file.
|
||||
- `[ ]` 5.14 — Promote and verify one immutable release. Normal `main` pushes
|
||||
now run checks only; manual dispatch requires source preflight and a configured
|
||||
webhook, and scans images before publishing. Still make the full preflight
|
||||
validate the active stack, deploy the tested immutable image references, test
|
||||
clean install and upgrade, check application readiness after Portainer
|
||||
redeploys, and isolate concurrent CI stacks.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -77,14 +77,15 @@ the signatures bundled into that image. On closeout it reported ClamAV
|
||||
below the seven-day alert threshold.
|
||||
|
||||
The multipart transport supports uploads up to 5 GiB, but `SCAN_MAX_BYTES` and
|
||||
ClamAV stream limits release at most 128 MiB by default. Larger files remain
|
||||
blocked. Supporting larger files requires a deliberate resource/timeout design,
|
||||
not simply increasing the upload limit.
|
||||
ClamAV stream limits release at most 128 MiB by default. As of 2026-09-23 the
|
||||
API and customer picker reject larger files before transfer. Supporting them
|
||||
requires a deliberate resource/timeout design, not simply increasing the
|
||||
transport limit.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
docker compose exec -T api python3 -m app.security_status
|
||||
docker compose -f compose.local.yaml exec -T api python3 -m ops.security_status
|
||||
```
|
||||
|
||||
An exit status of 1 requires review. At closeout, attention was expected because
|
||||
@@ -131,15 +132,12 @@ change when the advisory database or selected base digest changes.
|
||||
The files in `deploy/` and `.gitea/workflows/` are a guarded delivery mechanism,
|
||||
not an approval to operate the current application on the public internet.
|
||||
|
||||
Corrected 2026-09-21: an earlier version of this section described gates the
|
||||
workflow did not contain. The workflow now runs static validation, the
|
||||
integration suite against a live stack, and a blocking Trivy secret scan before
|
||||
publishing. The source preflight is advisory unless
|
||||
`ENFORCE_PRODUCTION_PREFLIGHT` is set, and image vulnerabilities are reported
|
||||
rather than enforced, because the current bases carry HIGH/CRITICAL findings
|
||||
with no upstream fix. Base images are still mutable tags, not digests.
|
||||
`PORTAINER.md` holds the authoritative table of what gates and what does not.
|
||||
It publishes both `latest` and the full commit SHA, then calls the Portainer
|
||||
Updated 2026-09-23: pushes to `main` run checks only. A manual workflow run on
|
||||
`main` requires the source preflight and a configured Portainer webhook before
|
||||
building. It scans built images before publication; CRITICAL findings block and
|
||||
HIGH findings are reported. The browser suites run in a required Compose Chrome
|
||||
container. `PORTAINER.md` holds the authoritative gate table. A permitted release
|
||||
publishes both `latest` and the full commit SHA, then calls the Portainer
|
||||
webhook. Application/provider secrets are created directly as versioned external
|
||||
Swarm secrets and never cross the workflow. Rollback selects the prior commit SHA
|
||||
in Portainer and does not roll back the database.
|
||||
|
||||
@@ -8,6 +8,7 @@ WORKDIR /app
|
||||
COPY infra/requirements.txt infra/requirements.lock /app/infra/
|
||||
RUN pip install --no-cache-dir --require-hashes -r infra/requirements.lock
|
||||
COPY app /app/app
|
||||
COPY ops /app/ops
|
||||
# The local image carries the suites so they can run inside the stack network.
|
||||
# deploy/Dockerfile.api deliberately does not: tests are not part of what ships.
|
||||
COPY tests /app/tests
|
||||
|
||||
13
infra/Dockerfile.browser-tests
Normal file
13
infra/Dockerfile.browser-tests
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM node:22-bookworm-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends chromium ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /workspace
|
||||
COPY tests /workspace/tests
|
||||
COPY web /workspace/web
|
||||
RUN mkdir -p /workspace/output/local \
|
||||
&& chown -R node:node /workspace/output
|
||||
USER node
|
||||
ENV CHROME_BIN=/usr/bin/chromium
|
||||
@@ -1,8 +1,8 @@
|
||||
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; }
|
||||
server_name localhost site kanban;
|
||||
if ($host !~ ^(localhost|127\.0\.0\.1|site|kanban)$) { return 400; }
|
||||
root /usr/share/nginx/html;
|
||||
index ${WEB_INDEX};
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
@@ -23,8 +23,8 @@ server {
|
||||
}
|
||||
server {
|
||||
listen 81;
|
||||
server_name localhost;
|
||||
if ($host !~ ^(localhost|127\.0\.0\.1)$) { return 400; }
|
||||
server_name localhost site kanban;
|
||||
if ($host !~ ^(localhost|127\.0\.0\.1|site|kanban)$) { return 400; }
|
||||
client_max_body_size 2m;
|
||||
location / {
|
||||
limit_req zone=api_limit burst=100 nodelay;
|
||||
|
||||
@@ -9,9 +9,10 @@ from uuid import uuid4
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
BACKUPS = ROOT / 'backups'
|
||||
COMPOSE = ['docker','compose','-f','compose.local.yaml']
|
||||
|
||||
def docker(script, *args, **kwargs):
|
||||
return subprocess.run(['docker','compose','exec','-T','db','sh','-c',script,'sh',*args],
|
||||
return subprocess.run([*COMPOSE,'exec','-T','db','sh','-c',script,'sh',*args],
|
||||
cwd=ROOT,check=True,**kwargs)
|
||||
|
||||
def checksum(path):
|
||||
@@ -21,7 +22,7 @@ def checksum(path):
|
||||
return digest.hexdigest()
|
||||
|
||||
def compose_exec(service, *command, **kwargs):
|
||||
return subprocess.run(['docker','compose','exec','-T',service,*command],
|
||||
return subprocess.run([*COMPOSE,'exec','-T',service,*command],
|
||||
cwd=ROOT,check=True,**kwargs)
|
||||
|
||||
def create():
|
||||
@@ -38,7 +39,7 @@ def create():
|
||||
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',
|
||||
result=compose_exec('api','python','-m','ops.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 ')]
|
||||
@@ -111,7 +112,7 @@ def verify(path):
|
||||
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)
|
||||
compose_exec('api','python','-m','ops.storage_backup','verify',stdin=stream)
|
||||
print('PASS: combined database and clean-object backup verified. Active data was untouched.')
|
||||
|
||||
if __name__=='__main__':
|
||||
|
||||
@@ -96,5 +96,5 @@ def main(path: Path) -> int:
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) != 2:
|
||||
raise SystemExit('usage: python -m local.staging_readiness PATH')
|
||||
raise SystemExit('usage: python -m ops.staging_readiness PATH')
|
||||
raise SystemExit(main(Path(sys.argv[1])))
|
||||
|
||||
@@ -188,5 +188,5 @@ def verify_archive():
|
||||
|
||||
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')
|
||||
raise SystemExit('usage: python -m ops.storage_backup export|verify')
|
||||
export_archive() if sys.argv[1] == 'export' else verify_archive()
|
||||
|
||||
@@ -13,6 +13,30 @@ const html=await readFile('web/index.html','utf8');
|
||||
const hashes=[...html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)]
|
||||
.filter(m=>! /\bsrc\s*=/i.test(m[1]))
|
||||
.map(m=>"'sha256-"+createHash('sha256').update(m[2]).digest('base64')+"'");
|
||||
function pdfFixture({pages=1,media='[0 0 720 360]',crop='',rotate=0,unit=1}={}){
|
||||
const objects=[
|
||||
'<< /Type /Catalog /Pages 2 0 R >>',
|
||||
'<< /Type /Pages /Kids ['+Array.from({length:pages},(_,i)=>i+3+' 0 R').join(' ')+
|
||||
'] /Count '+pages+' /MediaBox '+media+' >>',
|
||||
...Array.from({length:pages},()=> '<< /Type /Page /Parent 2 0 R'+
|
||||
(crop?' /CropBox '+crop:'')+(rotate?' /Rotate '+rotate:'')+
|
||||
(unit!==1?' /UserUnit '+unit:'')+' >>')
|
||||
];
|
||||
const chunks=['%PDF-1.6\n%\xE2\xE3\xCF\xD3\n'], offsets=[0];
|
||||
let length=Buffer.byteLength(chunks[0],'latin1');
|
||||
for(let i=0;i<objects.length;i++){
|
||||
offsets.push(length);
|
||||
const part=(i+1)+' 0 obj\n'+objects[i]+'\nendobj\n';
|
||||
chunks.push(part);length+=Buffer.byteLength(part,'latin1');
|
||||
}
|
||||
const xref=length;
|
||||
chunks.push('xref\n0 '+(objects.length+1)+'\n0000000000 65535 f \n');
|
||||
for(const offset of offsets.slice(1))chunks.push(String(offset).padStart(10,'0')+' 00000 n \n');
|
||||
chunks.push('trailer\n<< /Size '+(objects.length+1)+
|
||||
' /Root 1 0 R >>\nstartxref\n'+xref+'\n%%EOF\n');
|
||||
return Buffer.from(chunks.join(''),'latin1');
|
||||
}
|
||||
const pdfData=options=>JSON.stringify(pdfFixture(options).toString('base64'));
|
||||
const server=createServer(async(req,res)=>{
|
||||
if(req.url.startsWith('/api/')){
|
||||
res.setHeader('Content-Type','application/json');
|
||||
@@ -27,7 +51,7 @@ const server=createServer(async(req,res)=>{
|
||||
// Serve any script the page asks for, resolved inside web/, rather than
|
||||
// a hardcoded list: the Site's behaviour is split across several files and a
|
||||
// list would silently 404 the next one added.
|
||||
if(/^\/[\w.-]+\.js$/.test(req.url)){
|
||||
if(/^\/[\w.-]+\.js$/.test(req.url) || /^\/vendor\/pdf\.(worker\.)?min\.js$/.test(req.url)){
|
||||
try{
|
||||
const body=await readFile(resolve('web'+req.url));
|
||||
res.setHeader('Content-Type','text/javascript');res.end(body);return;
|
||||
@@ -39,6 +63,7 @@ await new Promise(r=>server.listen(0,'127.0.0.1',r));
|
||||
const profile=await mkdtemp(tmpdir()+'/dtf-artwork-');
|
||||
const chrome=spawn(process.env.CHROME_BIN||'/usr/bin/google-chrome-stable',[
|
||||
'--headless=new','--disable-gpu','--no-first-run','--no-default-browser-check',
|
||||
...(process.env.CHROME_NO_SANDBOX==='1'?['--no-sandbox']:[]),
|
||||
'--remote-debugging-port=0','--user-data-dir='+profile,'about:blank'
|
||||
],{stdio:['ignore','ignore','pipe']});
|
||||
let stderr='',ws,next=0;
|
||||
@@ -128,6 +153,9 @@ try{
|
||||
assert.deepEqual(await evaluate(`({shown:!$('tipoEnvio').hidden,
|
||||
on:[...document.querySelectorAll('#tipoEnvio .cam.on')].map(b=>b.dataset.tipo)})`),
|
||||
{shown:true,on:['folha']});
|
||||
await evaluate(`(()=>{const f=new File(['x'],'oversize.cdr');Object.defineProperty(f,'size',{value:128*1048576+1});sel([f]);})()`);
|
||||
assert.equal(await evaluate(`folhas.length===0 && $('recusa').textContent.includes('128 MB')`),true,
|
||||
'files beyond the scanner limit are rejected before browser analysis');
|
||||
await evaluate(`pickTipo('avulsa')`);
|
||||
assert.equal(await evaluate('modo'),'avulsa');
|
||||
await evaluate('sendImage()');
|
||||
@@ -135,6 +163,14 @@ try{
|
||||
assert.deepEqual(await evaluate(`({mode:modo,sheets:folhas.length,width:artes[0].cm,quantity:artes[0].q})`),{mode:'avulsa',sheets:0,width:0,quantity:1});
|
||||
await fill('[data-cm]',20);await fill('[data-q]',6);
|
||||
let layout=await packed(6);
|
||||
await waitFor(()=>evaluate(`itemAtual?.production?.sources[0]?.copies===6`),'per-file production record');
|
||||
assert.deepEqual(await evaluate(`({version:itemAtual.production.version,source:itemAtual.production.sources[0]})`),
|
||||
{version:2,source:{kind:'artwork',width_cm:20,length_cm:40,copies:6,
|
||||
rotation_degrees:0,mirrored:false,measurement:'file'}});
|
||||
assert.equal(await evaluate('itemAtual.production.placements.length'),6);
|
||||
assert.equal(await evaluate('itemAtual.production.height_cm'),121);
|
||||
assert.deepEqual(await evaluate('itemAtual.production.placements.map(p=>[p.source_index,p.copy_index,p.x_cm,p.y_cm])'),
|
||||
[[0,0,0,0],[0,1,20.5,0],[0,2,0,40.5],[0,3,20.5,40.5],[0,4,0,81],[0,5,20.5,81]]);
|
||||
assert.equal(layout.heading,'Montagem ao vivo');assert.equal(layout.height,121);
|
||||
assert.equal(layout.billed,1.3);
|
||||
assert.deepEqual(layout.pos.map(p=>[p.x,p.y]),[[0,0],[20.5,0],[0,40.5],[20.5,40.5],[0,81],[20.5,81]]);
|
||||
@@ -149,6 +185,32 @@ try{
|
||||
await click('[data-esp]');
|
||||
await waitFor(()=>evaluate(`$('vArea').querySelector('canvas').toDataURL()!==${JSON.stringify(beforeMirror)}`),'mirror updates pixels');
|
||||
assert.equal(await evaluate('metros'),0.235);
|
||||
await waitFor(()=>evaluate(`itemAtual?.production?.sources[0]?.mirrored===true`),'transforms in production record');
|
||||
assert.deepEqual(await evaluate(`({rotation:itemAtual.production.sources[0].rotation_degrees,copies:itemAtual.production.sources[0].copies})`),
|
||||
{rotation:90,copies:9});
|
||||
await waitFor(()=>evaluate(`itemAtual?.qualityStatus==='rejected'`),'low resolution refusal');
|
||||
assert.equal(await evaluate('cartPodeEnviar()'),false);
|
||||
await fill('[data-cm]',3);
|
||||
await waitFor(()=>evaluate(`itemAtual?.qualityStatus==='ok'`),'rotated DPI uses image height');
|
||||
assert.equal(await evaluate('Math.round(dpiDe(artes[0]))'),339);
|
||||
await click('[data-giro]');
|
||||
await waitFor(()=>evaluate(`itemAtual?.qualityStatus==='warning'`),'unrotated resolution warning');
|
||||
assert.equal(await evaluate('Math.round(dpiDe(artes[0]))'),169);
|
||||
assert.equal(await evaluate('cartPodeEnviar()'),false);
|
||||
await click('#cienteOk');
|
||||
assert.equal(await evaluate('cartPodeEnviar()'),true);
|
||||
await fill('[data-q]',8);
|
||||
assert.equal(await evaluate('itemAtual===null'),true,'editing invalidates the old cart immediately');
|
||||
await waitFor(()=>evaluate(`itemAtual?.qualityStatus==='warning'`),'edited warning');
|
||||
assert.equal(await evaluate('cartPodeEnviar()'),false,'acknowledgement does not survive an edit');
|
||||
await fill('[data-cm]',58);
|
||||
await waitFor(()=>evaluate(`itemAtual===null && artes[0].cm===58`),'oversized width rejected');
|
||||
assert.equal(await evaluate('cartPodeEnviar()'),false);
|
||||
assert.equal(await evaluate(`(()=>{try{encaixar([{w:58,h:10,img:null}],57);return false}catch(error){return error instanceof RangeError}})()`),true,
|
||||
'packing engine must not shrink an oversized source');
|
||||
await click('[data-rm]');
|
||||
assert.equal(await evaluate(`artes.length===0 && itemAtual===null && !cartPodeEnviar()`),true,
|
||||
'removed artwork cannot remain in the cart');
|
||||
// A transparent asymmetric image exposes masks that ignore user transforms.
|
||||
// Its top-left quarter becomes bottom-right after a mirror and 90° turn.
|
||||
assert.equal(await evaluate(`(()=>{
|
||||
@@ -169,6 +231,8 @@ try{
|
||||
await evaluate(`(()=>{const el=$('lista').querySelector('[data-repf]');el.value=2;el.dispatchEvent(new Event('change',{bubbles:true}));})()`);
|
||||
await waitFor(()=>evaluate('metros===0.684'),'ready-sheet repetitions');
|
||||
assert.equal(await evaluate(`document.querySelectorAll('.folhaPrevia').length`),1);
|
||||
await evaluate(`(()=>{folhas.push({f:new File(['manual'],'manual.cdr'),med:null,rep:1,m:1,an:null});pintaFolha();})()`);
|
||||
await waitFor(()=>evaluate(`itemAtual?.nota===0 && itemAtual?.unit===TABELA[modo]`),'mixed analyzed and manual sheets use table pricing');
|
||||
// An image too small to span the film is refused, never silently repriced.
|
||||
await click('#bVoltar');await click('[data-modo="file"]');
|
||||
await evaluate('sendImage()');
|
||||
@@ -182,6 +246,33 @@ try{
|
||||
await waitFor(()=>evaluate(`!!$('lista').querySelector('[data-comp]')`),'manual ready sheet');
|
||||
await evaluate(`(()=>{const el=$('lista').querySelector('[data-comp]');el.value=1.01;el.dispatchEvent(new Event('change',{bubbles:true}));})()`);
|
||||
await waitFor(()=>evaluate('itemAtual?.total===21.89'),'unchanged manual pricing');
|
||||
await evaluate(`folhas[0].semAnalise='<img src=x onerror=alert(1)>';pintaFolha()`);
|
||||
assert.equal(await evaluate(`!$('lista').querySelector('img') && $('lista').textContent.includes('<img src=x')`),true,
|
||||
'PDF parser errors are text, never executable markup');
|
||||
// Parsed geometry honors inherited MediaBox, CropBox, rotation and UserUnit.
|
||||
// Extra pages and unreadable PDFs must never fall through to manual pricing.
|
||||
const pdfFile=(data,name='sheet.pdf')=>`new File([Uint8Array.from(atob(${data}),c=>c.charCodeAt(0))],${JSON.stringify(name)},{type:'application/pdf'})`;
|
||||
const rotated=await evaluate(`medirFolha(${pdfFile(pdfData({media:'[0 0 720 1440]',crop:'[0 0 360 720]',rotate:90,unit:2}))})`);
|
||||
assert.deepEqual(rotated,{larg:50.8,alt:25.4,fonte:'página do PDF · UserUnit 2'});
|
||||
const rendered=await evaluate(`rasterizarPdf(${pdfFile(pdfData({media:'[0 0 720 1440]',crop:'[0 0 360 720]',rotate:90,unit:2}))},50.8,25.4).then(r=>({ok:!!r.tela,error:r.erro||null}))`);
|
||||
assert.deepEqual(rendered,{ok:true,error:null});
|
||||
const multi=await evaluate(`medirFolha(${pdfFile(pdfData({pages:2}))})`);
|
||||
assert.equal(multi.rejected,true);assert.match(multi.reason,/2 páginas/);
|
||||
const beyondSpec=await evaluate(`medirFolha(${pdfFile(pdfData({media:'[0 0 20000 720]'}))})`);
|
||||
assert.equal(beyondSpec.rejected,true);assert.match(beyondSpec.reason,/508 cm/);
|
||||
const broken=await evaluate(`medirFolha(new File(['broken'],'broken.pdf',{type:'application/pdf'}))`);
|
||||
assert.equal(broken.rejected,true);
|
||||
await click('#bVoltar');await click('[data-modo="file"]');
|
||||
await evaluate(`sel([${pdfFile(pdfData({pages:2}),'two-pages.pdf')}])`);
|
||||
await waitFor(()=>evaluate(`folhas.length===1 && !!folhas[0].measurementError`),'multipage PDF refusal');
|
||||
assert.equal(await evaluate(`avaliar().pronto`),false);
|
||||
assert.equal(await evaluate(`cartPodeEnviar()`),false);
|
||||
assert.match(await evaluate(`$('lista').textContent`),/não pode ser orçado/);
|
||||
await click('#bVoltar');await click('[data-modo="avulsa"]');
|
||||
await evaluate(`sel([new File(['not an image'],'broken.png',{type:'image/png'})])`);
|
||||
await waitFor(()=>evaluate('artes.length===1 && artes[0].decodeError===true'),'failed image decode');
|
||||
await fill('[data-cm]',20);
|
||||
await waitFor(()=>evaluate('itemAtual===null && !cartPodeEnviar()'),'undecodable image cannot be quoted');
|
||||
// PNG drag/drop follows exactly the same artwork path; UV stays UV. Reaching it
|
||||
// from a by-metre product is one declared click, and it is reversible.
|
||||
for(const [mode,expected] of [['file','avulsa'],['avulsa','avulsa'],['uvfile','uv'],['uv','uv']]){
|
||||
@@ -201,6 +292,21 @@ try{
|
||||
await fill('[data-q]',7);
|
||||
await evaluate(`carregarImagem=realLoad;delayed[0]()`);
|
||||
await packed(7);
|
||||
const timeoutCleanup=await evaluate(`(async()=>{
|
||||
const realLoader=carregarPdfJs, realTimer=setTimeout, realWorker=temWorker;
|
||||
let destroyed=false, fail;
|
||||
carregarPdfJs=async()=>({getDocument:()=>({
|
||||
promise:new Promise((_,reject)=>{fail=reject}),
|
||||
destroy:()=>{destroyed=true;fail(new Error('cancelled'));return Promise.resolve()}
|
||||
})});
|
||||
temWorker=true;
|
||||
window.setTimeout=(fn,ms)=>realTimer(fn,ms===30000?1:ms);
|
||||
try{
|
||||
const result=await rasterizarPdf({arrayBuffer:async()=>new ArrayBuffer(1)},10,10);
|
||||
return {timedOut:result.erro==='demorou demais neste navegador',destroyed};
|
||||
}finally{carregarPdfJs=realLoader;window.setTimeout=realTimer;temWorker=realWorker;}
|
||||
})()`);
|
||||
assert.deepEqual(timeoutCleanup,{timedOut:true,destroyed:true});
|
||||
// Inspect the supplied local artwork, when requested, using the real file input.
|
||||
if(process.env.ARTWORK_FILE){
|
||||
await click('#bVoltar');await click('[data-modo="file"]');
|
||||
|
||||
@@ -14,6 +14,11 @@ try {
|
||||
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',
|
||||
...(process.env.CHROME_NO_SANDBOX==='1'?['--no-sandbox']:[]),
|
||||
// Production uses HTTPS; internal Compose HTTP names need a secure context
|
||||
// for Web Crypto during the upload and checkout journey.
|
||||
...(process.env.CHROME_TRUST_TEST_ORIGINS==='1'
|
||||
? ['--unsafely-treat-insecure-origin-as-secure=http://site,http://kanban'] : []),
|
||||
'--remote-debugging-port=0','--user-data-dir='+profile,'about:blank'
|
||||
],{stdio:['ignore','ignore','pipe']});
|
||||
const pause=ms=>new Promise(r=>setTimeout(r,ms));
|
||||
@@ -41,7 +46,9 @@ try{
|
||||
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));
|
||||
const siteOrigin=process.env.SITE_BROWSER_ORIGIN||'http://localhost:'+(process.env.SITE_PORT||8080);
|
||||
const kanbanOrigin=process.env.KANBAN_BROWSER_ORIGIN||'http://localhost:'+(process.env.KANBAN_PORT||8081);
|
||||
const site=await page(siteOrigin);
|
||||
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});
|
||||
@@ -74,9 +81,14 @@ try{
|
||||
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);
|
||||
try{
|
||||
await waitFor(async()=> (await site.text()).includes('Arquivos enviados.'),'browser upload and quote',45000);
|
||||
}catch(error){
|
||||
console.error('Checkout status:',await site.eval('document.getElementById("checkoutStatus")?.textContent'));
|
||||
throw error;
|
||||
}
|
||||
const qid=await site.eval('localStorage.getItem("dtf-quote")');
|
||||
const kanban=await page('http://localhost:'+(process.env.KANBAN_PORT||8081));
|
||||
const kanban=await page(kanbanOrigin);
|
||||
await kanban.fill('#email',process.env.OPERATOR_EMAIL||'operator@example.test');
|
||||
await kanban.fill('#password',process.env.OPERATOR_PASSWORD||'local-operator-only');
|
||||
await kanban.eval('document.getElementById("login").requestSubmit()');
|
||||
@@ -85,6 +97,14 @@ try{
|
||||
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');
|
||||
assert.equal(await site.eval('pedido[0].production.sources[0].copies'),1);
|
||||
await site.eval('pedido[0].production.sources[0].copies=2;pintaPedido()');
|
||||
await waitFor(async()=> (await site.text()).includes('O carrinho mudou'),'same-price production edit invalidates quote');
|
||||
await site.eval('pedido[0].production.sources[0].copies=1;pintaPedido()');
|
||||
await site.fill('#fMail','changed-browser@example.test');
|
||||
await waitFor(async()=> (await site.text()).includes('O carrinho mudou'),'quote invalidated by cart edit');
|
||||
assert.equal(await site.eval('[...document.querySelectorAll("button")].some(x=>x.textContent==="Criar pedido de teste")'),false);
|
||||
await site.fill('#fMail','local-browser@example.test');
|
||||
await site.eval('window.dtfCheckout()');
|
||||
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 de teste").click()');
|
||||
@@ -92,6 +112,8 @@ try{
|
||||
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`);
|
||||
assert.deepEqual(await kanban.eval(`(()=>{const spec=board.orders.find(o=>o.id===${JSON.stringify(oid)}).snapshot.items[0].production;const source=spec.sources[0];return {kind:source.kind,copies:source.copies,length:Number(source.length_cm),height:Number(spec.height_cm),placed:spec.placements.length}})()`),
|
||||
{kind:'sheet',copies:1,length:101,height:101,placed:1});
|
||||
// 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'){
|
||||
@@ -115,7 +137,7 @@ try{
|
||||
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);
|
||||
const portal=await page(siteOrigin+'/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');
|
||||
@@ -127,7 +149,7 @@ try{
|
||||
// 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');
|
||||
await waitFor(()=>portal.eval('document.getElementById("logout")?.hidden===true'),'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);
|
||||
|
||||
116
tests/payment_test.py
Normal file
116
tests/payment_test.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""The webhook path, against a running stack.
|
||||
|
||||
A provider retries. It delivers out of order, twice, and late. None of that may
|
||||
produce a second order or a second notification to the customer, and nothing
|
||||
unsigned may produce one at all.
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from urllib.error import HTTPError
|
||||
from urllib.request import Request, urlopen
|
||||
from uuid import uuid4
|
||||
|
||||
from tests.smoke_test import BASE, Client, upload_bytes, item_spec, with_host
|
||||
|
||||
SECRET = os.environ.get('PAYMENT_WEBHOOK_SECRET', 'local-webhook-secret').encode()
|
||||
|
||||
|
||||
def deliver(payload, expected=200, signature=None):
|
||||
body = json.dumps(payload).encode()
|
||||
sig = signature if signature is not None else hmac.new(SECRET, body, hashlib.sha256).hexdigest()
|
||||
request = Request(BASE + '/api/payments/webhook', data=body,
|
||||
headers=with_host({'Content-Type': 'application/json',
|
||||
'x-payment-signature': sig}))
|
||||
try:
|
||||
with urlopen(request, timeout=30) as response:
|
||||
assert response.status == expected, (response.status, expected)
|
||||
return json.load(response)
|
||||
except HTTPError as exc:
|
||||
assert exc.code == expected, (exc.code, expected, exc.read().decode())
|
||||
return {}
|
||||
|
||||
|
||||
def reviewed_quote():
|
||||
"""A quote an operator has approved, ready to be paid."""
|
||||
customer = Client()
|
||||
customer.call('/session')
|
||||
uid = upload_bytes(customer, b'PAYMENT WEBHOOK TEST')
|
||||
item = item_spec('file', '1.01', 0, uid)
|
||||
profile = {'cnpj': '11222333000181', 'zap': '11999999999',
|
||||
'mail': 'payment-' + uuid4().hex[:8] + '@example.test'}
|
||||
quote = customer.call('/quotes', {'request_key': str(uuid4()), 'customer': profile,
|
||||
'items': [item], 'freight': {'service': 'pickup'}})
|
||||
approved = customer.call('/operator/quotes/' + quote['id'] + '/approve',
|
||||
{'items': [item]}, operator=True)
|
||||
return customer, quote['id'], approved['total_cents']
|
||||
|
||||
|
||||
def run():
|
||||
customer, quote_id, total = reviewed_quote()
|
||||
|
||||
# Nothing unsigned creates an order, and a tampered body is not signed.
|
||||
deliver({'event_id': 'unsigned-' + uuid4().hex, 'reference': quote_id,
|
||||
'status': 'approved', 'amount_cents': total}, expected=403, signature='')
|
||||
deliver({'event_id': 'tampered-' + uuid4().hex, 'reference': quote_id,
|
||||
'status': 'approved', 'amount_cents': total}, expected=403, signature='0' * 64)
|
||||
assert not customer.call('/quotes/' + quote_id)['order'], 'unsigned delivery created an order'
|
||||
print('PASS: unsigned and tampered deliveries are refused and create nothing')
|
||||
|
||||
# An approved payment for the wrong amount must not become an order.
|
||||
deliver({'event_id': 'short-' + uuid4().hex, 'reference': quote_id,
|
||||
'status': 'approved', 'amount_cents': total - 100})
|
||||
assert not customer.call('/quotes/' + quote_id)['order'], 'underpayment created an order'
|
||||
deliver({'event_id': 'missing-amount-' + uuid4().hex, 'reference': quote_id,
|
||||
'status': 'approved'})
|
||||
assert not customer.call('/quotes/' + quote_id)['order'], 'missing paid amount created an order'
|
||||
deliver({'event_id': 'invalid-amount-' + uuid4().hex, 'reference': quote_id,
|
||||
'status': 'approved', 'amount_cents': str(total)})
|
||||
assert not customer.call('/quotes/' + quote_id)['order'], 'non-integer paid amount created an order'
|
||||
print('PASS: a missing, invalid or mismatched paid amount is refused')
|
||||
|
||||
# The real thing, then the same delivery again, and a second event for the
|
||||
# same quote: a provider does all three.
|
||||
event = 'paid-' + uuid4().hex
|
||||
payload = {'event_id': event, 'reference': quote_id, 'status': 'approved',
|
||||
'amount_cents': total}
|
||||
first = deliver(payload)
|
||||
assert first['status'] == 'applied', first
|
||||
order = customer.call('/quotes/' + quote_id)['order']
|
||||
assert order, 'approved payment did not create an order'
|
||||
|
||||
again = deliver(payload)
|
||||
assert again['status'] == 'duplicate', again
|
||||
later = deliver({**payload, 'event_id': 'retry-' + uuid4().hex})
|
||||
assert 'already existed' in later.get('outcome', ''), later
|
||||
assert customer.call('/quotes/' + quote_id)['order']['id'] == order['id'], 'a second order appeared'
|
||||
print('PASS: one order from a repeated and re-sent approval')
|
||||
|
||||
assert customer.call('/orders/dev-paid', {'quote_id': quote_id})['id'] == order['id']
|
||||
other = Client()
|
||||
other.call('/session')
|
||||
other.call('/orders/dev-paid', {'quote_id': quote_id}, expected=404)
|
||||
print('PASS: another customer cannot retrieve the paid order by quote id')
|
||||
|
||||
# The customer is told once, not once per delivery.
|
||||
board = Client()
|
||||
events = board.call('/operator/board', operator=True)['events']
|
||||
paid = [e for e in events if e['payload'].get('order_id') == order['id']
|
||||
and e['payload'].get('event') == 'payment_approved']
|
||||
assert len(paid) == 2, f'expected one tiny and one whatsapp event, got {len(paid)}'
|
||||
assert {e['provider'] for e in paid} == {'tiny', 'whatsapp'}, paid
|
||||
print('PASS: exactly one notification per provider for the order')
|
||||
|
||||
# A payment that was never reviewed, and one for something that is not a quote.
|
||||
deliver({'event_id': 'nonsense-' + uuid4().hex, 'reference': 'not-a-uuid',
|
||||
'status': 'approved', 'amount_cents': 100})
|
||||
deliver({'event_id': 'missing-' + uuid4().hex, 'reference': str(uuid4()),
|
||||
'status': 'approved', 'amount_cents': 100})
|
||||
deliver({'event_id': 'pending-' + uuid4().hex, 'reference': quote_id,
|
||||
'status': 'pending', 'amount_cents': total})
|
||||
print('PASS: unknown references and non-approved statuses are recorded without acting')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run()
|
||||
60
tests/quote_pagination_test.py
Normal file
60
tests/quote_pagination_test.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""The 101st pending quote and older approved quotes remain reachable on the board."""
|
||||
from uuid import uuid4
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from app.core import db
|
||||
from tests.smoke_test import Client
|
||||
|
||||
|
||||
def run():
|
||||
owner = uuid4()
|
||||
pending_ids = [uuid4() for _ in range(105)]
|
||||
approved_ids = [uuid4() for _ in range(22)]
|
||||
created = pending_ids + approved_ids
|
||||
draft = {'customer': {'mail': 'pagination-fixture@example.test'},
|
||||
'items': [], 'freight': {'service': 'pickup'}}
|
||||
try:
|
||||
with db.connect() as c:
|
||||
for uid in pending_ids:
|
||||
c.execute('''INSERT INTO dtf_local.quotes
|
||||
(id,owner,request_key,request_hash,draft,created_at)
|
||||
VALUES(%s,%s,%s,%s,%s,now()+interval '1 hour')''',
|
||||
(uid, owner, uuid4(), 'pagination-fixture', Jsonb(draft)))
|
||||
for uid in approved_ids:
|
||||
c.execute('''INSERT INTO dtf_local.quotes
|
||||
(id,owner,request_key,request_hash,draft,approved,approved_at,created_at)
|
||||
VALUES(%s,%s,%s,%s,%s,%s,now(),now()+interval '1 hour')''',
|
||||
(uid, owner, uuid4(), 'pagination-fixture', Jsonb(draft),
|
||||
Jsonb({'items': [], 'total_cents': 0})))
|
||||
|
||||
client = Client()
|
||||
board = client.call('/operator/board', operator=True)
|
||||
assert board['pending_total'] >= 105
|
||||
assert board['approved_total'] >= 22
|
||||
for kind, fixture_ids in [('pending', pending_ids), ('approved', approved_ids)]:
|
||||
first = [q for q in board['quotes'] if (q['approved'] is None) == (kind == 'pending')]
|
||||
seen = {q['id'] for q in first}
|
||||
assert len(first) == (100 if kind == 'pending' else 20)
|
||||
last = first[-1]
|
||||
for _ in range(5):
|
||||
path = '/operator/quotes?' + urlencode({
|
||||
'kind': kind, 'limit': 50,
|
||||
'before_created_at': last['created_at'], 'before_id': last['id']})
|
||||
page = client.call(path, operator=True)
|
||||
assert page['quotes'], 'An older quote page disappeared'
|
||||
assert not seen.intersection(q['id'] for q in page['quotes']), 'Quote page repeated rows'
|
||||
seen.update(q['id'] for q in page['quotes'])
|
||||
if set(map(str, fixture_ids)) <= seen:
|
||||
break
|
||||
last = page['quotes'][-1]
|
||||
assert set(map(str, fixture_ids)) <= seen, f'{kind} quotes were hidden by the board limit'
|
||||
print('PASS: 105 pending and 22 approved quotes remain reachable across board pages')
|
||||
finally:
|
||||
with db.connect() as c:
|
||||
c.execute('DELETE FROM dtf_local.quotes WHERE id=ANY(%s)', (created,))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run()
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Harmless EICAR anti-malware test and blocked download/quote regressions."""
|
||||
from uuid import uuid4
|
||||
from tests.smoke_test import Client, upload_bytes
|
||||
from tests.smoke_test import Client, upload_bytes, item_spec
|
||||
|
||||
def run():
|
||||
customer=Client();customer.call('/session')
|
||||
@@ -9,7 +9,7 @@ def run():
|
||||
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)
|
||||
'items':[item_spec('file','1',0,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.')
|
||||
|
||||
@@ -37,14 +37,15 @@ class Client:
|
||||
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):
|
||||
def call(self,path,body=None,operator=False,expected=200,method=None):
|
||||
headers=with_host({'Content-Type':'application/json'})
|
||||
if operator:
|
||||
if self.operator_client is None:
|
||||
self.operator_client=Client()
|
||||
self.operator_client.call('/operator/login',{'email':os.environ.get('OPERATOR_EMAIL','operator@example.test'),'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)
|
||||
return self.operator_client.call(path,body,expected=expected,method=method)
|
||||
request=Request(BASE+'/api'+path,data=None if body is None else json.dumps(body).encode(),
|
||||
headers=headers,method=method)
|
||||
try:
|
||||
with self.opener.open(request,timeout=30) as response:
|
||||
assert response.status==expected,(path,response.status,expected)
|
||||
@@ -78,10 +79,32 @@ def upload_bytes(client, content, name='LOCAL-TEST.cdr', order_id=None, expected
|
||||
wait_scan(client,uid,operator,expected_scan)
|
||||
return uid
|
||||
|
||||
def item_spec(mode, metres, grade, uid):
|
||||
film_width=28.5 if mode in ('uvfile','uv') else 57
|
||||
length_cm=float(metres)*100
|
||||
return {'mode':mode,'metres':str(metres),'grade':grade,'uploads':[uid],
|
||||
'production':{'version':2,'film_width_cm':film_width,'height_cm':length_cm,
|
||||
'sources':[{'upload_id':uid,
|
||||
'kind':'sheet' if mode in ('file','uvfile') else 'artwork',
|
||||
'width_cm':film_width,'length_cm':length_cm,'copies':1,
|
||||
'rotation_degrees':0,'mirrored':False,'measurement':'customer'}],
|
||||
'placements':[{'source_index':0,'copy_index':0,'x_cm':0,'y_cm':0,
|
||||
'width_cm':film_width,'length_cm':length_cm,
|
||||
'rotation_degrees':0,'mirrored':False}]},
|
||||
'quality_status':'unverified' if grade==0 else 'ok',
|
||||
'quality_acknowledged':False}
|
||||
|
||||
def run():
|
||||
client=Client();other=Client()
|
||||
config=client.call('/session');other.call('/session')
|
||||
assert client.call('/health')['integrations']=='fake'
|
||||
assert 0 < config['max_upload_bytes'] <= 128 * 1024 * 1024
|
||||
client.call('/uploads',{'name':'too-large.cdr',
|
||||
'size':config['max_upload_bytes']+1},expected=413)
|
||||
cancelled=client.call('/uploads',{'name':'CANCELLED-PART.cdr','size':3})['id']
|
||||
other.call('/uploads/'+cancelled,expected=404,method='DELETE')
|
||||
assert client.call('/uploads/'+cancelled,method='DELETE')['cancelled']
|
||||
client.call('/uploads/'+cancelled,expected=410)
|
||||
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']
|
||||
@@ -104,10 +127,15 @@ def run():
|
||||
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')]
|
||||
items=[item_spec(m,'2.75',90,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,'items':[{k:v for k,v in items[0].items() if k!='production'}]},expected=422)
|
||||
outside={**items[0],'production':{**items[0]['production'],
|
||||
'placements':[{**items[0]['production']['placements'][0],'x_cm':1}]}}
|
||||
client.call('/quotes',{**draft,'items':[outside]},expected=422)
|
||||
client.call('/quotes',{**draft,'items':[{**items[0],'metres':'1.00'}]},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']
|
||||
@@ -116,6 +144,9 @@ def run():
|
||||
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)
|
||||
altered={**items[0],'production':{**items[0]['production'],
|
||||
'sources':[{**items[0]['production']['sources'][0],'measurement':'file'}]}}
|
||||
client.call('/operator/quotes/'+qid+'/approve',{'items':[altered,*items[1:]]},operator=True,expected=422)
|
||||
# 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)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"""Customer identity, correction and final-file trust boundaries against local stack."""
|
||||
from uuid import uuid4
|
||||
from urllib.request import urlopen
|
||||
from tests.smoke_test import Client, upload_bytes
|
||||
from tests.smoke_test import Client, upload_bytes, item_spec
|
||||
|
||||
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]}
|
||||
item=item_spec('file','1.01',0,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)
|
||||
@@ -47,6 +47,10 @@ def run():
|
||||
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)
|
||||
premature_final=upload_bytes(customer,b'FINAL BEFORE CUSTOMER CORRECTION',order_id=oid)
|
||||
version=customer.call('/operator/orders/'+oid+'/final-files',
|
||||
{'version':version,'files':[{'item_index':0,'upload_id':premature_final}],
|
||||
'note':'Prepared before customer sent the new correction'},operator=True)['version']
|
||||
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)
|
||||
@@ -54,6 +58,7 @@ def run():
|
||||
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)
|
||||
assert not any(f['kind']=='final' and f['active'] for f in files), 'new correction kept a stale final active'
|
||||
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)
|
||||
|
||||
@@ -6,6 +6,23 @@
|
||||
let draftId = localStorage.getItem('dtf-quote');
|
||||
let requestKey = localStorage.getItem('dtf-request-key');
|
||||
let requestBody = localStorage.getItem('dtf-request-body');
|
||||
let quotedCart = localStorage.getItem('dtf-quote-cart');
|
||||
let refreshVersion = 0;
|
||||
function cartSnapshot() {
|
||||
const items=[...pedido,...(itemAtual?[itemAtual]:[])];
|
||||
return JSON.stringify({customer:cliente,delivery:entrega,items:items.map(item=>({
|
||||
mode:item.modo,metres:item.metros,grade:item.nota,production:item.production,
|
||||
quality:item.qualityStatus,
|
||||
acknowledged:item.qualityAcknowledged,
|
||||
files:(item.localFiles||[]).map(file=>({name:file.name,size:file.size,lastModified:file.lastModified}))
|
||||
}))});
|
||||
}
|
||||
function clearDraft() {
|
||||
draftId=null;quotedCart=null;requestKey=null;requestBody=null;
|
||||
for(const key of ['dtf-quote','dtf-quote-cart','dtf-request-key','dtf-request-body'])
|
||||
localStorage.removeItem(key);
|
||||
actions.replaceChildren();
|
||||
}
|
||||
const api = async (path, body) => {
|
||||
const response = await fetch('/api'+path, {
|
||||
credentials: 'same-origin', headers: {'Content-Type':'application/json'},
|
||||
@@ -19,6 +36,12 @@
|
||||
return data;
|
||||
};
|
||||
const ready = api('/session');
|
||||
ready.then(session=>{
|
||||
window.dtfUploadMaxBytes=session.max_upload_bytes;
|
||||
const limit=document.getElementById('zLimite');
|
||||
if(limit)limit.textContent='Até '+(session.max_upload_bytes/1048576).toFixed(0)+
|
||||
' MB por arquivo enquanto a verificação de segurança para arquivos grandes é preparada.';
|
||||
}).catch(()=>{});
|
||||
window.dtfSessionReady=ready;
|
||||
window.dtfApi=api;
|
||||
ready.catch(error => { status.textContent = error.message; });
|
||||
@@ -50,8 +73,10 @@
|
||||
if (busy) return;
|
||||
if (draftId) { await refresh(); status.scrollIntoView({behavior:'smooth',block:'nearest'}); return; }
|
||||
if (!clienteOk() || !entrega.cotado) return;
|
||||
if (!cartPodeEnviar()) return message('Revise a qualidade e confirme a ressalva de cada item antes de enviar o pedido.');
|
||||
const cart = [...pedido,...(itemAtual?[itemAtual]:[])];
|
||||
if (!cart.length) return message('Adicione um item ao pedido.');
|
||||
const initialCart=cartSnapshot();
|
||||
busy = true;
|
||||
$('bPagar').disabled = true;
|
||||
try {
|
||||
@@ -62,9 +87,15 @@
|
||||
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});
|
||||
if (item.production?.version!==2 || item.production.sources?.length!==uploads.length)
|
||||
throw new Error('A montagem deste item precisa ser refeita antes da cotação.');
|
||||
items.push({mode:item.modo, metres:String(item.metros), grade:item.nota, uploads,
|
||||
production:{...item.production,sources:item.production.sources.map((source,index)=>({
|
||||
upload_id:uploads[index],...source}))},
|
||||
quality_status:item.qualityStatus,quality_acknowledged:item.qualityAcknowledged});
|
||||
}
|
||||
const content = {customer:{...cliente},items,freight:{service:entrega.tipo==='retira'?'pickup':'mock-standard',postal_code:entrega.tipo==='retira'?'':entrega.cep}};
|
||||
if (cartSnapshot()!==initialCart) throw new Error('O carrinho mudou durante o envio. Confira os itens e envie de novo.');
|
||||
const serialized = JSON.stringify(content);
|
||||
if (!requestKey || serialized !== requestBody) {
|
||||
requestKey = crypto.randomUUID(); requestBody = serialized;
|
||||
@@ -72,6 +103,7 @@
|
||||
localStorage.setItem('dtf-request-body',requestBody);
|
||||
}
|
||||
const quote = await api('/quotes',{request_key:requestKey,...content});
|
||||
quotedCart=initialCart;localStorage.setItem('dtf-quote-cart',quotedCart);
|
||||
draftId=quote.id; localStorage.setItem('dtf-quote',draftId);
|
||||
await refresh();
|
||||
status.scrollIntoView({behavior:'smooth',block:'nearest'});
|
||||
@@ -80,10 +112,17 @@
|
||||
};
|
||||
async function refresh() {
|
||||
if (!draftId) return;
|
||||
const version=++refreshVersion, shownId=draftId;
|
||||
try {
|
||||
await ready;
|
||||
const quote=await api('/quotes/'+draftId);
|
||||
const quote=await api('/quotes/'+shownId);
|
||||
if(version!==refreshVersion || draftId!==shownId) return;
|
||||
actions.replaceChildren();
|
||||
if (quote.status!=='paid' && (!quotedCart || quotedCart!==cartSnapshot())) {
|
||||
message('O carrinho mudou ou não está disponível neste navegador. A cotação anterior continua separada; envie o carrinho atual para uma nova revisão.');
|
||||
button('Enviar carrinho atual',()=>{clearDraft();window.dtfCheckout();});
|
||||
return;
|
||||
}
|
||||
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') {
|
||||
@@ -93,6 +132,7 @@
|
||||
return;
|
||||
}
|
||||
button('Criar pedido de teste',async event=>{
|
||||
if (!quotedCart || quotedCart!==cartSnapshot()) { await refresh(); return; }
|
||||
event.target.disabled=true;
|
||||
try {
|
||||
const order=await api('/orders/dev-paid',{quote_id:draftId});
|
||||
@@ -104,18 +144,23 @@
|
||||
});
|
||||
} else if (quote.status==='paid') {
|
||||
message('Pedido #'+quote.order.number+' · etapa: '+quote.order.state+'.');
|
||||
button('Novo pedido',()=>{localStorage.removeItem('dtf-quote');localStorage.removeItem('dtf-request-key');localStorage.removeItem('dtf-request-body');location.reload();});
|
||||
button('Novo pedido',()=>{clearDraft();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();});
|
||||
button('Nova cotação',clearDraft);
|
||||
}
|
||||
} catch(error) {
|
||||
if(version!==refreshVersion || draftId!==shownId) return;
|
||||
message(error.message);
|
||||
actions.replaceChildren();
|
||||
button('Limpar referência e tentar de novo',()=>{draftId=null;requestKey=null;localStorage.removeItem('dtf-quote');localStorage.removeItem('dtf-request-key');actions.replaceChildren();});
|
||||
button('Limpar referência e tentar de novo',clearDraft);
|
||||
}
|
||||
}
|
||||
const quoteFromPortal=new URLSearchParams(location.search).get('quote');
|
||||
if(quoteFromPortal && /^[0-9a-f-]{36}$/.test(quoteFromPortal)){draftId=quoteFromPortal;localStorage.setItem('dtf-quote',draftId);}
|
||||
if(quoteFromPortal && /^[0-9a-f-]{36}$/.test(quoteFromPortal)){
|
||||
if(draftId!==quoteFromPortal){quotedCart=null;localStorage.removeItem('dtf-quote-cart');}
|
||||
draftId=quoteFromPortal;localStorage.setItem('dtf-quote',draftId);
|
||||
}
|
||||
window.addEventListener('dtf-cart-changed',()=>{if(draftId) refresh();});
|
||||
refresh();
|
||||
})();
|
||||
|
||||
@@ -1035,6 +1035,7 @@ footer a:hover{color:var(--laranja2)}
|
||||
<div class="zona" id="zona" tabindex="0" role="button">
|
||||
<div class="ico">↑</div>
|
||||
<b id="zTit">Arraste aqui</b><span id="zSub"></span>
|
||||
<span id="zLimite">Até 128 MB por arquivo enquanto a verificação de segurança para arquivos grandes é preparada.</span>
|
||||
<span class="bt">ou escolher no computador</span>
|
||||
</div>
|
||||
<div class="recusa" id="recusa" style="display:none"></div>
|
||||
@@ -1098,9 +1099,8 @@ footer a:hover{color:var(--laranja2)}
|
||||
<li class="s"><b>PDF é o que conferimos melhor.</b> Nele medimos a resolução de
|
||||
cada imagem dentro da folha, uma por uma. Se a sua arte é boa, é onde a nota
|
||||
aparece com mais precisão</li>
|
||||
<li class="a"><b>PDF só até 5 m.</b> O limite é do próprio formato: a página do PDF
|
||||
para em 508 cm. Acima disso o arquivo sai fora do padrão e só abre certo em quem
|
||||
o gerou — divida em partes ou mande PNG</li>
|
||||
<li class="a"><b>PDF grande.</b> Sem UserUnit, a página passa do limite de 508 cm
|
||||
do formato; divida a folha ou mande PNG. Envie um PDF de uma página por folha.</li>
|
||||
<li class="a"><b>TIFF, PSD, AI e CDR</b> a gente aceita, mas ninguém consegue
|
||||
conferir sozinho: alguém abre na mão, sai mais devagar e o metro vai pela
|
||||
tabela, sem desconto de nota</li>
|
||||
|
||||
@@ -3,6 +3,8 @@ const $ = id=>document.getElementById(id);
|
||||
// Remove credentials saved by older local builds. Only HttpOnly sessions now.
|
||||
sessionStorage.removeItem('dtf-operator');
|
||||
let board;
|
||||
let extraQuotes={pending:[],approved:[]};
|
||||
let moreQuotes={pending:false,approved:false};
|
||||
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;}
|
||||
@@ -16,25 +18,67 @@ function files(container,items){
|
||||
const link=node('a');link.href=result.url;link.referrerPolicy='no-referrer';link.download=result.name;link.click();
|
||||
}));
|
||||
}
|
||||
function productionLines(container,item){
|
||||
if(!item.production){container.append(node('p','Instruções de produção ausentes; não produzir este item.','meta'));return;}
|
||||
container.append(node('p','Especificação v'+item.production.version+' · qualidade '+item.quality_status+
|
||||
(item.quality_status==='warning'?' · ressalva '+(item.quality_acknowledged?'aceita':'não aceita'):''),'meta'));
|
||||
if(item.production.placements){
|
||||
container.append(node('p',item.production.placements.length+' peças posicionadas em '+
|
||||
item.production.film_width_cm+' × '+item.production.height_cm+' cm de filme','meta'));
|
||||
const download=node('button','Baixar manifesto da montagem');
|
||||
download.type='button';
|
||||
download.onclick=()=>{
|
||||
const url=URL.createObjectURL(new Blob([JSON.stringify(item.production,null,2)],{type:'application/json'}));
|
||||
const link=node('a');link.href=url;
|
||||
link.download='dtf-layout-'+item.production.sources[0].upload_id.slice(0,8)+'.json';
|
||||
link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
|
||||
};
|
||||
container.append(download);
|
||||
}
|
||||
item.production.sources.forEach((source,index)=>container.append(node('p',
|
||||
(index+1)+'. '+source.kind+' · '+source.copies+' × '+source.width_cm+' × '+source.length_cm+
|
||||
' cm · giro '+source.rotation_degrees+'°'+(source.mirrored?' · espelhada':'')+
|
||||
' · medida '+source.measurement+' · arquivo '+source.upload_id.slice(0,8),'meta')));
|
||||
}
|
||||
async function load(){
|
||||
try{
|
||||
board=await api('/board');$('login').hidden=true;
|
||||
board=await api('/board');
|
||||
extraQuotes={pending:[],approved:[]};
|
||||
moreQuotes={pending:board.pending_total>board.quotes.filter(q=>!q.approved).length,
|
||||
approved:board.approved_total>board.quotes.filter(q=>!!q.approved).length};
|
||||
$('login').hidden=true;
|
||||
$('status').textContent='Atualizado às '+new Date().toLocaleTimeString();
|
||||
render();
|
||||
}catch(e){$('status').textContent=e.message;$('login').hidden=false;}
|
||||
}
|
||||
async function loadMoreQuotes(kind){
|
||||
const shown=[...board.quotes.filter(q=>kind==='pending'?!q.approved:!!q.approved),...extraQuotes[kind]];
|
||||
const last=shown.at(-1);
|
||||
const params=new URLSearchParams({kind,limit:'50'});
|
||||
if(last){params.set('before_created_at',last.created_at);params.set('before_id',last.id);}
|
||||
const page=await api('/quotes?'+params);
|
||||
const known=new Set(shown.map(q=>q.id));
|
||||
extraQuotes[kind].push(...page.quotes.filter(q=>!known.has(q.id)));
|
||||
moreQuotes[kind]=page.has_more;
|
||||
render();
|
||||
}
|
||||
function render(){
|
||||
$('reviews').replaceChildren();
|
||||
if(board.quotes.length)$('reviews').append(node('h2','Cotações · conferência comercial'));
|
||||
for(const quote of board.quotes){
|
||||
if(board.pending_total || board.approved_total)
|
||||
$('reviews').append(node('h2','Cotações · '+board.pending_total+' pendentes · '+
|
||||
board.approved_total+' aprovadas sem pedido'));
|
||||
for(const quote of [...board.quotes,...extraQuotes.pending,...extraQuotes.approved]){
|
||||
const card=node('article',undefined,'review');
|
||||
card.append(node('b',quote.id.slice(0,8)+' · '+quote.draft.customer.mail));
|
||||
if(quote.status!=='pending_review'){
|
||||
if(quote.status==='pending_review' && quote.draft.items.some(item=>item.production?.version!==2)){
|
||||
card.append(node('p','Cotação com montagem antiga. Peça ao cliente para enviar uma nova cotação.'));
|
||||
}else 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+' '));
|
||||
productionLines(row,item);
|
||||
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);
|
||||
@@ -47,6 +91,10 @@ function render(){
|
||||
}
|
||||
const downloads=node('div',undefined,'actions');files(downloads,quote.draft.items);card.append(downloads);$('reviews').append(card);
|
||||
}
|
||||
for(const [kind,label] of [['pending','Carregar cotações pendentes mais antigas'],
|
||||
['approved','Carregar cotações aprovadas mais antigas']]){
|
||||
if(moreQuotes[kind])$('reviews').append(action(label,()=>loadMoreQuotes(kind)));
|
||||
}
|
||||
$('kan').replaceChildren();
|
||||
const colors=['#00b8da','#e0357c','#efb700','#edebe6','#e0642a','#48b072'];
|
||||
Object.entries(board.states).forEach(([state,title],index)=>{
|
||||
@@ -60,7 +108,10 @@ function render(){
|
||||
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'));
|
||||
for(const item of order.snapshot.items){
|
||||
card.append(node('p',item.mode+' · '+item.billed_metres+' m · nota '+item.grade,'meta'));
|
||||
productionLines(card,item);
|
||||
}
|
||||
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)));
|
||||
|
||||
@@ -4,7 +4,23 @@
|
||||
scope and run top to bottom, exactly as the original did. */
|
||||
// ── carrinho
|
||||
let itemAtual=null;
|
||||
function invalidaItemAtual(){
|
||||
if(!itemAtual) return;
|
||||
itemAtual=null;
|
||||
$('atual').style.display='none';
|
||||
$('carrLin').innerHTML='';
|
||||
pintaPedido();
|
||||
}
|
||||
function itemPodeEnviar(item){
|
||||
return item.qualityStatus==='ok' || item.qualityStatus==='unverified' ||
|
||||
(item.qualityStatus==='warning' && item.qualityAcknowledged===true);
|
||||
}
|
||||
function cartPodeEnviar(){
|
||||
const items=[...pedido,...(itemAtual?[itemAtual]:[])];
|
||||
return items.length>0 && items.every(itemPodeEnviar);
|
||||
}
|
||||
function limpaPaineis(){
|
||||
invalidaItemAtual();
|
||||
['qual','prev'].forEach(id=>$(id).classList.remove('on'));
|
||||
if(!pedido.length){ $('carr').classList.remove('on'); }
|
||||
}
|
||||
@@ -37,6 +53,7 @@ function pintaPedido(){
|
||||
$('bPagar').textContent = (pedido.length + (itemAtual?1:0))>1
|
||||
? 'Pagar os '+(pedido.length+(itemAtual?1:0))+' itens de uma vez'
|
||||
: 'Ir para o pagamento';
|
||||
$('bPagar').disabled = !cartPodeEnviar() || !entrega.cotado || !clienteOk();
|
||||
if(pedido.length || itemAtual) $('carr').classList.add('on');
|
||||
if($('cartLink')) $('cartLink').textContent='Carrinho ('+n+')';
|
||||
window.dispatchEvent(new Event('dtf-cart-changed'));
|
||||
@@ -62,8 +79,36 @@ function carrinho(){
|
||||
'<div class="l" style="color:var(--laranja2)"><span>Revisão e reencaixe</span><b>grátis</b></div>';
|
||||
$('carrLin').innerHTML=l;
|
||||
$('atual').style.display='';
|
||||
const sources=(ehFolha()?folhas:artes).map(x=>ehFolha()
|
||||
? {kind:'sheet',width_cm:x.med?.larg||larguraFilme(),length_cm:+(x.m*100).toFixed(4),
|
||||
copies:x.rep||1,rotation_degrees:0,mirrored:false,
|
||||
measurement:x.med?'file':'customer'}
|
||||
: {kind:'artwork',width_cm:x.cm,length_cm:+(x.cm*propDe(x)).toFixed(4),
|
||||
copies:x.q,rotation_degrees:x.giro||0,mirrored:!!x.esp,
|
||||
measurement:x.src?'file':'customer'});
|
||||
let layout;
|
||||
if(ehFolha()){
|
||||
let y=0;
|
||||
const placements=[];
|
||||
sources.forEach((source,source_index)=>{
|
||||
for(let copy_index=0;copy_index<source.copies;copy_index++){
|
||||
placements.push({source_index,copy_index,x_cm:0,y_cm:+y.toFixed(4),
|
||||
width_cm:source.width_cm,length_cm:source.length_cm,
|
||||
rotation_degrees:0,mirrored:false});
|
||||
y+=source.length_cm;
|
||||
}
|
||||
});
|
||||
layout={height_cm:+y.toFixed(4),placements};
|
||||
}else{
|
||||
if(!montagemLayout) return;
|
||||
layout=montagemLayout;
|
||||
}
|
||||
itemAtual={modo, tit:MODOS[modo].tit, nota, metros, cob, unit, total:tot,
|
||||
localFiles:(ehFolha()?folhas:artes).map(x=>x.f),
|
||||
production:{version:2,film_width_cm:larguraFilme(),sources,...layout},
|
||||
qualityStatus:$('ciente').classList.contains('recusa')?'rejected':
|
||||
$('ciente').classList.contains('on')?'warning':nota===0?'unverified':'ok',
|
||||
qualityAcknowledged:false,
|
||||
desc:fmtM(cob)+' m · '+rs(unit)+'/m · nota '+nota};
|
||||
pintaPedido();
|
||||
previa();
|
||||
@@ -74,4 +119,3 @@ function carrinho(){
|
||||
}
|
||||
}
|
||||
function qm(t,c){ const m=$('qmsg'); m.style.display='block'; m.style.color=c||'var(--verde)'; m.innerHTML=t; }
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ const folhaTotalM=()=>folhas.reduce((t,x)=>t+(x.m||0)*(x.rep||1),0);
|
||||
// Cada modo por metro tem o seu par de artes avulsas, e o seletor anda nos dois sentidos.
|
||||
const PAR={file:'avulsa', avulsa:'file', uvfile:'uv', uv:'uvfile'};
|
||||
let montagemCm=0; // altura da folha montada pelo motor, em cm
|
||||
let montagemLayout=null; // posições aprovadas de cada cópia, em cm
|
||||
const ZOOMS=[1,1.5,2,3,4]; let zoomI=0; // ampliação da montagem ao vivo
|
||||
let pedido=[]; // itens já fechados · o pagamento é um só
|
||||
// A escolha de entrega vale para o pedido inteiro, não por item. No Tiny, retirada
|
||||
@@ -100,9 +101,7 @@ const precoBase=n=>
|
||||
(FAIXAS[modo].find(f=>n>=f[0])||FAIXAS[modo][FAIXAS[modo].length-1])[1];
|
||||
const descontoMetragem=()=>0; // sem progressão por quantidade
|
||||
const cls=n=>n>=85?'ok':n>=50?'av':'er';
|
||||
const dpiDe=a=>a.cm>0? a.px/(a.cm/2.54) : 0;
|
||||
const dpiDe=a=>a.cm>0? (a.giro?a.py:a.px)/(a.cm/2.54) : 0;
|
||||
// girar 90° inverte a proporção · espelhar não muda medida, só o desenho
|
||||
const propDe=a=>{ const p=a.prop||1; return a.giro? 1/p : p; };
|
||||
const TAMANHOS={57:[10,15,20,25,28.2], 28.5:[5,8,10,14,28.2]};
|
||||
const px=f=>Math.round(Math.min(6000,Math.max(600,Math.sqrt(f.size/1024)*95)));
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ function pintaEntrega(){
|
||||
? 'DTF é impresso depois que você paga. Avisamos no WhatsApp quando estiver '+
|
||||
'<b>pronto para retirar</b> — não venha antes do aviso.'
|
||||
: '';
|
||||
$('bPagar').disabled = !entrega.cotado || !clienteOk();
|
||||
$('bPagar').disabled = !entrega.cotado || !clienteOk() || !cartPodeEnviar();
|
||||
pintaPedido();
|
||||
}
|
||||
|
||||
@@ -156,6 +156,10 @@ $('bCep').addEventListener('click',async()=>{
|
||||
});
|
||||
|
||||
$('bMais').addEventListener('click',()=>{
|
||||
if(itemAtual && !itemPodeEnviar(itemAtual)){
|
||||
$('qual').scrollIntoView({behavior:'smooth',block:'nearest'});
|
||||
return;
|
||||
}
|
||||
if(itemAtual){ pedido.push(itemAtual); itemAtual=null; }
|
||||
$('atual').style.display='none'; $('carrLin').innerHTML='';
|
||||
$('foco').classList.remove('on'); $('cards').style.display='';
|
||||
@@ -170,4 +174,3 @@ $('bPagar').addEventListener('click',()=>{
|
||||
else alert('O pedido online está indisponível no momento. Tente novamente em instantes.');
|
||||
});
|
||||
pintaEntrega();
|
||||
|
||||
|
||||
@@ -134,6 +134,8 @@ function encaixar(pecas,W){
|
||||
}
|
||||
|
||||
const postas=[];
|
||||
if(pecas.some(p=>!Number.isFinite(p.w) || p.w<=0 || p.w>W))
|
||||
throw new RangeError('A largura solicitada não cabe no filme.');
|
||||
const ordem=[...pecas].sort((a,b)=>(b.w*b.h)-(a.w*a.h));
|
||||
|
||||
ordem.forEach(p=>{
|
||||
@@ -173,10 +175,8 @@ function encaixar(pecas,W){
|
||||
}
|
||||
});
|
||||
if(!melhor){
|
||||
const k=Math.min(1, W/p.w); // cabe na largura, nem que seja reduzindo
|
||||
const w=p.w*k, h=p.h*k;
|
||||
const m=mascara(p.img,w,h,p.a?.giro||0,p.a?.esp);
|
||||
melhor={cx:0,cy:linhas,m,x:0,y:linhas*CEL,w,h,rot:0,ref:p};
|
||||
const m=mascara(p.img,p.w,p.h,p.a?.giro||0,p.a?.esp);
|
||||
melhor={cx:0,cy:linhas,m,x:0,y:linhas*CEL,w:p.w,h:p.h,rot:0,ref:p};
|
||||
}
|
||||
// a folga só existe entre peças, não é folha cobrada
|
||||
ocupar(melhor.m,melhor.cx,melhor.cy);
|
||||
@@ -190,7 +190,7 @@ function encaixar(pecas,W){
|
||||
function desenhaMontagem(area,lista,mts,W,fora,aoTerminar){
|
||||
const versao=area.montagemVersao=(area.montagemVersao||0)+1;
|
||||
const filme=larguraFilme(), PX_CM=W/filme;
|
||||
const itens=lista.map(a=>({...a}));
|
||||
const itens=lista.map((a,sourceIndex)=>({...a,sourceIndex}));
|
||||
if(fora) fora.innerHTML='';
|
||||
if(!itens.length){
|
||||
area.innerHTML='<div class="semmont"><b>A montagem aparece aqui</b>'+
|
||||
@@ -201,7 +201,7 @@ function desenhaMontagem(area,lista,mts,W,fora,aoTerminar){
|
||||
|
||||
Promise.all(itens.map(a=>carregarImagem(a.f).then(img=>({a,img})))).then(imagens=>{
|
||||
if(area.montagemVersao!==versao) return;
|
||||
const cargas=imagens.flatMap(c=>Array.from({length:c.a.q||1},()=>({...c})));
|
||||
const cargas=imagens.flatMap(c=>Array.from({length:c.a.q||1},(_,copyIndex)=>({...c,copyIndex})));
|
||||
cargas.forEach(c=>{
|
||||
c.w = c.a.cm; // o motor trabalha em centímetros
|
||||
c.h = c.w * (c.img ? (c.a.giro? c.img.width/c.img.height : c.img.height/c.img.width) : 1);
|
||||
@@ -255,7 +255,12 @@ function desenhaMontagem(area,lista,mts,W,fora,aoTerminar){
|
||||
+ (girou? ' · <b style="display:inline">'+girou+
|
||||
(girou>1?' artes giradas':' arte girada')+' 90°</b> para caber melhor':'');
|
||||
(fora||area).appendChild(el);
|
||||
if(aoTerminar) aoTerminar({alturaCm, uso, girou});
|
||||
if(aoTerminar) aoTerminar({alturaCm, uso, girou,
|
||||
placements:enc.pos.map(p=>({source_index:p.ref.a.sourceIndex,
|
||||
copy_index:p.ref.copyIndex,x_cm:p.x,y_cm:p.y,
|
||||
width_cm:+p.w.toFixed(4),length_cm:+p.h.toFixed(4),
|
||||
rotation_degrees:(p.rot+(p.ref.a.giro||0))%360,
|
||||
mirrored:!!p.ref.a.esp}))});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -265,6 +270,7 @@ function previaArtes(){ desenhaMontagem($('pArea'), artes, metros, 560); }
|
||||
let tMont=null;
|
||||
function previaAoVivo(){
|
||||
clearTimeout(tMont);
|
||||
montagemLayout=null;
|
||||
$('vArea').montagemVersao=($('vArea').montagemVersao||0)+1;
|
||||
if(ehFolha()){
|
||||
$('vUso').innerHTML='';
|
||||
@@ -296,11 +302,13 @@ function previaAoVivo(){
|
||||
return;
|
||||
}
|
||||
tMont=setTimeout(()=>{
|
||||
const prontas=artes.filter(a=>a.cm>0);
|
||||
const prontas=artes.filter(a=>a.cm>0 && a.cm<=larguraFilme() && a.src);
|
||||
const W=Math.round(300*ZOOMS[zoomI]);
|
||||
$('vArea').classList.toggle('ampliado', zoomI>0);
|
||||
desenhaMontagem($('vArea'), prontas, 0, W, $('vUso'), r=>{
|
||||
montagemCm = r.alturaCm; // altura real da folha montada
|
||||
montagemLayout=r.placements ? {height_cm:+r.alturaCm.toFixed(4),
|
||||
placements:r.placements} : null;
|
||||
$('vMt').textContent = r.alturaCm? fmtM(cobrar(r.alturaCm/100))+' m' : '—';
|
||||
resumoArtes(); // o resumo lê a mesma folha
|
||||
agendaAvaliacao(); // a nota só sai com a metragem pronta
|
||||
@@ -312,12 +320,12 @@ function previaAoVivo(){
|
||||
function motivoNota(){
|
||||
if(!nota) return 'sem nota ainda · a conferência roda na sala antes de imprimir';
|
||||
if(!ehFolha()){
|
||||
const ruins=artes.filter(a=>a.cm>0 && (a.px/(a.cm/2.54))<300);
|
||||
const ruins=artes.filter(a=>a.cm>0 && dpiDe(a)<300);
|
||||
if(!ruins.length) return 'nota '+nota+' · todas as artes em 300 DPI ou mais';
|
||||
const pior=ruins.reduce((p,a)=>(a.px/(a.cm/2.54))<(p.px/(p.cm/2.54))?a:p);
|
||||
const pior=ruins.reduce((p,a)=>dpiDe(a)<dpiDe(p)?a:p);
|
||||
return 'nota '+nota+' · '+ruins.length+(ruins.length>1?' artes abaixo':' arte abaixo')+
|
||||
' de 300 DPI · a menor é "'+pior.f.name+'" com '+
|
||||
Math.round(pior.px/(pior.cm/2.54))+' DPI em '+pior.cm.toFixed(1).replace('.',',')+' cm';
|
||||
Math.round(dpiDe(pior))+' DPI em '+pior.cm.toFixed(1).replace('.',',')+' cm';
|
||||
}
|
||||
return nota>=90 ? 'nota '+nota+' · resolução ótima em toda a folha'
|
||||
: 'nota '+nota+' · a resolução é o único ponto — o resto corrigimos por você';
|
||||
@@ -350,4 +358,3 @@ function previa(){
|
||||
catch(e){ $('pArea').innerHTML='<div class="semprev"><b>Prévia indisponível</b>'+
|
||||
'O arquivo será processado normalmente.</div>'; }
|
||||
}
|
||||
|
||||
|
||||
189
web/site-pdf.js
189
web/site-pdf.js
@@ -10,9 +10,8 @@
|
||||
const PDFJS_URL='/vendor/pdf.min.js';
|
||||
const PDFJS_WORKER=new URL('/vendor/pdf.worker.min.js', location.origin).href;
|
||||
let pdfLibP=null, temWorker=null;
|
||||
// Alguns navegadores e iframes bloqueiam Worker de blob. Descobrimos antes de
|
||||
// tentar, porque sem worker o PDF é lido na thread principal e precisa de outra
|
||||
// estratégia: grade mais grossa e mais tempo.
|
||||
// Check whether this browser can create a same-origin Worker. The worker file
|
||||
// is served alongside this script and does not need a blob URL.
|
||||
function testaWorker(url){
|
||||
try{ const w=new Worker(url); w.terminate(); return true; }catch(e){ return false; }
|
||||
}
|
||||
@@ -25,10 +24,8 @@ function carregarPdfJs(){
|
||||
sc.src=PDFJS_URL;
|
||||
sc.onload=()=>{
|
||||
try{
|
||||
const codigo='importScripts("'+PDFJS_WORKER+'");';
|
||||
const url=URL.createObjectURL(new Blob([codigo],{type:'application/javascript'}));
|
||||
temWorker=testaWorker(url);
|
||||
window.pdfjsLib.GlobalWorkerOptions.workerSrc = temWorker? url : PDFJS_WORKER;
|
||||
temWorker=testaWorker(PDFJS_WORKER);
|
||||
window.pdfjsLib.GlobalWorkerOptions.workerSrc=PDFJS_WORKER;
|
||||
}catch(e){
|
||||
temWorker=false;
|
||||
try{ window.pdfjsLib.GlobalWorkerOptions.workerSrc=PDFJS_WORKER; }catch(e2){}
|
||||
@@ -61,7 +58,7 @@ function dpiDasImagens(page, ops){
|
||||
const larguraPt=Math.hypot(ctm[0],ctm[1]);
|
||||
if(larguraPt<1) continue;
|
||||
const areaPt=Math.abs(ctm[0]*ctm[3]-ctm[1]*ctm[2]);
|
||||
dpis.push({dpi: im.width/(larguraPt/72), area: areaPt});
|
||||
dpis.push({dpi: im.width/(larguraPt*page.userUnit/72), area: areaPt});
|
||||
}
|
||||
}
|
||||
return dpis;
|
||||
@@ -88,43 +85,58 @@ function resumoDpi(lista){
|
||||
}
|
||||
|
||||
// Rasteriza a primeira página a 1 mm por pixel e devolve a tela para a análise
|
||||
function rasterizarPdf(file, larguraCm, alturaCm){
|
||||
let motivo=null;
|
||||
const trabalho=carregarPdfJs().then(lib=>{
|
||||
if(!lib){ motivo='não foi possível carregar o leitor de PDF'; return null; }
|
||||
// sem worker tudo roda na thread principal · grade mais grossa para caber
|
||||
const folga = temWorker===false ? 2.2 : 1;
|
||||
return file.arrayBuffer().then(buf=>lib.getDocument({
|
||||
data:buf, disableFontFace:true, isEvalSupported:false, useSystemFonts:false,
|
||||
verbosity:0 // sem worker é fallback, não erro
|
||||
}).promise).then(doc=>
|
||||
doc.getPage(1).then(page=>{
|
||||
const vp1=page.getViewport({scale:1});
|
||||
const passo=passoDe(larguraCm, alturaCm||larguraCm)*folga;
|
||||
const alvo=Math.max(8, Math.round(larguraCm/passo));
|
||||
const esc=alvo/vp1.width;
|
||||
const vp=page.getViewport({scale:esc});
|
||||
const cv=document.createElement('canvas');
|
||||
cv.width=Math.max(8,Math.round(vp.width)); cv.height=Math.max(8,Math.round(vp.height));
|
||||
const ctx=cv.getContext('2d',{willReadFrequently:true});
|
||||
return page.render({canvasContext:ctx, viewport:vp, background:'rgba(0,0,0,0)'})
|
||||
.promise.then(()=>page.getOperatorList()).then(ops=>{
|
||||
let dpis=[];
|
||||
try{ dpis=dpiDasImagens(page,ops); }catch(e){}
|
||||
const r=resumoDpi(dpis);
|
||||
return {tela:cv, dpi: r? r.ponderado : null, res:r};
|
||||
});
|
||||
})
|
||||
);
|
||||
}).catch(e=>{ motivo=(e&&e.message)? e.message : 'erro ao abrir o PDF'; return null; });
|
||||
// sem worker o processo é lento: damos mais tempo antes de desistir
|
||||
const espera = temWorker===false ? 90000 : 30000;
|
||||
const tarde=new Promise(r=>setTimeout(()=>{ motivo='demorou demais neste navegador'; r('tempo'); }, espera));
|
||||
return Promise.race([trabalho, tarde]).then(r=>{
|
||||
const saida = r==='tempo'? null : r;
|
||||
if(!saida) return {erro: motivo||'não deu para conferir', semWorker: temWorker===false};
|
||||
return saida;
|
||||
async function rasterizarPdf(file, larguraCm, alturaCm){
|
||||
const lib=await carregarPdfJs();
|
||||
if(!lib) return {erro:'não foi possível carregar o leitor de PDF'};
|
||||
// Decide after the worker check; the old timeout always used 30 seconds.
|
||||
const espera=temWorker===false ? 90000 : 30000;
|
||||
const folga=temWorker===false ? 2.2 : 1;
|
||||
let loading=null, doc=null, rendering=null, expired=false, timer=null;
|
||||
const trabalho=(async()=>{
|
||||
try{
|
||||
const buf=await file.arrayBuffer();
|
||||
if(expired) return null;
|
||||
loading=lib.getDocument({data:buf, disableFontFace:true,
|
||||
isEvalSupported:false, useSystemFonts:false, verbosity:0});
|
||||
doc=await loading.promise;
|
||||
if(expired || doc.numPages!==1) return null;
|
||||
const page=await doc.getPage(1);
|
||||
if(expired) return null;
|
||||
const vp1=page.getViewport({scale:1});
|
||||
const passo=passoDe(larguraCm, alturaCm||larguraCm)*folga;
|
||||
const alvo=Math.max(8,Math.round(larguraCm/passo));
|
||||
const vp=page.getViewport({scale:alvo/vp1.width});
|
||||
const cv=document.createElement('canvas');
|
||||
cv.width=Math.max(8,Math.round(vp.width)); cv.height=Math.max(8,Math.round(vp.height));
|
||||
const ctx=cv.getContext('2d',{willReadFrequently:true});
|
||||
rendering=page.render({canvasContext:ctx, viewport:vp, background:'rgba(0,0,0,0)'});
|
||||
await rendering.promise;
|
||||
if(expired) return null;
|
||||
const ops=await page.getOperatorList();
|
||||
if(expired) return null;
|
||||
let dpis=[];
|
||||
try{ dpis=dpiDasImagens(page,ops); }catch(e){}
|
||||
const r=resumoDpi(dpis);
|
||||
return {tela:cv, dpi:r? r.ponderado:null, res:r};
|
||||
}catch(e){
|
||||
if(expired) return null;
|
||||
return {erro:(e&&e.message)||'erro ao abrir o PDF', semWorker:temWorker===false};
|
||||
}finally{
|
||||
if(doc) doc.destroy().catch(()=>{});
|
||||
else if(loading) loading.destroy().catch(()=>{});
|
||||
}
|
||||
})();
|
||||
const tardio=new Promise(resolve=>{
|
||||
timer=setTimeout(()=>{
|
||||
expired=true;
|
||||
try{ if(rendering) rendering.cancel(); }catch(e){}
|
||||
if(loading) loading.destroy().catch(()=>{});
|
||||
resolve({erro:'demorou demais neste navegador',semWorker:temWorker===false});
|
||||
},espera);
|
||||
});
|
||||
const result=await Promise.race([trabalho,tardio]);
|
||||
clearTimeout(timer);
|
||||
return result||{erro:'não deu para conferir',semWorker:temWorker===false};
|
||||
}
|
||||
|
||||
function medirFolha(file){
|
||||
@@ -143,29 +155,40 @@ function medirFolha(file){
|
||||
return;
|
||||
}
|
||||
if(/\.pdf$/.test(nome)){
|
||||
const pedaco=(inicio,fim)=>new Promise(r=>{
|
||||
const fr=new FileReader();
|
||||
fr.onload=()=>r(new TextDecoder('latin1').decode(new Uint8Array(fr.result)));
|
||||
fr.onerror=()=>r('');
|
||||
fr.readAsArrayBuffer(file.slice(inicio,fim));
|
||||
});
|
||||
const M=4*1048576;
|
||||
Promise.all([pedaco(0,M), pedaco(Math.max(0,file.size-M), file.size)]).then(([a,b])=>{
|
||||
const txt=a+b;
|
||||
const re=/\/MediaBox\s*\[\s*(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)/;
|
||||
const m=txt.match(re);
|
||||
if(!m) return res(null);
|
||||
// UserUnit escala a página inteira · sem ele, 1 unidade = 1/72 pol
|
||||
const uu=txt.match(/\/UserUnit\s+([\d.]+)/);
|
||||
const esc=uu? Math.max(1,Math.min(75000,+uu[1])) : 1;
|
||||
const ptW=Math.abs(+m[3]-+m[1]), ptH=Math.abs(+m[4]-+m[2]);
|
||||
if(!(ptW>0&&ptH>0)) return res(null);
|
||||
const w=ptW*PT_CM*esc, h=ptH*PT_CM*esc;
|
||||
// acima de 14.400 unidades sem UserUnit o arquivo está fora da especificação
|
||||
const foraDoPadrao = !uu && (ptW>PDF_MAX_PT || ptH>PDF_MAX_PT);
|
||||
res({larg:+w.toFixed(1), alt:+h.toFixed(1),
|
||||
fonte:'página do PDF'+(uu? ' · UserUnit '+esc : ''), foraDoPadrao});
|
||||
});
|
||||
// PDF boxes can be inherited, compressed, rotated, and scaled by UserUnit.
|
||||
// Searching raw bytes for /MediaBox can read the wrong object or miss it.
|
||||
let doc=null;
|
||||
carregarPdfJs().then(async lib=>{
|
||||
if(!lib) throw new Error('Não foi possível carregar o leitor de PDF.');
|
||||
doc=await lib.getDocument({data:await file.arrayBuffer(),
|
||||
disableFontFace:true, isEvalSupported:false, useSystemFonts:false,
|
||||
verbosity:0}).promise;
|
||||
if(doc.numPages!==1)
|
||||
return {rejected:true, reason:'O PDF tem '+doc.numPages+
|
||||
' páginas. Envie cada folha como um PDF de uma página para calcular o preço correto.'};
|
||||
const page=await doc.getPage(1);
|
||||
const view=page.view, unit=page.userUnit, vp=page.getViewport({scale:1});
|
||||
if(!Array.isArray(view) || view.length!==4 ||
|
||||
!Number.isFinite(unit) || unit<=0 ||
|
||||
!Number.isFinite(vp.width) || !Number.isFinite(vp.height) ||
|
||||
vp.width<=0 || vp.height<=0)
|
||||
return {rejected:true, reason:'Não conseguimos determinar o tamanho desta página PDF. Exporte-a novamente.'};
|
||||
const ptW=Math.abs(view[2]-view[0]), ptH=Math.abs(view[3]-view[1]);
|
||||
if(!(ptW>0 && ptH>0) ||
|
||||
(unit===1 && (ptW>PDF_MAX_PT || ptH>PDF_MAX_PT)))
|
||||
return {rejected:true, reason:'A página passa do limite de 508 cm sem UserUnit. Divida a folha ou exporte em PNG.'};
|
||||
// This pinned PDF.js build exposes the page's rotated view in points;
|
||||
// userUnit is separate and must scale both physical dimensions.
|
||||
const w=vp.width*unit*PT_CM, h=vp.height*unit*PT_CM;
|
||||
if(!Number.isFinite(w) || !Number.isFinite(h) || w<=0 || h<=0 || h>6000)
|
||||
return {rejected:true, reason:'O tamanho desta página PDF não é suportado. Divida a folha em partes menores.'};
|
||||
// Keep precise geometry for the fit and quote; the UI rounds only
|
||||
// when displaying dimensions.
|
||||
return {larg:+w.toFixed(3), alt:+h.toFixed(3),
|
||||
fonte:'página do PDF'+(unit!==1 ? ' · UserUnit '+unit : '')};
|
||||
}).then(res).catch(()=>res({rejected:true,
|
||||
reason:'Não conseguimos ler este PDF. Exporte-o novamente como PDF de uma página ou PNG.'}))
|
||||
.finally(()=>{ if(doc) doc.destroy().catch(()=>{}); });
|
||||
return;
|
||||
}
|
||||
res(null);
|
||||
@@ -173,12 +196,13 @@ function medirFolha(file){
|
||||
}
|
||||
|
||||
function pintaFolha(){
|
||||
invalidaItemAtual();
|
||||
const L=larguraFilme();
|
||||
pintaTipoEnvio(); // trava o seletor enquanto houver folha
|
||||
if(!folhas.length){ $('lista').innerHTML=''; agendaAvaliacao(); previaAoVivo(); return; }
|
||||
|
||||
$('lista').innerHTML = folhas.map((x,i)=>{
|
||||
const lido=!!x.med, larga=lido && x.med.larg>L+0.5, suspeito=lido && x.med.foraDoPadrao;
|
||||
const lido=!!x.med, larga=lido && x.med.larg>L, suspeito=lido && x.med.foraDoPadrao;
|
||||
const sub=(x.m||0)*(x.rep||1);
|
||||
let medida='';
|
||||
if(lido){
|
||||
@@ -210,6 +234,9 @@ function pintaFolha(){
|
||||
? ' Ela cabe no <b>DTF têxtil de 57 cm</b> — troque de produto ali em cima.'
|
||||
: ' Remonte a folha em '+n1(L)+' cm e suba de novo.')+'</em>':'')+
|
||||
'</div>';
|
||||
}else if(x.measurementError){
|
||||
medida='<div class="med alerta"><b>Este PDF não pode ser orçado</b><span>'+
|
||||
escapeHTML(x.measurementError)+'</span></div>';
|
||||
}else{
|
||||
const teto=TABELA[modo], piso=pisoEscada();
|
||||
const dif=(x.m>0)? (teto-piso)*cobrar(x.m*(x.rep||1)) : 0;
|
||||
@@ -231,7 +258,7 @@ function pintaFolha(){
|
||||
'<span class="progT">'+(x.pct<50?'lendo o arquivo…':
|
||||
x.pct<70?'medindo a folha…':'conferindo a arte…')+'</span>'
|
||||
: x.semAnalise
|
||||
? '<div class="progF"><b>Não conseguimos conferir esta folha.</b> '+x.semAnalise+
|
||||
? '<div class="progF"><b>Não conseguimos conferir esta folha.</b> '+escapeHTML(x.semAnalise)+
|
||||
'. Seguimos com o preço de tabela — se quiser a nota e o desconto, exporte a mesma '+
|
||||
'folha em <b>PNG</b>, que a conferência é bem mais leve.</div>'
|
||||
: '';
|
||||
@@ -271,6 +298,8 @@ function pintaFolha(){
|
||||
$('lista').querySelectorAll('[data-comp]').forEach(el=>el.addEventListener('change',e=>{
|
||||
folhas[+el.dataset.comp].m=Math.max(0,Math.min(60,+e.target.value||0)); pintaFolha();
|
||||
}));
|
||||
$('lista').querySelectorAll('[data-repf], [data-comp]').forEach(el=>
|
||||
el.addEventListener('input',invalidaItemAtual));
|
||||
agendaAvaliacao(); previaAoVivo();
|
||||
}
|
||||
|
||||
@@ -323,10 +352,15 @@ function vizPeca(a){
|
||||
const n1=v=>v.toFixed(1).replace('.',',');
|
||||
|
||||
function pintaArtes(){
|
||||
invalidaItemAtual();
|
||||
$('lista').innerHTML=artes.map((a,i)=>{
|
||||
let barra='', dica='', calc='<span>informe a largura para ver a qualidade</span>';
|
||||
if(a.cm>0){
|
||||
const dpi=Math.round(a.px/(a.cm/2.54)), n=Math.max(6,Math.min(100,Math.round(dpi/300*100)));
|
||||
if(a.decodeError){
|
||||
calc='<span class="er">Não conseguimos ler esta imagem; exporte novamente em PNG ou JPG</span>';
|
||||
}else if(a.cm>larguraFilme()){
|
||||
calc='<span class="er">Largura maior que o filme de '+n1(larguraFilme())+' cm</span>';
|
||||
}else if(a.cm>0 && a.src){
|
||||
const dpi=Math.round(dpiDe(a)), n=Math.max(6,Math.min(100,Math.round(dpi/300*100)));
|
||||
barra='<div class="qb2"><i class="'+cls(n)+'" style="width:'+n+'%"></i></div>';
|
||||
calc='<span class="'+cls(n)+'"><b>'+n+'%</b> · '+dpi+' DPI</span><span>'+
|
||||
a.q+' × '+n1(a.cm)+' × '+n1(a.cm*propDe(a))+' cm</span>';
|
||||
@@ -341,7 +375,8 @@ function pintaArtes(){
|
||||
dica = vizPeca(a) + dica;
|
||||
}
|
||||
return '<div class="art'+(a===recemChegada?' nova':'')+'"><div class="l1"><b>'+escapeHTML(a.f.name)+'</b>'+
|
||||
'<span style="color:var(--fraco);font-size:10.5px">'+a.px+' px</span>'+
|
||||
'<span style="color:var(--fraco);font-size:10.5px">'+
|
||||
(a.px>0?a.px+' × '+a.py+' px':'a ler')+'</span>'+
|
||||
'<button data-rm="'+i+'">×</button></div><div class="cps">'+
|
||||
'<div><label>largura na peça</label><input type="number" min="1" max="'+larguraFilme()+
|
||||
'" placeholder="cm" data-cm="'+i+'" value="'+(a.cm||'')+'"></div>'+
|
||||
@@ -358,11 +393,12 @@ function pintaArtes(){
|
||||
}).join('');
|
||||
$('lista').querySelectorAll('[data-rm]').forEach(b=>b.addEventListener('click',()=>{artes.splice(+b.dataset.rm,1);pintaArtes();}));
|
||||
$('lista').querySelectorAll('[data-cm]').forEach(i=>i.addEventListener('change',()=>{artes[+i.dataset.cm].cm=+i.value||0;pintaArtes();}));
|
||||
$('lista').querySelectorAll('[data-q]').forEach(i=>i.addEventListener('change',()=>{artes[+i.dataset.q].q=Math.max(1,Math.floor(+i.value||1));pintaArtes();}));
|
||||
$('lista').querySelectorAll('[data-q]').forEach(i=>i.addEventListener('change',()=>{artes[+i.dataset.q].q=Math.max(1,Math.min(200,Math.floor(+i.value||1)));pintaArtes();}));
|
||||
$('lista').querySelectorAll('[data-cm], [data-q]').forEach(i=>i.addEventListener('input',()=>{
|
||||
const largura=i.hasAttribute('data-cm');
|
||||
const a=artes[+(largura?i.dataset.cm:i.dataset.q)];
|
||||
a[largura?'cm':'q']=largura?Math.max(0,+i.value||0):Math.max(1,Math.floor(+i.value||1));
|
||||
a[largura?'cm':'q']=largura?Math.max(0,+i.value||0):Math.max(1,Math.min(200,Math.floor(+i.value||1)));
|
||||
invalidaItemAtual();
|
||||
previaAoVivo();
|
||||
}));
|
||||
$('lista').querySelectorAll('[data-usar]').forEach(b=>b.addEventListener('click',()=>{
|
||||
@@ -381,10 +417,10 @@ function pintaArtes(){
|
||||
previaAoVivo();
|
||||
}
|
||||
function resumoArtes(){
|
||||
const r=$('rA'), prontas=artes.filter(a=>a.cm>0);
|
||||
const r=$('rA'), prontas=artes.filter(a=>a.cm>0 && a.cm<=larguraFilme() && a.src);
|
||||
if(!prontas.length){ r.style.display='none'; return; }
|
||||
const m=montagemCm/100; // a mesma folha que aparece ao lado
|
||||
const notas=prontas.map(a=>Math.max(6,Math.min(100,Math.round((a.px/(a.cm/2.54))/300*100))));
|
||||
const notas=prontas.map(a=>Math.max(6,Math.min(100,Math.round(dpiDe(a)/300*100))));
|
||||
const media=Math.round(notas.reduce((s,n)=>s+n,0)/notas.length), pior=Math.min(...notas);
|
||||
const falta=artes.length-prontas.length;
|
||||
r.style.display='block'; r.className='resumoA on';
|
||||
@@ -397,4 +433,3 @@ function resumoArtes(){
|
||||
' m'+(cobrar(m)===MINIMO_M?' · mínimo':'')+'</b></div>':''):'')+
|
||||
(falta?'<div class="r" style="color:var(--laranja)"><span>faltam informar</span><b>'+falta+'</b></div>':'');
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
function avaliar(){
|
||||
if(ehFolha()){
|
||||
if(!folhas.length) return {pronto:false, falta:'Arraste sua folha montada para começar.'};
|
||||
const larga=folhas.find(x=>x.med && x.med.larg > larguraFilme()+0.5);
|
||||
const pdfInvalido=folhas.find(x=>x.measurementError);
|
||||
if(pdfInvalido) return {pronto:false, falta:pdfInvalido.f.name+': '+pdfInvalido.measurementError};
|
||||
const larga=folhas.find(x=>x.med && x.med.larg > larguraFilme());
|
||||
if(larga) return {pronto:false, falta:'A folha '+larga.f.name+' tem '+n1(larga.med.larg)+
|
||||
' cm e não cabe no filme de '+n1(larguraFilme())+' cm.'};
|
||||
const semMedida=folhas.filter(x=>!(x.m>0)).length;
|
||||
@@ -17,12 +19,14 @@ function avaliar(){
|
||||
metros=folhaTotalM();
|
||||
reencaixado=false;
|
||||
const ans=folhas.map(x=>x.an).filter(Boolean);
|
||||
if(!ans.length){
|
||||
// sem análise possível (PDF, CDR, PSD): não inventa nota
|
||||
const exts=[...new Set(folhas.map(f=>extDe(f.f.name)))].join(', ');
|
||||
if(ans.length!==folhas.length){
|
||||
// A nota precisa cobrir cada folha faturada. Uma folha sem análise não
|
||||
// herda a nota/desconto de outra folha que o navegador conseguiu abrir.
|
||||
const ausentes=folhas.filter(f=>!f.an);
|
||||
const exts=[...new Set(ausentes.map(f=>extDe(f.f.name)))].join(', ');
|
||||
mostraNota([
|
||||
['av','Sem nota · '+exts+' não abre no navegador',
|
||||
'não temos como conferir a arte antes de imprimir'],
|
||||
['av','Sem nota · '+exts+' sem análise completa',
|
||||
ausentes.length+' de '+folhas.length+' folha(s) precisam de conferência manual'],
|
||||
['er','Metro pela tabela · '+rs(TABELA[modo]),
|
||||
'exportando em PNG ou PDF, cai até '+rs(pisoEscada())],
|
||||
['fix','Alguém abre seu arquivo','a conferência é feita na mão, na sala']
|
||||
@@ -58,13 +62,18 @@ function avaliar(){
|
||||
return {pronto:true};
|
||||
}
|
||||
if(!artes.length) return {pronto:false, falta:'Arraste suas artes para começar.'};
|
||||
if(artes.some(a=>a.decodeError || !a.src || !(a.px>0) || !(a.py>0)))
|
||||
return {pronto:false, falta:'Não conseguimos ler uma das imagens. Exporte-a novamente em PNG ou JPG antes de continuar.'};
|
||||
if(artes.some(a=>!Number.isFinite(a.cm) || a.cm>larguraFilme()))
|
||||
return {pronto:false, falta:'A largura da arte precisa caber no filme de '+
|
||||
n1(larguraFilme())+' cm. Reduza a medida solicitada antes de continuar.'};
|
||||
const sem=artes.filter(a=>!a.cm).length;
|
||||
if(sem) return {pronto:false, falta:'Informe a largura de '+sem+' arte'+(sem>1?'s':'')+
|
||||
' para ver a nota e o preço.'};
|
||||
// metragem = altura da folha depois de montada, com os 5 mm entre peças
|
||||
metros=+(montagemCm/100).toFixed(4);
|
||||
if(!(metros>0)) return {pronto:false, falta:'Montando a folha…'};
|
||||
const notas=artes.map(a=>Math.max(6,Math.min(100,Math.round((a.px/(a.cm/2.54))/300*100))));
|
||||
const notas=artes.map(a=>Math.max(6,Math.min(100,Math.round(dpiDe(a)/300*100))));
|
||||
const media=Math.round(notas.reduce((s,n)=>s+n,0)/notas.length), pior=Math.min(...notas);
|
||||
mostraNota([
|
||||
['ok','Montagem por nossa conta','5 mm garantidos'],
|
||||
@@ -182,5 +191,8 @@ function ressalvaResolucao(){
|
||||
$('cienteOk').addEventListener('change',e=>{
|
||||
if($('ciente').classList.contains('recusa')) return; // recusa não se aceita
|
||||
$('qOk').disabled=!e.target.checked;
|
||||
if(itemAtual && itemAtual.qualityStatus==='warning'){
|
||||
itemAtual.qualityAcknowledged=e.target.checked;
|
||||
pintaEntrega();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -37,6 +37,16 @@ function sel(fs){
|
||||
const fora = fs.filter(f=>!regra.test(f.name));
|
||||
fs = fs.filter(f=>regra.test(f.name));
|
||||
recusa(fora);
|
||||
const max=window.dtfUploadMaxBytes||128*1048576;
|
||||
const grandes=fs.filter(f=>f.size>max);
|
||||
if(grandes.length){
|
||||
const el=$('recusa');
|
||||
el.style.display='block';
|
||||
el.innerHTML='<b>Arquivo acima do limite de '+(max/1048576).toFixed(0)+
|
||||
' MB.</b> A verificação de segurança ainda não consegue liberar arquivos maiores. '+
|
||||
'Divida ou compacte a arte antes de enviar.';
|
||||
fs=fs.filter(f=>f.size<=max);
|
||||
}
|
||||
if(!fs.length) return;
|
||||
if(ehFolha()){
|
||||
const auto=fs.filter(f=>AUTO.test(f.name)).length;
|
||||
@@ -60,6 +70,9 @@ function sel(fs){
|
||||
novas.forEach(x=>{
|
||||
x.pct=5; pintaFolha();
|
||||
medirFolha(x.f).then(md=>{
|
||||
if(md && md.rejected){
|
||||
x.measurementError=md.reason; x.pct=null; pintaFolha(); return;
|
||||
}
|
||||
if(md && md.dpiFolha!=null && md.dpiFolha<DPI_RECUSA) return recusaFolha(x, md);
|
||||
x.med=md; if(md) x.m=md.alt/100; x.pct=55; pintaFolha();
|
||||
if(md && RENDERIZA.test(x.f.name)){
|
||||
@@ -93,7 +106,7 @@ function sel(fs){
|
||||
});
|
||||
return;
|
||||
}
|
||||
const novos=fs.map(f=>({f,px:px(f),cm:0,q:1,prop:1,giro:0,esp:false}));
|
||||
const novos=fs.map(f=>({f,px:0,py:0,cm:0,q:1,prop:1,giro:0,esp:false}));
|
||||
artes=novos.concat(artes); // a mais recente fica no topo da fila
|
||||
recemChegada=novos[0];
|
||||
pintaArtes();
|
||||
@@ -102,11 +115,12 @@ function sel(fs){
|
||||
}
|
||||
function medir(a){
|
||||
return carregarImagem(a.f).then(img=>{
|
||||
if(img){ a.prop=img.height/img.width; a.px=img.width; a.src=img.src; } else a.prop=a.prop||1;
|
||||
if(img){ a.prop=img.height/img.width; a.px=img.width; a.py=img.height; a.src=img.src; }
|
||||
else a.decodeError=true;
|
||||
return a;
|
||||
});
|
||||
}
|
||||
// A metragem sai do arquivo, nunca do peso em bytes.
|
||||
// imagem → proporção da imagem aplicada à largura do filme
|
||||
// PDF → MediaBox da primeira página, em pontos (1 pt = 1/72 pol)
|
||||
// PDF → página única lida pelo PDF.js, com boxes, rotação e UserUnit
|
||||
// TIFF, PSD, AI, CDR → o navegador não abre; o cliente informa o comprimento
|
||||
|
||||
Reference in New Issue
Block a user