Paul Nothaft 696c69a6d0 fix(setup): put the setup token where a NAS user can find it (#1218) (#1219)
* fix(setup): put the setup token where a NAS user can find it (#1218)

The token file was never missing — it was in a subdirectory nobody opens. The
all-in-one image points DATA_DIR at /data/db, so the file lands beside the
database inside the single volume; someone browsing that volume from a NAS
container UI sees db/, storage/, logs/, backup/ and gives up. There is no shell
on those boxes to run the documented `docker exec … cat` with, and the token
value is deliberately kept out of the logs, so the install looked like it had
swallowed its own bootstrap credential.

When DATA_ROOT names a different directory, the token is now written there too
— /data/SETUP_TOKEN, the first thing visible on opening the volume. The compose
stack sets no DATA_ROOT and keeps exactly one file, so nothing changes there.

Each copy is written independently: the canonical one failing while the
volume-root copy succeeds still leaves a readable token, and only a run where
every write failed falls back to logging the value. The startup banner names
every copy rather than just the first, which is what sent people into db/.

Both copies are 0600 and both are removed the moment setup completes. That is
what makes a second copy of a single-use bootstrap secret acceptable rather
than careless — and writing the test for it turned up that the burn path had
TWO independent unlinks, one in clearSetupToken and one at the end of
createInitialAdmin. Only the first had been updated, so the volume-root copy
survived the burn: a live-looking token that no longer works, which is worse
than no token at all.

Docs for the same issue are already out (PicPeak/docs#15); .env.example now
names the AIO paths too.

* fix(setup): enforce 0600 on a token file that already exists (#1218)

External review. fs.writeFileSync's `mode` applies only when the file is
created — writing over an existing inode truncates it and leaves its
permissions untouched. A SETUP_TOKEN someone had copied to the volume root by
hand at 0644 would keep that mode, so the first-admin bootstrap credential sat
group- and world-readable on a shared NAS mount while this code claimed 0600.

Unlink then create, rather than chmod after write: recreating gives a fresh
inode with the right mode and no window where the credential is on disk under
the wrong one. The chmod stays as a fallback for an unlink that failed for a
reason other than the file being absent.

Test fails against the un-fixed code.

* fix(setup): drop a token copy that cannot be made private (#1218)

Round 2 of external review. Asking for 0600 is not the same as getting it: a
CIFS/SMB mount — which is what a NAS commonly offers — carries no Unix modes,
so chmod is a silent no-op and the file keeps whatever file_mode= the mount
forces, typically 0644. This feature targets exactly those hosts, so it now
verifies the resulting mode instead of assuming the request took.

A copy that cannot be made private is removed rather than left lying there, and
it does not count as written — so an install where neither copy can be
protected falls through to the existing log fallback, which reaches the
operator alone. Previously a chmod that threw after a successful write left the
credential on disk, and a success on the other path cleared the error, so
nothing reported the exposed copy at all.

Test simulates the mode-less mount with chmod as a no-op and stat reporting
0644; it fails against the un-fixed code.

* fix(setup): never write the token through a foreign inode, or into the logs (#1218)

Round 3 of external review, two findings, both about the credential ending up
readable by someone else on exactly the shared mounts this feature targets.

**The log fallback defeated the point.** When no copy can be made private, the
old branch logged the token at warn — and logger.js writes warnings to
combined.log under LOG_DIR, which in the all-in-one image sits on the same
mount as the token file. The credential moved from a file we had just refused
to leave, into another file just as readable, that outlives setup. The warning
no longer carries the token; server.js already prints it on stdout when no file
was written, which reaches `docker logs` without touching the shared volume.

**A file that could not be deleted was written through anyway.** The
pre-write unlink swallowed every error, so a 0666 SETUP_TOKEN owned by another
user in a sticky or ACL-controlled directory — still writable — received the
live token into its existing inode. Only ENOENT is ignored now. And when the
mode check finds an exposed copy it cannot remove, that is recorded separately
and reported at error level: a success on the other path clears writeError, and
an exposed credential must not be silenced by an unrelated success.

Two tests, both failing against the un-fixed code.

* fix(setup): fail closed on an exposed token, and refuse a raced symlink (#1218)

Round 4 of external review.

**An exposed copy left the token valid.** A directory that permits creation and
denies deletion — ACL-backed or CIFS — could keep a group/world-readable file
holding a live setup token, and /setup/admin went on accepting it: anyone able
to read the mount could take the first super-admin account. Reporting that was
not enough. The token is now revoked when a readable copy cannot be removed,
which turns what is left on disk into a dead string. Private copies are removed
with it, since they hold the same value. The next boot mints a fresh one and
skips the undeletable file rather than rewriting it, so this converges instead
of looping on the same exposure.

**The write followed a raced symlink.** On a group-writable mount another local
user could drop a symlink at the path between the unlink and the write, and the
default 'w' flag would follow it — putting the live token in a file they own.
Now created with 'wx' (O_CREAT|O_EXCL), which neither overwrites nor follows a
link; having just unlinked, anything present again is that race. The mode check
uses lstat for the same reason: it must describe the file, not a link target.

**A verification that threw left the file behind.** writeFileSync succeeding and
lstat then failing — plausible on the network filesystems this targets — left an
unverified live copy on disk, and a success on the other path cleared the error
so nothing said so. Cleanup is now keyed on 'did this iteration create a file',
so every post-creation failure removes it.

Three tests, one new; the new one fails against the un-fixed code. Full backend
suite at the known baseline.

* fix(setup): report the written token path again, so the banner stays quiet (#1218)

A regression I introduced one commit ago. Rewriting the write loop dropped the
three lines after it that publish the result, so writtenTokenFile stayed null
even on a completely successful write.

server.js prints the token itself only when no file was written. With this
reporting nothing, the banner took that failure branch on every fresh install
and put the live super-admin setup token into stdout and `docker logs` — beside
a perfectly good 0600 file. That is the exact leak this path was built to
close, reopened by a refactor that touched none of the logic around it.

Found by external review, not by the suite: nothing asserted the accessor, only
the files on disk. Now guarded — the new test fails against the regression.

* fix(setup): survive a worker race, and revoke a copy that predates this run (#1218)

Round 6 of external review.

**A pre-existing exposed copy was invisible to the revocation.** A restart
reuses the token from the database, so an old file holding that value is a live
credential. If it had become group-readable and could not be deleted, nothing
tracked it — created was false, so the fail-closed path never fired and
/setup/admin kept accepting what was in that file. An undeletable file at the
token path is now treated as live and triggers the same revocation.

**A losing worker printed the token.** The shipped PM2 cluster config runs
several workers against one DATA_DIR. Both pass the unlink, one wins the
exclusive create, and the loser's wx write threw EEXIST — so it recorded
nothing and its banner printed the live token into its own log while a
perfectly good 0600 file already existed. EEXIST now checks the file: private,
regular, and holding the same token counts as this loop's work already done.

**A write that created the file and then threw left it behind.** ENOSPC, a
short write, a delayed close on a network mount — writeFileSync can populate
the inode before failing, and cleanup keyed on the call returning skipped it.
Keyed on the write being attempted now, with an existence check.

Two tests, both failing against the un-fixed code. Full backend suite at the
known baseline (2342 passing).

* refactor(setup): drop the volume-root token copy, keep the hardening (#1218)

The second copy was for discoverability: DATA_DIR points into /data/db on the
all-in-one image, and a NAS user browsing the volume does not open a folder
called db. Six review rounds later it had earned a second inode to race, to
verify, to clean up and to revoke — a symlink guard, an exclusive create, an
lstat check, cluster-race handling and fail-closed revocation, nearly all of it
load-bearing only because there were two files instead of one.

That is a lot of attack surface for a convenience the documentation covers
better. PicPeak/docs#15 now points NAS users at ADMIN_PASSWORD, which creates
the admin on first boot and needs no file at all, and names the db/
subdirectory for anyone who does want the token. Neither needs a second copy.

So: one file in DATA_DIR again, as before. Everything the review turned up
stays, because none of it was about the second copy — the token is created with
O_CREAT|O_EXCL so a raced symlink cannot capture it, its mode is verified with
lstat rather than assumed, a copy that cannot be made private is removed, one
that cannot be removed revokes the token instead of being logged about, a
partial write is cleaned up, a concurrent worker's good file is accepted rather
than triggering the log fallback, and the token never reaches the log files.

setupTokenFilePaths and writtenSetupTokenFiles are gone with their tests; the
hardening tests remain and still fail against unfixed code.

* fix(setup): publish the token atomically instead of racing over one inode (#1218)

Round 7 of external review found a race in the exclusive-create approach: two
PM2 workers reaching the write together, the loser sees the winner's file after
the inode exists but before its content lands, judges it wrong, and deletes it
— after which the winner's own verification fails too, both report nothing
written, and both print the live token into their logs.

Rather than teach the loser to wait, the shared inode is gone. The token is
written to a per-process temporary file, verified there, and published with
rename(2). That is atomic: the file never appears at the published path with
the wrong mode or half its content, a symlink sitting at that path is replaced
rather than followed, and concurrent workers simply publish the same value one
after another. The unlink-then-create dance, the EEXIST handling and the
cross-worker deletion all disappear with it.

Verifying the mode BEFORE the rename is the stronger order too: a credential
that cannot be made private on a mode-less mount now never reaches the
published path at all, instead of being written and then cleaned up.

If publishing fails and something is still sitting at the token path, it is
treated as a live credential we could not replace, and the token is revoked —
unchanged in intent from the previous round, simpler in mechanism.

* fix(setup): drop a dead assignment and an unused import (#1218)

Both flagged by the code-quality review on #1219. `createdTmp = false`
after rename(2) is never read — rename consumes the temp file, so the
catch has nothing left to clean up either way. `os` was never used in
the test.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-28 08:08:54 +02:00

PicPeak Logo

📸 PicPeak

Open-source, self-hosted photo sharing for events.

License: MIT Docker Buy Me A Coffee

Homepage · Live Demo · Documentation · Support


PicPeak is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Built for photographers and event organizers, it makes it simple to share beautiful, time-limited photo galleries with clients while keeping full control over your data and branding.

PicPeak Gallery Preview

Important

PicPeak has moved to its own GitHub organization. Docker images are now at ghcr.io/picpeak/picpeak/{backend,frontend,aio,ml} (and on Docker Hub as picpeak/{backend,frontend,aio,ml}) and active development is on main. The old ghcr.io/the-luap/... path still responds but its tags are frozen at 2026-05-27 — if updates never arrive, check your image path first. See docs/migration-to-org.md for the one-line docker-compose.yml edit.

Contents

🎮 Live Demo

Try PicPeak without installing anything — demo.picpeak.app · admin panel

Email Password
demo@picpeak.app Demo2026!

The demo resets periodically. Uploaded content may be removed without notice.

🚀 Quick Start

Get PicPeak running in under 5 minutes:

# Clone the repository
git clone https://github.com/PicPeak/picpeak.git
cd picpeak

# Copy the environment template — the defaults work out of the box.
# Machine secrets (JWT, DB, Redis) are auto-generated on first run, and the
# admin account is created in the browser. Edit .env only to customise
# (domain, SMTP, storage paths, …) — nothing is required.
cp .env.example .env

# Start with Docker Compose
docker compose up -d

# Access at http://localhost:3000

On first start, open http://localhost:3000/admin and follow the in-browser setup to create your admin account. Full details — the one-time setup token, Docker file permissions, and ARM64 notes — are in First-run setup.

Updating / release channels: set PICPEAK_CHANNEL (stable default, or beta) in .env, then docker compose pull && docker compose up -d. See RELEASING.md for the promotion cadence.

Or: one container, no compose file

For a home server, a NAS, or a single small studio, the all-in-one image runs the whole app as one process with SQLite — no compose file, no separate database, no reverse proxy to wire up:

docker run -d --name picpeak -p 3000:3000 \
  -v picpeak:/data \
  ghcr.io/picpeak/picpeak/aio:main

No environment variables to set — the JWT secret is generated on first start and kept on the volume.

Then open http://localhost:3000/admin and read the setup token with docker exec picpeak cat /data/db/SETUP_TOKEN, or open db/SETUP_TOKEN on the volume with any file manager if the host has no shell.

:main is the active-development tag, and today it is the only one the all-in-one image has — Dockerfile.aio landed after the current stable release, so :stable and :latest first appear for this image once the aio build reaches the stable branch. Switch to :stable then, or pin a published version tag if you would rather not track main.

The compose stack above is still the right choice for anything busier — SQLite takes one writer at a time, and Postgres is what scales. You can move to it later without reinstalling: take a .picpeak backup and restore it into the full stack. See Single-container install for the volume layout, the external-Postgres variant, TLS, and the limits.

Docker images

GHCR Docker Hub
Backend ghcr.io/picpeak/picpeak/backend picpeak/backend
Frontend ghcr.io/picpeak/picpeak/frontend picpeak/frontend
All-in-one ghcr.io/picpeak/picpeak/aio picpeak/aio
ML sidecar (optional) ghcr.io/picpeak/picpeak/ml picpeak/ml

Both registries get the same digests and the same tags — stable/latest, a pinned x.y.z, and beta/main for the active development channel — for linux/amd64 and linux/arm64. Keep every image in one install on the same tag.

🌟 Why PicPeak?

Unlike expensive SaaS solutions, PicPeak gives you:

  • 💰 No Monthly Fees — one-time setup, unlimited galleries
  • 🔒 Complete Data Control — your photos stay on your server
  • 🎨 White-Label Ready — full branding customization
  • 📱 Mobile-First Design — beautiful on all devices
  • 🌍 Multi-Language — built-in i18n (EN, DE)

Features

For photographers — drag & drop upload, auto-expiring & password-protected galleries, automated emails, an analytics dashboard, custom themes, a public landing page, and a Live Slideshow projector view that auto-picks-up new uploads during live events.

For clients — clean mobile-optimized galleries, one-click bulk downloads, smart search, People in this gallery face grouping (opt-in per gallery, needs the optional ML sidecar), optional guest uploads, and download protection (watermarking + right-click prevention).

Technical — Docker-ready, automatic thumbnail generation, external media reference mode, smart archiving of expired galleries, S3-compatible storage backends, webhooks, and security-first defaults (JWT, rate limiting, CORS).

🧾 For studios — CRM & Accounting (Beta, off by default)
  • 📝 Quotes → Contracts → Invoices — one deal lineage; cancel-and-reissue (Storno) keeps issued invoices immutable
  • ⏱️ Hours Logging & Calendar — per-customer time tracking; admin calendar of events, logged hours, and pending quotes/contracts
  • 🧾 Inbound Supplier Invoices & Expenses — capture received invoices (upload/camera, rasterised server-side), categorise, and re-bill costs to clients
  • 📊 Tax Report & Accountant Export — period-scoped income/cost report with VAT breakdown; PDF/CSV plus a Treuhänder/Banana (Swiss/LI) journal export
  • 🌍 VAT & Multi-currency — single VAT-code registry snapshotted onto each document

Warning

CRM & Accounting — examples only, verify locally. Feature-flagged off by default. Seeded contract blocks are written by the maintainer, not a lawyer; QR-bills/SEPA payloads and every tax, VAT and Treuhänder/Banana figure are computed from your input and defaults and are jurisdiction-specific guidance only. Have your lawyer review contracts, scan a test QR with your bank's app, and verify all numbers with your accountant / Treuhänder / tax authority before customer-facing use. Read the CRM disclaimers first.

📖 Documentation

Full documentation lives at docs.picpeak.app — deployment, admin settings, API, branding, and more.

Topic Link
🚀 Deployment (Docker, env, reverse proxy, SSL) docs.picpeak.app/deployment
📦 Single-container install (one docker run, SQLite) docs.picpeak.app/deployment/single-container
⚙️ Admin settings reference docs.picpeak.app/guides/admin-settings
🎯 Creating events docs.picpeak.app/guides/creating-events
📽️ Live Slideshow docs.picpeak.app/features/live-slideshow
🙂 People in galleries (face grouping) docs.picpeak.app/features/face-recognition
💾 Backup & Restore docs.picpeak.app/guides/backup-restore
🔌 API reference docs.picpeak.app/api
🪝 Webhooks docs.picpeak.app/features/webhooks
💾 Storage backends (local / S3) docs.picpeak.app/features/storage-backends
💻 System requirements & tuning docs.picpeak.app/deployment/system-requirements
🧾 CRM & Accounting docs.picpeak.app/features/crm · disclaimers
🗺️ Roadmap GitHub Issues

Project meta: Contributing · License · Security · Code of Conduct

📊 Comparison with Alternatives

Feature PicPeak PicDrop Scrapbook.de Pixieset
Self-Hosted
Custom Branding Full Limited Limited (paid)
Monthly Cost $0* $29-199 €19-99 ~$60
Storage Limit Unlimited** 50-500GB 100-1000GB 3GBUnlimited***
Client Uploads Limited
API Access Paid
Open Source
Customer Accounts
Quotes / Contracts / Invoices 🧪 Beta
Incoming Invoices & Accounting 🧪 Beta

*You bring your own server and, optionally, a domain. **Limited only by your server storage. ***Pixieset's "unlimited" is photos only; video is capped by plan. 🧪 Beta = built but feature-flagged off by default.

🏗️ Tech Stack

  • Backend: Node.js, Express, SQLite/PostgreSQL
  • Frontend: React, Tailwind CSS, Framer Motion
  • Storage: Local filesystem (default) or S3-compatible object store (AWS S3, MinIO, R2, B2, Wasabi, Spaces) — see Storage Backends
  • Email: SMTP with customizable templates
  • Analytics: Privacy-focused with Umami integration
  • External media: point PicPeak at EXTERNAL_MEDIA_ROOT to reference existing originals read-only, index quickly, and generate thumbnails on demand

📸 Screenshots

Click to see the admin dashboard, analytics, and event management

🎛️ Admin Dashboard

PicPeak Admin Dashboard

📊 Analytics & Insights

PicPeak Analytics Dashboard

📁 Event Management

PicPeak Events Management

🤝 Contributing

We love contributions! PicPeak is built by photographers, for photographers — whether you're fixing bugs, adding features, or improving docs. See the Contributing Guide to get started.

Found a security issue? Please open a security issue. See SECURITY.md for the policy.

Support the Project

PicPeak is free, open source, and self-hostable forever. If it saves you time or replaces a paid subscription, consider buying me a coffee — it directly funds new features, bug fixes, and keeping the demo + docs running. You can also star the repo, share it, file good bug reports, or open a PR.

🙏 Acknowledgments

PicPeak is inspired by the best features of commercial platforms while remaining completely open source. It's developed with AI assistance, but human-tested end-to-end, security-audited, and human-reviewed for quality.

👥 Contributors

A huge thank you to the people whose code, reports, and feedback have shaped PicPeak:

@the-luap — creator and lead maintainer

  • Gallery foundation (events, uploads, sharing, download protection, templates)
  • Backup & restore, analytics, branding/theming
  • The architecture every later feature builds on

@Luca-Timo

  • Native Apple Silicon multi-arch images
  • CRM & accounting suite (quotes/contracts/invoices)
  • Hours logging & Treuhänder/Banana tax export
  • Gallery header/banner decoupling

@Rekoo-PS — bug reports & product feedback

  • Login-loop fix, mobile-lightbox overhaul, bulk-delete workflow
  • Also a BuyMeACoffee supporter

If you've contributed and aren't listed here, please open a PR — this list is meant to grow.

📄 License

PicPeak is released under the MIT License. Use it freely for personal or commercial projects.


Made with ❤️ by photographers, for photographers
Homepage · Live Demo · Documentation · Support

S
Description
Secure photo sharing platform for weddings and events with automatic expiration and email notifications
Readme MIT 157 MiB
2025-07-24 15:24:20 +02:00
Languages
JavaScript 59.8%
TypeScript 38.5%
Shell 0.7%
Python 0.5%
CSS 0.4%
Other 0.1%