Compare commits
5 Commits
99fd92bdb8
...
aa0eba3457
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa0eba3457 | ||
|
|
fc6f708e95 | ||
|
|
0f16868f2d | ||
|
|
67aa6c970c | ||
|
|
9046ec6db0 |
@@ -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
402
ROADMAP.md
Normal 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
197
compose.local.yaml
Normal 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:
|
||||
@@ -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
|
||||
|
||||
136
dtf-site.html
136
dtf-site.html
@@ -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){
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)))
|
||||
|
||||
Reference in New Issue
Block a user