Private photo sharing app my wife and I
0

Configure Feed

Select the types of activity you want to include in your feed.

TypeScript 92.8%
CSS 5.3%
HTML 0.9%
Nix 0.5%
Other 0.6%
3 1 0

Clone this repository

https://git.vm.fail/anirudh.fi/aniverse https://git.vm.fail/did:plc:om7u44pg7saideizwj4e3ala
ssh://git@knot1.tangled.sh:2222/anirudh.fi/aniverse ssh://git@knot1.tangled.sh:2222/did:plc:om7u44pg7saideizwj4e3ala

For self-hosted knots, clone URLs may differ based on your setup.


README.md

aniverse#

A private, Instagram-style photo feed for exactly two people — Anirudh and Anita. Multi-photo posts with captions, locations per photo or per post (Google Places autocomplete, pre-filled from EXIF GPS), comments, emoji reactions (double-tap to ❤️), switchable themes (Pastel / Warm / Brutalist / Noir, in the avatar menu), full-res originals kept forever.

Stack: one Cloudflare Worker serving a React SPA (Vite) + Hono JSON API · D1 (data) · R2 (photo originals + cached variants) · Images binding (resizing) · Google login (Anita) · passkeys (Anirudh) · installable PWA.

Local development#

sh nix develop # node 22 + resvg (or use direnv: `direnv allow`) npm install cp .dev.vars.example .dev.vars # defaults work; Google keys optional locally npm run db:migrate:local npm run dev

Then:

  • Easiest login: with DEV_PASSWORD=dev in .dev.vars, the login page grows a "local dev" box — type the password and enter as either Anirudh or Anita. (Only rendered when the endpoint is enabled, and the endpoint only works on localhost.)
  • Passkey login: visit http://localhost:5173/setup?key=dev-setup-secret once to enroll, after that use "Sign in with passkey" on /login.
  • Google login: needs real OAuth credentials in .dev.vars (add http://localhost:5173/api/auth/google/callback as an authorized redirect URI on the client).
  • Location autocomplete: needs GOOGLE_MAPS_API_KEY in .dev.vars; without it the app works, the pickers just say "search unavailable".

Deploying (one-time checklist)#

  1. Cloudflare resources sh npx wrangler login npx wrangler d1 create aniverse # paste database_id into wrangler.jsonc npx wrangler r2 bucket create aniverse-photos npm run db:migrate:remote
  2. Fix the allowlist — in wrangler.jsonc, replace CHANGE_ME_anita@gmail.com with Anita's real Google email.
  3. Google Cloud console (console.cloud.google.com, one project for both):
    • OAuth consent screen: External, stay in Testing mode, add both of your emails as test users (no verification needed for 2 users).
    • Credentials → OAuth client (Web): authorized redirect URI https://YOUR_DOMAIN/api/auth/google/callback. Copy client ID + secret.
    • Enable APIs: Places API (New) and Geocoding API. Create an API key restricted to those two. (Requires a billing account; usage at 2-person scale sits inside the free tier.)
  4. Secrets sh npx wrangler secret put GOOGLE_OAUTH_CLIENT_ID npx wrangler secret put GOOGLE_OAUTH_CLIENT_SECRET npx wrangler secret put GOOGLE_MAPS_API_KEY npx wrangler secret put SETUP_SECRET # any long random string
  5. Deploy sh npm run deploy
  6. Domain first, then passkeys. Passkeys are cryptographically bound to the hostname — decide the final domain (custom domain or the workers.dev URL) before enrolling. Then visit https://YOUR_DOMAIN/setup?key=<SETUP_SECRET> on each of Anirudh's devices (Face ID / Touch ID). Anita just taps "Continue with Google".
  7. Phones: open the site in Safari → Share → Add to Home Screen. It runs standalone like an app (the future native iOS app will reuse this same /api).

Costs#

Everything runs on Cloudflare's free tier: R2 has no egress fees, image transformations are cached back into R2 so each photo variant is generated exactly once (well inside the 5,000/month free transformations), and static assets are free. The only external dependency is Google Maps Platform (billing account required, but per-SKU free tiers cover this usage).

Local-first#

  • The TanStack Query cache persists to localStorage, so the app paints the last-seen feed instantly on launch and revalidates in the background.
  • A service worker (production builds only — npm run build && npx vite preview to test) precaches the app shell, caches photos cache-first (they're immutable per URL), and falls back to the cached feed when offline. An "offline — showing saved memories" chip appears when disconnected.
  • Deleting a post is optimistic: it vanishes immediately and reappears if the server call fails.

Notes#

  • The whole app is private: every /api/* and /img/* route requires a session; only allowlisted emails can ever create one.
  • Originals are stored untouched in R2 under originals/<photoId> (grab one at /img/<photoId>/original); feed/thumb/full variants are lazily generated webp.
  • Post timestamps are unix milliseconds in the DB (cursor tie-breaking); the API returns seconds.
  • EXIF orientation is handled: display dimensions are stored orientation-corrected, production Cloudflare Images auto-orients pixels, and the sharp-based local simulator (which doesn't) gets an explicit rotation compensation that only activates on localhost.