Compare commits

..

5 Commits

Author SHA1 Message Date
Cauê Faleiros
aa0eba3457 docs: track outstanding work in ROADMAP.md
All checks were successful
Build and deploy / Validate source (push) Successful in 14s
Build and deploy / Publish images and notify Portainer (push) Successful in 1m7s
Findings from the 2026-09-18 audit, ordered by block, each with its acceptance
criterion and audit id. Block 0 is closed; the remaining blocks record security,
architecture, scale and maintenance work, including the decisions that need a
product answer before any code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:33:28 -03:00
Cauê Faleiros
fc6f708e95 chore: restore a working localhost stack
docker-compose.yml became the production/R2 stack, but LOCAL_SETUP.md still
documented "docker compose up --build" against .env.example, which fails on
missing R2_ENDPOINT, SITE_DOMAIN and KANBAN_DOMAIN, and MinIO was gone.

Add compose.local.yaml: builds from source, MinIO storage, fake providers,
disposable credentials, an app database password distinct from the
administrator one, and published origins in ALLOWED_ORIGINS so browser writes
are not rejected. Correct the documented command and the Kanban login, which
listed a username the email-validated model rejects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:33:27 -03:00
Cauê Faleiros
0f16868f2d fix: stop the application database role reusing the admin password
docker-compose.yml passed POSTGRES_PASSWORD as APP_DB_PASSWORD, so the DML-only
dtf_app role and the owning administrator shared one credential and the
privilege separation bootstrap.py sets up was decorative.

APP_DB_PASSWORD is now its own required variable, and bootstrap refuses to run
when it matches the administrator password, in both the URL and discrete-field
configuration forms.

Deploying this requires APP_DB_PASSWORD to be set in the stack environment
first; db-init rotates the role to it on the same deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:32:55 -03:00
Cauê Faleiros
67aa6c970c fix: make the by-metre product reachable and its declaration verified
Navigation links carried data-modo-cta and opened loose artwork directly, so
every entry point except the price card bypassed "Arquivo por metro". Dropping
a PNG or JPG from the by-metre editor then switched the order to loose artwork
and repriced it, while the same screen advertised PNG/JPG in its drop zone and
marked that path as the cheaper one.

Replace the guessing with a declaration: the product is the choice. #tipoEnvio
shows ready sheet and loose artwork side by side with both prices, in all four
modes, reversible until a file is attached and locked afterwards. sel() no
longer reassigns modo, so a file can never change product or price on its own.

Verify the declaration instead of trusting it. medirFolha returns dpiFolha, and
an image without the pixels to span the film width at DPI_RECUSA is refused as
a sheet, with the numbers shown and one click to send it as loose artwork.
Previously a small image declared as a sheet was billed by the metre.

Scope pintaCaminhos to #caminhos .cam: its global selector was clearing the new
control. Move the checkout status out of #carr, which the success path hides,
so a paid order still confirms itself to the customer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:32:38 -03:00
Cauê Faleiros
9046ec6db0 fix: restore session creation broken by missing import
local/auth.py used os.environ without importing os, so new_session raised
NameError. Every first visit to /api/session, every registration and every
login returned 500, which left Site checkout, cart recovery and the customer
portal unusable since the R2 stack change.

Read COOKIE_SECURE once as a module constant and share it with local/app.py
instead of resolving the same variable in two places.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:32:17 -03:00
9 changed files with 764 additions and 56 deletions

View File

@@ -12,16 +12,19 @@ public container images and Python packages; running integrations are local.
From this repository directory:
```bash
docker compose up --build
docker compose -f compose.local.yaml up --build
```
`docker-compose.yml` is the production/R2 stack and will not start locally; the
localhost stack is always `compose.local.yaml`.
No `.env` is required: Compose has the same disposable defaults as `.env.example`.
To customize, copy `.env.example` to `.env` and edit it. Never use real credentials.
For a detached start with readiness verification:
```bash
docker compose up --build -d --wait
docker compose ps
docker compose -f compose.local.yaml up --build -d --wait
docker compose -f compose.local.yaml ps
```
| Component | Local URL / port | Purpose |
@@ -39,7 +42,7 @@ docker compose ps
Use `localhost` consistently; mixing it with `127.0.0.1` creates a different
browser session. Published ports bind only to `127.0.0.1`.
Kanban default login: `operator` / `local-operator-only`.
Kanban default login: `operator@example.test` / `local-operator-only`.
MinIO default login: `dtf_local` / `local-storage-only`.
These are public, disposable development values, not real credentials.
Interactive API documentation is disabled; `/docs` and `/redoc` are not local

402
ROADMAP.md Normal file
View File

@@ -0,0 +1,402 @@
# DTF System — Working Roadmap
> Internal engineering tracker. Not a client document, not a promise sheet.
> The client-facing narrative lives in `output/pdf/dtf-plano-producao-e-roadmap.pdf`
> and in the weekly report (`Relatorio-Semana-1-DTF.docx`).
>
> 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 on 2026-09-18 (0.1–0.6 done and verified against a
live stack). Next: Block 1 needs client inputs for 1.1/1.2, so Block 2 (2.1, 2.2,
2.3) and 5.1 are the ones that can start immediately.
**Last audit:** 2026-09-18, full read of `local/`, `dtf-site.html`, `deploy/`,
`.gitea/`, docs and legacy prototypes. Findings below carry their audit IDs.
| Status | Meaning |
|---|---|
| `[ ]` | not started |
| `[~]` | in progress |
| `[x]` | done and verified |
| `[?]` | blocked on a decision (product or client), not on code |
---
## Block 0 · Broken right now
Nothing in this block is optional. Until it is closed, the system cannot be
demonstrated, and the week-1 claims cannot be defended.
### `[x]` 0.1 — API returns 500 on every session, login and registration `(F1)`
`local/auth.py:46` calls `os.environ` and the module never imports `os`.
Reproduced: `NameError: name 'os' is not defined`. `/api/session` calls
`new_session()` whenever there is no cookie, so the Site checkout bridge, cart
recovery and the customer portal all fail on first visit. Introduced in `e3e37f6`.
- Add `import os` to `local/auth.py`.
- Move the `COOKIE_SECURE` read to a module constant so it is evaluated once.
- **Accept:** a fresh browser hits the Site and `/api/session` returns 200 with a
`cart_scope`; `local/smoke_test.py` and `local/workflow_test.py` pass.
### `[x]` 0.2 — "Arquivo por metro" is unreachable in practice
Two independent causes, both from 2026-09-18 commits. Verified in a browser.
**a. Every entry point hard-routes to loose artwork** (`483a083`)
| Control | Currently opens |
|---|---|
| Nav "Impressão DTF" | `avulsa` |
| "DTF Têxtil · 57 cm" | `avulsa` |
| "DTF UV · 28,5 cm" | `uv` |
| Hero "Enviar minha arte" | `avulsa` |
Only the price card reaches `file`. Revert the three `data-modo-cta` attributes
so navigation links land on the product chooser, not on a product.
**b. Dropping a PNG/JPG in `file` mode silently switches the order** `(F25)`
`dtf-site.html` `sel()` — from `abrir('file')`, dropping `imagem-teste.jpg` gives
`modo: "avulsa"`, header "Artes avulsas", price `R$ 29,90/m`. Meanwhile the same
screen says *"Arraste suas folhas montadas · PNG, JPG ou PDF"*, marks
*"PNG, JPG ou PDF · a partir de R$ 14,90"* as the recommended path, and sets
`input.accept=".png,.jpg,.jpeg,.pdf"`. The page invites the drop and then
reprices the order 50% higher.
The escape hatch `#imagemComoFolha` sits above the drop zone as small text inside
an informational notice, defaults to unchecked, and must be ticked *before* the
drop. After the switch fires, `pintaModo()` sets `trocarParaAvulsa.hidden = true`,
so the checkbox disappears and there is no way back in place.
Net effect: the only formats that survive `file` mode are PDF/TIFF/PSD/AI/CDR —
the path the UI itself marks as the worse option. A mixed drop (JPG + PDF) is
rejected and accepts nothing.
- Make the ready-sheet choice an explicit two-option control **inside** the drop
area, styled like the existing `.cam` selector — not a checkbox in a notice.
- Stop advertising PNG/JPG in the by-metre drop zone while rejecting them.
- When a switch does happen, show the price change and offer one-click undo.
- **Accept:** a customer can complete a by-metre order with a PNG from any entry
point, and no product/price change ever happens without a visible confirmation.
### `[x]` 0.3 — Ready-sheet declaration is unverified and worth money `(F22 related)`
Ticking `#imagemComoFolha` is an honour-system claim that moves the price from
R$ 29,90/m to R$ 19,90/m (R$ 14,90 with a good grade). `medirFolha` then derives
sheet height purely from aspect ratio × 57 cm — the original bug, now opt-in.
Measured with `imagem-teste.jpg` (466 × 659 px):
| Route | System behaviour | Billed |
|---|---|---|
| Ticked | treated as a 57 × 80,6 cm mounted sheet | 1 m × R$ 19,90 = **R$ 19,90** |
| Not ticked | 20 cm wide, packs to 28,3 cm of film | 1 m × R$ 29,90 = **R$ 29,90** |
- Validate the claim: declared width must be ≈ film width (57 / 28,5 cm) at a
plausible DPI before the sheet model is accepted.
- Re-check server-side in `/api/operator/quotes/{id}/approve` before pricing.
- **Accept:** a small single artwork declared as a ready sheet is rejected with a
clear message; a genuine 57 cm sheet passes; the operator sees the verdict.
### `[x]` 0.4 — Documented local startup fails `(F2)`
`LOCAL_SETUP.md` says `docker compose up --build` with no `.env`.
`docker compose --env-file .env.example config` exits 1: `R2_ENDPOINT`,
`R2_ACCESS_KEY_ID`, `SITE_DOMAIN`, `KANBAN_DOMAIN` missing. `docker-compose.yml`
became a production/R2 stack in `e3e37f6`; `.env.example` is still the MinIO one
and there is no MinIO service left.
- Decide: keep one production compose and add `compose.local.yaml` with MinIO, or
restore a local default. Recommend the former.
- **Accept:** a clean clone reaches a working Site + Kanban with the documented
command, and `LOCAL_SETUP.md` matches what actually runs.
### `[x]` 0.5 — App DB role shares the admin password `(F3)`
`docker-compose.yml:65` sets `APP_DB_PASSWORD: ${POSTGRES_PASSWORD}` — the same
value as `dtf_admin`. `bootstrap.py` grants the app role DML-only and then hands
it a credential that also logs in as the owner. Anyone reading the API container
env has admin on the database.
- **Accept:** distinct secrets; connecting as `dtf_app` with the admin password fails.
### `[x]` 0.6 — A paid order showed the customer nothing (found while closing Block 0)
`#checkoutStatus` and `#checkoutActions` lived inside `#carr`, and the success path
in `checkout.js` clears the cart (`pedido=[]; limpaPaineis()`) before writing the
confirmation — `.carr{display:none}` then hid the panel holding it. Present since
the first commit; it only surfaced once 0.1 made a payment reachable at all.
Both elements now sit in their own always-visible `.checkout` container.
### How Block 0 was verified
A live stack (`compose.local.yaml`), then the full suite:
| Check | Result |
|---|---|
| `/api/session` on a cold browser | 200 with `cart_scope` + session cookie |
| smoke · workflow · security · scanning | pass |
| retention · runtime security (in-container) | pass |
| `artwork_browser_test.mjs` | pass, updated to the new declared-product behaviour |
| `browser_test.mjs` end-to-end | pass — upload → quote → operator approval → paid order → all Kanban states |
| 4 CI unit tests | pass |
Ports 8090/8091/8010 were used; 8080 was held by an unrelated preview server.
---
## Block 1 · Week 2 — committed to the client
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.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.5 — Main Kanban production states consolidated.
- `[ ]` 1.6 — **Block 0.2 + 0.3**, promised as "início da próxima semana".
`[!]` 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.
---
## Block 2 · Security — before any public exposure
### `[ ]` 2.1 — Rate limiting and audit logs are blind to the client `(F5)`
uvicorn runs without trusted proxy headers (`forwarded_allow_ips` defaults to
`127.0.0.1`; nginx is a different container IP), so `request.client.host` is nginx
for every request. `rate_limit('auth-source', ...)` at 60/15min becomes a single
global bucket — **60 failed logins lock out every customer** — and every `audit()`
record has no attacker IP.
- Set `--proxy-headers` with `FORWARDED_ALLOW_IPS` scoped to the nginx service, or
read `X-Forwarded-For` explicitly at the edge.
- **Accept:** two clients on different IPs have independent buckets; audit rows
carry the real IP.
### `[ ]` 2.2 — The public site throttles itself `(F6)`
`local/app.py:115` — `rate_limit('guest-sessions', ENVIRONMENT, 120, 900)` is keyed
on the environment name: 120 new visitors per 15 minutes **site-wide** (~8/min).
Normal traffic 429s. Key per source IP (after 2.1) and raise the ceiling.
### `[ ]` 2.3 — `deploy/stack.yaml` cannot boot `(F7)`
It passes `DATABASE_URL_FILE`, `AWS_ACCESS_KEY_ID_FILE`, `OPERATOR_PASSWORD_FILE`,
`OPERATOR_USER`. The code reads `DATABASE_URL`, `AWS_ACCESS_KEY_ID`,
`OPERATOR_PASSWORD`, `OPERATOR_EMAIL`, and no `_FILE` loader exists.
`app.py:58` does `os.environ['OPERATOR_PASSWORD']` → `KeyError` → 500 instead of 503.
- Implement `local/secrets.py` (the preflight already expects it) reading `*_FILE`
with env fallback. Reconcile `OPERATOR_USER` vs `OPERATOR_EMAIL`.
- **Accept:** the stack renders and boots against Swarm secrets; missing operator
config yields 503, not 500.
### `[ ]` 2.4 — The documented release gate does not exist `(F8)`
`PORTAINER.md` and `SECURITY_REPORT.md` claim the workflow runs the full isolated
suite, Trivy HIGH/CRITICAL image gates, secret scanning and the source preflight
before calling Portainer. `.gitea/workflows/deploy.yml` runs `py_compile` plus four
unit tests, then builds, pushes `latest` and calls the webhook **unconditionally**.
`deploy/production_preflight.py` is never invoked — only its unit test runs.
- Either implement the gate or correct both documents. Do not leave the gap.
### `[ ]` 2.5 — The preflight has silently decayed `(F9)`
It blocks by string-matching source. **4 of 6 markers are dead** after the R2
refactor: `'This runtime only supports APP_ENV=local'`,
`'Only local S3 storage is supported'`, `"allowed_hosts=['localhost', '127.0.0.1']"`,
`"'environment': 'local'"`. String gates weaken without failing.
- Replace marker matching with behavioural assertions (import the module, assert
the adapter classes in use).
### `[ ]` 2.6 — Base images are not pinned `(F10)`
Dockerfiles default to mutable `python:3.12-slim` / `nginx:1.28-alpine`, the
workflow passes no digest build-args, and `--pull` makes builds non-reproducible —
while `PORTAINER.md` documents digest-pinned immutable bases.
### `[ ]` 2.7 — pdf.js loaded from CDN without integrity `(F11)`
3.11.174 from `cdnjs`, no SRI, and CSP allows the whole host for `script-src` **and**
`worker-src`. Vendor the asset or pin `integrity` and narrow the CSP to the exact path.
### `[ ]` 2.8 — Single shared operator credential `(F12)`
One `OPERATOR_EMAIL`/`OPERATOR_PASSWORD` for the whole factory; `movements.operator`
records the same name for everyone. The meeting asked for traceability, and the old
`kanban/main.py` explicitly designed separation of duties (Mayana classifies,
Thales/Alexandre authorise). Needs real per-person accounts with roles.
### `[ ]` 2.9 — No TLS in the stack `(F13)`
Ports publish plain HTTP on 18080/18081 while `COOKIE_SECURE: "true"` — cookies are
silently dropped unless something external terminates TLS. Nothing in the repo
provisions certificates; `TAREFAS.md` A2 still lists it as pending.
### `[ ]` 2.10 — No email verification, no password recovery `(F14)`
A locked-out customer has no path back, and registration accepts any CNPJ without
proving control of the e-mail. Needs a transactional mail provider — **client input**.
### `[ ]` 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.
---
## Block 3 · Architecture — needs a decision before code
### `[?]` 3.1 — Manual quote approval contradicts the 24h business case `(F16)`
Payment requires `quotes.approved`, set only by an authenticated operator. The
meeting's premise was that the 17h30 order waiting until 5am is what costs the
money. As built, a 2am order still waits for a person. `CONTEXT.md` frames this as a
temporary development trust boundary — the risk is that it silently becomes the
delivered model.
**Decide:** what makes a quote auto-approvable (mode, metre range, grade floor,
returning customer), and what still routes to a human.
### `[?]` 3.2 — Billable metres are computed in the customer's browser `(F17, F18)`
For loose artwork, `metros` comes from `desenhaMontagem`/`encaixar` — a canvas
alpha-mask packer running client-side. The server never recomputes it.
`passoDe()`/`CELULAS_MAX` coarsen the grid for large sheets and image decoding
differs by browser, so **the same cart can price differently on different devices**.
The code comments reference "o motor do servidor"; that engine does not exist.
Worse, the layout the customer is quoted on is never produced — operators upload
final files by hand, so billed metres ≠ printed metres and a designer redoes work
the site already did.
**Decide:** port the packer to the server as the pricing authority and the print-file
generator, with the browser as preview only. This is the single largest gap between
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.
**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).
### `[ ]` 3.4 — Upload throughput `(F20)`
8 MiB parts, strictly sequential in `local/static/upload.js:21`, one presign
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)`
`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.
---
## Block 4 · Scale and performance
- `[ ]` 4.1 — Missing indexes `(F23)`. `local/schema.sql` indexes only
`uploads(owner)`. Add `orders(owner)`, `quotes(owner)`, `order_files(order_id)`,
`movements(order_id)`, and a partial index on
`outbox(available_at) WHERE delivered_at IS NULL` — the worker polls that table
every second and it only grows.
- `[ ]` 4.2 — `/api/operator/board` is unpaginated `(F24)`: every order ever, plus a
per-quote subquery each. Fine at 10 orders, not at 200/day.
- `[ ]` 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.
- `[ ]` 4.5 — Dead config `(F28)`: `stack.yaml` sets `CLAMD_HOST: scanner`,
`scanning.py` hardcodes `'scanner'`.
---
## Block 5 · Hygiene and maintenance
- `[ ]` 5.1 — CI coverage `(F33)`. The smoke, workflow, security, retention and
browser suites exist under `local/` and would have caught 0.1 — none run in CI.
Add a compose-backed job. **Highest leverage item in this block.**
- `[ ]` 5.2 — Remove or archive the dead prototypes `(F30)`: `portal/`, `kanban/`,
`agente/`, root `schema.sql` (~1,500 lines describing an abandoned model). Several
expose unauthenticated endpoints taking the acting user from the request body
(`/api/puxar`, `/api/devolver`).
- `[ ]` 5.3 — Root `requirements.txt` is the prototype's `(F31)`: wildcard pins,
unused `sqlmodel`/`pyvips`/`qrcode`/`pillow`, next to the hash-locked
`local/requirements.lock`.
- `[ ]` 5.4 — `pip==26.2.1` pinned as a runtime dependency `(F32)` — pip ships inside
the read-only production image.
- `[ ]` 5.5 — Doc drift `(F34)`. `README.md`, `CONTEXT.md`, `LOCAL_SETUP.md` and
`SECURITY_REPORT.md` describe a MinIO localhost stack, an API with "no external
network route", a `operator` / `local-operator-only` login the email-validated
model rejects, and a release gate — none match the current tree.
- `[ ]` 5.6 — `dtf-site.html` is 2,889 lines with commercial rules, the packing
engine, PDF analysis, UI and checkout inline `(F29)`. Split at least the pricing
table and the packer so 3.2 has somewhere to land.
- `[ ]` 5.7 — Commercial rules duplicated between `FAIXAS` (JS) and `TIERS` (Python)
`(F26)`. `test_pricing` guards parity; generate one from the other instead.
- `[ ]` 5.8 — The Site promises retention the system does not honour. The cart aside
still reads *"O arquivo fica guardado por 90 dias e o histórico do pedido por 12
meses"*, while `CONTEXT.md` and the implemented retention are 30 days maximum.
This is a customer-facing commercial promise, so correct the copy or the policy —
do not leave them disagreeing. Found 2026-09-18 while closing Block 0.
---
## Done
### Week 1 — infrastructure, uploads, service base
- `[x]` Compose stack: Site, Kanban, API, PostgreSQL, worker, ClamAV, R2/MinIO storage.
- `[x]` Gitea + Portainer publication path; web service startup and API rollout fixed.
- `[x]` Database passwords with special characters handled via discrete libpq fields.
- `[x]` Kanban e-mail/password login; missing operator config no longer breaks stack boot.
- `[x]` Direct resumable multipart browser → private object storage.
- `[x]` Quarantine + ClamAV gate: only `clean` files can be quoted, paid, downloaded or queued.
- `[x]` Retention worker: incomplete 1d, rejected 3d, originals 7d after approval, finals 30d.
- `[x]` Server-side pricing authority with parity test against the Site JavaScript (4,444 cases).
- `[x]` Loose-artwork packing flow: per-file width, copies, rotate, mirror, live 57 cm preview,
5 mm gap, ruler and watermark preserved; metres follow packed height.
- `[x]` Rotation/mirror applied to packing masks; stale renders no longer overwrite a newer preview.
- `[x]` Hash-locked Python dependencies; Trivy reports in `output/security/`.
### Block 0 — 2026-09-18
- `[x]` `import os` restored in `local/auth.py`; `COOKIE_SECURE` is now a single
module constant shared with `local/app.py`.
- `[x]` Navigation links no longer preselect a product (`data-modo-cta` removed).
- `[x]` Ready sheet vs loose artwork is an explicit, priced, reversible selector
(`#tipoEnvio`), shown in all four modes and locked once a file is attached.
The product is the declaration; `sel()` no longer switches anything silently.
- `[x]` `medirFolha` returns `dpiFolha`; an image that cannot span the film width
at `DPI_RECUSA` is refused as a sheet, with one click to send it as loose artwork.
- `[x]` `pintaCaminhos` scoped to `#caminhos .cam` — its global `.cam` selector was
clobbering the new control.
- `[x]` `compose.local.yaml` restored (MinIO, fake providers, builds from source).
- `[x]` `APP_DB_PASSWORD` separated from `POSTGRES_PASSWORD`, with a `bootstrap.py`
guard that refuses identical credentials in both configuration forms.
- `[x]` Checkout confirmation moved out of the panel the success path hides.
### Reporting
- `[x]` Week-1 client report (`Relatorio-Semana-1-DTF.docx`), corrected 2026-09-18 to
remove the inaccurate "Arquivo por metro permanece separado, com seleção explícita"
claim and the internal commit reference.

197
compose.local.yaml Normal file
View File

@@ -0,0 +1,197 @@
# Localhost development stack. Builds from source, uses MinIO, fake providers and
# disposable credentials. `docker-compose.yml` is the production/R2 stack and is
# NOT usable locally; the two are deliberately separate files.
#
# docker compose -f compose.local.yaml up --build
#
# Defaults here mirror `.env.example`; copy it to `.env` only to customise.
x-app: &app
build:
context: .
dockerfile: local/Dockerfile
environment: &environment
APP_ENV: local
DATABASE_URL: postgresql://${APP_DB_USER:-dtf_app}:${APP_DB_PASSWORD:-local-app-database-only}@db:5432/${POSTGRES_DB:-dtf_local}
S3_ENDPOINT: http://storage:9000
S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:9000}
S3_BUCKET: ${S3_BUCKET:-dtf-local-artwork}
AWS_ACCESS_KEY_ID: ${S3_APP_USER:-dtf_app}
AWS_SECRET_ACCESS_KEY: ${S3_APP_PASSWORD:-local-app-storage-only}
AWS_DEFAULT_REGION: us-east-1
OPERATOR_EMAIL: ${OPERATOR_EMAIL:-operator@example.test}
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}
COOKIE_SECURE: "false"
PAYMENT_ADAPTER: fake
FREIGHT_ADAPTER: fake
TINY_ADAPTER: fake
WHATSAPP_ADAPTER: fake
STORAGE_ADAPTER: s3-local
MOCK_FREIGHT_CENTS: ${MOCK_FREIGHT_CENTS:-1500}
MAX_UPLOAD_BYTES: ${MAX_UPLOAD_BYTES:-5368709120}
UPLOAD_PART_BYTES: ${UPLOAD_PART_BYTES:-8388608}
STORAGE_QUOTA_BYTES: ${STORAGE_QUOTA_BYTES:-53687091200}
OWNER_UPLOAD_QUOTA_BYTES: ${OWNER_UPLOAD_QUOTA_BYTES:-10737418240}
MAX_PENDING_UPLOADS: ${MAX_PENDING_UPLOADS:-10}
SCAN_MAX_BYTES: ${SCAN_MAX_BYTES:-134217728}
networks: [local]
init: true
security_opt: [no-new-privileges:true]
cap_drop: [ALL]
read_only: true
tmpfs: [/tmp]
logging:
driver: json-file
options: {max-size: "10m", max-file: "3"}
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-dtf_local}
POSTGRES_USER: ${POSTGRES_USER:-dtf_local}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-local-database-only}
volumes: [postgres-data:/var/lib/postgresql/data]
networks: [local]
healthcheck:
test: [CMD-SHELL, 'pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB"']
interval: 5s
timeout: 3s
retries: 30
storage:
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
command: server /data --console-address :9001
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-dtf_local}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-local-storage-only}
ports: ["127.0.0.1:9000:9000", "127.0.0.1:9001:9001"]
volumes: [storage-data:/data]
networks: [local, edge]
healthcheck:
test: [CMD, mc, ready, local]
interval: 5s
timeout: 3s
retries: 30
db-init:
build:
context: .
dockerfile: local/Dockerfile
command: python -m local.bootstrap
environment:
# Local only: the app role keeps a password distinct from the administrator.
DATABASE_ADMIN_URL: postgresql://${POSTGRES_USER:-dtf_local}:${POSTGRES_PASSWORD:-local-database-only}@db:5432/${POSTGRES_DB:-dtf_local}
APP_DB_USER: ${APP_DB_USER:-dtf_app}
APP_DB_PASSWORD: ${APP_DB_PASSWORD:-local-app-database-only}
networks: [local]
depends_on:
db: {condition: service_healthy}
restart: on-failure
storage-init:
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
entrypoint: [/bin/sh, /init.sh]
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-dtf_local}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-local-storage-only}
S3_APP_USER: ${S3_APP_USER:-dtf_app}
S3_APP_PASSWORD: ${S3_APP_PASSWORD:-local-app-storage-only}
S3_BUCKET: ${S3_BUCKET:-dtf-local-artwork}
volumes:
- ./local/storage-init.sh:/init.sh:ro
- ./local/storage-policy.json:/policy.json:ro
- ./local/storage-lifecycle.json:/lifecycle.json:ro
networks: [local]
depends_on:
storage: {condition: service_healthy}
restart: on-failure
scanner:
image: clamav/clamav@sha256:9cb27d7660bdf66e9878c832cb433dd8aa152cfbe16f3c2c0084c80b04ae22b4
entrypoint: [clamd, --foreground=true, --config-file=/etc/clamav/clamd.conf]
volumes: [./local/clamd.conf:/etc/clamav/clamd.conf:ro]
networks: [local]
security_opt: [no-new-privileges:true]
healthcheck:
test: [CMD, clamdscan, --config-file=/etc/clamav/clamd.conf, --ping, "3"]
start_period: 60s
interval: 10s
timeout: 5s
retries: 30
deploy:
resources:
limits: {memory: 3G}
api:
<<: *app
command: uvicorn local.app:app --host 0.0.0.0 --port 8000 --no-access-log
depends_on:
db-init: {condition: service_completed_successfully}
storage-init: {condition: service_completed_successfully}
healthcheck:
test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 5s
timeout: 3s
retries: 30
worker:
<<: *app
command: python -m local.worker
depends_on:
api: {condition: service_healthy}
scanner: {condition: service_healthy}
healthcheck:
test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://localhost:8002/health')"]
interval: 5s
timeout: 3s
retries: 12
site:
build:
context: .
dockerfile: local/Dockerfile.web
environment:
S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:9000}
ports:
- "127.0.0.1:${SITE_PORT:-8080}:80"
- "127.0.0.1:${API_PORT:-8000}:81"
networks: [local, edge]
depends_on:
api: {condition: service_healthy}
healthcheck:
test: [CMD, wget, -q, -O, /dev/null, http://127.0.0.1/health]
interval: 5s
timeout: 3s
retries: 12
kanban:
build:
context: .
dockerfile: local/Dockerfile.web
environment:
WEB_INDEX: kanban.html
S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:9000}
ports: ["127.0.0.1:${KANBAN_PORT:-8081}:80"]
networks: [local, edge]
depends_on:
api: {condition: service_healthy}
healthcheck:
test: [CMD, wget, -q, -O, /dev/null, http://127.0.0.1/health]
interval: 5s
timeout: 3s
retries: 12
volumes:
postgres-data:
storage-data:
networks:
local:
internal: true
edge:

View File

@@ -5,7 +5,9 @@ x-app-environment: &app-environment
DATABASE_HOST: db
DATABASE_NAME: dtf
DATABASE_USER: dtf_app
DATABASE_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
# Must differ from POSTGRES_PASSWORD: the runtime role is DML-only, and reusing
# the administrator credential would make that restriction meaningless.
DATABASE_PASSWORD: ${APP_DB_PASSWORD:?set APP_DB_PASSWORD}
S3_ENDPOINT: ${R2_ENDPOINT:?set R2_ENDPOINT}
S3_PUBLIC_ENDPOINT: ${R2_ENDPOINT:?set R2_ENDPOINT}
S3_BUCKET: ${R2_BUCKET:?set R2_BUCKET}
@@ -62,7 +64,7 @@ services:
DATABASE_ADMIN_USER: dtf_admin
DATABASE_ADMIN_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
APP_DB_USER: dtf_app
APP_DB_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
APP_DB_PASSWORD: ${APP_DB_PASSWORD:?set APP_DB_PASSWORD}
networks: [backend]
deploy:
replicas: 1

View File

@@ -577,6 +577,8 @@ h1 em{font-style:normal;color:var(--laranja)}
/* ── Carrinho no padrão de checkout: entrega à esquerda, resumo fixo à direita ── */
.carr{display:none;margin-top:18px}
.checkout{margin-top:14px}
.checkout #checkoutStatus:empty{display:none}
.carr.on{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:18px;align-items:start}
@media(max-width:900px){.carr.on{grid-template-columns:1fr}}
.carr h3{font-family:"Inter",sans-serif;font-size:19px;font-weight:700;margin-bottom:12px}
@@ -780,10 +782,10 @@ footer a:hover{color:var(--laranja2)}
</div>
</div>
<div class="mi on">
<a href="#envio" data-modo-cta="avulsa">Impressão DTF</a>
<a href="#envio">Impressão DTF</a>
<div class="sub">
<a href="#envio" data-modo-cta="avulsa">DTF Têxtil · 57 cm</a>
<a href="#envio" data-modo-cta="uv">DTF UV · 28,5 cm</a>
<a href="#envio">DTF Têxtil · 57 cm</a>
<a href="#envio">DTF UV · 28,5 cm</a>
</div>
</div>
<div class="mi">
@@ -867,7 +869,7 @@ footer a:hover{color:var(--laranja2)}
<p class="sub2">Mande a arte, veja a nota em segundos e a folha montada antes de fechar.
<b>Revisão e remanejamento por nossa conta.</b></p>
<div class="hcta">
<a href="#envio" class="btn1" data-modo-cta="avulsa">Enviar minha arte</a>
<a href="#envio" class="btn1">Enviar minha arte</a>
<span class="mini">sem cadastro · resposta em segundos</span>
</div>
</div>
@@ -1000,13 +1002,22 @@ footer a:hover{color:var(--laranja2)}
<div class="foco2" id="foco2">
<div class="fila">
<div class="trocouCam" id="trocouCam" style="display:none"></div>
<div class="trocarModo" id="trocarParaAvulsa" hidden>
<b>PNG e JPG entram como artes avulsas.</b> Informe largura e quantidade;
montamos a folha, com o preço de artes avulsas.
<label><input type="checkbox" id="imagemComoFolha">
Minha imagem já é uma folha montada de <span id="larguraFolhaPronta">57</span> cm
· usar o preço de arquivo por metro</label>
<div class="caminhos" id="tipoEnvio" hidden>
<button type="button" class="cam on" data-tipo="folha">
<span class="mk2"></span>
<div><b>Já é uma folha montada</b>
<span>a imagem ocupa a largura inteira do filme de
<span id="larguraFolhaPronta">57</span> cm</span>
<em id="tipoFolhaPreco">—</em></div>
</button>
<button type="button" class="cam" data-tipo="avulsa">
<span class="mk2"></span>
<div><b>São artes separadas</b>
<span>você informa a largura de cada uma e nós montamos a folha</span>
<em id="tipoAvulsaPreco">—</em></div>
</button>
</div>
<div class="trocouCam" id="avisoTipo" style="display:none"></div>
<div class="caminhos" id="caminhos">
<button class="cam on" data-cam="auto">
<span class="mk2"></span>
@@ -1232,13 +1243,17 @@ footer a:hover{color:var(--laranja2)}
<button id="bPagar">Ir para o pagamento</button>
<button id="bMais" class="sec">Adicionar outro produto</button>
</div>
<p id="checkoutStatus" class="avisoE" role="status" aria-live="polite"></p>
<div id="checkoutActions"></div>
<p class="obs">A nota fiscal sai no CNPJ informado. O arquivo fica guardado por 90 dias e o histórico
do pedido por 12 meses, para você repetir sem subir de novo.</p>
</aside>
</div>
<!-- Fora de #carr de propósito: o pedido pago esvazia o carrinho, e a
confirmação não pode sumir junto com o painel. -->
<div class="checkout">
<p id="checkoutStatus" class="avisoE" role="status" aria-live="polite"></p>
<div id="checkoutActions"></div>
</div>
</div></section>
<!-- INFORMAÇÃO EM CARROSSEL -->
@@ -1418,6 +1433,11 @@ let CLIENTE='SEU NOME AQUI', PEDIDO='000000';
let modo=null, artes=[], recemChegada=null, metros=0, nota=0, reencaixado=false;
let folhas=[]; // [{f, med, rep, m}] · o cliente pode subir várias
const folhaTotalM=()=>folhas.reduce((t,x)=>t+(x.m||0)*(x.rep||1),0);
// O cliente declara o que está mandando escolhendo o produto, em vez de o site
// adivinhar pelo formato do arquivo. A declaração de folha montada é conferida na
// medição: uma imagem sem pixel para ocupar a largura do filme não é uma folha.
// 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
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ó
@@ -1458,14 +1478,10 @@ const px=f=>Math.round(Math.min(6000,Math.max(600,Math.sqrt(f.size/1024)*95)));
// ── escolha
document.querySelectorAll('.ec').forEach(b=>b.addEventListener('click',()=>abrir(b.dataset.modo)));
document.querySelectorAll('[data-modo-cta]').forEach(b=>b.addEventListener('click',e=>{
e.preventDefault(); abrir(b.dataset.modoCta);
}));
function abrir(m){
modo=m; artes=[]; folhas=[]; metros=0; nota=0; reencaixado=false;
montagemCm=0;
$('imagemComoFolha').checked=false;
$('imagemComoFolha').disabled=false;
avisoTipo('');
pintaModo();
$('lista').innerHTML=''; $('rA').style.display='none'; recusa([]);
recemChegada=null; caminho='auto'; avisoCam(''); pintaCaminhos();
@@ -1484,10 +1500,64 @@ function pintaModo(){
$('inp').accept = ehFolha(m) ? '.png,.jpg,.jpeg,.pdf'
: '.png,.jpg,.jpeg,.webp';
$('catFoco').className='cat '+(ehFolha(m)?'file':'av');
$('trocarParaAvulsa').hidden=!ehFolha(m);
$('larguraFolhaPronta').textContent=String(c.larg).replace('.',',');
$('foco2').classList.remove('sofila'); // a caixa de requisitos vale nos 4 modos
$('montcabTit').textContent = ehFolha(m) ? 'Sua folha' : 'Montagem ao vivo';
pintaTipoEnvio();
}
// O par folha montada / artes separadas fica visível e com preço nos dois lados,
// nos dois modos: nenhuma troca acontece sem o cliente ver o que muda, e sempre
// dá para voltar atrás sem recomeçar o pedido.
function pintaTipoEnvio(){
const el=$('tipoEnvio'); if(!el) return;
el.hidden=!modo;
if(el.hidden) return;
const folha = ehFolha() ? modo : PAR[modo];
const avulsa = ehFolha() ? PAR[modo] : modo;
$('tipoFolhaPreco').textContent='a partir de '+rs(pisoEscada(folha));
$('tipoAvulsaPreco').textContent='a partir de '+rs(pisoEscada(avulsa))+' · montagem inclusa';
$('larguraFolhaPronta').textContent=String(MODOS[folha].larg).replace('.',',');
const atual = ehFolha() ? 'folha' : 'avulsa';
el.querySelectorAll('.cam').forEach(b=>{
b.classList.toggle('on', b.dataset.tipo===atual);
b.disabled=folhas.length>0 || artes.length>0; // já tem arquivo: a escolha está feita
});
}
function avisoTipo(txt){
const el=$('avisoTipo'); if(!el) return;
el.innerHTML=txt; el.style.display = txt? '' : 'none';
}
// A imagem foi declarada folha montada mas não tem resolução para isso. Devolve o
// arquivo com o motivo e o caminho certo — sem trocar preço por conta própria.
function recusaFolha(x, md){
const arquivo=x.f, destino = modo==='uvfile' ? 'uv' : 'avulsa';
folhas=folhas.filter(y=>y!==x);
pintaFolha();
avisoTipo('<b>'+escapeHTML(arquivo.name)+' não é uma folha montada.</b> '+
'Para ocupar os '+n1(larguraFilme())+' cm do filme ela teria só '+md.dpiFolha+
' DPI, e o mínimo para imprimir é '+DPI_RECUSA+'. '+
'Se for uma arte para montarmos, use <b>'+MODOS[destino].tit+'</b>, '+
'a partir de '+rs(pisoEscada(destino))+'/m. '+
'<button type="button" id="usarAvulsa">Enviar como '+MODOS[destino].tit.toLowerCase()+'</button>');
const botao=$('usarAvulsa');
if(botao) botao.addEventListener('click',()=>{
avisoTipo(''); trocaTipo(PAR[modo]); sel([arquivo]);
});
}
document.querySelectorAll('#tipoEnvio .cam').forEach(b=>b.addEventListener('click',()=>{
if(b.disabled) return;
const querFolha=b.dataset.tipo==='folha';
if(querFolha===ehFolha()) return;
avisoTipo(''); trocaTipo(PAR[modo]); // escolha explícita do cliente
}));
// Troca declarada, nunca silenciosa: o cliente já viu os dois preços no seletor.
function trocaTipo(destino){
if(!destino || modo===destino) return;
folhas=[]; artes=[]; recemChegada=null; metros=0; nota=0; montagemCm=0;
modo=destino;
$('lista').innerHTML=''; $('rA').style.display='none'; recusa([]);
caminho='auto'; avisoCam('');
pintaModo(); pintaCaminhos(); limpaPaineis(); agendaAvaliacao(); previaAoVivo();
}
$('bVoltar').addEventListener('click',()=>{
itemAtual=null; $('atual').style.display='none'; $('carrLin').innerHTML=''; pintaPedido();
@@ -1525,18 +1595,9 @@ Z.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){I.click();e.pr
I.addEventListener('change',()=>sel([...I.files]));
function sel(fs){
if(!fs.length) return;
// Pixels cannot tell a loose artwork from a finished sheet. Images default
// to the artwork model unless the customer explicitly declares a ready sheet.
// Update this same editor in place; do not reopen it or discard uploaded files.
if(ehFolha() && !$('imagemComoFolha').checked && fs.some(f=>ACEITA.avulsa.test(f.name))){
if(fs.some(f=>!ACEITA.avulsa.test(f.name))){
$('recusa').style.display='block';
$('recusa').textContent='Envie artes avulsas e folhas prontas separadamente. Para uma imagem já montada, marque a opção de folha pronta.';
return;
}
modo=modo==='uvfile'?'uv':'avulsa';
pintaModo(); pintaCaminhos();
}
// Pixels cannot tell a loose artwork from a finished sheet, so the customer
// declares it by choosing the product in the selector above. The declaration is
// verified in medirFolha; the file never changes product or price on its own.
const regra = ehFolha() ? ACEITA.folha : ACEITA.avulsa;
const fora = fs.filter(f=>!regra.test(f.name));
fs = fs.filter(f=>regra.test(f.name));
@@ -1559,12 +1620,12 @@ function sel(fs){
pintaCaminhos();
}
const novas=fs.map(f=>({f, med:null, rep:1, m:0, semAnalise:null}));
$('imagemComoFolha').checked=true;
folhas=folhas.concat(novas); // soma, não substitui
pintaFolha();
novas.forEach(x=>{
x.pct=5; pintaFolha();
medirFolha(x.f).then(md=>{
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)){
carregarImagem(x.f).then(img=>{
@@ -1846,7 +1907,11 @@ function medirFolha(file){
carregarImagem(file).then(img=>{
if(!img || !img.width) return res(null);
const L=larguraFilme();
res({larg:L, alt:+(img.height/img.width*L).toFixed(1), fonte:'proporção da imagem'});
// Uma imagem só é folha montada se tiver pixel para ocupar a largura do
// filme. Sem esta conferência, qualquer arte pequena vira "folha" e leva
// o preço por metro sem nunca ter sido montada.
res({larg:L, alt:+(img.height/img.width*L).toFixed(1), fonte:'proporção da imagem',
dpiFolha:Math.round(img.width/(L/2.54)), px:img.width});
});
return;
}
@@ -1882,8 +1947,7 @@ function medirFolha(file){
function pintaFolha(){
const L=larguraFilme();
$('imagemComoFolha').disabled=folhas.length>0;
if(!folhas.length) $('imagemComoFolha').checked=false;
pintaTipoEnvio(); // trava o seletor enquanto houver folha
if(!folhas.length){ $('lista').innerHTML=''; agendaAvaliacao(); previaAoVivo(); return; }
$('lista').innerHTML = folhas.map((x,i)=>{
@@ -2769,7 +2833,7 @@ function pintaCaminhos(){
const ehF=modo&&ehFolha();
$('caminhos').style.display = ehF? '' : 'none';
if(!ehF) return;
document.querySelectorAll('.cam').forEach(b=>b.classList.toggle('on', b.dataset.cam===caminho));
document.querySelectorAll('#caminhos .cam').forEach(b=>b.classList.toggle('on', b.dataset.cam===caminho));
$('camA').textContent='a partir de '+rs(pisoEscada());
$('camB').textContent=rs(TABELA[modo])+' fixo';
$('inp').accept = caminho==='auto' ? '.png,.jpg,.jpeg,.pdf'
@@ -2778,7 +2842,7 @@ function pintaCaminhos(){
? 'PNG, JPG ou PDF · conferência automática, nota na tela'
: 'CDR, AI, PSD ou TIFF · sem conferência, metro pela tabela';
}
document.querySelectorAll('.cam').forEach(b=>b.addEventListener('click',()=>{
document.querySelectorAll('#caminhos .cam').forEach(b=>b.addEventListener('click',()=>{
caminho=b.dataset.cam; avisoCam(''); pintaCaminhos();
}));
function avisoCam(txt){

View File

@@ -17,7 +17,7 @@ from . import db
from .adapters import FakeFreight, FakePayment, LocalS3Storage, require_runtime
from .models import Freight, Move, Pay, QuoteRequest, Review, UploadStart, OperatorLogin
from .pricing import price
from .auth import owner, session_row, new_session, operator, throttle, audit, rate_limit
from .auth import COOKIE_SECURE, owner, session_row, new_session, operator, throttle, audit, rate_limit
from .scanning import require_clean
require_runtime()
@@ -28,7 +28,6 @@ ENVIRONMENT = os.environ.get('APP_ENV', 'local')
PUBLIC_ORIGIN = os.environ.get('PUBLIC_ORIGIN', 'http://localhost')
ALLOWED_HOSTS = [host for host in os.environ.get('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',') if host]
ALLOWED_ORIGINS = [origin for origin in os.environ.get('ALLOWED_ORIGINS', PUBLIC_ORIGIN).split(',') if origin]
COOKIE_SECURE = os.environ.get('COOKIE_SECURE', 'false').lower() == 'true'
PART_BYTES = int(os.environ.get('UPLOAD_PART_BYTES', '8388608'))
if not 5242880 <= PART_BYTES <= 67108864:
raise RuntimeError('UPLOAD_PART_BYTES must be between 5 and 64 MiB')

View File

@@ -103,9 +103,28 @@ try{
if(drop)$('zona').dispatchEvent(new DragEvent('drop',{dataTransfer:dt,bubbles:true,cancelable:true}));
else{$('inp').files=dt.files;$('inp').dispatchEvent(new Event('change',{bubbles:true}));}
};
// A genuine mounted sheet spans the film width with printable resolution.
window.sendSheet=async()=>{
const cm=larguraFilme(), px=Math.ceil(cm/2.54*150);
const c=document.createElement('canvas');c.width=px;c.height=Math.round(px*0.6);
const ctx=c.getContext('2d');ctx.fillStyle='white';ctx.fillRect(0,0,c.width,c.height);
ctx.fillStyle='#222';ctx.fillRect(40,40,px/4,px/4);
const blob=await new Promise(r=>c.toBlob(r,'image/png'));
const dt=new DataTransfer();dt.items.add(new File([blob],'folha-montada.png',{type:'image/png'}));
$('inp').files=dt.files;$('inp').dispatchEvent(new Event('change',{bubbles:true}));
};
window.pickTipo=t=>document.querySelector('#tipoEnvio .cam[data-tipo="'+t+'"]').click();
})()`);
// Reproduce the reported entry path, with no navigation workaround.
// Navigation links must reach the chooser, never preselect a product.
assert.equal(await evaluate(`document.querySelectorAll('[data-modo-cta]').length`),0);
await click('[data-modo="file"]');
// By-metre opens declaring a mounted sheet; switching is explicit and priced.
assert.deepEqual(await evaluate(`({shown:!$('tipoEnvio').hidden,
on:[...document.querySelectorAll('#tipoEnvio .cam.on')].map(b=>b.dataset.tipo)})`),
{shown:true,on:['folha']});
await evaluate(`pickTipo('avulsa')`);
assert.equal(await evaluate('modo'),'avulsa');
await evaluate('sendImage()');
await waitFor(()=>evaluate(`artes.length===1 && !!artes[0].src`),'JPG artwork model');
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});
@@ -134,26 +153,35 @@ try{
const m=result.pos[0].m;
return !m.bits[2*m.cw+2] && !m.bits[2*m.cw+30] && !!m.bits[15*m.cw+30];
})()`),true);
// Explicit finished-sheet images retain their separate dimensions and price.
// A real finished sheet keeps its own dimensions and by-metre price.
await click('#bVoltar');await click('[data-modo="file"]');
await click('#imagemComoFolha');await evaluate('sendImage()');
await waitFor(()=>evaluate('folhas.length===1 && !!folhas[0].previewSrc && metros===1.14'),'ready-sheet image');
await evaluate('sendSheet()');
await waitFor(()=>evaluate('folhas.length===1 && !!folhas[0].previewSrc && metros===0.342'),'ready-sheet image');
assert.deepEqual(await evaluate(`({mode:modo,art:artes.length,title:$('montcabTit').textContent,rate:precoBase(100)})`),{mode:'file',art:0,title:'Sua folha',rate:14.9});
assert.equal(await evaluate(`document.querySelectorAll('[data-cm]').length`),0);
// With a sheet loaded the declaration is locked; it cannot flip under the customer.
assert.equal(await evaluate(`document.querySelector('#tipoEnvio .cam[data-tipo="avulsa"]').disabled`),true);
await evaluate(`(()=>{const el=$('lista').querySelector('[data-repf]');el.value=2;el.dispatchEvent(new Event('change',{bubbles:true}));})()`);
await waitFor(()=>evaluate('metros===2.28'),'ready-sheet repetitions');
await waitFor(()=>evaluate('metros===0.684'),'ready-sheet repetitions');
assert.equal(await evaluate(`document.querySelectorAll('.folhaPrevia').length`),1);
// Keep manual ready-sheet uploads intact and refuse ambiguous mixed batches.
// An image too small to span the film is refused, never silently repriced.
await click('#bVoltar');await click('[data-modo="file"]');
await evaluate('sendImage()');
await waitFor(()=>evaluate(`!!document.getElementById('usarAvulsa')`),'ready-sheet refusal');
assert.deepEqual(await evaluate(`({mode:modo,sheets:folhas.length,art:artes.length})`),{mode:'file',sheets:0,art:0});
await click('#usarAvulsa');
await waitFor(()=>evaluate(`modo==='avulsa' && artes.length===1`),'one-click recovery into loose artwork');
// Keep manual ready-sheet uploads intact.
await click('#bVoltar');await click('[data-modo="file"]');
await evaluate(`sel([new File(['sheet'],'sheet.cdr'),new File(['image'],'art.png')])`);
assert.deepEqual(await evaluate('[folhas.length,artes.length]'),[0,0]);
await evaluate(`sel([new File(['sheet'],'sheet.cdr')])`);
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');
// PNG drag/drop follows exactly the same artwork path; UV stays UV.
for(const [mode,expected] of [['file','avulsa'],['avulsa','avulsa'],['uvfile','uv']]){
// 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']]){
await click('#bVoltar');await click('[data-modo="'+mode+'"]');
if(await evaluate(`ehFolha()`))await evaluate(`pickTipo('avulsa')`);
await evaluate(`sendImage('image/png',true)`);
await waitFor(()=>evaluate('artes.length===1 && !!artes[0].src'),'PNG drop');
assert.equal(await evaluate('modo'),expected);

View File

@@ -1,5 +1,6 @@
"""Local customer passwords and revocable database sessions. No email service."""
import hashlib
import os
import secrets
import logging
import json
@@ -7,6 +8,8 @@ from uuid import UUID, uuid4
from fastapi import HTTPException, Request
from .db import connect
COOKIE_SECURE = os.environ.get('COOKIE_SECURE', 'false').lower() == 'true'
def password_hash(password, salt=None):
salt = salt or secrets.token_hex(16)
digest = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=5).hex()
@@ -42,8 +45,7 @@ def new_session(c, response, identity=None):
sid = uuid4()
identity = identity or uuid4()
c.execute('INSERT INTO dtf_local.sessions(id,owner) VALUES(%s,%s)', (sid,identity))
response.set_cookie('dtf_session', str(sid), httponly=True,
secure=os.environ.get('COOKIE_SECURE', 'false').lower() == 'true',
response.set_cookie('dtf_session', str(sid), httponly=True, secure=COOKIE_SECURE,
samesite='strict', max_age=86400*7)
return identity

View File

@@ -1,6 +1,7 @@
"""One-shot schema/role setup. Only this job receives database admin credentials."""
import os
from pathlib import Path
from urllib.parse import urlparse
import psycopg
from psycopg import sql
@@ -16,8 +17,18 @@ def admin_connect():
return psycopg.connect(os.environ['DATABASE_ADMIN_URL'])
def admin_password():
if os.environ.get('DATABASE_ADMIN_HOST'):
return os.environ.get('DATABASE_ADMIN_PASSWORD')
url = os.environ.get('DATABASE_ADMIN_URL', '')
return urlparse(url).password
def main():
role = os.environ['APP_DB_USER']
password = os.environ['APP_DB_PASSWORD']
if password == admin_password():
raise RuntimeError('Application and database administrator passwords must differ')
with admin_connect() as c:
admin, database = c.execute('SELECT current_user,current_database()').fetchone()
if role == admin:
@@ -25,7 +36,7 @@ def main():
if not c.execute('SELECT 1 FROM pg_roles WHERE rolname=%s', (role,)).fetchone():
c.execute(sql.SQL('CREATE ROLE {} LOGIN').format(sql.Identifier(role)))
c.execute(sql.SQL('ALTER ROLE {} NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS PASSWORD {}').format(
sql.Identifier(role), sql.Literal(os.environ['APP_DB_PASSWORD'])))
sql.Identifier(role), sql.Literal(password)))
c.execute(Path(__file__).with_name('schema.sql').read_text())
c.execute(sql.SQL('REVOKE CREATE ON SCHEMA public FROM PUBLIC'))
c.execute(sql.SQL('GRANT CONNECT ON DATABASE {} TO {}').format(sql.Identifier(database),sql.Identifier(role)))