feat: place PDF artwork in print files, add card payment, count only failed logins
All checks were successful
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Integration suite on a real stack (push) Successful in 2m23s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images and notify Portainer (push) Has been skipped

PDF artwork: a single-page PDF source is placed in the print file as a
vector form through pikepdf, never rasterised, using the CropBox and
inherited /Rotate the Site measured with pdf.js. Multi-page and protected
PDFs go to hand preparation. PyMuPDF was not used because of its AGPL
licence. Raster tests cover crop, page rotation, placement rotation and
mirroring, and fail when the rotation or crop handling is broken.

Card payment: Mercado Pago's Card Payment Brick on the Site when
MP_PUBLIC_KEY is set; the card becomes a one-time token in Mercado Pago's
secure fields. Each card attempt has its own idempotency key, and the intent
route refuses new attempts once a payment is approved or a card is in
review, so a quote cannot be charged twice. The Site CSP admits Mercado
Pago's origins only through PAYMENT_CSP_SOURCES, empty by default.

Logins: every attempt counts against the source address, only failures
against the account. Counting successful sign-ins let ordinary use lock an
operator out and made CI's final browser sign-in fail.

No new required settings; production behaviour is unchanged until the
provider credentials are configured. Verified with the full CI integration
sequence locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cauê Faleiros
2026-09-24 13:14:56 -03:00
parent e3d5558198
commit 4c01e932c3
23 changed files with 703 additions and 64 deletions

View File

@@ -105,7 +105,8 @@ class FakePayment:
return secret.encode() if secret else None
def create(self, quote_id: str, total_cents: int, customer: dict, method: dict | None = None) -> dict:
return {'provider': 'fake', 'id': f'local-{quote_id}',
kind = (method or {}).get('type', 'pix')
return {'provider': 'fake', 'id': f'local-{quote_id}-{kind}',
'status': 'pending', 'total_cents': total_cents}
def sign(self, body: bytes) -> str:

View File

@@ -9,7 +9,7 @@ from psycopg.types.json import Jsonb
from ..core import db
from ..artwork import submit_files
from ..core.auth import (DUMMY_PASSWORD_HASH, audit, client_ip, new_session, owner,
password_hash, password_matches, session_row, throttle, transfer_guest)
login_failed, password_hash, password_matches, session_row, throttle, transfer_guest)
from ..core.models import ArtworkSubmission, Login, Register
from ..runtime import STATES, file_rows, owned_order, storage
from ..scanning import require_clean
@@ -26,6 +26,8 @@ def current(request):
def register(body: Register, request: Request, response: Response):
email = body.customer.mail.strip().lower()
throttle(email, request)
# Registration attempts keep counting against the email, as before.
login_failed(email)
previous = current(request)
encoded = password_hash(body.password)
identity = uuid4()
@@ -53,6 +55,7 @@ def login(body: Login, request: Request, response: Response):
stored = account['password_hash'] if account else DUMMY_PASSWORD_HASH
matches = password_matches(body.password, stored)
if not account or not matches:
login_failed(email)
audit('customer_login_failed', ip=client_ip(request))
raise HTTPException(401, 'Invalid email or password')
previous = current(request)

View File

@@ -1,4 +1,6 @@
"""Health, session bootstrap and freight quoting."""
import os
from fastapi import APIRouter, HTTPException, Request, Response
from ..core import db
@@ -32,7 +34,9 @@ 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': upload_limit_bytes(), 'payment_provider': payment.name}
'max_upload_bytes': upload_limit_bytes(), 'payment_provider': payment.name,
# Public by design: Mercado Pago's card form needs it in the browser.
'payment_public_key': os.environ.get('MP_PUBLIC_KEY', '') if payment.name == 'mercadopago' else ''}
@router.post('/api/freight')
def quote_freight(body: Freight):

View File

@@ -12,7 +12,7 @@ from psycopg.types.json import Jsonb
from ..core import db
from ..core.auth import (COOKIE_SECURE, DUMMY_PASSWORD_HASH, audit, client_ip, operator,
password_matches, throttle)
login_failed, password_matches, throttle)
from ..core.models import Move, OperatorLogin, Resolution, Review
from ..core.pricing import price
from ..printjobs import queue as queue_print_files
@@ -35,6 +35,7 @@ def operator_login(body: OperatorLogin, request: Request, response: Response):
stored = account['password_hash'] if account else DUMMY_PASSWORD_HASH
matches = password_matches(body.password, stored)
if not account or not account['active'] or not matches:
login_failed('operator:'+email)
audit('operator_login_failed', ip=client_ip(request), operator=email)
raise HTTPException(401, 'Invalid operator login')
token = secrets.token_urlsafe(32)

View File

@@ -71,10 +71,16 @@ def intent(body: PaymentIntent, session_id=Depends(owner)):
raise HTTPException(404 if 'not found' in str(refusal) else 409, str(refusal))
if c.execute('SELECT 1 FROM dtf_local.orders WHERE quote_id=%s', (body.quote_id,)).fetchone():
raise HTTPException(409, 'Quote is already paid')
existing = c.execute('''SELECT * FROM dtf_local.payment_intents WHERE quote_id=%s AND method=%s
AND status='pending' ORDER BY created_at DESC LIMIT 1''', (body.quote_id, body.method.type)).fetchone()
if existing:
return existing['response']
# Never a second charge: an approved payment is waiting for its
# notification to become the order, and a card in review may still be.
if c.execute('''SELECT 1 FROM dtf_local.payment_intents WHERE quote_id=%s
AND (status='approved' OR (method='card' AND status='pending'))''', (body.quote_id,)).fetchone():
raise HTTPException(409, 'A payment for this quote is already approved or in review')
if body.method.type == 'pix':
existing = c.execute('''SELECT * FROM dtf_local.payment_intents WHERE quote_id=%s AND method='pix'
AND status='pending' ORDER BY created_at DESC LIMIT 1''', (body.quote_id,)).fetchone()
if existing:
return existing['response']
try:
created = payment.create(str(body.quote_id), quote['approved']['total_cents'],
quote['approved']['customer'], body.method.model_dump())

View File

@@ -94,10 +94,25 @@ def rate_limit(scope, identity, limit, seconds=900):
audit('rate_limit', scope=scope)
raise HTTPException(429, 'Too many requests. Try again later.', headers={'Retry-After':str(seconds)})
ACCOUNT_FAILURES = 10
def throttle(email, request):
# Independent account and source buckets prevent bypass by rotating emails.
# Every attempt counts against the source. Only failures count against the
# account: guessing a password is what the account bucket stops, and
# counting successful sign-ins too let ordinary use lock an operator out.
rate_limit('auth-source', client_ip(request), 60)
rate_limit('auth-account', email, 10)
key = hashlib.sha256(('auth-account|'+email).encode()).hexdigest()
with connect() as c:
row = c.execute("""SELECT attempts FROM dtf_local.login_attempts
WHERE key=%s AND started_at >= now()-interval '900 seconds'""", (key,)).fetchone()
if row and row['attempts'] >= ACCOUNT_FAILURES:
audit('rate_limit', scope='auth-account')
raise HTTPException(429, 'Too many requests. Try again later.', headers={'Retry-After': '900'})
def login_failed(email):
"""Count a failed sign-in (or registration attempt) against the account."""
rate_limit('auth-account', email, 1_000_000)
def operator(request: Request):
token = request.cookies.get('dtf_operator', '')

View File

@@ -79,8 +79,12 @@ class MercadoPagoPayment:
body['issuer_id'] = method['issuer_id']
else:
raise ValueError('Unsupported payment method')
response = self.http.post('/v1/payments', json=body,
headers={'X-Idempotency-Key': f'dtf-quote-{quote_id}-{method["type"]}'})
# A PIX retry must return the same code. A card retry after a decline
# is a new attempt with a new token, so the token is part of the key;
# the intent route refuses new attempts once one is approved or in review.
key = f'dtf-quote-{quote_id}-pix' if method['type'] == 'pix' else \
f"dtf-quote-{quote_id}-card-{hashlib.sha256(method['token'].encode()).hexdigest()[:24]}"
response = self.http.post('/v1/payments', json=body, headers={'X-Idempotency-Key': key})
response.raise_for_status()
payment = response.json()
transaction = (payment.get('point_of_interaction') or {}).get('transaction_data') or {}

View File

@@ -12,13 +12,15 @@ in the file, a JPEG keeps its original bytes, and transparency survives as a
soft mask. The output is therefore about the size of the artwork, not of a
57 cm x 20 m raster, and it never needs that raster in memory.
Only formats whose pixels can be read here are generated: JPEG, PNG, WebP and
TIFF. Anything else (PDF, PSD, AI, CDR), or a file whose proportions do not
match the size it was quoted at, is refused with a reason, and the operator
prepares that item by hand exactly as before.
Raster sources are JPEG, PNG, WebP and TIFF. A single-page PDF is placed as a
vector form, never rasterised. Anything else (PSD, AI, CDR, multi-page or
protected PDFs), or a file whose proportions do not match the size it was
quoted at, is refused with a reason, and the operator prepares that item by
hand exactly as before.
"""
import math
import os
import tempfile
import zlib
from decimal import Decimal
@@ -288,8 +290,85 @@ def placement_matrix(placement, page_height_pt):
return [ax - ox, ay - oy, cx - ox, cy - oy, ox, oy]
def check_layout(item, sizes):
"""Refuse a layout the file cannot reproduce faithfully. Returns the lowest DPI."""
class PdfPage:
"""One page of a customer PDF, placed as a vector form: nothing is rasterised.
The box and orientation are the ones the Site measured with pdf.js: the
CropBox (which pikepdf uses for the form's BBox), turned by the page's
/Rotate, inherited or not.
"""
def __init__(self, path, name):
import pikepdf
self.name = name
try:
self.pdf = pikepdf.open(path)
except pikepdf.PasswordError as exc:
raise Unsupported(f'"{name}" is password-protected') from exc
except Exception as exc:
raise Unsupported(f'"{name}" is not a PDF this generator can read') from exc
try:
pages = len(self.pdf.pages)
if pages != 1:
raise Unsupported(f'"{name}" has {pages} pages; only single-page PDFs are generated automatically')
self.page = self.pdf.pages[0]
self.rotation = int(self.page.rotation) % 360
if self.rotation not in (0, 90, 180, 270):
raise Unsupported(f'"{name}" has an unsupported page rotation')
box = [float(v) for v in self.page.cropbox]
except Unsupported:
self.pdf.close()
raise
except Exception as exc:
self.pdf.close()
raise Unsupported(f'"{name}" has a page this generator cannot read') from exc
self.x0, self.y0 = min(box[0], box[2]), min(box[1], box[3])
self.w, self.h = abs(box[2] - box[0]), abs(box[3] - box[1])
if self.w <= 0 or self.h <= 0:
self.pdf.close()
raise Unsupported(f'"{name}" has an empty page')
# As displayed, which is what the proportions are checked against.
self.size = (self.h, self.w) if self.rotation in (90, 270) else (self.w, self.h)
def normalise(self):
"""Matrix from the page's box, as displayed, onto the unit square."""
a, d = 1 / self.w, 1 / self.h
scale = [a, 0, 0, d, -self.x0 * a, -self.y0 * d]
turn = {0: [1, 0, 0, 1, 0, 0], 90: [0, -1, 1, 0, 0, 1],
180: [-1, 0, 0, -1, 1, 1], 270: [0, 1, -1, 0, 1, 0]}[self.rotation]
return multiply(scale, turn)
def form(self, target):
"""The page as a form XObject copied into the target document."""
form = self.page.as_form_xobject(handle_transformations=False)
group = self.page.obj.get('/Group')
if group is not None and '/Group' not in form:
form.Group = group
return target.copy_foreign(form)
def close(self):
self.pdf.close()
def multiply(first, then):
"""PDF matrices: a point transformed by `first`, then by `then`."""
a1, b1, c1, d1, e1, f1 = first
a2, b2, c2, d2, e2, f2 = then
return [a1 * a2 + b1 * c2, a1 * b2 + b1 * d2, c1 * a2 + d1 * c2, c1 * b2 + d1 * d2,
e1 * a2 + f1 * c2 + e2, e1 * b2 + f1 * d2 + f2]
def open_source(path, name):
with open(path, 'rb') as handle:
head = handle.read(1024)
if b'%PDF-' in head:
return PdfPage(path, name)
return SourceImage(path, name)
def check_layout(item, sizes, vector=()):
"""Refuse a layout the file cannot reproduce faithfully. Returns the lowest
DPI of the raster sources (vector pages have none)."""
production = item['production']
height = Decimal(str(production['height_cm']))
billed = Decimal(str(item['billed_metres'])) * 100
@@ -307,6 +386,8 @@ def check_layout(item, sizes):
source = production['sources'][placement['source_index']]
raise Unsupported(f'file {placement["source_index"] + 1} has proportions that do not match '
f'the quoted {source["width_cm"]} x {source["length_cm"]} cm')
if placement['source_index'] in vector:
continue
dpi = width_px / (width_cm / 2.54)
lowest = dpi if lowest is None else min(lowest, dpi)
return lowest
@@ -317,6 +398,10 @@ def render(item, files, out, title):
`files` maps each source index to (local path, original name). Returns the
evidence the Kanban shows: page size, what was billed, and the lowest DPI.
Raster sources are written by the streaming writer above. PDF sources are
then added as vector forms by a second pass through pikepdf, so a sheet
exported as PDF keeps its vectors, fonts and transparency.
"""
production = item['production']
if production.get('version') != 2:
@@ -325,33 +410,23 @@ def render(item, files, out, title):
try:
for index in range(len(production['sources'])):
path, name = files[index]
sources.append(SourceImage(path, name))
lowest_dpi = check_layout(item, [source.size for source in sources])
sources.append(open_source(path, name))
vector = {index for index, source in enumerate(sources) if isinstance(source, PdfPage)}
lowest_dpi = check_layout(item, [source.size for source in sources], vector)
width_pt = float(production['film_width_cm']) * PT_PER_CM
height_pt = float(production['height_cm']) * PT_PER_CM
unit = max(1, math.ceil(max(width_pt, height_pt) / MAX_PAGE_PT))
pdf = PdfWriter(out)
images = [source.embed(pdf) for source in sources]
commands = [b'%s 0 0 %s 0 0 cm\n' % (serialize(1 / unit), serialize(1 / unit))] if unit > 1 else []
for placement in production['placements']:
matrix = placement_matrix(placement, height_pt)
commands.append(b'q ' + b' '.join(serialize(v) for v in matrix) +
b' cm /Im%d Do Q\n' % placement['source_index'])
content = pdf.stream({'Filter': Name('FlateDecode')}, deflate(commands))
pages = pdf.alloc()
page_box = [0, 0, width_pt / unit, height_pt / unit]
page = {'Type': Name('Page'), 'Parent': pages, 'MediaBox': page_box, 'TrimBox': page_box,
'Resources': {'XObject': {f'Im{index}': ref for index, ref in enumerate(images)}},
'Contents': content}
if unit > 1:
page['UserUnit'] = unit
page_ref = pdf.obj(page)
pdf.obj({'Type': Name('Pages'), 'Kids': [page_ref], 'Count': 1}, pages)
root = pdf.obj({'Type': Name('Catalog'), 'Pages': pages})
info = pdf.obj({'Title': title, 'Producer': 'DTF System print-file generator'})
pdf.finish(root, info)
first = tempfile.TemporaryFile() if vector else out
try:
write_rasters(production, sources, first, title, width_pt, height_pt, unit)
if vector:
first.seek(0)
add_vector_pages(production, sources, first, out, height_pt)
finally:
if vector:
first.close()
finally:
for source in sources:
source.close()
@@ -360,5 +435,54 @@ def render(item, files, out, title):
'billed_metres': str(item['billed_metres']),
'placements': len(production['placements']),
'sources': len(sources),
'vector_sources': len(vector),
'min_dpi': round(lowest_dpi) if lowest_dpi else None,
'user_unit': unit}
def write_rasters(production, sources, out, title, width_pt, height_pt, unit):
pdf = PdfWriter(out)
images = {index: source.embed(pdf) for index, source in enumerate(sources)
if isinstance(source, SourceImage)}
# The user-space scale is not wrapped in q/Q, so it also applies to the
# vector placements appended by the second pass.
commands = [b'%s 0 0 %s 0 0 cm\n' % (serialize(1 / unit), serialize(1 / unit))] if unit > 1 else []
for placement in production['placements']:
if placement['source_index'] not in images:
continue
matrix = placement_matrix(placement, height_pt)
commands.append(b'q ' + b' '.join(serialize(v) for v in matrix) +
b' cm /Im%d Do Q\n' % placement['source_index'])
content = pdf.stream({'Filter': Name('FlateDecode')}, deflate(commands))
pages = pdf.alloc()
page_box = [0, 0, width_pt / unit, height_pt / unit]
page = {'Type': Name('Page'), 'Parent': pages, 'MediaBox': page_box, 'TrimBox': page_box,
'Resources': {'XObject': {f'Im{index}': ref for index, ref in images.items()}},
'Contents': content}
if unit > 1:
page['UserUnit'] = unit
page_ref = pdf.obj(page)
pdf.obj({'Type': Name('Pages'), 'Kids': [page_ref], 'Count': 1}, pages)
root = pdf.obj({'Type': Name('Catalog'), 'Pages': pages})
info = pdf.obj({'Title': title, 'Producer': 'DTF System print-file generator'})
pdf.finish(root, info)
def add_vector_pages(production, sources, first, out, height_pt):
import pikepdf
with pikepdf.open(first) as document:
page = document.pages[0]
xobjects = page.obj.Resources.XObject
for index, source in enumerate(sources):
if isinstance(source, PdfPage):
xobjects[f'/Pdf{index}'] = source.form(document)
commands = []
for placement in production['placements']:
source = sources[placement['source_index']]
if not isinstance(source, PdfPage):
continue
matrix = multiply(source.normalise(), placement_matrix(placement, height_pt))
commands.append(b'q ' + b' '.join(serialize(v) for v in matrix) +
b' cm /Pdf%d Do Q\n' % placement['source_index'])
page.contents_add(pikepdf.Stream(document, b''.join(commands)), prepend=False)
document.save(out, min_version='1.6')

View File

@@ -35,6 +35,7 @@ x-app: &app
MP_ACCESS_TOKEN: ${MP_ACCESS_TOKEN:-}
MP_WEBHOOK_SECRET: ${MP_WEBHOOK_SECRET:-}
MP_NOTIFICATION_URL: ${MP_NOTIFICATION_URL:-}
MP_PUBLIC_KEY: ${MP_PUBLIC_KEY:-}
FREIGHT_ADAPTER: fake
TINY_ADAPTER: ${TINY_ADAPTER:-fake}
TINY_CLIENT_ID: ${TINY_CLIENT_ID:-}
@@ -192,6 +193,8 @@ services:
dockerfile: infra/Dockerfile.web
environment:
S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:${STORAGE_PORT:-9000}}
# Empty unless testing Mercado Pago's card form; see docs/LOCAL_SETUP.md.
PAYMENT_CSP_SOURCES: ${PAYMENT_CSP_SOURCES:-}
ports:
# Published ports are host-wide even bound to loopback, so on a shared
# machine any of them can collide with something unrelated. CI overrides
@@ -215,6 +218,7 @@ services:
environment:
WEB_INDEX: kanban.html
S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:${STORAGE_PORT:-9000}}
PAYMENT_CSP_SOURCES: ""
ports: ["127.0.0.1:${KANBAN_PORT:-8081}:80"]
networks: [local, edge]
depends_on:

View File

@@ -27,7 +27,7 @@ server {
add_header Referrer-Policy no-referrer always;
add_header X-Frame-Options DENY always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' @SCRIPT_HASHES@; script-src-attr 'none'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://cdn.vnda.com.br; connect-src 'self' ${S3_PUBLIC_ENDPOINT} https://cdn.vnda.com.br; worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' @SCRIPT_HASHES@ ${PAYMENT_CSP_SOURCES}; script-src-attr 'none'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://cdn.vnda.com.br ${PAYMENT_CSP_SOURCES}; connect-src 'self' ${S3_PUBLIC_ENDPOINT} https://cdn.vnda.com.br ${PAYMENT_CSP_SOURCES}; frame-src 'self' ${PAYMENT_CSP_SOURCES}; worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always;
location = /health { access_log off; return 200 'ok'; }
location /api/ {

View File

@@ -150,6 +150,9 @@ services:
WEB_INDEX: index.html
PUBLIC_HOST: ${SITE_DOMAIN:?set SITE_DOMAIN}
S3_PUBLIC_ENDPOINT: ${R2_ENDPOINT:?set R2_ENDPOINT}
# Mercado Pago's card form loads from these origins; empty keeps the
# Site at script-src 'self'. Set together with the Mercado Pago adapter.
PAYMENT_CSP_SOURCES: ${PAYMENT_CSP_SOURCES:-}
networks: [backend]
ports:
- target: 8080
@@ -172,6 +175,7 @@ services:
WEB_INDEX: kanban.html
PUBLIC_HOST: ${KANBAN_DOMAIN:?set KANBAN_DOMAIN}
S3_PUBLIC_ENDPOINT: ${R2_ENDPOINT:?set R2_ENDPOINT}
PAYMENT_CSP_SOURCES: ""
networks: [backend]
ports:
- target: 8080

View File

@@ -231,7 +231,10 @@ shows the status per item, the page size and the lowest DPI, and offers
**Baixar PDF**. In **Arquivos de produção** the generated file is preselected
as the final file; untick it to upload one by hand instead.
Only JPEG, PNG, WebP and TIFF are generated. PDF, PSD, AI and CDR artwork, a
JPEG, PNG, WebP and TIFF are embedded as images. A single-page PDF is placed
as a vector form (never rasterised), using the page's CropBox and `/Rotate`
exactly as the Site measured it with pdf.js; the card then shows **PDF
vetorial**. PSD, AI and CDR artwork, multi-page or password-protected PDFs, a
file whose proportions do not match the quoted size, a layout longer than was
billed, or an image above `PRINT_MAX_PIXELS` (250 Mpx by default) goes to
**preparar à mão** with the reason, and the operator prepares it as before.
@@ -304,14 +307,25 @@ agree the test with the client and cancel the test orders afterwards.
with a fake token server; it saves and restores any existing connection.
With Mercado Pago selected, an approved quote shows **Pagar com PIX** on the
Site instead of the local test button. The order is created only by the signed
Site instead of the local test button, and **Pagar com cartão** when
`MP_PUBLIC_KEY` is set. The card form is Mercado Pago's Card Payment Brick: the
card is typed into Mercado Pago's secure fields and only a one-time token
reaches the API. It loads from Mercado Pago, so the Site's CSP must allow it:
```bash
MP_PUBLIC_KEY=TEST-...
PAYMENT_CSP_SOURCES=https://sdk.mercadopago.com https://*.mercadopago.com https://*.mlstatic.com https://*.mercadolibre.com
```
`PAYMENT_CSP_SOURCES` is empty by default, which keeps the Site at
`script-src 'self'`. Once a payment for a quote is approved, or a card payment
is in review, the API refuses any further attempt for that quote. The order is created only by the signed
notification, after the payment is fetched from the Mercado Pago API and its
BRL amount matches the approved total. Mercado Pago must be able to reach the
webhook, so a local run needs a public HTTPS tunnel to the Site port. A paid
notification that cannot become an order, or a refund on an existing order,
appears under **Pagamentos que precisam de atenção** on the Kanban until an
operator records the resolution. Card payment needs the Mercado Pago public key
and its card form on the Site; that part is not built yet.
operator records the resolution.
## Local backup and restore check

View File

@@ -230,8 +230,15 @@ From the report already sent. These are dated promises, not backlog.
binds each provider payment to its quote; `/api/payments/intent` starts a
PIX and the Site shows its QR code. Refused paid events and refunds on
existing orders now stay on the Kanban until an operator records a
resolution. Unit-tested against a fake transport only; card form (needs the
public key), sandbox run and refund policy remain.
resolution. Unit-tested against a fake transport only.
**Card form (2026-09-24):** Mercado Pago's Card Payment Brick on the Site,
shown when `MP_PUBLIC_KEY` is set; the card becomes a one-time token in
Mercado Pago's secure fields. Each card attempt has its own idempotency key
(a decline can be retried with another card) and the intent route refuses
any new attempt once a payment is approved or a card is in review, so a
quote cannot be charged twice. The Site CSP gains the Mercado Pago origins
only through `PAYMENT_CSP_SOURCES`, empty by default. Not yet rendered
against a real public key; sandbox run and refund policy remain.
- `[ ]` 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.
@@ -268,8 +275,13 @@ From the report already sent. These are dated promises, not backlog.
mirror, and `tests.print_file_test` passes on the running stack (generate,
download, approve as final, queue; hand-preparation routing and retry).
**Still open:** a FlexiPRINT import of real
generated files (including one longer than 5 m, which uses `UserUnit`), and
PDF artwork, which this generator does not compose.
generated files (including one longer than 5 m, which uses `UserUnit`).
**PDF artwork (2026-09-24):** a single-page PDF source is placed as a vector
form through pikepdf (MPL-2.0; PyMuPDF was rejected for its AGPL licence),
using the CropBox and inherited `/Rotate` the Site measured with pdf.js.
Raster tests cover crop, page rotation, placement rotation and mirroring,
and were shown to fail when the rotation or crop handling is broken.
Multi-page and protected PDFs go to hand preparation.
- `[~]` 1.5 — Main Kanban production states consolidated. The six states and
their transitions are unchanged; cards now show the delivery address, the
print-file status per item, and a panel lists payments that need a person
@@ -692,13 +704,12 @@ print-file evidence still need correction before this item can close.
storage and storage-init now use Chainguard's MinIO build (ships `sh` and
`mc`, non-root), pinned by digest. Verified with a fresh local build and the
full integration sequence. Production uses R2 and is unaffected.
- `[ ]` 5.16 — The operator login limit (10 per account per 15 minutes) counts
successful logins too, and every test client signs in separately. The CI
sequence sits close to that limit: one extra login made the final browser
test's sign-in fail with 429 until `print_file_test` was changed to reuse
one session. Either count only failures toward the account bucket or give
the suites a shared operator session, so adding a suite cannot break
another.
- `[x]` 5.16 — The operator login limit (10 per account per 15 minutes) counted
successful logins too, so ordinary use could lock an operator out, and one
extra test login made CI's final browser sign-in fail with 429. Now every
attempt counts against the source address and only failures count against
the account; registration still counts every attempt. The security suite's
lockout check (ten failures, then 429) is unchanged and passes.
- `[ ]` 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.

View File

@@ -9,7 +9,7 @@ server {
add_header Referrer-Policy no-referrer always;
add_header X-Frame-Options DENY always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' @SCRIPT_HASHES@; script-src-attr 'none'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://cdn.vnda.com.br; connect-src 'self' ${S3_PUBLIC_ENDPOINT} https://cdn.vnda.com.br; worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' @SCRIPT_HASHES@ ${PAYMENT_CSP_SOURCES}; script-src-attr 'none'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://cdn.vnda.com.br ${PAYMENT_CSP_SOURCES}; connect-src 'self' ${S3_PUBLIC_ENDPOINT} https://cdn.vnda.com.br ${PAYMENT_CSP_SOURCES}; frame-src 'self' ${PAYMENT_CSP_SOURCES}; worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always;
location = /health { access_log off; return 200 'ok'; }
location /api/ {
limit_req zone=api_limit burst=100 nodelay;

View File

@@ -68,6 +68,233 @@ jmespath==1.1.0 \
# via
# boto3
# botocore
lxml==6.1.3 \
--hash=sha256:032a0a97eed428bd143c75a11118238546424ceb2fa311cca5f073aa44658dc4 \
--hash=sha256:05f5bce9af14fd1506997594bd81cee6d9c6b58ea80a39c058327aa6371ed9e9 \
--hash=sha256:0794e04ba343852c6d78e996c58ef4b8e579b4ecc72f8df0d4058bf843b4c96e \
--hash=sha256:0ab2467e405e748d93495fb5568e74044802b8d3ff2b2a1607c3f78c6e982de5 \
--hash=sha256:0bf5a3e397df2ec4258eb5eea4c1ac6cf013ca1abd04a176903bff20a70021fe \
--hash=sha256:0c0710ac085a157b593c38fbcacd950f15c4afa8e2057527185875ab302752bc \
--hash=sha256:0dee106e9aa97fb00541b1ed7827070564d0549c3d3fba8920e6b20fd980f748 \
--hash=sha256:0f17d83c48ee9dfd96abae3ac3e2108c76d2fc86ce96355e37b8da9f7f4ecc08 \
--hash=sha256:0feebef8d0521188d0157f758356072e840173aa61ca45b8b3f87959ac283dd5 \
--hash=sha256:13a620a3fcc20023f9e6ed5c383e00e826f1c2d5db554df2f67240760f9118e8 \
--hash=sha256:13d22c0d57355366b393936acf6b98a5e0edeadddd3fccbc6a846c50a76b8741 \
--hash=sha256:160fcf381f76c3aeac28a756bec44f48942a8f7245a87aa28e3a523b4d90cd87 \
--hash=sha256:16148acd77ed1d8836a56db883af2f5eed720f9723088110b16a0d08582130a6 \
--hash=sha256:170773d8a3cdc76259065523ddd978c44f9806e28605f08812e8f86783e44ac6 \
--hash=sha256:18293f8a8d8b6a8e71ef37706b659e3846a4261232158167b1ddf35f6994f633 \
--hash=sha256:18a4db52b5a7b53a3540b0b0f4123319334621ee8083d496de314d0bf06ff59a \
--hash=sha256:1a635e837b50a1819bebfedaac5916498ea024120969da8790500148fb0a894d \
--hash=sha256:1aeca87830c4fe649dcf93fe2b059525b71c72587f21be4ae4af7103082a79fa \
--hash=sha256:1b7c37339d7e75cab9a123a04248e243cefefb302ad6db566ea0c77cbcde421e \
--hash=sha256:1beb0f9909b26cee938df9ba56b15252a84429b1fc30ce6fca161390b9789a70 \
--hash=sha256:20384c2bbcbf87180c8c61eb60869699c1ec0cd09b62cfd13804022d860b0867 \
--hash=sha256:20428910dae17a1a93152a3ff2c0441d2f4932992c0797d65651dd0561f1792f \
--hash=sha256:207dfc3d47cf0e575e643bbc140dacc8863b39abaa1e5307cd64c7f2365b8a12 \
--hash=sha256:209c3ccbfe35a04ac6d24f0611f9d1cbf8025d49991b14acd935236234d6c156 \
--hash=sha256:2123e5aa075ac20d23c7af489255efd129cbfe190dbe88fd42598cc9df3199b6 \
--hash=sha256:21402998e4b78e7cce237d2788841aaa21ac9a4d1574d04dc2d12ee41ae807b5 \
--hash=sha256:2221e88679d1351e9a40aaee54bc65679b9795bbd0160bc3d5e36b163344eb75 \
--hash=sha256:22eec57e26c418cde02c051ce9914a365e52a7f135a565c6f0480242aeebab48 \
--hash=sha256:23c366231259cd75ad06495174701afb3fcb36a92917fa47de2d1f1bd9d95739 \
--hash=sha256:25f4118c438f96bb466e83108506d03d5c31b1bd2387e83e5b070bda6ded9c37 \
--hash=sha256:28a23fefdb345b2d4d0ff2860571b5ff9a89a28b6a120f720e8fb0324d346626 \
--hash=sha256:290f66b97ede0e552e1cb44a0fd8a74f9753ee635b50830a0b122fb72788d015 \
--hash=sha256:2b9b1325ca1c2a9a2dbb6eb913ae563313f2082ae60b03210f7e83ee80712274 \
--hash=sha256:2bec13085dc8ef48a3fe62f7dfcacfeda2c785cdf19cc8eeda2bb9ed081da165 \
--hash=sha256:2cae5d5c90a62d9139c512a0cb1aad1d182b022b5740daea2617eb5bf7fc658e \
--hash=sha256:2e01125896585139453cab8cb235893644d8815d7509520da95ae3ee8d1c1f79 \
--hash=sha256:2e62c569ec7531b679b184cbfe335c501c1d13c4b363560013019962eb630e6d \
--hash=sha256:2f5b2a2b9811b853b39bfa41367c6d78747b8e3e80e07fc5a24aae295c1a4d7d \
--hash=sha256:302f72413251c03f671e063c9414bed5dc8c927069e5abb69245521e51a4e81b \
--hash=sha256:32a409be3190b088f960ac92bfedfbef2f86c49ff940765e1548177592d20026 \
--hash=sha256:33cadd956b667997e4de1635fce9541f2e8ede2038fcde8cf55aa14d571d1bad \
--hash=sha256:379f8a75cf6eb7eef0af074b55f49ab73b868388a98de14646abcdfa4564bb11 \
--hash=sha256:3847e71a78cbbc1aff955dbbbaf2fff12153f611d3162c5beaa3395636cbc2f9 \
--hash=sha256:38fc4e4e4e084e0bd491949482527d406788045c546d4f8789e93fc527b91385 \
--hash=sha256:3a27ac6c780c8b8a1cd231b58407634cafc1c4cc28cd6c7141362df0f36351e7 \
--hash=sha256:3a48093cdb058a93af842ede9703520e810b05dcd0fc6d7190a06376c3bfb6bd \
--hash=sha256:3e42265103fb385d8642a78672edf376c6f7e1d3598a7a4f9cb1278f2f6b5f6f \
--hash=sha256:3e9a00d1c2c30936f7add097c41afc5da6556c580909104aafd382cac92a855c \
--hash=sha256:40983eabefd13da003e68170928c7acc011f0d095eefce5871a3c71c9385fb9a \
--hash=sha256:40bcbd9f94166ffe925811e730607385cec959f42fb1bb7dad83748680465221 \
--hash=sha256:41096ec0740a58dad03d3ae0c7486d306d20becefb13ceb1649835ab3eb64167 \
--hash=sha256:415e3a115c0d510e329020012834d1c0aa1c581ee53a218603e38abbc1dea70a \
--hash=sha256:41e2d428110b408e963b6fb18f9bbf1f5c027b56bd4b498d54556476c0aeb1c3 \
--hash=sha256:424aa5657141d306ba9ad1baab4b2c0a0719040075ee6c66aee9bb2dea2b5054 \
--hash=sha256:42632b4024ab24a6b488f559ac851312509888b6b80ae2aa11cf29a646a0d245 \
--hash=sha256:45222d94ddd511536f3b2f7d9deae3b2339b4ce0f075f1ca25703b07cad9dd21 \
--hash=sha256:4736e6c87e603146d8949d8501da621ad20c31015060d3fcf95ace2859f3e3e6 \
--hash=sha256:48542c9acba9ff9450bd18d871d2c2c8787fdb283572b623d206f1b927cd7d9e \
--hash=sha256:49fbc2682a9306135b7ec49e93f97f9c26689b9b7f96ed2742d8d6497e994d13 \
--hash=sha256:4a579dfb9c835f8ab47f4b8ed33440cbc75b806b73297208e6ec2a33e903740b \
--hash=sha256:4b061064b4a2fe8598a466d723d43dbcd5a610a5d5cfe02fb6226f5c17349f75 \
--hash=sha256:4e11e885e0704be185867fcf71b904d8f65d7d6877bc121f69870b0d0479ba7b \
--hash=sha256:4f4db7c7e954d289d71878938348b3d91b904a3e8210a11939359fb758a58e7d \
--hash=sha256:527195c188d7d0af748cd48d220ab8cdc5cb99be3d49ac4d9be7324d8abf9bc0 \
--hash=sha256:53258656846f5c48996b882fb4b135885e088a3ad3d96b4bc0530f95124d1f69 \
--hash=sha256:545ccc14fb05485f48b4439ec35beb16d5b5280eb6c81c658bd4707a2a119414 \
--hash=sha256:5609efdb0d3c95499c00046bc53648b3482ec2175b5503d6e611b3f0555dc71d \
--hash=sha256:5929d9df5e7e3379183be0e21f7d559618a5b61cb63280df6164019242e337ed \
--hash=sha256:5a143e6207579de8baeded4eaac9134413200359f1969d636f0bfb98ee8c3c8f \
--hash=sha256:5a721a98c649855963811b59b55755b30566e7f7fc40bdc9803d66dee9f811cf \
--hash=sha256:5cffe18571ccc51d742cd08cbb3f8b756de9311d18c7ea98f5d92f37b8fb60c2 \
--hash=sha256:5d12669a2c419b0e8dc423d23dea24bb82f6f9cb829f32e04674b0ba40322a7c \
--hash=sha256:5d582042c69857c364e8153de6e18e0da9b7b515a6a8113caf69a6ec8e0520f2 \
--hash=sha256:61116cec57ed69aebc70f37a545eec095339bb829efbdabcfb97c51e9536e158 \
--hash=sha256:611a51e61c92f62345a50b0035df6fc0d678f9299f33728826d831598862f59d \
--hash=sha256:623c8799c17128753c65699f1c3aa32402657393a9ad6db09ed8b98ddf76611d \
--hash=sha256:6374e9e382e5a98c9c5e66d41b357b470da1c54bce30f17f9dc4bcc58436cc1c \
--hash=sha256:66299564c046bc7e0cc5de5106601eae907e9fa5904cd68a323380a8502f7861 \
--hash=sha256:69cafd61aea04ebb3502c93c2aaa568b12931ca0802231e0b5de76bf8b6e74bd \
--hash=sha256:6a406d0b3cb207b0fa460ed4dc93e866f44f105da0169361cb18ff998a44c7f0 \
--hash=sha256:6ba4fe5bfbef6811a8e49b3719cde373ad399006c0c1ac184b7297116ecbba5d \
--hash=sha256:6cd11e7550d89e551a87dcec30f04b1fca32e86b68708aa01a4daa455d8605e5 \
--hash=sha256:6e1eb8a4cbffd5553680ad96be6680e364710656eced73d1dc90ec489df599a3 \
--hash=sha256:6ea2f13dce778ca072ccee598bca46a092ce192e8fd907b6c1f0e52c800529a0 \
--hash=sha256:71532ebf30be0048a45559b4fab15333fbaaf9042f658e878d918ecd0cf09805 \
--hash=sha256:73fc05988ed20809450474ba760a87c8ad4e455fc09783c02195e56ec634b41a \
--hash=sha256:75cc6569e86be5785b6188ef1642670c6adbc984e81ec35e224842ecd9eefcc8 \
--hash=sha256:773062aec2f2e56b2b22d37054123f0de8a22a4688a0c3376c3fe42685f975cf \
--hash=sha256:7ae4949f212a53b007dbc355884fda122545c5764a54256c9217e419a62a6559 \
--hash=sha256:7b2bb7d703bed7ac893bf7f40d97b5d9279d35d2ce460624ca28929eab0d5a3d \
--hash=sha256:7d0f5976aa2701996f759b30172925829867547bb073af0ae67d1307a0f0262c \
--hash=sha256:7d5a748d12dd9b535e0a130f60dae9ddf0adafbabe61e7864f55c7436c84547a \
--hash=sha256:7dd624c1eaa629ad44b59a1a0145fdf2d67895592dce94c9358b938b3d075e65 \
--hash=sha256:7f75b9b9fec2a9c6b18095c81865580e795b1441c429e42d22fcc82a77f40039 \
--hash=sha256:83e3a51e7933db700a0da0db31849db3a24022d9970da9bb73001e1d0326fd92 \
--hash=sha256:8499d464de86fab0f102313cce32a9bed9ab1f06ec813cf025cb790964fbb765 \
--hash=sha256:869dfcd4d381cb0ea87085cc4f011b9171b494ef21e76ad8665f6d5e2d1dc8a1 \
--hash=sha256:8753b8d51dbc86fd335ee31fcf7f3658e9f5c016d4edfb23f76ad295f4b8c9d0 \
--hash=sha256:887c021d9a977cff89cb273047c1352997b772a8908a25c21836861f69b92be1 \
--hash=sha256:88e719b9437f148f7e1465df845c758dd1598618cbea3a2fd1e61a715542f2b2 \
--hash=sha256:8a330c0ee5fa318c7b5cbbaad882baeca3f570357e7eb25ab34bf31008150758 \
--hash=sha256:8db38ff3fb7aee7d6a82ae4da2eef1178656fe1216841fbd24870062a9d60473 \
--hash=sha256:8e49a646acfab83c68974f4aa1d0a2acca9e88d7d627ae0fc13201b14b76d310 \
--hash=sha256:909f4e927bb051f7740d6367285fc60cdcfdaf0258c2dba4ff5ba7eadadc250c \
--hash=sha256:90f709b9accab6b2e4d14f5c8718203877a0486bcb3afd74d8b539ecd1e961d4 \
--hash=sha256:92d96586376fb79a33474797186bf993250152ee5c32650b67db78d54b92e6f3 \
--hash=sha256:93476b6514b373fc6ca67d26c442784f7807c86f00635bfe79f935c3eab2af17 \
--hash=sha256:97acecb11cbc411473f15b8d780df06d7a9f3a2aad9aca78364f56640c8fb70e \
--hash=sha256:97ce49699d87ebf8aad631b55d65b33219a4f1bfefbbf5bff19dc9af160aeaf9 \
--hash=sha256:9bde9ae026a55b9a192078dfa6e27dd0ca4a050171ab6272e92f97b757dfdf48 \
--hash=sha256:9e67324961ac9bbe616cce5100514d2e34d88665aeb07071e8b16eac55d06d94 \
--hash=sha256:9efe56a68179f3adc4de41861c9358931db03837c48dd5e1c78077b84dd07f3a \
--hash=sha256:a1932d7ce78a561367512c594fe66eac2b2ec9b9264cfd9b5f950622f4a116e2 \
--hash=sha256:a1cec0f99b9b914d39176347a93b7610dc09324491aee1cbc57cd291a41a1d55 \
--hash=sha256:a2e3f70673a1d5b82f38255f777d26cd855bf2092b1436c4867464a7892f9238 \
--hash=sha256:a43b3bdf11e477dc7770609d3477316f974354dfc8425d596f64f471cc8daf6e \
--hash=sha256:a5c18810318303ce9afb3f95e2ddb54834f96fa699a8600433fd5a93dcf44c56 \
--hash=sha256:a7eb78ba28b187e1e9203a55c60fcf70df2d22cb205fe6d51b9383d6097419f0 \
--hash=sha256:aa633613ff907ea91b9b0489a1f0da1b8725d8c6ccec6b77e8a1c9c235044bb0 \
--hash=sha256:aa9fd1ee2a5dacfc41039ed49ffeeacfa75bafbd255b69f3b578e11897a0e623 \
--hash=sha256:ace1d2c83b2bd24db5940600541140e87a325e119cb32d5fa9ad720d7e76648e \
--hash=sha256:b1cc980905221a5d8b3c476330730b3adb40ff80add71ffbdb6215ba055656f1 \
--hash=sha256:b37772102d44bb6628186accca3a121b1fa3a6b3d97518a8c29a5229ca4c0d0a \
--hash=sha256:b3ff39654f0ce6ebd4db154211136dbe7e8157bcc3bed2344c87f32c7c6ecb6c \
--hash=sha256:b477912f42c5c33405a10c759d22f80cf5af043ae02d95b9d8e5e5bc555739ed \
--hash=sha256:b49638355ea3bebba70da783ccbc630fd72afa16bc46c54474bfa1f9a915bbc6 \
--hash=sha256:b4fc6b03b9d9d90557274f571ab30e7fbbfc527955536935d96f98b6817a86e4 \
--hash=sha256:b50343241eb69fd85f7791cf8bcc7b1c4729826b7d59ba2f6b27db29638fa745 \
--hash=sha256:bc8dd3d9c93e70c3df974a201ac2958b6d77b465d813c51d1f15fa8e645763ae \
--hash=sha256:be5346653c0b0e34be96869ff9dbeba23860156f89a2896a64c64fb419260cb6 \
--hash=sha256:c00e26288784460885fe76e4d4b293573e0f791f52e6d60e27b42edf005922eb \
--hash=sha256:c1b50797ac246bb2942a04b6c0f69af0667aba7cf7535f39bbb1b3208fd5d128 \
--hash=sha256:c34ca1dc41bd86d9ff830d5bdf4e4a752bba6c54f7d2707027ce0eabd36084c9 \
--hash=sha256:c55e71a9b1db1f107efb60da49c093689b74c5c31a708e5379e2fd9439d4fbb5 \
--hash=sha256:c581b1d68b3845fb86c6b2983e755b29bf001461c59fa411d2c26a911b6559a9 \
--hash=sha256:c59e4265608da6a041f54646ecc0c9ecdbb19aaf14c4c684bb6c2114998cc415 \
--hash=sha256:c5e7ce578aa8a80910a72a8ca0bbea3baae10100827249001999726a788456d8 \
--hash=sha256:c66f858b82497173f73366795fc6ee8171620e75a338506d6b2e7bc16f5fca11 \
--hash=sha256:c6c0c13128a32eb04a51357e56a094e13aa8e6d3d1884de2e9ae923f6915e1a8 \
--hash=sha256:c9389b3784b56c58d933b5e0aecdf28f901b073ff385358d8a7d40907f6e14b2 \
--hash=sha256:ca0ec532ad2f5ba1e5ec120ac157769c57f01855b3d8bf37213f5d88abd9ba0a \
--hash=sha256:cad7617727a96d189bd6f979d0fadf765198c7934e85f4edaba9bf3ad919a300 \
--hash=sha256:cae82b5ca24b0c2beedb269f6e2a96f466acd926879ab00ae19f1a65cbf9ffb0 \
--hash=sha256:cc669256d28736f7f3a149df5c380c50ace2692ba3e62203d10656fade4a2145 \
--hash=sha256:ce1f220114959941170e22b8ad44279f6dee2dcef7591814d01ae805dc058889 \
--hash=sha256:cfb398886a7eb4c719161c3efcff2a1248febc53a4d8e5072d2d8a87fed84ac9 \
--hash=sha256:d077f21f4b16f0471353883748f126f62038760397c107bb9fad2ca94dc0dfb7 \
--hash=sha256:d0c5c362bc94f1929dc7e96e715bbe7bd17037f802e6d8f0d1545df9133c0559 \
--hash=sha256:d2765c18ce303149ee804b1f3dad11232726dd0a702d73a15cf19179ac8cc962 \
--hash=sha256:d44442effeb8781f392340c5dc8c6716fba41dbeacb82fd4c0f09026fb5ff682 \
--hash=sha256:d85dfab42dd672f87a7f76e9de7172962aee69fa12044f0d6e1a23cbd53fb80e \
--hash=sha256:d97c5227621af74b111882a290b10f371780a38eef9d9e730408fba2259b52fb \
--hash=sha256:d9a0d12846d6ce434fb3857918eef4315ec9b4769deb020c75828798614bfcfd \
--hash=sha256:d9b3e7d71bf6acff341233417abbdface29c647e3113892d9aaedc02eb4aa2bc \
--hash=sha256:da707f14ea3c35ee463d50acd596d6488e4b2b4ae7cf77a5bf93f55c023d63e8 \
--hash=sha256:da85db328e507da922d586c3c7416ec360ec22e9cd9e0700691afacde0c81f53 \
--hash=sha256:dc205732d593118cf701d986f40e9de7801bb2e371cb189ddbda9b7348f4d97e \
--hash=sha256:dc3a44689eea43eab836e5c98a8ab015dc2419987d1ea6eafc7c590cdff86bed \
--hash=sha256:dd5e90f34cffcfed97f36cf066325773d2b6021c60c29942e53a18b028501b1d \
--hash=sha256:ddcf547bea2aee967d6a77779376a45e77e610e8465147a1f3d7e20d539d6e32 \
--hash=sha256:e477aca0bc0d19f3b4ae9e4f2a1cfd687c31bf772d78734910658186b40b2477 \
--hash=sha256:e8b17e23df3e827a69d25af70990ca2420e92668aaffaeeb3cd2351d7916a023 \
--hash=sha256:e99e09ab7741f1281e2677f4c0058c7f5267d182530b09c87e4f6aa26adf3887 \
--hash=sha256:ea2c01cdb16dc12156e455007c406dfaaece0c89aa4ba0e3b47586779f951d41 \
--hash=sha256:ea6b1e9105b4b24a34c722432d9fb578f9ed83af21fa1abda639011e0f22bbb6 \
--hash=sha256:ebd054ad1737a68fb7c5c073d405cef2b88bb824e294de3b4a4e995b47f0e376 \
--hash=sha256:ec295280f4b37769256da025acf5890370355ac589c27e89caae0b5e9eedc702 \
--hash=sha256:f6449672f9c93316deb5e2839e18931f468670e44d5bd9b1301a5a9655d45c07 \
--hash=sha256:f683dc6300317700025e41d89a43e0276692ded16113a3c43eab704d605c58e5 \
--hash=sha256:f6b9d2aad499c769ee8287609ab0e6de99d8bcea99c6e6c2e64945259fd52fb2 \
--hash=sha256:f8b9c8ceebae6387d0dc77f7f4dbbfbfc962dba2efbfe6877486075a480726b4 \
--hash=sha256:fad67b12ffe0f71e02b4932b04883cbc76a9072bbd30731409d3523cf058b011 \
--hash=sha256:fbfb70ba01355251faf6b293171df49f73a88a1b6494db109ffea85442574458 \
--hash=sha256:fe91993149523aa59941b9e3c90e2eb45f57ad014697aef6c8b13339a59c019e \
--hash=sha256:febd35ef45f603c2d74b74655efdbf45e14f55fc0aef4ac82b663ca829b283e0 \
--hash=sha256:ff88a92cafde90888511242d1c54afcc1a8adbb6dc0a88fa7f87e29e92400d4a
# via pikepdf
packaging==26.3 \
--hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \
--hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
# via pikepdf
pikepdf==10.13.0.post1 \
--hash=sha256:01f80ca046d984752cf6f08093debc193884bee91c01c104aa0236767711a20f \
--hash=sha256:092a9bf15739e931ecab15ec3baee5d9629dad90ea4e42b779f6b439d2d1e462 \
--hash=sha256:0efb4faed9cbb59c1486f668af326096dac1ff0ca058eb48ec485742a9a655af \
--hash=sha256:1f76fcbe5d86f2ae6f231ba542cd04793d4c89e75bd5f62526b7412926ff2100 \
--hash=sha256:20c76343128ec41d5be58337b23c6f9f620fa7b8256a2d942460a48c07bbfe7b \
--hash=sha256:2c6e83f8a1828ec79cdec4df8cc07209eaf10ed7e4f5a90a7356b254bacc07d5 \
--hash=sha256:2db9a18074ba112e7c517e8c21dfd8894cc13b8a37ccf49a5841192170fb68eb \
--hash=sha256:365b94f2be7e2857c6cb5445b56dc52dc7417ba9f06c8a4282d9f521cb2d0fb8 \
--hash=sha256:3e18d5a009bbe5f3ab18f916fb9e28f0c5b0d920736e3a85cc10c627ec633596 \
--hash=sha256:414f42c83e5e6029870de1a988625dafc95781ebff10e15d82caeb5e69a83c9c \
--hash=sha256:43d70f244a4a1120a11cfe2227f8c03249fac6a7e3789a3116173102a3b9fe37 \
--hash=sha256:4b73f926ebae81f04bf14527af330bd00bb268be767e0f189f7c4c3e4ad7ae0a \
--hash=sha256:4bb5fe2090d246ad4b325d17f33a186f60f6763bba3d4ac3c4b863c8890e913a \
--hash=sha256:51fae4a4a3c6549aa4c405896ff7010f3e43e0c4f407c0bcee071ef13d271202 \
--hash=sha256:544f1be1b1e5630a79cd182a8663c439504099eb9eae0d342a50173d9825bdf3 \
--hash=sha256:55e53b4d8a4b1700f686f76e3a68411e421e962a4c8b1b90d00aab3f3e494a55 \
--hash=sha256:571efcd1d54e0dd817973c76c253feb6fb758c93bb0c16a893cc68f2a178d404 \
--hash=sha256:6b038cd5bcbb6c1952bcc271695eaf24c4199d72e45606ee5e467f837760820e \
--hash=sha256:7ba09ef5a5f26e38ee558d2a08223fee08a5ef1868962ae2d8590d4de3c8c92f \
--hash=sha256:87141ada970386ff6640db54f0bda734d3bde7960d3ba04a76768b48e25028ca \
--hash=sha256:8cb976331cb8b03ec3465e06d9e7a3eadbadb7e622be888e70f918ac732a105e \
--hash=sha256:8fb8f82dc43056a4b4f891e78ee1db4e3ced75ba3e87b836f8e28c8771228928 \
--hash=sha256:90f17cb174db88e08075c5bfa3c619df00dd84abbad34bfa1edf84863f0b01a7 \
--hash=sha256:94e04ed92fe42b8bcebf8c40462868dca4eb92581dcc2be1f0c4b8ee23347386 \
--hash=sha256:9613505f5b22203465d4224a3fd8cf69876ce8278442c6478ac6849f54724a30 \
--hash=sha256:98a7305e330f797da02b543d3ad57a134c4a14c6ec6f8d86d91aa9dd130c425b \
--hash=sha256:996a714a47cc725e3c48fe3901691d3353f3c2eeb9e0571aa2b16164abc40ff9 \
--hash=sha256:99f6afccd6119233e7133bd4c2ade48461de3ddd4269cc28a4f90cdf7c1372f5 \
--hash=sha256:a3e9104db5ea5b5a7c5fde417147900966a687de39ef91a5ea103fd4b773aee1 \
--hash=sha256:b63577c44fedf7ed6b971076f7c7ed0ff95a8bac627ac93b79e8b542214861a7 \
--hash=sha256:b69b89577b50617248185b6ad73cda0e4f15c57da11f7da8cc4032bf0d7d9ebe \
--hash=sha256:b7b0cbb135de32ec3f41651a08ab294e3c18ae9fec32516a48d27e64a53a47f0 \
--hash=sha256:b948e11f7dd3710f939194f00b4d75b0df87a30d53b31414128768ac25da77d8 \
--hash=sha256:c0b5127df90b0164dd846bddd0ed542326b2f69bd11f627afe48762cf16c2904 \
--hash=sha256:d3b58ccb30b93ba400e6a6a83315b4830eea49f6f19e18d78241fe6b1c49fec2 \
--hash=sha256:e19bab4320e4e8771b7816f7319ba37530fd28a40372840b75cab394ebf868e4 \
--hash=sha256:ef4ed47d40aa44deb063feb4a88e8bcf1c8fa0183ce526dc4295f7cbdb1292f8 \
--hash=sha256:f0eb89f06cad9231b9db54d81a22592b03b63924824a6b850febd2b75daa6546 \
--hash=sha256:f2463f650efab46905b9e279f5c776faf96c65bb45c1acf9f2de0e8a6eec5fb7 \
--hash=sha256:f3dedd02795626f17ee42d5c02ec4ec94e28aa47046ef430d4478454a8fbd07f \
--hash=sha256:f515a31c76cce043bbb7b781e77a343a4e26fa8f520ba337d30ddea0f7a0ce50 \
--hash=sha256:f7962a75cf22d0d683b49ab19b8966e94dc7014d9aa6806f3c0d2b37fb9ae607 \
--hash=sha256:fb05fb7b42d85754219b55111aa61beff4e48da56069e971de1e5aca380c0ac1
# via -r infra/requirements.txt
pillow==12.3.0 \
--hash=sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756 \
--hash=sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a \
@@ -156,7 +383,9 @@ pillow==12.3.0 \
--hash=sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d \
--hash=sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198 \
--hash=sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7
# via -r infra/requirements.txt
# via
# -r infra/requirements.txt
# pikepdf
psycopg==3.2.9 \
--hash=sha256:01a8dadccdaac2123c916208c96e06631641c0566b22005493f09663c7a8d3b6 \
--hash=sha256:2fbb46fcd17bc81f993f28c47f1ebea38d66ae97cc2dbc3cad73b37cefbff700

View File

@@ -5,3 +5,4 @@ psycopg[binary]==3.2.9
boto3==1.38.23
httpx==0.28.1
pillow==12.3.0
pikepdf==10.13.0.post1

View File

@@ -58,6 +58,20 @@ def run():
assert not customer.call('/quotes/' + quote_id)['order'], 'unsigned delivery created an order'
print('PASS: unsigned and tampered deliveries are refused and create nothing')
# Starting a PIX twice returns the same one. A card in review blocks every
# further attempt, so one quote can never be charged twice.
pix = customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}})
assert customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}})['id'] == pix['id']
customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'card'}}, expected=422)
card = {'type': 'card', 'token': 'tok-1', 'payment_method_id': 'visa', 'installments': 1}
customer.call('/payments/intent', {'quote_id': quote_id, 'method': card})
customer.call('/payments/intent', {'quote_id': quote_id, 'method': {**card, 'token': 'tok-2'}}, expected=409)
customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}}, expected=409)
stranger = Client()
stranger.call('/session')
stranger.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}}, expected=404)
print('PASS: payment start is idempotent for PIX and refuses a second charge')
# 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})

View File

@@ -23,13 +23,16 @@ PT_PER_CM = 72 / 2.54
CUSTOMER = {'cnpj': '11222333000181', 'zap': '11999999999', 'mail': 'print-test@example.test'}
def artwork_png():
def artwork(kind='PNG'):
image = Image.new('RGBA', (600, 300), (0, 0, 0, 0))
for x in range(40, 560):
for y in range(40, 260):
image.putpixel((x, y), (220, 30, 60, 255))
out = io.BytesIO()
image.save(out, 'PNG')
if kind == 'PDF':
image.convert('RGB').save(out, 'PDF', resolution=72)
else:
image.save(out, kind)
return out.getvalue()
@@ -75,7 +78,7 @@ def run():
client = Client()
client.call('/session')
uid = upload_bytes(client, artwork_png(), name='LOCAL-PRINT-TEST.png')
uid = upload_bytes(client, artwork(), name='LOCAL-PRINT-TEST.png')
order = paid_order(client, loose_item(uid))
order, row = wait_print(client, order['id'], 'ready')
assert row['detail']['placements'] == 2 and row['detail']['min_dpi'] == round(600 / (20 / 2.54))
@@ -103,6 +106,17 @@ def run():
'note': 'again'}, operator=True, expected=409)
print('PASS: generated file approved as final without re-uploading; order queued for printing')
# A single-page PDF is placed as a vector form, not rasterised.
pdf_upload = upload_bytes(client, artwork('PDF'), name='LOCAL-PRINT-TEST.pdf')
pdf_order = paid_order(client, loose_item(pdf_upload))
_, pdf_row = wait_print(client, pdf_order['id'], 'ready')
assert pdf_row['detail']['vector_sources'] == 1 and pdf_row['detail']['min_dpi'] is None, pdf_row
link = client.call('/operator/uploads/' + str(pdf_row['upload_id']) + '/download', operator=True)
with urlopen(link['url'], timeout=30) as response:
generated = response.read()
assert b'/Subtype /Form' in generated or b'/Subtype/Form' in generated
print('PASS: PDF artwork generated as a vector print file')
# A customer cannot see or reuse another order's generated file.
other = Client()
other.call('/session')

View File

@@ -106,9 +106,16 @@ class MercadoPagoTests(unittest.TestCase):
def test_card_payment_uses_the_browser_token_only(self):
self.mp.create('q', 1000, {'mail': 'a@example.test', 'cnpj': '11222333000181'},
{'type': 'card', 'token': 'tok_abc', 'payment_method_id': 'visa', 'installments': 3})
body = json.loads(self.requests[-1].content)
request = self.requests[-1]
body = json.loads(request.content)
self.assertEqual((body['token'], body['payment_method_id'], body['installments']), ('tok_abc', 'visa', 3))
self.assertNotIn('card_number', json.dumps(body))
# A new card attempt after a decline must not collide with the first.
first_key = request.headers['x-idempotency-key']
self.mp.create('q', 1000, {'mail': 'a@example.test', 'cnpj': '11222333000181'},
{'type': 'card', 'token': 'tok_def', 'payment_method_id': 'visa'})
self.assertNotEqual(self.requests[-1].headers['x-idempotency-key'], first_key)
self.assertTrue(first_key.startswith('dtf-quote-q-card-'))
if __name__ == '__main__':

View File

@@ -122,8 +122,13 @@ class PrintFileTests(unittest.TestCase):
fake_pdf = os.path.join(self.dir.name, 'art.pdf')
with open(fake_pdf, 'wb') as handle:
handle.write(b'%PDF-1.4\n%%EOF\n')
with self.assertRaisesRegex(Unsupported, 'not an image'):
with self.assertRaisesRegex(Unsupported, 'not a PDF'):
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [fake_pdf])
cdr = os.path.join(self.dir.name, 'art.cdr')
with open(cdr, 'wb') as handle:
handle.write(b'not artwork')
with self.assertRaisesRegex(Unsupported, 'not an image'):
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [cdr])
def test_long_layouts_scale_user_space_instead_of_splitting(self):
# 6 m is longer than a PDF page may be (about 5.08 m).
@@ -186,5 +191,107 @@ class PrintFileTests(unittest.TestCase):
self.assertEqual(sample(2, 2), (255, 255, 255))
class PdfSourceTests(unittest.TestCase):
"""A customer PDF is placed as a vector form, sized and turned like an image."""
def setUp(self):
self.dir = tempfile.TemporaryDirectory()
self.addCleanup(self.dir.cleanup)
def pdf(self, name, image=None, rotate=None, crop=None, pages=1, password=None):
import pikepdf
path = os.path.join(self.dir.name, name)
image = image or quadrants(80, 40)
image.save(path, 'PDF', resolution=72, save_all=pages > 1,
append_images=[image] * (pages - 1))
if rotate is not None or crop or password:
with pikepdf.open(path, allow_overwriting_input=True) as document:
if rotate is not None:
document.Root.Pages.Rotate = rotate # inherited, not on the page
if crop:
document.pages[0].obj.CropBox = pikepdf.Array(crop)
encryption = pikepdf.Encryption(owner=password, user=password) if password else None
document.save(path, encryption=encryption or False)
return path
def render(self, spec, paths):
out = io.BytesIO()
detail = render(spec, {i: (p, os.path.basename(p)) for i, p in enumerate(paths)}, out, 'test')
return out.getvalue(), detail
def test_pdf_page_is_a_vector_form_placed_per_copy(self):
path = self.pdf('sheet.pdf')
pdf, detail = self.render(item([placement(0, 0, 20, 10), placement(20, 0, 20, 10, copy=1)],
[source(20, 10, copies=2)], 10), [path])
self.assertTrue(pdf.startswith(b'%PDF-1.6'))
self.assertEqual(detail['vector_sources'], 1)
self.assertIsNone(detail['min_dpi'])
import pikepdf
with pikepdf.open(io.BytesIO(pdf)) as document:
page = document.pages[0]
forms = [x for x in page.Resources.XObject.values() if x.Subtype == '/Form']
self.assertEqual(len(forms), 1)
self.assertAlmostEqual(float(page.mediabox[2]), 57 * PT_PER_CM, places=2)
content = b''.join(s.read_bytes() for s in page.obj.Contents) if isinstance(page.obj.Contents, pikepdf.Array) else page.obj.Contents.read_bytes()
self.assertEqual(content.count(b'/Pdf0 Do'), 2)
def test_mixed_raster_and_pdf_sources(self):
png = os.path.join(self.dir.name, 'a.png')
quadrants(80, 40).save(png)
pdf_path = self.pdf('b.pdf')
spec = item([placement(0, 0, 20, 10), placement(0, 0, 20, 10, index=1)],
[source(20, 10), source(20, 10)], 10)
spec['production']['placements'][1]['x_cm'] = 25
pdf, detail = self.render(spec, [png, pdf_path])
self.assertEqual((detail['sources'], detail['vector_sources']), (2, 1))
self.assertEqual(detail['min_dpi'], round(80 / (20 / 2.54)))
def test_refuses_pdfs_it_cannot_place(self):
with self.assertRaisesRegex(Unsupported, '2 pages'):
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [self.pdf('two.pdf', pages=2)])
with self.assertRaisesRegex(Unsupported, 'password'):
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10),
[self.pdf('locked.pdf', password='secret')])
with self.assertRaisesRegex(Unsupported, 'proportions'):
self.render(item([placement(0, 0, 20, 20)], [source(20, 20)], 20), [self.pdf('square.pdf')])
@unittest.skipIf(fitz is None, 'PyMuPDF is not installed')
def test_drawn_pdf_matches_what_the_site_measured(self):
framed = Image.new('RGB', (100, 60), (255, 255, 255))
framed.paste(quadrants(80, 40), (10, 10))
cases = {
# (page /Rotate, placement rotation, mirrored) -> TL, TR, BL, BR as drawn
'plain': (self.pdf('plain.pdf'), 0, False, (RED, GREEN, BLUE, YELLOW)),
# CropBox trims the white frame, exactly as pdf.js shows the page.
'cropped': (self.pdf('crop.pdf', image=framed, crop=[10, 10, 90, 50]), 0, False,
(RED, GREEN, BLUE, YELLOW)),
# An inherited /Rotate 90 is displayed turned clockwise.
'rotated page': (self.pdf('rot.pdf', rotate=90), 0, False, (BLUE, RED, YELLOW, GREEN)),
'placement turn': (self.pdf('turn.pdf'), 90, False, (BLUE, RED, YELLOW, GREEN)),
'mirrored': (self.pdf('mirror.pdf'), 0, True, (GREEN, RED, YELLOW, BLUE)),
}
for label, (path, turn, mirrored, expected) in cases.items():
displayed_portrait = label == 'rotated page'
portrait = displayed_portrait != (turn == 90)
width, length = (10, 20) if portrait else (20, 10)
src = (10, 20) if displayed_portrait else (20, 10)
spec = item([placement(12, 5, width, length, turn, mirrored)],
[source(*src, rotation=0, mirrored=mirrored)], 30)
pdf, _ = self.render(spec, [path])
page = fitz.open(stream=pdf, filetype='pdf')[0]
dpi = 40
pixmap = page.get_pixmap(dpi=dpi, alpha=False)
def sample(x_cm, y_cm):
return pixmap.pixel(int(x_cm / 2.54 * dpi), int(y_cm / 2.54 * dpi))
got = (sample(12 + width * .25, 5 + length * .25), sample(12 + width * .75, 5 + length * .25),
sample(12 + width * .25, 5 + length * .75), sample(12 + width * .75, 5 + length * .75))
for actual, wanted in zip(got, expected):
self.assertTrue(all(abs(a - w) < 40 for a, w in zip(actual, wanted)),
f'{label}: {got} != {expected}')
self.assertEqual(sample(2, 2), (255, 255, 255), label)
if __name__ == '__main__':
unittest.main()

View File

@@ -137,6 +137,14 @@
try { showPix(await api('/payments/intent',{quote_id:draftId,method:{type:'pix'}})); }
catch(error) { message(error.message); event.target.disabled=false; }
});
if ((await ready).payment_public_key) {
button('Pagar com cartão',async event=>{
if (!quotedCart || quotedCart!==cartSnapshot()) { await refresh(); return; }
event.target.disabled=true;
try { await showCard(quote.approved.total_cents); }
catch(error) { message(error.message); event.target.disabled=false; }
});
}
return;
}
if ((await ready).environment !== 'local') {
@@ -198,6 +206,73 @@
} catch(_) {}
},5000);
}
// Card: Mercado Pago's own form (Card Payment Brick). The card is typed into
// Mercado Pago's secure fields and becomes a one-time token; the number never
// reaches this page's code or our server. As with PIX, the order is created
// by the provider's notification, so the page only waits for it.
let sdkLoading=null;
function loadMercadoPago() {
if (window.MercadoPago) return Promise.resolve();
sdkLoading = sdkLoading || new Promise((resolve,reject)=>{
const script=document.createElement('script');
script.src='https://sdk.mercadopago.com/js/v2';
script.onload=resolve;
script.onerror=()=>{sdkLoading=null;reject(new Error('Não foi possível carregar o formulário do Mercado Pago.'));};
document.head.append(script);
});
return sdkLoading;
}
let cardBrick=null;
async function showCard(totalCents) {
await loadMercadoPago();
const session=await ready;
actions.replaceChildren();
const holder=document.createElement('div');
holder.id='cardPaymentBrick';
holder.style.cssText='max-width:520px;margin-top:8px';
actions.append(holder);
message('Pagamento com cartão: '+rs(totalCents/100)+'.');
if (cardBrick) { try { await cardBrick.unmount(); } catch(_) {} }
const mp=new window.MercadoPago(session.payment_public_key,{locale:'pt-BR'});
cardBrick=await mp.bricks().create('cardPayment','cardPaymentBrick',{
initialization:{amount:totalCents/100, payer:{email:cliente.mail}},
customization:{paymentMethods:{maxInstallments:12}},
callbacks:{
onReady:()=>{},
onError:error=>{ console.error(error); message('Erro no formulário do cartão. Confira os dados e tente de novo.'); },
onSubmit:async data=>{
const result=await api('/payments/intent',{quote_id:draftId,method:{
type:'card', token:data.token, payment_method_id:data.payment_method_id,
installments:Number(data.installments)||1,
issuer_id:data.issuer_id==null?null:String(data.issuer_id)}});
if (result.status==='approved' || result.status==='pending') {
message(result.status==='approved'
? 'Pagamento aprovado. Seu pedido entra na produção em instantes.'
: 'Pagamento em análise pelo Mercado Pago. Avisamos assim que for confirmado.');
waitForOrder();
} else {
message('Pagamento recusado pelo Mercado Pago ('+(result.status_detail||result.status)+'). '+
'Confira os dados ou use outro cartão.');
throw new Error('rejected');
}
}
}
});
}
function waitForOrder() {
clearInterval(pixTimer);
pixTimer=setInterval(async()=>{
try {
const quote=await api('/quotes/'+draftId);
if (quote.status==='paid') {
clearInterval(pixTimer);
pedido=[]; itemAtual=null; limpaPaineis();
await window.dtfClearCart?.();
await refresh();
}
} catch(_) {}
},5000);
}
const quoteFromPortal=new URLSearchParams(location.search).get('quote');
if(quoteFromPortal && /^[0-9a-f-]{36}$/.test(quoteFromPortal)){
if(draftId!==quoteFromPortal){quotedCart=null;localStorage.removeItem('dtf-quote-cart');}

View File

@@ -18,4 +18,4 @@ label{display:inline-flex;gap:8px;align-items:center;margin:5px}#kan{display:gri
<p id="status" role="status"></p>
<section id="payments"></section><section id="reviews"></section><div id="kan"></div>
<details><summary>Eventos locais de integração</summary><pre id="events"></pre></details>
<script src="/upload.js"></script><script src="/kanban.js?v=tiny-v3-1"></script></body></html>
<script src="/upload.js"></script><script src="/kanban.js?v=pdf-print-1"></script></body></html>

View File

@@ -55,7 +55,8 @@ function printFiles(card,order){
const line=node('p','Item '+(index+1)+' · '+(row?PRINT_STATUS[row.status]:'não gerado'),'meta');
if(row?.status==='ready'){
line.textContent+=' · '+row.detail.film_width_cm+' × '+row.detail.height_cm+' cm'+
(row.detail.min_dpi?' · menor resolução '+row.detail.min_dpi+' DPI':'');
(row.detail.min_dpi?' · menor resolução '+row.detail.min_dpi+' DPI':'')+
(row.detail.vector_sources?' · PDF vetorial':'');
line.append(action('Baixar PDF',download(row.upload_id)));
}
if(row?.status==='manual')line.append(node('span',' · '+row.detail.reason));