Initial Jerboa image site
ober
48763443f1276b040d1590f9af61172cacdeff99
new file mode 100644 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/build/ +/dist/ +/var/ +.jerbuild-hashes +*.db +*.db-shm +*.db-wal +*.so +*.dylib new file mode 100644 --- /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. + new file mode 100644 --- /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 new file mode 100644 --- /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). new file mode 100644 --- /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; + } +} + new file mode 100644 --- /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. new file mode 100644 --- /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; + } +} + new file mode 100644 --- /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))) new file mode 100644 --- /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")) new file mode 100644 --- /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