Downloads require an Authorization header, which <iframe>/<img> cannot
send. The previous blob: workaround broke under download managers (IDM
hooks blob URLs, fails to resolve them, and leaves a 0-byte file) and in
Chrome's mobile emulation, which has no inline PDF plugin.
Backend:
- preview_token.py: HMAC-signed token bound to one (quote, kind, file)
with a 5-minute TTL, keyed off an existing secret
- GET /quotes/{id}/preview-url/{kind}/{attachment} mints the URL
- the two file GET routes accept a Bearer session OR the signed ?t= token
(they leave the global auth dep, so the other six file routes now carry
it explicitly — nothing is open by omission)
Frontend:
- api.files.previewUrl() replaces the blob helper
- shared FilePreviewModal + useFilePreview; FileList and the dashboard
quote card both use it
- PDFs render with the browser's native viewer on desktop and pdf.js on
canvas where no inline viewer exists (mobile); pdf.js is fed the bytes
in memory so no PDF URL is exposed for a download manager to intercept
- removed the per-attachment download buttons on the dashboard card and
the modal header
Ops:
- nginx: explicit MIME types (.mjs as text/javascript) — a module worker
served as octet-stream is refused and crashed the page
- frontend service bypasses the nginx entrypoint, whose apk manifest call
hangs when the package mirror is unreachable (502s)
prod_v2's inline preview modal and EditModal redesign conflicted with the
hardening branch's authenticated file API (urlOf/downloadUrl were removed
because <a href>/<img> cannot send the Authorization header).
- FileList: kept prod_v2's design (icons, preview modal, per-row actions)
and adapted it — previews now fetch the bytes with a Bearer token and
use a local objectURL (api.files.blobUrl), with revoke on close/unmount;
the row download uses the authenticated onDownload.
- EditModal: took prod_v2's tabbed redesign wholesale (it already carried
our globalSpecs, clean material name and target_project work) and
converted its FileList to onDownload + loadPreview.
- AcceptModal: converted the remaining urlOf sites and removed duplicated
file/date blocks the earlier merge had left outside the scroll body.
- api.ts: restored the global handle401 (definition + all four fetch
sites) alongside the new blobUrl helper.
Conflict in init_db.py resolved keeping the audit_log table ensure
function (new-one) — prod_v5 predates it and its side was empty.
Both sides' SQL lands: audit_log table plus the category-free materials
model.
Conflicts resolved keeping the hardened/current side:
- docker-compose.yml: kept new-one's db-backup service, hardened env
(ENVIRONMENT/DEV_LOGIN/CORS/issuer var) and build args; prod_v2's
8083/8085 dev port remaps dropped as they conflict with the hardened
Casdoor issuer expectation.
- AcceptModal.tsx: kept the per-item target_project model (43ff048) over
prod_v2's older projUnion candidate-project logic, kept the
authenticated file upload/download blocks, and adopted prod_v2's
sticky footer.
RUNTIME BUG (caught by live smoke tests): the global auth dependency
queried the request-scoped session, auto-beginning its transaction — any
endpoint opening 'async with db.begin()' afterwards crashed with
'transaction already begun' (POST /materials 500). get_current_user /
get_actor now resolve in a short-lived session (expire_on_commit=False
keeps the context usable detached); inventory/pm scopes keep passing the
request session explicitly.
Smoke suites rewritten against CURRENT contracts:
- assertions are behavior-based (shapes, envelopes, filters), no magic
seed counts; dynamic lookups instead of hard-coded names/ids
- rerun-tolerant: tolerate 409-on-create by reusing prior artifacts
- aligned with current rules: revert blocked after inventory receipts
(v2.6), attachments allowed in every status, termsheets optional at
quote level
- SMOKE_USER/SMOKE_PASSWORD env for login (default tester/123456)
Verified on a fresh volume: migration + audit_log bootstrap, Casdoor
seed (bcrypt), demo data, and 29+76+61 smoke checks all green.
- slowapi limiter on POST /auth/login (10/min per IP, Persian error msg);
edge-level coarse limits stay in Traefik
- mem_limit on every main-compose service (~4.4GB worst case, protects the
8GB shared host during rebuilds)
- monitoring images pinned to verified tags (prom v3.4.1, grafana 11.6.3,
loki/promtail 3.5.0, node-exporter v1.9.1, postgres-exporter v0.17.1,
cadvisor v0.49.1); exporter image name fixed to postgres-exporter
- db-backup sidecar: pg_dump -Fc of procurement+casdoor every
BACKUP_INTERVAL_SECONDS into BACKUP_DIR, retention via BACKUP_KEEP_DAYS
- audit_log table (migration.sql + runtime ensure_audit_table) with
model + transactional helper (utils/audit.record) — wired into user
create/role-change and inventory transaction creation
- get_actor: quote_events.actor now stores the VERIFIED user's display
name instead of the raw bearer token
The specs combobox drew only from materials in the selected topic, further
narrowed by the row's category and brand, so a spec was offered only when
some material in that topic shared the same category AND brand. On a new
quote the user therefore lost specs recorded in earlier quotes.
- api.specsList(): new global source reading the attribute-definitions
registry (topic/category/brand independent)
- materialOptions('spec'): unions the registry, specs of all materials,
and specs typed in sibling rows — no topic/category/brand filter
- EditModal: same global registry added to its spec options
Verified the registry endpoint returns labels and that the union includes
specs absent from material attributes (e.g. 'مسی').
Adding a new item wrote the material name as 'brand + category + specs'
(e.g. 'پارس لوله لوله PE100 سایز ۱۶'), while specs were also stored
correctly in EAV attributes. The wizard then tried to recover the base
name by stripping brand and specs out of that string, but never stripped
the injected category — so the spec stayed baked into the cached material
name and the مشخصه appeared as part of the material name on later visits.
- api.ts / WizardPage / EditModal: build name from the material itself
only (brand goes to brand_ids, specs to attributes)
- materialBaseName: strip category too, never strip a part that would
empty the name, and reject a spec-only result; legacy rows whose name
cannot be disentangled fall back to the stored name rather than a guess
- WizardPage: match existing materials on category+brand so legacy rows
are still reused instead of duplicated
Brings in prod_v3 5084623 on top of production 9e8a236. Merged without
conflicts: prod_v3's base (7ead60c) is an ancestor of production, and
production's extra tab-bar refinements (17c7ae6) touch disjoint regions.
Brings in main_v2 17c7ae6 (tab bar sizing, inventory actions, date picker
proportions, stats lead time).
Conflicts resolved in favour of main_v2, which deliberately removed the
'تغییر موجودی' top button (nav lives in the tab bar / InventoryTopNav now)
and rewrote the proforma date field. Dropped the now-unused Button import.
Merges main_v2 a110741 (mobile UI optimizations) into production and
reinstates the mobile bottom navigation that f0e4243 had removed.
Conflicts in DashboardPage, QuoteCard and InventoryPage resolved in
favour of main_v2's mobile-first markup, keeping production's newer
design system (Button variant="primary" on InventoryPage).
f0e4243's removal was reverted in effect: MobileTabBar is restored and
remounted in App.tsx, and PageLayout regains max-md:pb-24 clearance so
content is not hidden behind the fixed bar.