Initial Jerboa image site

ober

48763443f1276b040d1590f9af61172cacdeff99

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d703e75
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+/build/
+/dist/
+/var/
+.jerbuild-hashes
+*.db
+*.db-shm
+*.db-wal
+*.so
+*.dylib
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..e8a9ddc
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,22 @@
+MIT License
+
+Copyright (c) 2026
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..e40b506
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,68 @@
+JERBOA_SOURCE_HOME ?= $(abspath $(CURDIR)/../jerboa)
+JERBOA_DIST ?= $(JERBOA_SOURCE_HOME)/dist/jerboa
+JERBOA ?= $(if $(wildcard $(JERBOA_DIST)),$(JERBOA_DIST),jerboa)
+JERBUILD ?= $(JERBOA) jerbuild
+JERBOA_HOME ?= $(shell $(JERBUILD) --jerboa-home 2>/dev/null)
+ifeq ($(JERBOA_HOME),)
+JERBOA_HOME := $(HOME)/mine/jerboa
+endif
+
+SINATRA_DIR ?= $(abspath $(CURDIR)/../jerboa-sinatra)
+JSQLITE_DIR ?= $(abspath $(CURDIR)/../jerboa-sqlite)
+BUILD_DIR ?= build
+SRC_STAGE := $(BUILD_DIR)/src
+TEST_STAGE := $(BUILD_DIR)/tests
+LIB_STAGE := $(BUILD_DIR)/lib
+TEST_LIB_STAGE := $(BUILD_DIR)/test-lib
+LIBDIRS := $(LIB_STAGE):$(TEST_LIB_STAGE):$(SINATRA_DIR)/build/lib:$(JSQLITE_DIR)/lib:$(JERBOA_HOME)/lib
+SQLITE_LIB_PATH := $(JSQLITE_DIR)
+UNAME_S := $(shell uname -s)
+SQLITE_PREFIX ?= $(shell if [ "$(UNAME_S)" = "Darwin" ]; then brew --prefix sqlite 2>/dev/null || echo /opt/homebrew/opt/sqlite; fi)
+SQLITE_LIBDIR ?= $(if $(SQLITE_PREFIX),$(SQLITE_PREFIX)/lib,)
+ENTRY ?= src/app.ss
+BINARY_OUTPUT ?= dist/jerboa-imagesite
+BINARY_NATIVE_LDFLAGS ?= $(if $(filter Darwin,$(UNAME_S)),-lc++,)
+
+.PHONY: deps build test run binary import-check clean distclean
+
+deps:
+	$(MAKE) -C $(SINATRA_DIR) build
+	$(MAKE) -C $(JSQLITE_DIR) build
+
+build: deps
+	rm -rf $(SRC_STAGE) $(TEST_STAGE) $(LIB_STAGE) $(TEST_LIB_STAGE)
+	mkdir -p $(SRC_STAGE)/imagesite $(TEST_STAGE)/imagesite $(LIB_STAGE) $(TEST_LIB_STAGE)
+	cp src/imagesite/*.ss $(SRC_STAGE)/imagesite/
+	cp tests/imagesite/*.ss $(TEST_STAGE)/imagesite/
+	$(JERBUILD) transpile $(SRC_STAGE) $(LIB_STAGE) --force
+	$(JERBUILD) transpile $(TEST_STAGE) $(TEST_LIB_STAGE) --force
+
+test: build
+	JERBOA_SQLITE_LIB="$(SQLITE_LIB_PATH)" \
+	DYLD_LIBRARY_PATH="$(SQLITE_LIBDIR):$(SQLITE_LIB_PATH):$$DYLD_LIBRARY_PATH" \
+	LD_LIBRARY_PATH="$(SQLITE_LIBDIR):$(SQLITE_LIB_PATH):$$LD_LIBRARY_PATH" \
+	$(JERBUILD) exec --libdirs "$(LIBDIRS)" tests/test-runner.ss
+
+run: build
+	JERBOA_SQLITE_LIB="$(SQLITE_LIB_PATH)" \
+	DYLD_LIBRARY_PATH="$(SQLITE_LIBDIR):$(SQLITE_LIB_PATH):$$DYLD_LIBRARY_PATH" \
+	LD_LIBRARY_PATH="$(SQLITE_LIBDIR):$(SQLITE_LIB_PATH):$$LD_LIBRARY_PATH" \
+	$(JERBUILD) exec --libdirs "$(LIBDIRS)" $(ENTRY)
+
+binary: build
+	mkdir -p $(dir $(BINARY_OUTPUT))
+	JERBOA_HOME="$(JERBOA_HOME)" \
+	JERBOA_SQLITE_LIB="$(SQLITE_LIB_PATH)" \
+	$(JERBUILD) binary --static-native $(foreach flag,$(BINARY_NATIVE_LDFLAGS),--extra-ldflag $(flag)) --libdirs "$(LIBDIRS)" $(ENTRY) $(BINARY_OUTPUT)
+
+import-check: build
+	JERBOA_SQLITE_LIB="$(SQLITE_LIB_PATH)" \
+	DYLD_LIBRARY_PATH="$(SQLITE_LIBDIR):$(SQLITE_LIB_PATH):$$DYLD_LIBRARY_PATH" \
+	LD_LIBRARY_PATH="$(SQLITE_LIBDIR):$(SQLITE_LIB_PATH):$$LD_LIBRARY_PATH" \
+	$(JERBUILD) exec --libdirs "$(LIBDIRS)" support/import-check.ss
+
+clean:
+	rm -rf $(BUILD_DIR)
+
+distclean: clean
+	rm -rf dist var
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..13fc1d6
--- /dev/null
+++ b/README.md
@@ -0,0 +1,123 @@
+# jerboa-imagesite
+
+`jerboa-imagesite` is a small Piwigo replacement for the Wendy Fournier
+Sanborn photo archive and similar filesystem-backed galleries.
+
+The design keeps the durable archive simple:
+
+- originals and videos stay on disk under a media root;
+- generated thumbnails/previews are optional files under a derivative root;
+- `jerboa-sqlite` stores albums, media rows, tags, captions, visibility, and
+  sync history;
+- Jerboa-Sinatra serves the catalog UI, search, JSON endpoints, family access
+  gate, and admin sync hooks;
+- nginx should serve large `/media/` and `/derivatives/` files directly in
+  production.
+
+This is intentionally not a PHP plugin system. It takes the useful Piwigo ideas
+that mattered in the existing deployment, physical albums, tags, search,
+family-gated access, video support, sidecar metadata, and sync-from-disk, while
+keeping the runtime native Scheme plus SQLite.
+
+## Build
+
+```sh
+make build
+```
+
+The Makefile builds sibling dependencies from `../jerboa-sinatra` and
+`../jerboa-sqlite`, then transpiles this repository into `build/lib`.
+
+## Test
+
+```sh
+make test
+```
+
+## Run Locally
+
+```sh
+export JERBOA_IMAGESITE_SESSION_SECRET="$(openssl rand -hex 32)"
+export JERBOA_IMAGESITE_ACCESS_ANSWER="beth"
+export JERBOA_IMAGESITE_MEDIA_ROOT="/srv/wendyfourniersandborn/photos/published"
+export JERBOA_IMAGESITE_DB="/var/db/jerboa-imagesite/imagesite.db"
+make run
+```
+
+Then visit `http://127.0.0.1:4580/`.
+
+For a first local smoke test without production media:
+
+```sh
+mkdir -p var/media/family var/db
+cp some-photo.jpg var/media/family/
+JERBOA_IMAGESITE_SESSION_SECRET=dev-secret \
+JERBOA_IMAGESITE_SESSION_SECURE=0 \
+JERBOA_IMAGESITE_ACCESS_ANSWER=beth \
+JERBOA_IMAGESITE_MEDIA_ROOT="$PWD/var/media" \
+JERBOA_IMAGESITE_DB="$PWD/var/db/imagesite.db" \
+make run
+```
+
+In another shell:
+
+```sh
+curl -H "X-Imagesite-Admin-Token: dev-admin" \
+  http://127.0.0.1:4580/admin/sync
+```
+
+Set `JERBOA_IMAGESITE_ADMIN_TOKEN=dev-admin` before starting the server if you
+want the admin sync endpoint enabled.
+
+## Filesystem Layout
+
+The current Ansible/Piwigo deployment stages media like this:
+
+```text
+/srv/wendyfourniersandborn/photos/inbox
+/srv/wendyfourniersandborn/photos/metadata
+/srv/wendyfourniersandborn/photos/classified
+/srv/wendyfourniersandborn/photos/published
+```
+
+`jerboa-imagesite` indexes only the configured published media root. Directory
+names become album paths. A file at:
+
+```text
+published/reunion/IMG_0001.jpg
+```
+
+becomes album `reunion` and media path `reunion/IMG_0001.jpg`.
+
+Rows are marked missing during sync when a previously indexed file is absent.
+They are not deleted automatically, so a bad mount or partial rsync does not
+destroy catalog work.
+
+## Metadata Sidecars
+
+Sidecar import is deliberately simple and append-only friendly. For a media file
+`family/IMG_0001.jpg`, the syncer looks for either:
+
+```text
+metadata/family/IMG_0001.jpg.json
+metadata/family/IMG_0001.json
+```
+
+Supported keys:
+
+```json
+{
+  "title": "Wendy at the lake",
+  "caption": "Summer trip",
+  "taken_at": "1998-07-04",
+  "tags": ["family", "lake"],
+  "favorite": true,
+  "width": 1600,
+  "height": 1200
+}
+```
+
+## Production
+
+See [docs/deployment.md](docs/deployment.md) and
+[deploy/nginx.conf.example](deploy/nginx.conf.example).
diff --git a/deploy/nginx.conf.example b/deploy/nginx.conf.example
new file mode 100644
index 0000000..1797c74
--- /dev/null
+++ b/deploy/nginx.conf.example
@@ -0,0 +1,38 @@
+server {
+    listen 80;
+    server_name wendyfourniersandborn.com www.wendyfourniersandborn.com;
+
+    location /.well-known/acme-challenge/ {
+        root /usr/local/www/nginx;
+    }
+
+    location / {
+        return 301 https://$host$request_uri;
+    }
+}
+
+server {
+    listen 443 ssl;
+    server_name wendyfourniersandborn.com www.wendyfourniersandborn.com;
+
+    ssl_certificate     /etc/ssl/wendyfourniersandborn.com/wendyfourniersandborn.com.fullchain.pem;
+    ssl_certificate_key /etc/ssl/wendyfourniersandborn.com/wendyfourniersandborn.com.key;
+    ssl_protocols TLSv1.2 TLSv1.3;
+    ssl_prefer_server_ciphers off;
+
+    client_max_body_size 512M;
+
+    add_header X-Content-Type-Options nosniff always;
+    add_header Referrer-Policy no-referrer always;
+
+    location / {
+        proxy_pass http://127.0.1.20:4580;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto https;
+        proxy_set_header X-Forwarded-Host $host;
+        proxy_http_version 1.1;
+    }
+}
+
diff --git a/docs/deployment.md b/docs/deployment.md
new file mode 100644
index 0000000..9e54621
--- /dev/null
+++ b/docs/deployment.md
@@ -0,0 +1,66 @@
+# Deployment
+
+The existing Piwigo deployment runs in a FreeBSD jail behind host nginx. The
+replacement keeps the same public front door, TLS certificate, and published
+media tree, but removes PHP, MariaDB, and the Piwigo plugin runtime.
+
+## Runtime Inputs
+
+```sh
+JERBOA_IMAGESITE_DB=/var/db/jerboa-imagesite/imagesite.db
+JERBOA_IMAGESITE_MEDIA_ROOT=/srv/<archive>/photos/published
+JERBOA_IMAGESITE_METADATA_ROOT=/srv/<archive>/photos/metadata
+JERBOA_IMAGESITE_DERIVATIVE_ROOT=/srv/<archive>/photos/derivatives
+JERBOA_IMAGESITE_BIND=127.0.1.20
+JERBOA_IMAGESITE_PORT=4580
+JERBOA_IMAGESITE_SESSION_SECRET=<random 32+ bytes>
+JERBOA_IMAGESITE_ACCESS_QUESTION="What was Wendy's middle name?"
+JERBOA_IMAGESITE_ACCESS_ANSWER=<answer>
+JERBOA_IMAGESITE_ADMIN_TOKEN=<random admin token>
+```
+
+## Sync
+
+Run sync after rsyncing approved media:
+
+```sh
+curl -fsS \
+  -H "X-Imagesite-Admin-Token: $JERBOA_IMAGESITE_ADMIN_TOKEN" \
+  http://127.0.1.20:4580/admin/sync
+```
+
+The syncer marks all existing media rows missing at the start of a transaction,
+then clears that marker as it sees files. If the sync fails, the transaction is
+rolled back.
+
+## nginx
+
+Use nginx for TLS and large static files:
+
+- `/` proxies to the Jerboa app;
+- `/app.css` can proxy or be served from the app public directory;
+- `/media/` should alias the published media root after the family gate has
+  been passed by the app, or proxy to the app for simpler first deployment;
+- `/derivatives/` can alias generated previews/thumbs when available.
+
+The simple first deployment is to proxy everything to Jerboa. The scalable
+deployment uses nginx `auth_request` against a future `/auth/media` endpoint
+before serving media aliases directly.
+
+See `deploy/nginx.conf.example` for the initial proxy configuration.
+
+## Replacing Piwigo
+
+The Piwigo-specific pieces that go away:
+
+- jail packages: `php*`, `mariadb*`, Piwigo zip, VideoJS plugin;
+- FastCGI/PHP-FPM config;
+- MariaDB database and Piwigo local config.
+
+The pieces that stay:
+
+- host-side archive workspace;
+- rsync staging flow;
+- TLS certificate and host nginx front door;
+- family access question;
+- published-only media policy.
diff --git a/public/app.css b/public/app.css
new file mode 100644
index 0000000..4e883d9
--- /dev/null
+++ b/public/app.css
@@ -0,0 +1,297 @@
+:root {
+  color-scheme: light;
+  --ink: #222;
+  --muted: #666;
+  --line: #d9ddd3;
+  --paper: #fbfbf8;
+  --panel: #ffffff;
+  --accent: #2d6a4f;
+  --accent-2: #7a4e2d;
+}
+
+* {
+  box-sizing: border-box;
+}
+
+body {
+  margin: 0;
+  font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+  color: var(--ink);
+  background: var(--paper);
+}
+
+a {
+  color: inherit;
+}
+
+.topbar {
+  position: sticky;
+  top: 0;
+  z-index: 2;
+  display: grid;
+  grid-template-columns: minmax(190px, 1fr) minmax(240px, 520px) auto;
+  gap: 16px;
+  align-items: center;
+  padding: 12px 20px;
+  border-bottom: 1px solid var(--line);
+  background: rgba(251, 251, 248, 0.96);
+}
+
+.brand {
+  font-weight: 700;
+  text-decoration: none;
+}
+
+nav {
+  display: flex;
+  gap: 12px;
+  font-size: 14px;
+}
+
+nav a,
+.back {
+  color: var(--accent);
+  text-decoration: none;
+}
+
+main {
+  width: min(1180px, calc(100vw - 32px));
+  margin: 0 auto;
+  padding: 28px 0 56px;
+}
+
+section {
+  margin: 0 0 34px;
+}
+
+.hero {
+  padding: 24px 0 10px;
+}
+
+h1 {
+  margin: 0 0 8px;
+  font-size: clamp(30px, 5vw, 58px);
+  line-height: 1;
+  letter-spacing: 0;
+}
+
+h2 {
+  margin: 0 0 14px;
+  font-size: 22px;
+}
+
+p {
+  line-height: 1.5;
+}
+
+.muted,
+.empty,
+.sync-note {
+  color: var(--muted);
+}
+
+.search,
+.wide-search {
+  display: flex;
+  gap: 8px;
+}
+
+input,
+button {
+  min-height: 40px;
+  border: 1px solid var(--line);
+  border-radius: 6px;
+  font: inherit;
+}
+
+input {
+  width: 100%;
+  padding: 0 12px;
+  background: white;
+}
+
+button {
+  padding: 0 14px;
+  border-color: var(--accent);
+  color: white;
+  background: var(--accent);
+  cursor: pointer;
+}
+
+.metrics {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
+  gap: 10px;
+}
+
+.metric {
+  padding: 14px;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  background: var(--panel);
+}
+
+.metric strong {
+  display: block;
+  font-size: 28px;
+}
+
+.metric span {
+  color: var(--muted);
+}
+
+.album-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+  gap: 10px;
+}
+
+.album {
+  display: flex;
+  min-height: 86px;
+  flex-direction: column;
+  justify-content: space-between;
+  padding: 14px;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  background: var(--panel);
+  text-decoration: none;
+}
+
+.album small,
+.media-card span {
+  color: var(--muted);
+}
+
+.media-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+  gap: 12px;
+}
+
+.media-card {
+  display: block;
+  min-width: 0;
+  color: inherit;
+  text-decoration: none;
+}
+
+.media-card img,
+.video-tile {
+  display: block;
+  width: 100%;
+  aspect-ratio: 1;
+  object-fit: cover;
+  border-radius: 8px;
+  border: 1px solid var(--line);
+  background: #e9ece5;
+}
+
+.media-card span {
+  display: block;
+  margin-top: 6px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  font-size: 14px;
+}
+
+.video-tile {
+  display: grid;
+  place-items: center;
+  color: var(--accent-2);
+  font-weight: 700;
+}
+
+.viewer {
+  display: grid;
+  place-items: center;
+  background: #111;
+}
+
+.viewer img,
+.viewer video {
+  display: block;
+  max-width: 100%;
+  max-height: 78vh;
+}
+
+.details {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) minmax(220px, 340px);
+  gap: 24px;
+}
+
+dl {
+  display: grid;
+  grid-template-columns: 80px 1fr;
+  gap: 8px 12px;
+  margin: 0;
+}
+
+dt {
+  color: var(--muted);
+}
+
+dd {
+  margin: 0;
+  word-break: break-word;
+}
+
+.tags {
+  grid-column: 1 / -1;
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+
+.tags span {
+  padding: 4px 8px;
+  border: 1px solid var(--line);
+  border-radius: 999px;
+  background: white;
+}
+
+.gate {
+  display: grid;
+  min-height: 100vh;
+  place-items: center;
+}
+
+.gate-panel {
+  width: min(440px, calc(100vw - 32px));
+  padding: 24px;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  background: white;
+}
+
+.gate-panel label {
+  display: grid;
+  gap: 8px;
+}
+
+.gate-panel form {
+  display: grid;
+  gap: 12px;
+}
+
+.error {
+  color: #9d1c1c;
+}
+
+@media (max-width: 760px) {
+  .topbar {
+    position: static;
+    grid-template-columns: 1fr;
+  }
+
+  nav {
+    flex-wrap: wrap;
+  }
+
+  .details {
+    grid-template-columns: 1fr;
+  }
+}
+
diff --git a/src/app.ss b/src/app.ss
new file mode 100644
index 0000000..4c6d504
--- /dev/null
+++ b/src/app.ss
@@ -0,0 +1,203 @@
+(import (jerboa prelude)
+        (only (std misc thread) thread-sleep!)
+        (std text json)
+        (sinatra)
+        (imagesite config)
+        (only (imagesite util)
+              string-blank? string-prefix? percent-encode-path percent-decode
+              normalize-answer path-basename)
+        (imagesite db)
+        (imagesite sync)
+        (imagesite views))
+
+(def config (load-config))
+(def db (imagesite-open (config-db-path config)))
+
+(def (required-session-secret!)
+  (when (string-blank? (config-session-secret config))
+    (error 'jerboa-imagesite "JERBOA_IMAGESITE_SESSION_SECRET is required")))
+
+(def (gate-enabled?)
+  (not (string-blank? (config-access-answer config))))
+
+(def (authorized?)
+  (or (not (gate-enabled?))
+      (session-ref "family-access")))
+
+(def (safe-next raw)
+  (let ((next (if (and raw (string-prefix? "/" raw) (not (string-prefix? "//" raw)))
+                raw
+                "/")))
+    next))
+
+(def (require-access!)
+  (unless (authorized?)
+    (redirect (string-append "/gate?next=" (percent-encode-path (sinatra-request-url (request)))))))
+
+(def (admin-authorized?)
+  (let ((token (config-admin-token config)))
+    (and (not (string-blank? token))
+         (let ((header-token (sinatra-request-header (request) "X-Imagesite-Admin-Token"))
+               (query-token (hash-get (sinatra-request-query-params (request)) "token")))
+           (or (and header-token (string=? header-token token))
+               (and query-token (string=? query-token token)))))))
+
+(def (json-response obj)
+  (content-type! "application/json")
+  (json-object->string obj))
+
+(def (redirect-response path)
+  (status! 302)
+  (header! "Location" path)
+  "")
+
+(def (open-media-file rel-path)
+  (let ((decoded (percent-decode rel-path)))
+    (if (safe-relative-path? decoded)
+      (path-join (config-media-root config) decoded)
+      #f)))
+
+(required-session-secret!)
+
+(configure
+  (set-option! "bind" (config-bind config))
+  (set-option! "port" (config-port config))
+  (enable! 'sessions)
+  (set-option! "session-secret" (config-session-secret config))
+  (set-option! "session-secure" (config-session-secure? config))
+  (set-option! "static" #t)
+  (set-option! "public-folder" "public")
+  (set-option! "static-max-bytes" 1048576)
+  (set-option! "force-headers"
+    (list
+     (cons "Content-Security-Policy" "default-src 'self'; img-src 'self' data:; media-src 'self'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; object-src 'none'")
+     (cons "X-Content-Type-Options" "nosniff")
+     (cons "Referrer-Policy" "no-referrer")
+     (cons "X-Frame-Options" "DENY"))))
+
+(before
+  (header! "X-Powered-By" "Jerboa"))
+
+(before "/"
+  (require-access!))
+
+(before "/albums/*"
+  (require-access!))
+
+(before "/media-view/*"
+  (require-access!))
+
+(before "/search"
+  (require-access!))
+
+(before "/api/*"
+  (require-access!))
+
+(GET "/gate"
+  (let ((next (safe-next (hash-get (sinatra-request-query-params (request)) "next"))))
+    (gate-page (config-access-question config) #f next)))
+
+(POST "/gate"
+  (let* ((body (sinatra-request-body-params (request)))
+         (answer (normalize-answer (or (hash-get body "answer") "")))
+         (next (safe-next (hash-get body "next"))))
+    (if (and (gate-enabled?) (string=? answer (config-access-answer config)))
+      (begin
+        (session-set! "family-access" "yes")
+        (redirect-response next))
+      (gate-page (config-access-question config) #t next))))
+
+(GET "/logout"
+  (session-destroy!)
+  (redirect-response "/gate"))
+
+(GET "/"
+  (home-page (list-albums db) (recent-media db 36) (counts db) (latest-sync db)))
+
+(GET "/albums/"
+  (album-page (or (album-by-path db "") (hash ("title" "Albums") ("path" "") ("media_count" 0)))
+              (recent-media db 240)))
+
+(GET "/albums/*"
+  (let* ((album-path (percent-decode (car (splat))))
+         (album (and (safe-relative-path? (if (string=? album-path "") "root" album-path))
+                     (album-by-path db album-path))))
+    (if album
+      (album-page album (media-by-album db album-path))
+      (begin (status! 404) (not-found-page)))))
+
+(GET "/media-view/:id"
+  (let* ((id (string->number (param "id")))
+         (media (and id (media-by-id db id))))
+    (if media
+      (media-page media (media-tags db id))
+      (begin (status! 404) (not-found-page)))))
+
+(GET "/media/*"
+  (require-access!)
+  (if (config-dev-media-route? config)
+    (let* ((rel (car (splat)))
+           (path (open-media-file rel)))
+      (if (and path (file-exists? path) (file-regular? path))
+        (send-file path filename: (path-basename path) disposition: "inline")
+        (begin (status! 404) "Not Found")))
+    (begin (status! 404) "Media is served by nginx in this deployment.")))
+
+(GET "/search"
+  (let ((query (string-trim (or (hash-get (sinatra-request-query-params (request)) "q") ""))))
+    (search-page query (if (string-blank? query) '() (search-media db query 240)))))
+
+(GET "/api/albums"
+  (json-response (hash ("albums" (list-albums db)))))
+
+(GET "/api/media/:id"
+  (let* ((id (string->number (param "id")))
+         (media (and id (media-by-id db id))))
+    (if media
+      (json-response (hash ("media" media) ("tags" (media-tags db id))))
+      (begin (status! 404) (json-response (hash ("error" "not found")))))))
+
+(GET "/api/search"
+  (let ((query (string-trim (or (hash-get (sinatra-request-query-params (request)) "q") ""))))
+    (json-response (hash ("query" query)
+                         ("results" (if (string-blank? query) '() (search-media db query 240)))))))
+
+(GET "/admin"
+  (require-access!)
+  (admin-page (counts db) (latest-sync db) (not (string-blank? (config-admin-token config)))))
+
+(GET "/admin/sync"
+  (require-access!)
+  (if (admin-authorized?)
+    (json-response (sync-media-root! db (config-media-root config) (config-metadata-root config)))
+    (begin
+      (status! 403)
+      (json-response (hash ("error" "admin token required"))))))
+
+(POST "/admin/sync"
+  (require-access!)
+  (if (admin-authorized?)
+    (json-response (sync-media-root! db (config-media-root config) (config-metadata-root config)))
+    (begin
+      (status! 403)
+      (json-response (hash ("error" "admin token required"))))))
+
+(not-found
+  (status! 404)
+  (not-found-page))
+
+(error-handler
+  (status! 500)
+  (layout "Error" "<section><h1>Internal Server Error</h1></section>"))
+
+(displayln "Starting jerboa-imagesite")
+(display "Listening on http://")
+(display (config-bind config))
+(display ":")
+(display (config-port config))
+(newline)
+
+(let ((srv (RUN!)))
+  (let loop ()
+    (thread-sleep! 3600)
+    (loop)))
diff --git a/src/imagesite/config.ss b/src/imagesite/config.ss
new file mode 100644
index 0000000..f592707
--- /dev/null
+++ b/src/imagesite/config.ss
@@ -0,0 +1,47 @@
+(import (only (imagesite util)
+              getenv/default env-truthy? normalize-answer))
+
+(export load-config config-ref config-ref/default config-db-path
+        config-media-root config-metadata-root config-derivative-root
+        config-bind config-port config-session-secret
+        config-session-secure?
+        config-access-question config-access-answer config-admin-token
+        config-dev-media-route?)
+
+(def (load-config)
+  (hash
+   ("db-path" (getenv/default "JERBOA_IMAGESITE_DB" "var/imagesite.db"))
+   ("media-root" (getenv/default "JERBOA_IMAGESITE_MEDIA_ROOT" "var/media"))
+   ("metadata-root" (getenv/default "JERBOA_IMAGESITE_METADATA_ROOT" "var/metadata"))
+   ("derivative-root" (getenv/default "JERBOA_IMAGESITE_DERIVATIVE_ROOT" "var/derivatives"))
+   ("bind" (getenv/default "JERBOA_IMAGESITE_BIND" "127.0.0.1"))
+   ("port" (or (string->number (getenv/default "JERBOA_IMAGESITE_PORT" "4580")) 4580))
+   ("session-secret" (getenv/default "JERBOA_IMAGESITE_SESSION_SECRET" ""))
+   ("session-secure" (env-truthy? "JERBOA_IMAGESITE_SESSION_SECURE" #t))
+   ("access-question" (getenv/default "JERBOA_IMAGESITE_ACCESS_QUESTION" "What was Wendy's middle name?"))
+   ("access-answer" (normalize-answer (getenv/default "JERBOA_IMAGESITE_ACCESS_ANSWER" "")))
+   ("admin-token" (getenv/default "JERBOA_IMAGESITE_ADMIN_TOKEN" ""))
+   ("dev-media-route" (env-truthy? "JERBOA_IMAGESITE_DEV_MEDIA_ROUTE" #t))))
+
+(def (config-ref config key)
+  (if (hash-key? config key)
+    (hash-ref config key #f)
+    (error 'config-ref "missing config key" key)))
+
+(def (config-ref/default config key default)
+  (if (hash-key? config key)
+    (hash-ref config key default)
+    default))
+
+(def (config-db-path config) (config-ref config "db-path"))
+(def (config-media-root config) (config-ref config "media-root"))
+(def (config-metadata-root config) (config-ref config "metadata-root"))
+(def (config-derivative-root config) (config-ref config "derivative-root"))
+(def (config-bind config) (config-ref config "bind"))
+(def (config-port config) (config-ref config "port"))
+(def (config-session-secret config) (config-ref config "session-secret"))
+(def (config-session-secure? config) (config-ref config "session-secure"))
+(def (config-access-question config) (config-ref config "access-question"))
+(def (config-access-answer config) (config-ref config "access-answer"))
+(def (config-admin-token config) (config-ref config "admin-token"))
+(def (config-dev-media-route? config) (config-ref config "dev-media-route"))
diff --git a/src/imagesite/db.ss b/src/imagesite/db.ss
new file mode 100644
index 0000000..25b3aaa
--- /dev/null
+++ b/src/imagesite/db.ss
@@ -0,0 +1,368 @@
+(import (except (jerboa prelude)
+                sqlite-open sqlite-close sqlite-exec sqlite-prepare
+                sqlite-finalize sqlite-step sqlite-query)
+        (jerboa-sqlite)
+        (only (imagesite util)
+              safe-relative-path? relative-dirname path-basename
+              string-replace-char media-title media-kind media-mime
+              string-blank? ensure-directory! path-dirname))
+
+(export imagesite-open imagesite-close imagesite-init!
+        scalar counts with-imagesite-transaction
+        ensure-album! upsert-media! update-media-metadata! set-media-tags!
+        list-albums album-by-path recent-media media-by-id media-by-album
+        search-media list-tags media-tags latest-sync record-sync!
+        mark-all-media-missing!)
+
+(def (imagesite-open path)
+  (ensure-directory! (path-dirname path))
+  (let ((db (sqlite-open path)))
+    (sqlite-exec db "PRAGMA foreign_keys = ON")
+    (sqlite-exec db "PRAGMA journal_mode = WAL")
+    (sqlite-exec db "PRAGMA synchronous = NORMAL")
+    (imagesite-init! db)
+    db))
+
+(def (imagesite-close db)
+  (sqlite-close db))
+
+(def (imagesite-init! db)
+  (sqlite-exec db "CREATE TABLE IF NOT EXISTS albums (
+    id INTEGER PRIMARY KEY,
+    path TEXT NOT NULL UNIQUE,
+    title TEXT NOT NULL,
+    description TEXT NOT NULL DEFAULT '',
+    visibility TEXT NOT NULL DEFAULT 'public',
+    sort_rank INTEGER NOT NULL DEFAULT 0,
+    created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+  )")
+  (sqlite-exec db "CREATE TABLE IF NOT EXISTS media (
+    id INTEGER PRIMARY KEY,
+    album_id INTEGER NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
+    rel_path TEXT NOT NULL UNIQUE,
+    basename TEXT NOT NULL,
+    title TEXT NOT NULL,
+    caption TEXT NOT NULL DEFAULT '',
+    kind TEXT NOT NULL,
+    mime TEXT NOT NULL,
+    size_bytes INTEGER NOT NULL DEFAULT 0,
+    mtime INTEGER NOT NULL DEFAULT 0,
+    width INTEGER,
+    height INTEGER,
+    taken_at TEXT,
+    published INTEGER NOT NULL DEFAULT 1,
+    missing INTEGER NOT NULL DEFAULT 0,
+    favorite INTEGER NOT NULL DEFAULT 0,
+    created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+  )")
+  (sqlite-exec db "CREATE TABLE IF NOT EXISTS tags (
+    id INTEGER PRIMARY KEY,
+    name TEXT NOT NULL UNIQUE,
+    slug TEXT NOT NULL UNIQUE
+  )")
+  (sqlite-exec db "CREATE TABLE IF NOT EXISTS media_tags (
+    media_id INTEGER NOT NULL REFERENCES media(id) ON DELETE CASCADE,
+    tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
+    PRIMARY KEY (media_id, tag_id)
+  )")
+  (sqlite-exec db "CREATE TABLE IF NOT EXISTS sync_runs (
+    id INTEGER PRIMARY KEY,
+    started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    media_seen INTEGER NOT NULL DEFAULT 0,
+    media_added INTEGER NOT NULL DEFAULT 0,
+    media_updated INTEGER NOT NULL DEFAULT 0,
+    albums_seen INTEGER NOT NULL DEFAULT 0,
+    notes TEXT NOT NULL DEFAULT ''
+  )")
+  (sqlite-exec db "CREATE INDEX IF NOT EXISTS idx_media_album ON media(album_id)")
+  (sqlite-exec db "CREATE INDEX IF NOT EXISTS idx_media_published ON media(published, missing)")
+  (sqlite-exec db "CREATE INDEX IF NOT EXISTS idx_media_taken ON media(taken_at)")
+  (ensure-album! db "" "Home"))
+
+(def (object pairs)
+  (let ((table (make-hash-table)))
+    (for-each
+      (lambda (pair) (hash-put! table (car pair) (cdr pair)))
+      pairs)
+    table))
+
+(def (scalar db sql . args)
+  (let ((rows (apply sqlite-query db sql args)))
+    (if (null? rows) #f (vector-ref (car rows) 0))))
+
+(def (with-imagesite-transaction db thunk)
+  (sqlite-exec db "BEGIN")
+  (try
+    (let ((result (thunk)))
+      (sqlite-exec db "COMMIT")
+      result)
+    (catch (e)
+      (try (sqlite-exec db "ROLLBACK") (catch (rollback-error) #f))
+      (raise e))))
+
+(def (counts db)
+  (object