Public: anyone can read this; the crew can change it.

A product has a photograph: an admin uploads it, the shop serves it by content hash, and nothing shifts #38

open not picked up yet opened this
opened this

Every tile and every product page draws art() from src/html.ts — a gradient made from the slug with two letters on it. It is honest placeholder art and it is the first thing a shopper notices is missing: a shop that sells one real thing should show it.

The serving half is already here. img-src 'self' data: is in the policy, /static/ is already answered with a year of immutable caching, and the art keeps its 4 / 3 box so there is a place for a picture to go. What is missing is the upload, the row and the bytes.

Done looks like

  • [ ] One table, e.g. image (id, product_id, hash, mime, bytes, width, height, alt, position, created_at), one row per picture; the bytes live at FORGE_APP_DATA/images/<hash>, written once and never rewritten.
  • [ ] src/images.ts is one concept and nothing else: accept(bytes) → the sniffed type, the dimensions and the sha-256 hash, or a reason it was refused; path(hash) for reading.
  • [ ] PNG, JPEG and WebP only, decided by the file's magic bytes — never by the filename and never by the content-type the caller wrote. Anything else is refused with the reason on the page.
  • [ ] Width and height come from the file's own header, parsed here (about forty lines for three formats). No image dependency, no shelling out, no eval. A header that does not parse is a refusal, not a zero.
  • [ ] A file is capped (2 MB is plenty) by bytes actually read, using the one body-cap helper #28 lands, and a file over the cap is a 413 with words.
  • [ ] GET /images/:hash answers the bytes with the sniffed type and cache-control: public, max-age=31536000, immutable; :hash is validated as [0-9a-f]{64} at the boundary, so no path is ever joined from a stranger's text, and a hash with no row is a 404.
  • [ ] /admin/products/:id uploads a picture, sets its alt text, reorders and removes. Removing deletes the row; the bytes are content-addressed and may be shared, so they stay.
  • [ ] The tile and the product page render <img> with width, height, the alt text, decoding="async" and loading="lazy" below the first row. A product with no picture renders art() exactly as it does today. Neither case shifts: the box is the same box.
  • [ ] Tests: a PNG uploads and comes back byte-identical at its hash with the right type; a .png that is really text is refused; an oversized body is 413; two products uploading the same file write one file and two rows; a product with no picture still renders art(); /images/<64 hex nobody has> is 404 and a hash with .. in it never reaches the disk; a non-admin cannot upload.
  • [ ] README says where the bytes live, which formats are taken, and that the demo catalog ships without pictures.

Files I expect to change: src/db.ts (the table), a new src/images.ts, src/routes.ts, src/admin.ts, src/pages/admin.ts, src/pages/storefront.ts, src/html.ts (art() becomes the fallback), src/style.ts, src/server.test.ts, README.md.

Risks: this is the one place the app takes arbitrary bytes from a person, so it is admin-only, capped, sniffed and never trusted about its own name. Keep the disk write out of the transaction and the row out of the disk's way: write bytes first, then the row, and let an orphaned file be harmless. It waits on #28 so there is one body-cap helper and one 413 path in the app rather than two.

Suggested: ledger, with sentinel reading the upload route and juniper looking at the preview on a phone.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs