~ober/jerboa-imagesite

Imported from ~/mine/jerboa-imagesite

download snapshot

about

# 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's `(std db sqlite)` API, backed by vendored jsqlite, 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;
- the app can terminate TLS directly and serves gated media with byte-range
  support for video playback.

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`, uses the
configured Jerboa tree's vendored `vendor/jsqlite/src`, then transpiles this
repository into `build/lib`.

For the FreeBSD jail deployment, build a single target binary with the
cross-build variables used by the Ansible playbook:

```sh
make static-binary \
  STATIC_TARGET_MACHINE=ta6fb \
  STATIC_RUST_TARGET=x86_64-unknown-freebsd \
  STATIC_BINARY_OUTPUT=dist/jerboa-imagesite-freebsd-amd64
```

## Test

```sh
make test
make web-security-smoke
```

The hostile smoke starts the standalone server on loopback and proves that
credentials are POST-only, cross-origin and missing-CSRF requests fail, GET
mutations are inert, opaque sessions rotate at login, and a media symlink swap
cannot escape the configured root.

## Run Locally

```sh
export JERBOA_IMAGESITE_SESSION_SECRET="$(openssl rand -hex 32)"
export JERBOA_IMAGESITE_ACCESS_ANSWER="<family-answer>"
export JERBOA_IMAGESITE_MEDIA_ROOT="/srv/wendyfourniersanborn/photos/published"
export JERBOA_IMAGESITE_DB="/var/db/jerboa-imagesite/imagesite.db"
make run
```

Then visit `http://127.0.0.1:4580/`.

For direct TLS, set cert and key paths:

```sh
export JERBOA_IMAGESITE_PORT=8443
export JERBOA_IMAGESITE_TLS_CERT=/path/fullchain.cer
export JERBOA_IMAGESITE_TLS_KEY=/path/privkey.key
make run
```

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=dev-answer \
JERBOA_IMAGESITE_MEDIA_ROOT="$PWD/var/media" \
JERBOA_IMAGESITE_DB="$PWD/var/db/imagesite.db" \
make run
```

In another shell:

```sh
curl -X POST -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.

Browser gate/admin credentials are accepted only in form-encoded POST bodies.
They are never accepted from query parameters. Browser mutations require a
random, session-bound CSRF value and same-origin validation. The cookie holds
only an opaque random session identifier; it is `HttpOnly`, `SameSite=Strict`,
and `Secure` by default. The administrator grant expires server-side after one
hour and login failures are rate-limited and audited without logging answers.

## Filesystem Layout

The Ansible deployment stages media like this:

```text
/srv/wendyfourniersanborn/photos/inbox
/srv/wendyfourniersanborn/photos/metadata
/srv/wendyfourniersanborn/photos/classified
/srv/wendyfourniersanborn/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.

The media root is opened as a no-follow directory descriptor. Walks use
descriptor-relative `openat`/`fstatat`, reject links and inode swaps, stay on the
root filesystem, remember visited device/inode pairs, and enforce depth (32),
entry (100,000), per-file (8 GiB), and aggregate (256 GiB) limits. Direct media
serving is development-only and disabled by default; production deployments
should use an internal nginx `X-Accel-Redirect` location with symlinks disabled.

## 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).

recent commits