feat: optional password protection per card #11
Reference in New Issue
Block a user
No description provided.
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What
m wants an optional, simple password protection on a card. Sender can optionally set a password while composing; the recipient must enter it to view the card. Optional — cards without a password behave exactly as today.
Behaviour
password_hashcolumn via idempotent migration (postgres-role ownership, see #1-#4)./c/{token}): if the card has a password, serve a password prompt instead of the content. Verify server-side; only render the card (text/images/video) after successful auth — do NOT ship card content to the client before auth (no leak in initial HTML/JSON). On success, set a short-lived signed cookie / session for that card so a reload doesn't re-prompt.Security (non-negotiable)
Sequencing
Touches compose + render + handlers + schema → same single-worker track as #8/#9/#10 (sequence, no parallel worker on the compose flow). otto-head assigns after the ahead-of-it items land.
DoD
#11 done — optional per-card password protection
Shipped on
mai/hermes/issue-11-feat-optional(not yet merged/deployed — otto-head does that).Commit:
2118ace7dfWhat
Sender can set an optional
Passwort (optional)at compose. If set, the card is gated; the recipient must enter the password before the card renders. Cards without a password behave exactly as before.How it meets the DoD
golang.org/x/crypto), per-hash random salt, embedded cost; only the hash lands in a new nullablepassword_hashcolumn. Verified in e2e: DB stores$2a$10$…,LIKE '%plaintext%'= false. bcrypt's 72-byte limit is surfaced as a friendly error, never silent truncation./c/{token}serves a minimalpasswordprompt.htmlthat ships no card content (message, sender, recipient, occasion badge, or any image/video URL).POST /c/{token}/unlockverifies server-side. A render test asserts the prompt contains none of those sentinels./c/{token},HttpOnly,SameSite=Lax,Secureover HTTPS. The signed payload binds the cookie to its own token, so a valid cookie for one card cannot unlock another (unit-tested + e2e cross-card check). A reload within the window doesn't re-prompt.Falsches Passwort. Bitte versuche es noch einmal.(HTTP 401), mobile-first.007_password.sql: idempotentALTER TABLE … ADD COLUMN IF NOT EXISTS password_hash text. Keeps thepostgresowner, additive, safe for the old running binary (same pattern as 002/003/005/006).CARD_SESSION_SECRET(optional). Unset → a random per-process key (secure, but cookies don't survive a restart / don't validate across instances). Documented in README; set a stable value for persistence/scaling.Media-object limitation (called out, not silently left)
The page is gated, but uploaded media stay in the public
postcardsSupabase bucket under a<token>/…key (and thelocalbackend serves/uploads/unauthenticated). Anyone who knows the card's token can still construct the media object's direct URL and fetch it without the password. So the password protects the card page, not the raw media objects.Closing this fully requires making the bucket private + proxying every media fetch through the app behind the same cookie — an infra change (private bucket + migrating existing cards' public URLs) I've deferred pending your decision. Proxying alone while the bucket stays public would add complexity without closing the hole, so I intentionally didn't do it. This is documented in
README.mdunder Password protection → Known limitation.Question for m: want true media protection (flip the
postcardsbucket to private + app media-proxy, as a follow-up issue), or is page-gating + the documented limitation acceptable for now?Verification
gofmtclean ·go build ./...·go vet ./...·go test -race ./...all green. Full e2e against a throwaway Postgres (local storage, prod untouched): set-pw → prompt with no content leak; wrong pw → 401 + error; right pw → unlock + content; no-pw card → content directly (unchanged); creator auto-unlocked; cross-card cookie isolation; too-long pw rejected with no orphan. Test data + container cleaned up.Files
migrations/007_password.sql(new)internal/postcard/password.go(+password_test.go) — bcrypt hash/verify/HasPasswordinternal/server/cardauth.go(+cardauth_test.go) — signed per-card cookieinternal/server/handlers.go,server.go— gate, unlock handler, route, creator auto-unlockinternal/config/config.go—CARD_SESSION_SECRETweb/templates/passwordprompt.html(new),compose.html,web/static/style.cssinternal/server/password_render_test.go(new) — no-content + field render testsREADME.md— feature + limitation docs✅ Merged & deployed — password protection live on sendmy.cards
Merged
2118ace(merge1e108ed), deployed. Migration 007 applied. Set a stableCARD_SESSION_SECRETin the Dokploy env (so unlock cookies survive restarts/deploys).Verified live e2e on sendmy.cards: pw-protected card → fresh GET returns the prompt with no content leak (secret message absent from HTML), wrong password → 401, right password → 303 + signed cookie → content renders. No-password cards unchanged. bcrypt hashing, server-side verify, 12h per-card cookie. build/vet/test -race green.
Open question (worker flagged, now to m): the media-object public-URL limitation — page is gated but raw media stays fetchable by direct URL if you know the token. Asking m whether to do a follow-up (private bucket + media-proxy) or accept page-gating + documented limitation. Not closing — m closes.
m decision (PWA, 2026-06-17): page-protection is sufficient — NO private bucket / proxy — PROVIDED media filenames are tokenized + unguessable.
Rationale: today media keys are
{token}/0.png,/1.png,/video/0.mp4— the per-file part is sequential, so anyone holding the card token can construct the media URL and fetch it past a password gate. If the filename gets its own random/unguessable component ({token}/{rand}.png), then knowing the token alone is NOT enough — the media URL is only revealed on the (password-gated) rendered page. That closes the #11 media leak for password-protected cards without a private bucket + proxy. Tracked as #21. The private-bucket/proxy option is dropped.