{"id":50074721,"url":"https://github.com/vlumi/photo-diary","last_synced_at":"2026-05-29T11:00:29.768Z","repository":{"id":38820230,"uuid":"277016208","full_name":"vlumi/photo-diary","owner":"vlumi","description":"Calendar-based photo gallery platform for self-hosting","archived":false,"fork":false,"pushed_at":"2026-05-25T08:27:12.000Z","size":10409,"stargazers_count":3,"open_issues_count":34,"forks_count":2,"subscribers_count":1,"default_branch":"main","last_synced_at":"2026-05-25T09:18:09.149Z","etag":null,"topics":["gallery","web"],"latest_commit_sha":null,"homepage":"","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/vlumi.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null}},"created_at":"2020-07-04T01:22:19.000Z","updated_at":"2026-05-25T08:27:14.000Z","dependencies_parsed_at":"2026-03-09T05:12:17.191Z","dependency_job_id":null,"html_url":"https://github.com/vlumi/photo-diary","commit_stats":null,"previous_names":[],"tags_count":19,"template":false,"template_full_name":null,"purl":"pkg:github/vlumi/photo-diary","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vlumi%2Fphoto-diary","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vlumi%2Fphoto-diary/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vlumi%2Fphoto-diary/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vlumi%2Fphoto-diary/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/vlumi","download_url":"https://codeload.github.com/vlumi/photo-diary/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vlumi%2Fphoto-diary/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33648534,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-05-29T02:00:06.066Z","response_time":107,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["gallery","web"],"created_at":"2026-05-22T05:04:08.420Z","updated_at":"2026-05-29T11:00:29.760Z","avatar_url":"https://github.com/vlumi.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Photo Diary\n\n**Photo Diary** is a calendar-based photo gallery platform for self-hosting. The photos are arranged by the date they were shot, in calendar-based views, aimed at diaries and other, date-based photography projects.\n\nLive examples: [dailybw.misaki.fi](https://dailybw.misaki.fi) and [papusama.misaki.fi](https://papusama.misaki.fi).\n\nKey features include:\n\n- Calendar-based views (year, month, day, photo)\n  - Including map from embedded GPS information\n- Comprehensive photo statistics (time, gear, exposure settings, etc.)\n- Fast browsing — gallery content (apart from actual photos) loaded once at startup\n- User management and basic access control\n\n## Contents\n\n- [Structure](#structure)\n- [Setup](#setup)\n  - [Basic setup](#basic-setup)\n  - [Dev mode](#dev-mode)\n  - [Multi-instance deployment](#multi-instance-deployment)\n    - [Host prep](#one-time-host-prep) · [Getting the code](#getting-the-code-onto-the-host) · [Bootstrap](#bootstrapping-a-new-instance) · [Start](#starting-an-instance) · [Upgrade](#upgrading-an-instance) · [nginx](#nginx) · [Per-gallery vhost](#per-gallery-vhost-mapping) · [Day-to-day ops](#operating-an-instance)\n- [Features](#features)\n- [Photo pipeline](#photo-pipeline)\n- [Roadmap](#roadmap)\n- [Backlog](#backlog)\n- [Version history](#version-history)\n\n## Structure\n\nPhoto Diary is split into independent modules, each handling its own sub-system:\n\n- [react-app](react-app) — front-end React SPA. Served as static files by the backend; no separate hosting required.\n- [server](server) — Fastify + SQLite backend. Exposes `/api/v1` (with an OpenAPI doc at `/api/v1/docs`) and serves the bundled frontend.\n- [converter](converter) — back-end worker that pre-processes new photos (EXIF extraction, thumbnail/display renditions via sharp).\n\nThe three pieces communicate via the shared filesystem and SQLite DB rather than over a network — the converter writes to `photos/{display,thumbnail}/` and `inbox/*.json`, the operator (or a future admin UI) registers those JSONs into the DB via `bin/photo.ts`, and the server reads the DB to serve the API. Per-instance state (database, photo files, `.env`) lives in a single instance directory outside the repo; see [Setup](#setup) below.\n\n## Setup\n\n### Basic Setup\n\nQuickstart for a single personal instance. For the full production layout (multi-version code under `/opt/`, per-instance dirs under `/var/`, nginx vhosts, atomic upgrades, backup) see [Multi-Instance Deployment](#multi-instance-deployment) below; for an in-repo dev setup without an instance dir at all, see [Dev Mode](#dev-mode).\n\n1. **Prerequisites + install.** Node.js 22 or newer (npm 10+ recommended for workspaces). From the repo root, `npm run setup` installs every workspace ([server](server), [converter](converter), [react-app](react-app)) and builds the frontend into [server](server)/`build/`.\n2. **Bootstrap the instance directory** with [`bin/instance.ts`](bin/instance.ts). It creates the dir tree (`photos/{inbox,original,display,thumbnail}/`), generates `.env` with a fresh random `SECRET`, links `code` to this checkout, and surfaces operator shortcuts at `\u003cinstance\u003e/bin/{photo,gallery,user,access}.ts`:\n\n   ```sh\n   ./bin/instance.ts \u003cname\u003e --base \u003cparent-dir\u003e\n   ```\n\n   Default `--base` is `/var/photo-diary` (the production layout); pass any other path for a personal setup. See [Bootstrapping a new instance](#bootstrapping-a-new-instance) for the dir layout, `.env` keys, and re-run-as-doctor semantics.\n3. **Start the processes** via the per-instance `start-prod.sh` scripts (pm2-managed; the DB file is created at `\u003cinstance\u003e/db.sqlite3` on first start and the migration runner bootstraps the schema on every start):\n\n   ```sh\n   cd \u003cparent-dir\u003e/\u003cname\u003e\n   ./code/server/bin/start-prod.sh\n   ./code/converter/bin/start-prod.sh\n   ```\n\n4. **Seed the first user, gallery, and admin grant** via the per-instance operator shortcuts. See [server/README.md](server/README.md) for the full management toolkit and the access-control model:\n\n   ```sh\n   ./bin/user.ts passwd \u003cuser\u003e \u003cpassword\u003e\n   ./bin/gallery.ts \u003cgallery-id\u003e --title \"\u003cTitle\u003e\"\n   ./bin/access.ts level \u003cuser\u003e :all admin\n   ```\n\n5. **Set the instance `cdn`** — the public URL that serves `display/` and `thumbnail/` (typically the same nginx host):\n\n   ```sh\n   ./bin/meta.ts set instance_cdn https://photos.example.com/\n   ```\n\n   This overrides the frontend's `/` default at runtime; the bundle itself ships no per-instance config. The same value can also be set via the `/api/v1/meta` API or `UPDATE meta SET value='…' WHERE key='instance_cdn'` directly if the operator script isn't reachable.\n\n### Dev Mode\n\nMirror the prod layout with a dev \"instance\" inside the repo. The init script wires it up the same way as a real deploy, but with the `code` symlink pointing at the live source:\n\n```sh\n./bin/instance.ts dev --base .\n```\n\nThat gives you `\u003crepo\u003e/dev/` with `.env`, `photos/{inbox,…,thumbnail}/`, `code → \u003crepo\u003e` (the `dev/` path is gitignored, so the bootstrapped state won't pollute the repo). Each of server, converter, and react-app has a `bin/start-dev.sh` wrapper — run them in the foreground (tsx watch / vite, no pm2):\n\n```sh\ncd dev\n./code/server/bin/start-dev.sh        # terminal 1\n./code/converter/bin/start-dev.sh     # terminal 2\n./code/react-app/bin/start-dev.sh     # terminal 3 (vite dev server, proxies /api/* to localhost:4200)\n```\n\nIf you don't need photos in dev, you can also just `cd server \u0026\u0026 npm run dev` — the DB will land at `server/db.sqlite3` and you skip the instance-dir ceremony entirely.\n\n### Multi-Instance Deployment\n\nOne VM can host several Photo Diary instances under a single nginx, each pointing at its own code-clone via a `code` symlink in the instance directory. The frontend has no build-time per-instance config, so a single `npm run setup` per code checkout (run once when the checkout is created) covers every instance using that checkout.\n\nDirectory layout:\n\n```text\n/opt/photo-diary/                       # parent dir, owned by the deploy user (see below)\n  0.8.0/                                #   each version unpacked into its own subdir\n  0.11.0/                                #   so different instances can run different versions\n                                        #   and upgrades are atomic (flip a symlink)\n\n/var/photo-diary/\n  dailybw/                              # one directory per instance\n    .env                                # per-instance config (see below)\n    code -\u003e /opt/photo-diary/0.11.0      # symlink to the code version this instance runs\n    db.sqlite3                          # auto-created on first server start\n    photos/\n      inbox/  original/  display/  thumbnail/\n  travel/\n    .env\n    code -\u003e /opt/photo-diary/0.8.0      # different instance, possibly on a different version\n    db.sqlite3\n    photos/\n      …\n```\n\nSingle-version setups can drop the version subdirectory (`/opt/photo-diary/` for the code, `code -\u003e /opt/photo-diary` per instance).\n\n#### One-time host prep\n\nCreate the two parent directories (code at `/opt`, per-instance state at `/var`) owned by the deploy user, so subsequent steps don't need `sudo`:\n\n```sh\nsudo install -d -o \"$USER\" /opt/photo-diary /var/photo-diary\n```\n\n#### Getting the code onto the host\n\nGitHub auto-generates a source tarball for every tag. Extract it directly into a version subdirectory of `/opt/photo-diary/` with `tar --strip-components=1` (no rename step), then run `npm run setup` to install everything and build the bundled frontend:\n\n```sh\nV=0.11.0\nmkdir -p \"/opt/photo-diary/$V\"\ncurl -L \"https://github.com/vlumi/photo-diary/archive/refs/tags/v$V.tar.gz\" \\\n  | tar xz -C \"/opt/photo-diary/$V\" --strip-components=1\ncd \"/opt/photo-diary/$V\"\nnpm run setup\n```\n\nRepeat this block for each new version you want to land on this host.\n\n#### Bootstrapping a new instance\n\nThe `bin/instance.ts` script handles directory creation, `.env` generation (with a fresh random `SECRET`), the `code` symlink, and the per-instance `bin/` shortcuts in one shot. Invoke it from the version of the code you want the instance to run:\n\n```sh\n/opt/photo-diary/0.11.0/bin/instance.ts dailybw\n```\n\nThat creates `/var/photo-diary/dailybw/` with everything wired up — including `/var/photo-diary/dailybw/bin/{photo,gallery,user}.ts` symlinks so the routine operator commands are short paths (`./bin/photo.ts …` instead of `./code/server/bin/photo.ts …`). The script can be invoked from any working directory; the instance dir is derived from the name (and the `--base \u003cdir\u003e` flag, default `/var/photo-diary`, if you want instances under a different parent). Re-running on an existing instance acts as a doctor — verifies the directory tree, checks for missing required `.env` keys, reports `✓`/`✗`. Add `--fix` to append any missing keys with defaults (without touching existing values).\n\nThe generated `.env` covers the required keys. Optional per-instance frontend defaults can be added — these flow through `/api/v1/meta` to the frontend on boot:\n\n```sh\nDEFAULT_GALLERY=dailybw\nDEFAULT_THEME=grayscale\n```\n\n#### Starting an instance\n\n```sh\ncd /var/photo-diary/dailybw\n./code/server/bin/start-prod.sh\n./code/converter/bin/start-prod.sh\n```\n\nBoth `start-prod.sh` scripts source `.env` from the current working directory, then start pm2 with names derived from `INSTANCE_NAME` (`\u003cname\u003e` and `\u003cname\u003e-converter`). `pm2 save` after a successful start so they come back on reboot.\n\n#### Upgrading an instance\n\nRe-run `bin/instance.ts` from the new version of the code, then **delete + start** pm2 (not `restart`):\n\n```sh\npm2 stop dailybw dailybw-converter\n/opt/photo-diary/0.11.0/bin/instance.ts dailybw          # backs up the DB, flips the symlink\npm2 delete dailybw dailybw-converter                    # drop cached metadata\ncd /var/photo-diary/dailybw\n./code/server/bin/start-prod.sh                         # migration runner applies any schema bumps\n./code/converter/bin/start-prod.sh\npm2 save\n```\n\n`pm2 restart` won't pick up the symlink flip — pm2 caches the resolved script path and the package.json version at the original `start` time, so a restart re-execs the *old* paths and `pm2 list` keeps showing the previous version. `delete` + `start-prod.sh` forces re-resolution and is the only reliable upgrade path today.\n\nThe DB backup is named `db.sqlite3.pre-\u003cnew-version\u003e` — a plain file copy that assumes the instance is stopped (started instances may have an inconsistent backup). Rollback is manual: `cp db.sqlite3.pre-\u003cversion\u003e db.sqlite3`, point `code` back at the older version, then the same `pm2 delete \u0026\u0026 start-prod.sh` cycle.\n\n#### nginx\n\nOne server block per instance, proxying to its `PORT` (set in the instance's `.env`). nginx also serves `display/` and `thumbnail/` directly from disk — the server never streams photos, only their metadata. The `cdn` meta row (set via `./bin/meta.ts set instance_cdn …` — or the [API](server/README.md) / raw SQL as fallback) tells the frontend which URL to load images from; typically the same host the API is on.\n\nA realistic per-instance vhost with TLS, certbot-managed certs, proxy headers, and aggressive caching on the photo locations:\n\n```nginx\nserver {\n  listen 80;\n  server_name dailybw.example.com;\n  return 301 https://$host$request_uri;\n}\n\nserver {\n  listen 443 ssl http2;\n  server_name dailybw.example.com;\n\n  ssl_certificate     /etc/letsencrypt/live/dailybw.example.com/fullchain.pem;\n  ssl_certificate_key /etc/letsencrypt/live/dailybw.example.com/privkey.pem;\n  ssl_protocols       TLSv1.2 TLSv1.3;\n  ssl_ciphers         HIGH:!aNULL:!MD5;\n\n  # API + bundle — proxied to the per-instance pm2 process.\n  location / {\n    proxy_pass         http://127.0.0.1:4201;\n    proxy_http_version 1.1;\n    proxy_set_header   Host              $host;\n    proxy_set_header   X-Real-IP         $remote_addr;\n    proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;\n    proxy_set_header   X-Forwarded-Proto $scheme;\n  }\n\n  # Photo bytes — served directly from disk. The IDs are content-stable (the\n  # converter writes by EXIF-derived ID), so we can mark these immutable.\n  location /display/ {\n    alias /var/photo-diary/dailybw/photos/display/;\n    expires 30d;\n    add_header Cache-Control \"public, immutable\";\n    access_log off;\n  }\n  location /thumbnail/ {\n    alias /var/photo-diary/dailybw/photos/thumbnail/;\n    expires 30d;\n    add_header Cache-Control \"public, immutable\";\n    access_log off;\n  }\n\n  # If you push photos via the admin API (POST /api/v1/photos), bump this —\n  # the default 1 MB rejects most camera files. Skip if you only use the\n  # converter-inbox flow (which doesn't go through nginx).\n  client_max_body_size 50m;\n}\n```\n\nGet the cert via `certbot --nginx -d dailybw.example.com` (it'll insert the `ssl_*` lines and the http-to-https redirect for you on a clean vhost; the block above is what you'll end up with).\n\nUse a different `PORT` per instance (set in each instance's `.env`) — pm2 ensures the right server gets the right port.\n\n**Don't expose `inbox/` or `original/`.** Only `display/` and `thumbnail/` are meant to be public. After processing, the converter moves the raw image into `original/` (archived camera files — full EXIF, full resolution) and leaves the JSON sidecar in `inbox/`. The sidecars carry the full extracted EXIF including raw coordinates, which bypasses the `hide_map` privacy cascade if served directly. If you write a catch-all `location ~ /(display|thumbnail|inbox|original|…)` regex against the photo root, you're publishing both. Stick to per-directory `alias` blocks or anchor the regex to just the public dirs (e.g. `^/(display|thumbnail)/`).\n\n#### Per-gallery vhost mapping\n\nA single instance can serve multiple hostnames, each landing the visitor on a different gallery, without spinning up a separate instance. The mechanism is the `hostname` regex column on the `gallery` row — when the frontend boots on a host that exactly one gallery's regex matches, it redirects to that gallery instead of the gallery-list page.\n\nWorked example. One instance (`/var/photo-diary/multi/`) serves two domains, each landing on its own gallery:\n\n1. Create the galleries with hostname regexes:\n\n   ```sh\n   cd /var/photo-diary/multi\n   ./bin/gallery.ts dailybw --title \"Daily B\u0026W\" --hostname '^dailybw\\.example\\.com$'\n   ./bin/gallery.ts travel  --title \"Travel\"    --hostname '^travel\\.example\\.com$'\n   ```\n\n   The regex is matched with JavaScript's `RegExp.prototype.test` against `window.location.hostname` — anchors and escapes follow standard JS regex syntax.\n\n2. One nginx vhost per hostname, both proxying to the **same** backend port (the single instance):\n\n   ```nginx\n   server {\n     listen 443 ssl http2;\n     server_name dailybw.example.com;\n     # ... ssl_certificate, proxy headers, /display/, /thumbnail/ as above ...\n     location / { proxy_pass http://127.0.0.1:4201; ... }\n   }\n\n   server {\n     listen 443 ssl http2;\n     server_name travel.example.com;\n     # ... same ssl_certificate (or a separate cert), same proxy headers ...\n     location / { proxy_pass http://127.0.0.1:4201; ... }\n   }\n   ```\n\n3. The frontend selects which gallery to land on based on the hostname the visitor used. The fallback order is: single-gallery instance → matching `hostname` regex → `DEFAULT_GALLERY` env var → the gallery-list page.\n\nGotchas:\n\n- If two galleries' regexes both match the visitor's hostname, the frontend falls through to the next step (no redirect) since the match isn't unambiguous. Anchor your regexes with `^` and `$` to avoid accidental overlap.\n- The `/display/` and `/thumbnail/` aliases point at the **same** photo directory (the instance's `photos/`), so the bytes are shared across the per-gallery vhosts. That's intentional — galleries in the same instance are just different curations of the same photo pool.\n- `cdn` is a single meta value per instance, not per gallery. All the vhosts on one instance share it; usually that means setting `cdn` to one of the hostnames (say the primary one) and letting the others load images cross-origin from it.\n\n#### Operating an instance\n\nCommon day-to-day operations after an instance is running:\n\n```sh\npm2 list                                  # see all running processes, status, restarts, mem/cpu\npm2 logs dailybw                          # tail server logs for this instance\npm2 logs dailybw-converter --lines 1000   # tail converter logs, more history\npm2 restart dailybw dailybw-converter     # restart both halves after a config edit\npm2 stop dailybw dailybw-converter        # stop both\npm2 delete dailybw dailybw-converter      # stop and remove from the process list\npm2 save                                  # persist the current process list (resume on reboot)\npm2 startup                               # one-time: generate the boot script\n```\n\nLog files live at `~/.pm2/logs/\u003cname\u003e-out.log` and `~/.pm2/logs/\u003cname\u003e-error.log` (separately for the server and converter halves). They rotate automatically only if you install [pm2-logrotate](https://github.com/keymetrics/pm2-logrotate): `pm2 install pm2-logrotate`.\n\n**Backup.** Two pieces matter:\n\n- `\u003cinstance\u003e/db.sqlite3` — the source of truth for users, galleries, ACL, and photo metadata. Plain `cp` works if pm2 is stopped; for live backups use the SQLite online-backup API: `sqlite3 db.sqlite3 \".backup '/backups/$(date +%F)-db.sqlite3'\"`.\n- `\u003cinstance\u003e/photos/{original,display,thumbnail}/` — the bytes themselves. `inbox/` is transient (the converter empties it), no need to back it up.\n\nThe `bin/instance.ts` upgrade flow already creates `db.sqlite3.pre-\u003cversion\u003e` snapshots before flipping the `code` symlink — those are good restore points for a downgrade, but they're not a substitute for off-host backups.\n\n**Common symptoms and where to look:**\n\n| Symptom | First thing to check |\n| --- | --- |\n| Server won't start | `pm2 logs \u003cname\u003e` — most common: missing `SECRET` in `.env`, port already in use, or the migration runner threw on a DB inconsistency. |\n| Converter logs \"Invalid photo-repository directory structure\" | The `photos/{inbox,original,display,thumbnail}/` subdirs aren't all present — re-run `./bin/instance.ts \u003cname\u003e` (or `bin/instance.ts \u003cname\u003e` from the code root) to recreate any missing ones (idempotent). |\n| `no such table: …` after upgrade | A migration didn't apply. Check `sqlite3 db.sqlite3 \"SELECT value FROM meta WHERE key='schema_version'\"` and the server log on startup for \"Applying N DB migration(s)\". |\n| Frontend loads but `/api/v1/galleries` 401s | The user's token expired or the `SECRET` changed across restarts — login again. |\n| Map widget missing where you expected it | The `hide_map` cascade is hiding it; check with `./bin/access.ts list` (filter to `--user \u003cid\u003e` or `--gallery \u003cid\u003e` if needed) and the privacy doc in the server README. |\n\n## Features\n\n- Photos are segmented into galleries\n  - Each photo can be in any number of galleries\n  - Single level – no nesting\n  - One gallery view at a time\n  - Special galleries for more abstract concepts\n    - :all includes all photos\n    - :public includes all photos added to galleries\n    - :private includes photos not added to any galleries\n  - Hostname-based default gallery selection\n- SPA view\n  - Fast transition between views\n    - Pre-load current gallery\n  - Fast navigation to previous/next item\n    - Left/right arrow keys\n    - Swipe left/right\n    - Pre-load previous/next photo\n  - Year view – Calendar with heat-mapped days\n  - Month view – Thumbnails grouped by date\n  - Day view – Thumbnails\n  - Single photo view – Maximize to visible space, with photo properties\n- Statistics view\n  - General statistics\n    - Summary: total photos, total days, average per day\n    - By author\n    - By country\n  - Time distribution by year, year/month, month, weekday, hour\n  - Gear distribution by camera make, camera, lens, camera/lens\n  - Exposure distribution by focal length, aperture, shutter speed, sensitivity, EV, LV, resolution, orientation, aspect ratio\n  - All distribution values can be used to filter the photos\n    - Filters apply to both gallery and statistics views\n    - Filter values within a single category are additive, photos matching any are included\n    - Filter values across categories are subtractive, photos only matching all are included\n- Authentication\n  - User login\n  - Token-based\n    - User-specific secrets for simple revocation\n- Authorization\n  - Restricted access to galleries and functionality\n    - No access restrictions planned for the actual photo content, which may be in a CDN\n  - Multiple access levels\n    - No access\n    - View access\n    - Admin access\n  - Access levels granted by scope\n    - Global scope (:all)\n    - Public scope (:public)\n    - Gallery scope\n  - Default access level\n    - Through guest user (:guest), inherited by all users\n    - Inheritance may be overridden by broadening or narrowing access\n\n## Photo Pipeline\n\nEnd-to-end flow from a new JPG arriving on the host to it being browsable in the gallery:\n\n1. **Drop into `inbox/`.** Copy/move a JPG (or other supported format) into the instance's `photos/inbox/` directory. The converter watches this path (via chokidar) and picks the file up immediately.\n2. **Converter processes the file.** [converter](converter) reads the EXIF, generates display- and thumbnail-sized renditions (via sharp), and writes them to `photos/display/\u003cname\u003e.jpg` and `photos/thumbnail/\u003cname\u003e.jpg`. The extracted EXIF lands as a sibling JSON in `photos/inbox/\u003cname\u003e.json`. The original JPG is moved to `photos/original/\u003cname\u003e.jpg`. Result: the photo is on disk in three sizes, plus a JSON metadata file ready for ingestion.\n3. **Register in the DB.** Run `./bin/photo.ts photos/inbox/*.json --gallery \u003cid\u003e` from the instance dir. This reads each JSON, inserts a `photo` row with all the extracted EXIF (timestamp, camera, lens, geo, dimensions), and links the photo to the named gallery(ies) via `gallery_photo`. After this step the photo appears in the gallery on next page load.\n4. **(Optional) clean up.** The `inbox/*.json` files have served their purpose once ingested. They're harmless to leave but you can move/delete them to keep the inbox tidy.\n\nThe pipeline is intentionally split: the converter doesn't touch the DB at all, and the server doesn't read from the inbox. That lets you bulk-process a backlog of photos with the converter, eyeball the JSON sidecars, and then register them in batches via `./bin/photo.ts` with overrides applied if needed (e.g. `--country jp --place \"Yokohama, Kanagawa\"` for a whole trip).\n\n`./bin/photo.ts` also accepts JPG paths directly instead of JSON: in that mode it registers a bare `photo` row with no EXIF data (just the filename as the ID), useful when the metadata isn't worth recovering.\n\n## Roadmap\n\nActive milestones on the way to 1.0. Each bullet links the GitHub milestone for live status.\n\n- [**0.12 — Geocoded location surfaced**](https://github.com/vlumi/photo-diary/milestone/16): render the auto-populated `geocoded_place` per UI language in the metadata panel (#247), filter chain for geocoded state / city / district with cross-language-stable URLs (#344), Stats Places topic with drill-down by state / city / district (#345). All consumers of the data #246 lays down.\n- [**0.13 — Admin UI bundle**](https://github.com/vlumi/photo-diary/milestone/14): frontend admin view (#10), mutation API (#222), inbox subdirectories auto-link to galleries (#245), ACL user groups (#270), ACL `:all` floor rule (#271), more built-in themes (#279), admin theme selector (#287), converter picks up JSON in inbox (#333), per-language editing for place / title / description (#343).\n- [**0.14 — Composition + scale**](https://github.com/vlumi/photo-diary/milestone/15): hybrid galleries (#22), Postgres driver alongside SQLite (#265), saved filters / sub-galleries (#285), server-side stats with language-agnostic values and a single-key base cache (#286).\n- [**1.0 — Pre-release audits**](https://github.com/vlumi/photo-diary/milestone/4): test-coverage gap analysis (#194), frontend security audit (#217), end-to-end UI test suite (#261), documentation overhaul (#283).\n\n## Backlog\n\nIdeas without a milestone yet — too far out to schedule, kept here so they don't get lost.\n\n- **Dynamic display sizes** (#262) — pick the smallest rendition that covers the viewport, `srcset`-style.\n- **Photo license + original-size download** (#263) — license metadata on the photo row + a gated download path for `original/` bytes.\n- **Range filters for continuous variables** (#264) — time, focal length, aperture, etc. Coordinate-radius dropped in favour of place-categorical filtering after #246.\n- **Non-calendar gallery view** (#280) — alternative renderer for galleries that aren't date-shaped.\n- **Content localization for photo metadata** (#281) — per-language titles/captions/place names; manual-entry friction is the real blocker.\n- **Map modal: keep open across prev/next month/year navigation** (#321) — restores the cross-month/year geographic sweep that the inline-footer-map workflow had before the modal-isation in #313.\n- **Filter pills: edit value in place** (#342) — clicking an existing filter's value opens the same selector the topic-adder uses, so changing country=jp → fi takes two clicks instead of four.\n\n## Version History\n\nPhoto Diary is the third structural take on a long-running personal photo-gallery side project. The first ([pod.vlumi.net](https://web.archive.org/web/20131208222413/http://pod.vlumi.net/), 2004) was a calendar-grid view with one photo per day — initially Perl/CGI, later rewritten in Ruby/eruby (.rhtml) on Apache. The second ([github.com/vlumi/gallery](https://github.com/vlumi/gallery), 2012) stayed on the Ruby/eruby + Apache base, with SQLite for metadata, jQuery + Colorbox for the photo lightbox, Hammer.js for touch gestures, and a `bin/admin.rb` CLI for ingest (RMagick + exifr for thumbnailing and EXIF). It introduced multi-gallery support (one photo can belong to many galleries, virtual-host → gallery mapping), schema versioning, and per-gallery statistics — most of the conceptual surface Photo Diary still has.\n\nMajor release themes follow — see [CHANGELOG.md](CHANGELOG.md) for per-version detail.\n\n### 2020 — foundations\n\nInitial release and rapid follow-ups that established the core gallery, auth/ACL, map, stats, and filter model. The shape the app still has today was laid down here.\n\n- **0.1** (Jul 2020) — Initial release. Read-only calendar views (year/month/day/photo), separate front-end and back-end API, abstract DAO layer.\n- **0.2** (Jul 2020) — New SQLite schema with auth/ACL, embedded map with markers, EN/FI/JP localization, operator scripts for adding users/galleries/photos.\n- **0.3** (Jul 2020) — Per-gallery statistics view; map markers chronologically connected; stats moved client-side.\n- **0.4** (Aug 2020) — Photo property filters across gallery + stats. Migrated to styled-components, broader unit-test coverage.\n\n### 2021–2022 — maintenance\n\nA versioned API surface, instance-level metadata, and dependency catch-ups. Then a long pause.\n\n- **0.5** (Dec 2021) — `/api/v1` versioned API, instance metadata table, CDN-aware photo URL, gallery list with name/description from meta.\n- **0.5.1** (May 2022) — Aspect-ratio stats, react-scripts 5.\n\n### 2026 — modernization \u0026 multi-instance\n\nAfter the hiatus, a burst of releases that modernized the stack, formalized the multi-instance deploy story, and tightened privacy and security.\n\n- **0.6** (May 2026) — Modernization sweep. Express 5, Node 26 (jose replaces jsonwebtoken), ESM across all subtrees, TypeScript everywhere, better-sqlite3, React 19, Vite. Map added to the statistics page with marker clustering. Converter moves from ImageMagick to sharp (~20× faster).\n- **0.7** (May 2026) — Multi-instance deploy pattern: versioned code under `/opt/`, per-instance dirs under `/var/`, atomic upgrades via `code`-symlink flip. Privacy toggle via the four-cell `hide_map` cascade. Security baseline (helmet, login rate limit). Operator scripts renamed to bare nouns (`bin/photo.ts`, `bin/gallery.ts`, …). npm workspaces.\n- **0.8** (May 2026) — Backend framework swap: Express → Fastify, TypeBox schemas, OpenAPI doc + Swagger UI at `/api/v1/docs`, typed `AppError` hierarchy. Frontend adopts a generated `openapi-fetch` client, TanStack Query for server state, Zustand for client state, code-splits the Stats and Photo subtrees out of the main bundle.\n- **0.9** (May 2026) — Privacy hardening (collapse 403/404 distinctions to prevent gallery enumeration), JWT expiration (90 days), self-service password change, global 401 → login-modal handling, toast notifications, profile-icon `UserMenu`.\n- **0.10** (May 2026) — UI/UX polish across the calendar and Stats views. Photo view becomes a modal over Month with touch-tracking swipe and controlled zoom. Stats grows an expanded Summary, a Location card with map-in-modal, and modal-based deep dives for each category. Title bar carries a clickable breadcrumb with Up-navigation and a gallery/stats segmented control. The standalone Day view merges into Month, and seven new built-in themes ship. Server-side logout via refresh-token sessions.\n- **0.11** (May 2026) — Reverse-geocoded place hierarchy: converter intake fetches structured Nominatim data per photo (English canonical + optional extra languages), backfill daemon (`bin/photo-geocode.ts`) fills the existing archive, and `bin/photo.ts audit --country-mismatch` surfaces operator-vs-geocoded drift. Converter hardens around filename collisions (stable `\u003ctaken\u003e-\u003cuuid\u003e.\u003cext\u003e` rename at intake) and stub flows (writes a DB row directly, dedups on `(originalFilename, EXIF DateTimeOriginal)`). New `bin/meta.ts` operator script and `audit` subcommands across `bin/{photo,gallery,user,access,meta}.ts`. Stats Location card splits geotagged / not-geotagged with click-to-filter chips.\n\nSee the [Roadmap](#roadmap) for what's in flight after 0.11.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fvlumi%2Fphoto-diary","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fvlumi%2Fphoto-diary","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fvlumi%2Fphoto-diary/lists"}