Paul Nothaft 9003b34c8a fix(docker): default NODE_ENV=production so non-compose deploys don't fall back to SQLite (#1038) (#1040)
* fix(docker): default NODE_ENV=production so non-compose deploys don't fall back to SQLite (#1038)

knexfile.js selects its config block by NODE_ENV and the `development` block
defaults to sqlite3. The image never set NODE_ENV, so every deployment that
doesn't go through our compose files — Kubernetes, Helm, plain `docker run` —
silently ran on SQLite and ignored DB_HOST/DB_USER/DB_PASSWORD.

It stayed invisible because wait-for-db.sh is shell: it reads DB_HOST directly,
connects to Postgres, creates the database and logs "PostgreSQL is up" in the
same container where the Node process then writes to a SQLite file. Migrations
go through src/database/db.js → the same knexfile, so they also ran against
SQLite, leaving the provisioned Postgres database empty.

Setting the default alone would be unsafe: an affected install would flip to
Postgres on its next image pull and come up against an EMPTY database, which
reads as total data loss. So this adds a guard that runs before migrations
touch anything:

  - logs the resolved engine + target at boot
  - refuses to start when pointed at a virgin Postgres while a populated
    SQLite file exists, naming the file and the .picpeak export path for
    moving the data, with PICPEAK_ALLOW_EMPTY_PG=true as the escape hatch
  - warns but boots when Postgres settings are present yet SQLite is in use

Compose files already set NODE_ENV explicitly, so compose users are unaffected.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): stay on SQLite instead of blocking, and add a migration path (#1038)

Reworks the guard after walking through what an existing install actually
experiences on its next image pull.

Blocking was the wrong trade. An operator who had unknowingly been running on
SQLite (because the image left NODE_ENV unset) would have pulled the fix and
got a CrashLoopBackOff: data safe, galleries offline, for something they did
not do. Now the boot RESOLVES the engine before migrations run and stays on
whichever one holds the data:

  - Postgres configured but holding no galleries, while a populated SQLite file
    exists → keep serving from SQLite, print what happened and how to migrate.
  - once Postgres holds the data, the next restart switches over on its own.
  - an explicit DATABASE_CLIENT is always honoured.

Keyed on Postgres holding DATA, not on it having tables: a stray
`run-migrations` against the empty database creates every table, which would
otherwise blind the check.

Adds scripts/migrate-sqlite-to-postgres.js, which reuses the .picpeak
export/import services rather than hand-rolling a cross-engine copy. Two
additions were needed for the SQLite → Postgres direction, both opt-in and
CLI-only so the upload/restore UI is untouched:

  - `allowEngineSwitch` relaxes the importer's same-engine guard
  - cross-engine row coercion: SQLite has no real date or boolean types, so its
    rows carry epoch numbers where Postgres wants a timestamp and 0/1 where it
    wants a boolean, both of which Postgres rejects outright. Driven by the
    TARGET schema, never guessed from the value.

DELTA FROM THE BETA PR: this branch's import service has no resyncSequences()
— that landed on main only. Without it a cross-engine load leaves Postgres
identity sequences at 1 and the next insert collides on the primary key, so
the function is backported here and called ONLY on the cross-engine path.
Same-engine restores through the UI keep their current behaviour exactly.

Verified end to end on this branch against a real PostgreSQL 15: a seeded
SQLite install migrated across with booleans, timestamps and foreign keys
intact, and the next INSERT got id 2 rather than colliding.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): close four review findings on the SQLite fallback + migration (#1038)

External review (codex) found four issues, all confirmed against the code and
fixed here. Two of them could have cost data.

1. The engine resolver was reachable only through wait-for-db.sh. A Kubernetes
   manifest that sets `command`/`args`, or a plain `docker run … node
   server.js`, bypasses the entrypoint — exactly the deployment styles this fix
   targets. With NODE_ENV now baked into the image, such an install would have
   resolved to Postgres and come up against an empty database while its SQLite
   data sat there unseen. server.js now resolves the engine itself, before
   anything requires knexfile, via the same script the entrypoint uses.
   Verified by running `node server.js` directly against an install with
   stranded SQLite data: it logs the banner and serves SQLite.

2. Cross-engine loads double-encoded JSON. SQLite has no json type, so its json
   columns are TEXT holding JSON; the export dumps that as a string and
   serialiseJsonColumns stringified it again, storing `true` as the scalar
   string "true". app_settings.setting_value is json on every install, so this
   reshaped every migrated setting. The text is decoded before serialisation
   now — verified against a real Postgres: json_typeof(setting_value) is
   `boolean`, matching a native install exactly.

3. The migration could silently miss concurrent writes. If the backend keeps
   serving, rows written after the export never reach Postgres and vanish from
   view once the engine switches. The script now fingerprints the SQLite tables
   whose loss would be noticed, checks for drift BEFORE loading Postgres (so a
   detected race leaves the target untouched) and again after, and refuses with
   the exact rows that moved. It also says plainly to stop the backend first.

4. The child phases shared stdout with winston. Outside production, and
   whenever LOG_TO_CONSOLE=true, createPicpeak's own log line was concatenated
   with the archive path and the migration failed on a bogus filename. Payloads
   travel through a result file now; verified with LOG_TO_CONSOLE=true.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): close review round 2 — six more data-safety findings (#1038)

1. The engine choice is now PINNED once the data is in Postgres. Previously the
   boot decided from "does Postgres hold galleries", so an operator who later
   deleted every gallery would be sent back to the stale pre-migration SQLite
   file while their settings, admins and CRM data stayed in Postgres. The
   migration writes a marker next to the database file (and retires the file
   itself by renaming it); the marker wins over any probe.

2. The migration refused to overwrite Postgres only when it held GALLERIES. A
   target with admins, customers, invoices or projects but no galleries was
   wiped without --force. Both the source and target checks now look for user
   data across the tables that are empty on a fresh install.

3. Same bug in the other direction: an install with no galleries but real
   admins/settings/customers was refused a migration it was entitled to.

4. Drift detection covered four tables and only count/max(id), so an in-place
   UPDATE (event edit, password change) or a write to any other table passed
   unnoticed. It now fingerprints every table the export carries, including
   max(updated_at). It still is not a substitute for stopping the backend, and
   the script says so rather than implying a guarantee.

5. probeSqliteData() treated an unreadable or corrupt file as "no data", which
   would have switched the install to an empty Postgres — the very failure this
   module exists to prevent. It fails closed now and stays on SQLite so the real
   error surfaces.

6. The "you are leaving SQLite data behind" warning was unreachable: setting
   DATABASE_CLIENT skipped the probes, so the branch that produces it never had
   the inputs. Postgres and SQLite are both probed whenever Postgres is the
   engine in play.

Also: the final verification compares row counts for EVERY table rather than
just galleries, and flags only a shortfall — the import legitimately adds an
app_settings row (setSessionsValidAfter) that made the strict equality fail on
a first real run.

Verified against a real PostgreSQL 15 end to end, including: the marker keeps
an install on Postgres after every gallery is deleted; removing the marker and
restoring the file rolls back to SQLite as documented.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): close review round 3 — occupancy, bootstrap admin, secrets in /tmp (#1038)

1. Both engine probes judged occupancy by GALLERIES alone. An install whose
   galleries were all deleted, but which still has admins, customers or
   accounting records, was treated as empty: on the SQLite side that meant
   booting the empty Postgres and appearing to lose everything; on the Postgres
   side it meant diverting a live install to a stale SQLite file. Both now look
   across the tables that are empty on a fresh install, matching the migration
   script.

2. The migration ran migrate-schema BEFORE checking the target, and migration
   001 seeds a bootstrap admin when ADMIN_PASSWORD is set (common on legacy
   installs). The occupancy check then saw that admin and refused, pushing the
   operator towards --force against a genuinely empty database. The target is
   read first now.

3. probeSqliteData()'s warning went through the app logger, which writes to
   STDOUT when LOG_TO_CONSOLE=true — and the resolver's stdout is the protocol
   channel wait-for-db.sh captures, so DATABASE_CLIENT could have been set to a
   JSON log line. Diagnostics take an injected sink (stderr in the resolver),
   and the shell now validates the value it captured instead of trusting it.

4. The .picpeak archive holds password hashes, SMTP credentials and API keys in
   plaintext, and was only removed on the fully-successful path — any drift or
   import failure left it in /tmp. Every exit path removes it now.

5. A database-only migration still hauled every business-doc and upload through
   /tmp and back into the same volume. createPicpeak takes includeFiles:false
   for this path; rows move, files stay where they already are.

Verified against a real PostgreSQL 15: a gallery-less install with only an admin
account now stays on SQLite and migrates successfully with ADMIN_PASSWORD set;
the resolver emits exactly one token on stdout with LOG_TO_CONSOLE=true and a
corrupt database; a drift failure leaves Postgres untouched and no archive
behind.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): pin the boot to SQLite while a migration is unfinished (#1038)

Review round 4. A migration that dies after touching Postgres leaves rows
behind — schema creation alone seeds a bootstrap admin when ADMIN_PASSWORD is
set, and a drift or row-count failure can leave a partial load. Since the
occupancy probes were widened in round 3, those rows read as "Postgres is
occupied", so the next restart would switch engines and hide the SQLite data
that is still the database of record.

The script now writes a pin file next to the database BEFORE its first Postgres
write and clears it only on success (after the success marker exists, so no
restart in between can pick the wrong engine). While the pin is present the
resolver stays on SQLite and explains why.

Verified against a real PostgreSQL 15 by reproducing the exact scenario: a
migration failed mid-run with ADMIN_PASSWORD set, leaving one bootstrap admin
in Postgres. With the pin the next boot resolves to sqlite3; with the pin
removed it resolves to pg — the failure this closes. The subsequent successful
re-run clears the pin and the boot moves to Postgres.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): close review round 5 — occupancy, path drift, retry, host default (#1038)

1. A seeded bootstrap admin counted as "Postgres is occupied". core/001_init.js
   inserts one whenever ADMIN_PASSWORD is set, so a Postgres that was
   initialised once and never used would have beaten a SQLite file full of real
   galleries — the exact failure the guard exists to prevent, reintroduced by
   widening the probe in round 3. The two sides are deliberately asymmetric now:
   the SQLite probe counts any user data (err towards keeping data visible),
   the Postgres probe ignores rows that schema creation seeds (err towards
   requiring proof of real use).

2. The guard resolved DATABASE_PATH with its own logic while knexfile trimmed
   whitespace and collapsed the legacy duplicated-backend form. A path either
   engine normalised differently meant probing a file nobody uses, concluding
   there was no SQLite data, and booting an empty Postgres. The resolution now
   lives in one module both require.

3. Re-running after a partial migration — the documented recovery — was refused
   unless the operator passed the destructive-sounding --force, because the
   half-written rows read as target data. An unfinished run of this same script
   is now recognised as a safe retry.

4. wait-for-db.sh verified readiness against its own default host (`postgres`)
   while knexfile's production block defaults to `db`. With NODE_ENV now baked
   in, a bare `docker run` without DB_HOST would have passed the readiness check
   against one host and then dialled another. The entrypoint exports the exact
   connection it verified. Compose sets DB_HOST explicitly and is unaffected.

Verified: a Postgres holding only a seeded admin now loses to real SQLite data;
a DATABASE_PATH with surrounding whitespace resolves to the identical file in
both knexfile and the guard.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): close review round 6 — explicit-client bypasses, retry scope, cleanup (#1038)

1. An explicit DATABASE_CLIENT bypassed the unfinished-migration pin, because
   decideBootEngine honoured it first. docker-compose sets DATABASE_CLIENT=pg,
   so a failed migration would have restarted on a half-written Postgres on
   exactly the deployments that pin it. Worse in the other direction: with
   DATABASE_CLIENT=sqlite3, a SUCCESSFUL migration renames the source file, so
   the next start created a NEW, empty SQLite database and served that. The pin
   now outranks explicit pg (clearing the marker is the override), explicit
   sqlite3 is left alone since it already points at the data, and the migration
   refuses up front when the deployment pins anything other than pg.

2. The retry allowance was bound to the SQLite file, not to the target. An
   operator who repointed DB_HOST/DB_NAME between attempts could have replaced
   an unrelated populated database without --force. The pin records the target
   and the allowance only applies when it matches.

3. The printed rollback did not roll back: with data on both sides and no
   marker, the resolver still selects Postgres. It now spells out all three
   steps, including DATABASE_CLIENT=sqlite3.

4. A failure inside createPicpeak left a partial archive — plaintext hashes and
   credentials — in the caller-supplied temp dir, which that service
   deliberately does not clean. The export phase removes it on error.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): close review round 7 — pin bypass on direct start, real admins (#1038)

1. server.js only ran the engine resolver when DATABASE_CLIENT was unset, so a
   deployment that both bypasses the entrypoint (Kubernetes `command:`) AND
   pins DATABASE_CLIENT=pg never consulted the migration pin — the round-6 fix
   was unreachable on exactly that path, and a failed migration would have
   served a half-populated Postgres. The resolver now also runs whenever a pin
   file exists.

2. Round 5 excluded admin_users from Postgres occupancy to stop a seeded
   bootstrap admin counting as real data. That over-corrected: an install that
   has completed first-run setup but has no galleries yet has exactly one
   user-created row — an admin — so Postgres looked empty and, with a stale
   SQLite file present, the boot would switch away and the admin's credentials
   and configuration would disappear.

   core/001_init.js seeds must_change_password=true; setupService writes false
   once a human completes setup. The FLAG, not the table, distinguishes them,
   and a legacy NULL counts as a real admin.

Verified against a real PostgreSQL 15: a Postgres holding only the seeded row
loses to real SQLite data, the same Postgres wins once setup is completed, and
a server started directly with DATABASE_CLIENT=pg and a pin present comes up on
SQLite with the warning.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): close review round 8 — reset admins, CLI config, JSON nulls (#1038)

1. must_change_password is mutable: resetAdminPassword() re-raises it on REAL
   accounts (userManagementService.js:474). Round 7's discriminator therefore
   read a gallery-less Postgres whose only admin had been reset as an untouched
   bootstrap seed — and with a stale SQLite file present, the boot would have
   switched away and hidden those live credentials. The rule is layered now:
   more than one admin, any admin that has logged in, or must_change_password
   false all count as use. Only core/001_init.js's exact leftovers — one admin,
   never logged in, still flagged — read as a seed.

2. The CLI read process.env directly but never loaded the configuration the
   child phases get through knexfile, so running it directly (or via
   `docker exec`, which does not inherit wait-for-db.sh's exports) failed the
   pre-flight checks even with valid settings in backend/.env or
   /run/secrets/db_password. Both sources are loaded up front now.

3. The migration's target check counted a seeded bootstrap admin as user data
   while probePgData classified the identical row as empty, so migrating into a
   previously-initialised-but-unused Postgres demanded --force. Same rule on
   both sides.

4. Cross-engine JSON handling is simpler and no longer lossy. SQLite keeps json
   columns as TEXT holding valid JSON and Postgres accepts JSON text directly,
   so the correct action is to pass them through untouched. Round 1 parsed then
   re-serialised them to undo a double-stringify; that round-tripped the JSON
   literal `null` into SQL NULL, changing data and breaking NOT NULL json
   columns. Not serialising at all fixes both.

Verified against a real PostgreSQL 15: a migrated install now carries
json_typeof = null for a JSON null, object for a nested object, and boolean for
a boolean — matching a native install exactly.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): close review round 9 — probe error classes, marker ordering (#1038)

1. probePgData() answered every failure with "Postgres has data". That is right
   for an unreachable server — the app cannot run on it either way, and
   diverting a healthy pg install to a stale SQLite file over a transient blip
   would be worse — but wrong for a server that answers and then fails the
   query, which is what a half-built or damaged schema looks like. That is not
   evidence of data, and reporting it as such booted the empty Postgres and hid
   a populated SQLite file: the exact failure this guard exists to prevent.

   Reachability is now established with SELECT 1 first, so the two cases get
   opposite answers: unreachable → leave the configured engine alone;
   reachable-but-uninspectable → unproven, and the SQLite side wins if it
   actually holds data.

2. The success marker was written after the SQLite file was renamed away. A
   failure in between — a full disk — left the source retired with no marker:
   the next attempt reported "No SQLite database", the in-progress pin stayed,
   and the operator never saw the rollback path. The marker is written first
   and updated with the retired filename once the rename succeeds, so a failure
   at any point leaves everything recoverable.

Verified against a real PostgreSQL 15: a reachable database whose admin_users
table lacks the probed column now resolves to sqlite3 rather than hiding the
data, while an unreachable host still resolves to pg.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): don't fail the migration on empty SQLite-only tables (#1038)

Review round 10. The final verification flagged every source table missing from
Postgres, regardless of whether it held rows — and SQLite-only tables do exist:
initializeDatabase() creates an `events_new` scratch table and, when its legacy
column copy throws, the catch swallows the error and leaves the empty table
behind (db.js:236). The importer correctly skips tables Postgres does not have,
so verification then reported a mismatch AFTER the data had already landed,
exited 1, and left the install pinned to SQLite with no way to finish.

An absent target table only matters if the source actually had rows. Empty ones
are now listed and skipped.

Reproduced both ways against a real PostgreSQL 15 with an events_new table
present: without the fix the run ends in "ROW COUNTS DO NOT MATCH" and leaves
the in-progress pin; with it, the table is reported as skipped, the migration
completes and the pin is released.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): a completed migration overrides an implicit SQLite config (#1038)

Review round 11. The migration allowed the one configuration it should have
worried about most: DATABASE_CLIENT unset AND NODE_ENV not "production", which
resolves to the development block — i.e. sqlite3. That is precisely the state
the affected installs are in, since it is why they ended up on SQLite at all,
so an operator can easily run the migration before fixing it. The script then
renames the source database away, and the next start resolved to the implicit
sqlite3, created a NEW empty database and served it — after reporting success.

The success marker now overrides an IMPLICITLY resolved sqlite3 when Postgres
settings are present, because the marker is durable proof of where the data
actually went. An explicit DATABASE_CLIENT=sqlite3 still wins: that is the
documented rollback.

The script says something rather than refusing — refusing would block exactly
the population this exists for.

Reproduced with NODE_ENV and DATABASE_CLIENT both empty, against a real
PostgreSQL 15: the migration completes, the source is renamed away, and the
next boot resolves to pg with the data intact. Before this it resolved to
sqlite3 and would have served an empty database.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* refactor(db): drop the dead reachability flag in probePgData (#1038)

github-code-quality flagged `if (reachable)` as always true, and it is right:
the unreachable branch returns, so everything below it runs only when the probe
connected. The variable and the conditional were leftovers from a first draft
that used a single catch for both failure classes.

No behaviour change — the two error paths still return opposite answers.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): refuse to choose when both databases hold data (#1038)

Review round 12.

1. An install that ran on PostgreSQL, lost NODE_ENV/DATABASE_CLIENT, and kept
   working on SQLite has REAL data on both sides: old rows in Postgres, newer
   ones in SQLite. The stranded-data rule only protected SQLite when Postgres
   was empty, so pulling this fix would have booted Postgres and hidden every
   gallery created since the switch — the exact failure this PR exists to
   prevent, in a variant I had not considered.

   A completed migration leaves a marker saying which side is current. Without
   one, two populated databases are a conflict: the boot stops and prints both
   targets, the two DATABASE_CLIENT values that resolve it, and the migration
   command that merges them. This is the only deliberate refusal in the change —
   guessing here would hide data AND split subsequent writes across two
   databases.

2. probePgData was handed knexConfig.connection even when knexfile had resolved
   to SQLite (a completed migration whose environment still says sqlite3), so
   node-postgres dialled its own localhost defaults instead of DB_HOST/DB_NAME —
   false "unreachable" diagnostics and a needless delay on every boot. The probe
   target is now built from the environment when the config is not pg.

The conflict is honoured by all three entry points: the resolver exits 3 with an
empty stdout, wait-for-db.sh stops the container, and server.js refuses to start.

Two existing tests asserted that Postgres wins when both sides hold data. They
encoded the pre-conflict assumption and described a state that cannot occur
after a real migration (which always leaves a marker); both now pass the marker.

Found while testing: the resolver's logger shim had no .error, so the conflict
path threw, was swallowed by the fallback, and silently chose Postgres — the
precise outcome this refuses to make. The shim is complete now.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): symmetric bootstrap rule, one resolved Postgres target (#1038)

Review round 13. Both findings are consequences of earlier rounds.

1. The conflict rule added in round 12 counted an untouched SQLite bootstrap
   admin as data. core/001_init.js seeds one whenever ADMIN_PASSWORD is set —
   including into the accidental SQLite database — so a healthy Postgres install
   that had ever started once without NODE_ENV would have had a seeded-only
   SQLite file beside it, been declared a both-populated conflict, and REFUSED
   TO BOOT. The bootstrap discrimination is applied on both sides now; a
   setup-completed or logged-in admin still counts as real use on either.

2. The CLI's child phases inherited whichever knexfile block NODE_ENV selected.
   The development block defaults Postgres to localhost/postgres/photo_sharing,
   production to db/picpeak/picpeak — and this script is explicitly meant to run
   with NODE_ENV unset. With DB_USER/DB_NAME left to defaults it would therefore
   have migrated into `photo_sharing`, after which following the script's own
   advice to set NODE_ENV=production pointed the app at an empty `picpeak`.

   The target is resolved once, with production defaults, and passed explicitly
   to every phase — so the block knexfile happens to pick can no longer decide
   which database the data lands in. The pin and success marker record that same
   resolved identity.

Verified against a real PostgreSQL 15: a live Postgres beside a seeded-only
SQLite file now boots pg rather than refusing, flipping that admin to
setup-completed restores the conflict, and a migration records
localhost:7102/picpeak_r13b as its target rather than a defaulted guess.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): one Postgres identity everywhere; protect the credentials file (#1038)

Review round 14. Three of the six findings were the same defect as round 13's,
surfacing through paths that fix did not cover: the connection used to PROBE or
MIGRATE could differ from the one the application then OPENS, because
knexfile's development block points Postgres at localhost/postgres/photo_sharing
while production uses db/picpeak/picpeak.

1. server.js exported only DATABASE_CLIENT=pg after the resolver decided, so
   knexfile filled in host/user/database from whichever block NODE_ENV selected.
   With SQLite already retired by a migration, that meant opening an empty
   database. The whole connection is pinned now.

2. Two defaults existed for DB_HOST: wait-for-db.sh resolves and exports
   `postgres`, knexfile's production block says `db`. Since the entrypoint
   exports its value, `postgres` is what a running container actually uses — so
   a `docker exec` migration, which inherits neither, has to agree with that,
   not with the default that is only reached when the entrypoint did not run.

3. The migration's Postgres phases inherited an unset NODE_ENV and therefore the
   development block, which ignores DB_SSL entirely — a managed Postgres
   requiring TLS could never be migrated into. The phases run with production
   semantics now.

4. core/001_init.js writes data/ADMIN_CREDENTIALS.txt, and that data directory
   belongs to the SOURCE install. Bootstrapping the Postgres schema replaced the
   operator's real credentials file with ones for a temporary admin the import
   immediately discards. The file is preserved across the phase, including when
   it fails.

5. The boot line described knexConfig, so an install redirected to Postgres by a
   migration marker still logged "Database engine: sqlite (...)", contradicting
   the warning printed one line earlier.

6. On a both-populated conflict resolveBootEngine returns client:null, and both
   migration runners told the operator their data was in "null" and to set
   DATABASE_CLIENT=null. They now present the two real choices.

Verified against a real PostgreSQL 15: a migrated install started directly with
NODE_ENV unset now logs `postgres (localhost:7102/picpeak_r14)` and opens it,
where before it would have gone to the development block's photo_sharing.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* refactor(db): resolve the PostgreSQL target in exactly one place (#1038)

Rounds 13 and 14 both traced back to the same thing, each time through a caller
the previous fix had not covered: three different defaults existed for the same
connection.

  knexfile development : localhost / postgres / photo_sharing
  knexfile production  : db        / picpeak  / picpeak
  wait-for-db.sh       : postgres  / picpeak  / picpeak   (and it EXPORTS them)

So a process that probed or migrated against one could hand over to a process
that opened another. Patching each caller was not converging — the guard, then
the CLI's child phases, then server.js — so this deletes the divergence instead.

`src/utils/pgConnection.js` now owns the resolution and knexfile's development
and production blocks both derive from it, as does the engine guard. Same shape
as the earlier sqlitePath.js extraction, for the same reason.

The database NAME is what made this dangerous: a wrong host or user fails
loudly at connect time, while a wrong name connects fine and presents an empty
installation.

BEHAVIOUR CHANGE: with DATABASE_CLIENT=pg and no DB_* variables, a
non-production environment now resolves to postgres/picpeak/picpeak instead of
localhost/postgres/photo_sharing. Deployments are unaffected — compose sets
these explicitly and wait-for-db.sh exports them — but a local machine running
Postgres bare now needs DB_HOST=localhost DB_USER=postgres DB_NAME=photo_sharing
(or DATABASE_CLIENT=sqlite3, which is what backend/.env already uses). The
failure mode of getting this wrong is a refused connection, not a silently empty
database.

Side effect worth having: DB_SSL is now honoured whatever NODE_ENV says, so the
managed-Postgres case is fixed at the root rather than by forcing production
semantics onto the migration's child phases.

The test block keeps its own photo_sharing_test default — isolation is the point
there.

Verified: every block plus the guard resolve identically from the same
environment; explicit DB_* still wins; production's pool tuning is preserved;
and a full SQLite → PostgreSQL migration with NODE_ENV unset lands in the right
database with JSON shapes intact.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): two more components that guessed the database instead of asking (#1038)

Both found while sweeping for copies of the connection defaults. Checked in
detail first — one of my suspicions about them was wrong.

scripts/set-admin-password.js hand-rolled its own knex config while all four
sibling scripts (reset-admin-password, create-admin, show-admin-credentials,
reset-admin-mfa) use the application's connection. Two consequences:

  - it read DB_CLIENT, a variable nothing else in this codebase sets, so it
    defaulted to Postgres and could not work on a SQLite install at all;
  - it defaulted to database `picpeak_dev`, a name no other component uses.

It now uses `require('../src/database/db')` like its siblings, so it follows
whatever engine the install actually runs on. Timestamps are written as ISO
strings because it reaches SQLite now, where raw Date objects are the documented
landmine.

NOT changed: the script's "all existing sessions have been invalidated" notice
is accurate — auth.js compares token iat against password_changed_at — and it
deliberately leaves must_change_password alone, which is right for an operator
choosing a password rather than being issued one.

routes/adminSystem.js re-derived three things the live connection already knows,
and each could disagree with it:

  - the engine, from DATABASE_CLIENT || 'sqlite3' — so a Postgres install
    without an explicit DATABASE_CLIENT took the SQLite branch;
  - the Postgres database, from DB_NAME || 'picpeak';
  - the SQLite file, from a hardcoded ../../data/photo_sharing.db that ignored
    DATABASE_PATH entirely.

All three now come from db.client.config, with pg_database_size(current_database()).

Verified: set-admin-password works on SQLite (new hash verifies, old rejected)
and still on PostgreSQL; and on a SQLite install with a custom DATABASE_PATH the
size logic reports the real database (1,748,992 bytes) where the old code
reported a different file entirely (1,851,392) — or 0 where that path does not
exist.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): bind the migration marker to its target; fix a phantom table (#1038)

Review round 15.

1. The marker records `host:port/database`, but only its EXISTENCE was checked.
   Repoint DB_NAME or DB_HOST at a different, empty PostgreSQL after migrating
   and the marker would vouch for that one too — booting it, presenting an empty
   installation, and suppressing the SQLite fallback while the real data sits in
   the recorded target and the renamed rollback copy. The marker is compared
   against the current connection now, and a mismatch stops the boot with both
   targets named and the two ways out.

2. `incoming_invoices` is not a table — supplier documents live in
   `inbound_documents` (core migration 124). Both occupancy lists skip tables
   that do not exist, so those records were silently not protecting anything:
   an install whose only remaining data was inbound documents could be switched
   away from, or overwritten without --force. Verified every other name in the
   lists against the live schema at the same time.

Verified: a marker naming picpeak_original with picpeak_mk configured refuses
with exit 3 and prints both; making them agree boots pg.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-13 18:51:20 +02:00

📸 PicPeak - Open Source Photo Sharing for Events

Important

PicPeak has moved to its own GitHub organization.

  • Docker images are now published at ghcr.io/picpeak/picpeak/{backend,frontend}. The old path (ghcr.io/the-luap/picpeak/...) is no longer served — update your docker-compose.yml.
  • Branches: active development is now on main (was beta); the curated stable channel is now stable (was main). Existing PRs and clones auto-redirect via GitHub.

See docs/migration-to-org.md for the one-line docker-compose.yml edit and full details.

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

PicPeak Gallery Preview

🎮 Live Demo

Try PicPeak without installing anything:

Demo URL demo.picpeak.app
Admin Panel demo.picpeak.app/admin
Email demo@picpeak.app
Password Demo2026!

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

🌟 Why Choose 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
  • 🚀 Lightning Fast - Optimized performance and caching
  • 🌍 Multi-Language - Built-in i18n support (EN, DE)

Key Features

For Photographers

  • 📁 Drag & Drop Upload - Simply drop photos into folders
  • 🔗 External Media (Reference Mode) - Browse and import from a readonly external folder library without copying originals
  • Auto-Expiring Galleries - Set expiration dates (default: 30 days)
  • 🔐 Password Protection - Secure client galleries
  • 📧 Automated Emails - Creation confirmations and expiration warnings
  • 📊 Analytics Dashboard - Track views, downloads, and engagement
  • 📽️ Live Slideshow - A separate fullscreen "Diashow" link per event for projectors at live events — auto-picks-up new uploads while it runs, with transitions, a logo watermark, and image-fit/colour options (guide)
  • 🎨 Custom Themes - Match your brand perfectly
  • 🌐 Public Landing Page - Publish a curated marketing page when guests visit your root URL

For Clients

  • 🖼️ Beautiful Galleries - Clean, modern interface
  • 📱 Mobile Optimized - Swipe through photos on any device
  • ⬇️ Bulk Downloads - Download all photos with one click
  • 🔍 Smart Search - Find photos quickly
  • 📤 Guest Uploads - Optional client photo uploads
  • 🛡️ Download Protection - Advanced image protection with watermarking and right-click prevention

Technical Excellence

  • 🐳 Docker Ready - Deploy in minutes
  • 🔄 Auto-Processing - Automatic thumbnail generation
  • 🗂️ Reference Library Support - Point PicPeak at EXTERNAL_MEDIA_ROOT to reference existing originals, index quickly, and generate thumbnails on demand
  • 💾 Smart Storage - Automatic archiving of expired galleries
  • 🛡️ Security First - JWT auth, rate limiting, CORS protection
  • 📈 Scalable - From small studios to large agencies

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, scopable to income-only or cost-only
  • 🌍 VAT & Multi-currency - Single VAT-code registry snapshotted onto each document; data-driven per-country rates
  • ⚠️ Verify locally - Feature-flagged off by default. Seeded contracts, QR/IBAN and tax defaults are examples only — review your own legal and tax regulations first (see disclaimers below)

🚀 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 (see below). 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

First run — create your admin account

On first start with no ADMIN_PASSWORD set, PicPeak has no admin account yet and greets you with an in-browser setup screen — no credentials in .env:

  1. Open http://localhost:3000/admin — you'll be redirected to /setup.
  2. Read the one-time setup token from the 0600 file the backend writes it to (it is deliberately not printed to the logs — that would leave a live bootstrap credential in docker logs):
    docker compose exec backend cat /app/data/SETUP_TOKEN
    
    It is bind-mounted, so sudo cat data/SETUP_TOKEN on the host works too. Only if that file could not be written does the backend fall back to logging the token (docker compose logs backend | grep -i "setup token").
  3. Paste the token, set your admin email + password, and you're in. The token is single-use, and the setup screen closes permanently once an admin exists.

Prefer the old behaviour? Set ADMIN_PASSWORD in .env and PicPeak auto-creates the admin on first boot instead (credentials written to data/ADMIN_CREDENTIALS.txt).

Note on Docker file permissions

  • The backend container starts as root, chowns bind-mounted host directories (./storage, ./data, ./logs) to UID 1001 (nodejs), then drops privileges via su-exec before running the app. No host-side setup needed for fresh installs.
  • If you pin user: in a compose override (e.g. to map a specific host UID), the self-chown is skipped and you must pre-chown the host directories to that UID — see docs.picpeak.app/deployment/docker#permissions.

ARM64 (aarch64) systems: Pre-built images include native linux/arm64, no platform flags or emulation needed. If you're on an older image tag that's still amd64-only, see docker-compose.amd64.override.yml for a transitional fallback.

🔄 Release Channels

PicPeak offers two release channels for different needs. Stable promotions are cut from a known-good beta point every 46 weeks — see RELEASING.md for the maintainer's promotion criteria and cadence policy.

  • Production-ready releases
  • Thoroughly tested before release
  • Docker tags: stable, latest, or specific version like v2.3.0

Beta Channel

  • Early access to new features
  • May contain bugs or incomplete functionality
  • Docker tags: beta or specific version like v2.3.0-beta.1

Switching Channels

Set the PICPEAK_CHANNEL environment variable in your .env file:

# For stable releases (default)
PICPEAK_CHANNEL=stable

# For beta releases
PICPEAK_CHANNEL=beta

# For a specific version
PICPEAK_CHANNEL=v2.3.0

Then update your containers:

docker compose -f docker-compose.production.yml pull
docker compose -f docker-compose.production.yml up -d

Update Notifications

The admin dashboard automatically notifies you when updates are available for your channel. To disable update checks, set:

UPDATE_CHECK_ENABLED=false

📖 Documentation

Full documentation lives at docs.picpeak.app — deployment, admin settings reference, API docs, webhooks, archive lifecycle, branding, and everything else. Some quick links:

  • 🚀 Deployment - Docker, environment variables, reverse proxy, SSL
  • ⚙️ Admin Settings - Every tab in the Settings panel
  • 🎯 Creating Events - Full event field reference
  • 📽️ Live Slideshow - Fullscreen projector view that auto-updates during live events
  • 💾 Backup & Restore - Backup configuration, restore wizard, full disaster recovery
  • 🔌 API Reference - REST endpoints, OpenAPI spec, webhooks
  • 🪝 Webhooks - Event payloads, signing, filters, templates

Project meta:

🌐 Public Landing Page

Spotlight your studio with a customizable marketing page at /:

  • Head to Admin → CMS Pages to enable the public landing page toggle.
  • Edit the provided HTML template (rich sections, hero, testimonials) and optional CSS overrides.
  • The preview renders in a sandboxed iframe so you can iterate safely before publishing.
  • PicPeak sanitizes stored HTML and CSS server-side—scripts, iframes, and unsafe attributes are stripped automatically.
  • Use Reset to default anytime to restore the bundled template.
  • The backend caches the rendered landing page for 60 seconds by default; override with PUBLIC_SITE_CACHE_TTL_MS if you need a different TTL.
  • When the landing page is disabled PicPeak continues to serve the admin SPA/login exactly as before.

🎯 Use Cases

Perfect for:

  • 💒 Wedding Photographers - Share ceremony photos securely
  • 🎂 Event Photography - Birthday parties, corporate events
  • 📸 Portrait Studios - Client galleries with download limits
  • 🏢 Corporate Events - Internal photo sharing with branding
  • 🎓 School Photography - Secure parent access with expiration
  • 📽️ Live Events - Put a Live Slideshow on the venue projector that updates as you shoot

🏗️ 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

💾 Storage Backends

PicPeak supports two storage backends for photos, thumbnails, hero images, watermarks, and archive zips. Both are configured via environment variables; no code change is required to switch.

Capability STORAGE_BACKEND=local (default) STORAGE_BACKEND=s3
Photo / thumbnail / hero storage Local filesystem under STORAGE_PATH Bucket on any S3-compatible service
Admin UI upload
Filesystem auto-import (chokidar watcher) — disabled (use the upload API)
Watermarks, fingerprinting, fragmentation (materialized to a tmp file just-in-time)
Bulk download zips (cached + on-the-fly)
Backups
External media reference mode (EXTERNAL_MEDIA_ROOT) (always local) (still local — not migrated)

Switching to an S3-compatible backend

  1. Provision a bucket and credentials. The minimum IAM policy is documented in .env.example.
  2. Set STORAGE_BACKEND=s3 plus STORAGE_S3_BUCKET, STORAGE_S3_REGION, STORAGE_S3_ACCESS_KEY, STORAGE_S3_SECRET_KEY. For non-AWS providers (MinIO, R2, B2, …) also set STORAGE_S3_ENDPOINT.
  3. If you have existing local content, copy it first: node backend/scripts/migrate-storage.js --dry-run then node backend/scripts/migrate-storage.js. The script is idempotent and writes a failures CSV.
  4. Restart the backend. The startup check pings the bucket and refuses to boot on misconfig.

Note: presigned-URL serving (zero-bandwidth direct downloads from S3) is intentionally not in v1 — every request still streams through the backend so watermarks, devtools-detection, and access logging keep working.

🔔 Webhooks

PicPeak POSTs event/photo lifecycle notifications to URLs you configure under Settings → Webhooks. Each delivery is signed HMAC-SHA256 with a per-webhook secret in the X-PicPeak-Signature header so receivers can verify the request really came from your PicPeak instance.

Event types

Event Fires when
event.created Gallery created (admin or API)
event.published Draft becomes live (is_draft: true → false) — also fires when an event is created with is_draft=false
event.archived Bulk-archive, manual archive, or auto-archive on expiry
event.expired Expiration checker marks the gallery inactive (fires before event.archived in the cascade)
photo.uploaded Admin upload, API upload, guest upload, or auto-import
photo.deleted Single delete, bulk delete (NOT fired per-photo when an event is archived — receivers infer from event.archived to avoid flooding)

Payload shape

{
  "id": "delivery-uuid",
  "type": "event.published",
  "created_at": "2026-04-28T05:25:00.000Z",
  "data": {
    "event": { "id": 123, "slug": "wedding-smith", "share_url": "https://..." }
  }
}

Also sent on every request:

  • X-PicPeak-SignatureHMAC-SHA256(secret, raw_body) as hex
  • X-PicPeak-Event — the event type (handy for routing without parsing the body)
  • X-PicPeak-Delivery — UUID for idempotency on the receiver side
  • User-Agent: PicPeak-Webhooks/1.0

Verifying signatures

Node.js

const crypto = require('crypto');
function verify(secret, rawBody, signature) {
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(signature, 'hex');
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

Python

import hmac, hashlib
def verify(secret: str, raw_body: bytes, signature: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

curl + openssl (one-liner for a quick replay)

SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
[ "$SIG" = "$RECEIVED_SIG" ] && echo OK || echo MISMATCH

Retries + observability

  • 2xx → success, recorded with latency
  • Non-2xx or network error → exponential backoff: 1m → 5m → 30m → 2h → 12h, max 5 attempts
  • After max attempts: status failed, surfaces in Settings → Webhooks → Deliveries with a "Replay" button
  • Up to 5 deliveries in flight at once; one slow consumer can't block others (configurable via WEBHOOK_DELIVERY_CONCURRENCY)
  • Response body truncated to 1KB before storage so chatty receivers don't bloat the audit log

The deliveries page (/admin/webhooks/:id/deliveries) shows every attempt with timestamp, status, HTTP code, latency, payload sent, signature, and response. Click "Send test event" to fire a synthetic delivery for any event type.

SSRF protection

Webhook URLs are validated against the same private-IP blocklist used elsewhere in the app — loopback, private RFC1918 ranges, link-local, .local/.internal hostnames, cloud metadata endpoints. The check runs both at create time and per-delivery (DNS-rebinding mitigation).

For local development with a receiver on the same machine or docker network, set WEBHOOK_ALLOW_PRIVATE_URLS=true. Production deployments must leave this OFF.

💻 System Requirements

Minimum Requirements

  • CPU: 2 CPU cores
  • RAM: 4 GB minimum for a normal photo-upload workload — sharp/libvips decodes the full uncompressed frame before resize, and the default two worker loops at sharp-concurrency 2 can push peak RSS past 1.5 GB on a batch of 20-MP+ photos. On a 2 GB VPS that's enough to OOM-kill the backend mid-batch (surfaces as 503s on thumbnails — see Low-memory hosts below for the recipe to run on 2 GB).
  • Storage: 20GB minimum (plus photo storage needs)
  • OS: Linux (Ubuntu 20.04+), macOS, or Windows with WSL2
  • Node.js: v18.0.0 or higher
  • Database: SQLite (included) or PostgreSQL 12+
  • Docker: v20.10.0+
  • Docker Compose: v2.0.0+

Low-memory hosts

Running on 2 GB RAM (e.g. an entry-level VPS) is workable but requires tuning the upload-processor concurrency down. The backend auto-detects total RAM at startup via os.totalmem() — on a host that reports < 3 GB, it defaults UPLOAD_PROCESSOR_CONCURRENCY to 1 instead of 2 and logs a one-shot warning. You can pin the value explicitly in .env:

# Single worker loop — slower batch processing, lower peak RSS
UPLOAD_PROCESSOR_CONCURRENCY=1

The trade-off is throughput: a single worker processes one photo at a time, so a 100-photo batch takes ~2× as long but won't OOM. Health-check note: if the backend dies under memory pressure, the gallery serves 503 Service Unavailable on thumbnails until Docker's restart: unless-stopped brings the container back. Persistent 503s during/after an upload batch on a low-memory host are almost always this.

Video Support Requirements

When enabling video uploads, consider these additional resources:

Resource Recommendation Notes
RAM 4GB+ recommended FFmpeg processing requires more memory
Storage Plan for 10-100x more Videos are significantly larger than images
CPU Additional cores help Video thumbnail extraction is CPU-intensive
Bandwidth Higher throughput Video streaming requires more bandwidth

Technical Notes:

  • FFmpeg is bundled via npm (@ffmpeg-installer/ffmpeg) - no system installation required
  • Maximum upload size: 10GB per video file
  • Chunked upload support for files >100MB (resumable uploads)
  • Supported formats: MP4, WebM, MOV, AVI
  • Video thumbnails are automatically generated from the first few seconds

For Nginx/Reverse Proxy: If using Nginx, increase the client max body size:

client_max_body_size 10G;
proxy_read_timeout 3600;
proxy_send_timeout 3600;

🤝 Contributing

We love contributions! PicPeak is built by photographers, for photographers. Whether you're fixing bugs, adding features, or improving documentation, your help is welcome.

See our Contributing Guide for details.

📊 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 still bring your own server (own hardware or a VPS) and, if you want one, a domain. **Limited only by your server storage. ***Pixieset's "unlimited" is photos only; video is capped by plan (roughly 010 h depending on tier). 🧪 Beta = built but feature-flagged off by default (see Beta Features).

🛡️ Security

PicPeak takes security seriously:

  • 🔐 Password hashing with bcrypt
  • 🎫 JWT-based authentication
  • 🚦 Rate limiting on all endpoints
  • 🛡️ CORS protection
  • 📝 Activity logging
  • 🔒 Secure file access

Found a security issue? Please open a security issue on GitHub

📸 Screenshots

🎛️ Admin Dashboard

Get a complete overview of your photo galleries, analytics, and system status.

PicPeak Admin Dashboard

📊 Analytics & Insights

Track gallery performance, view statistics, and monitor user engagement.

PicPeak Analytics Dashboard

📁 Event Management

Organize and manage your photo galleries with intuitive event management tools.

PicPeak Events Management

Key Interface Highlights

👆 Click to see more interface details

What makes PicPeak's interface special:

  • 🎨 Clean Design: Modern, photographer-friendly interface
  • 📱 Responsive: Perfect on desktop, tablet, and mobile
  • Fast Loading: Optimized for quick photo browsing
  • 🔒 Secure Access: Password-protected galleries with expiration
  • 📤 Easy Uploads: Drag & drop functionality for effortless photo management
  • 🎯 Client-Focused: Intuitive gallery experience for your clients

🗺️ Roadmap

We're constantly improving PicPeak and welcome contributions from our community! If you have ideas for new features or want to help implement existing ones, please open an issue or submit a pull request. Your contributions help make PicPeak better for everyone.

🚧 Beta Features (Use at your own risk)

These features are currently in beta testing and may have limited functionality or stability:

Feature Description Status
CRM & Accounting Module Quotes, contracts, invoices (+ Storno), hours logging, calendar, and tax report — plus inbound supplier-invoice capture, internal expenses, and a Treuhänder/Banana (Swiss/LI) accountant-journal export. Feature-flagged off by default. Seeded contract blocks, payment terms, IBAN / QR-bill and tax defaults are examples only and need legal / financial / tax review before customer-facing use. See docs.picpeak.app/features/crm. 🧪 Beta
Simple Deployment Script One-click deployment script for quick server setup with automated configuration and dependency installation 🧪 Beta

📋 Future Enhancements

Feature Description Priority Status
Backup & Restore Comprehensive backup system with S3/MinIO support, automated scheduling, and safe restore functionality High Implemented
External Media Library (Reference Mode) Use an external folder library as a readonly source with import and ondemand thumbnail generation High Implemented
Download Protection Advanced image protection system with canvas rendering, invisible watermarking, right-click prevention, and DevTools detection to protect photos from unauthorized downloads High Implemented
Gallery Templates Multiple gallery layouts (grid, masonry, carousel, timeline, hero, mosaic) with custom CSS styling support. Includes starter templates like Apple Liquid Glass for complete visual customization Medium Implemented
Face Recognition AI-powered face detection to help guests find their photos and create automatic person-based albums Low 🔄 Open
Gallery Feedback Allow guests to like, rate, and comment on photos with admin notifications and moderation Medium Implemented
Video Support Upload and display videos alongside photos in galleries with streaming support Low Implemented
Multiple Administrators Support for multiple admin accounts with role-based permissions and activity tracking Low Implemented
Filtering & Export Options Filter photos by likes, ratings, comments, or favorites. Search by filename. Sort by date, name, size, or rating. Export filtered selections as ZIP or generate Capture One/Lightroom-compatible file lists for professional workflows Medium Implemented

Status Legend: Implemented | 🚧 In Progress | 🔄 Open | 📋 Planned

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 the time spent on new features, bug fixes, and keeping the demo + docs running.

Buy Me A Coffee

Other ways to support without spending anything: star the repo, share it with photographer friends, file good bug reports, or open a PR.

🙏 Acknowledgments

PicPeak is inspired by the best features of commercial platforms while remaining completely open source. Special thanks to all contributors who make this project possible.

👥 Contributors

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

  • @the-luap — creator and lead maintainer. Started the project and built PicPeak's foundation and the entire gallery experience (events, galleries, uploads, sharing, download protection, templates), plus backup & restore, analytics, system health, branding/theming, and WhatsApp notifications — and the architecture every later feature builds on.
  • @Luca-Timo — native Apple Silicon multi-arch images, external-URL toggle for legal CMS pages, the lazy-loaded folder tree picker, the admin-email picker on event creation, the data-driven self-hosted webfont system, the gallery header/banner decoupling, several typed-API refactors, and the CRM + accounting suite (quotes/contracts/invoices, hours logging, calendar, tax report, inbound supplier-invoice capture, expenses, and the Treuhänder/Banana export). Consistently raises the bar with thoughtful PRs.
  • @Rekoo-PS — sharp-eyed bug reporter and product feedback. Filed the issues that drove the login-loop fix, the gallery-loading skeleton work, the redirection cleanup, the mobile-lightbox overhaul, the admin-events search-counter fix, the photo-count column, and the bulk-delete workflow. Also a BuyMeACoffee supporter — the kind of feedback loop that keeps the project useful for real deployments.

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

🤖 AI-Assisted Development

This project was generated with the assistance of AI technology, but has been:

  • Fully tested end-to-end by human developers
  • 🔒 Security audited with comprehensive security checks
  • 👨‍💻 Human-reviewed for code quality and best practices
  • 🧪 Production-tested in real-world scenarios

We believe in transparent development practices and the responsible use of AI as a tool to accelerate development while maintaining high standards of quality and security.

⚠️ CRM & Accounting disclaimers — examples only, verify locally

The CRM & accounting modules (contracts, invoices, QR-bills, the tax report and the accountant exports) ship seeded content and computed figures that are intended as a starting point only:

  • Contract blocks (image rights, NDA, model release, cancellation, jurisdiction, …) are written by the maintainer, not by a lawyer. Every operator must have their lawyer review and adapt them before sending any contract to a customer.
  • QR-bills and SEPA EPC payloads are rendered from the data you typed. Picpeak is open source — please scan a test invoice with your bank's app to check the QR actually works. We are not responsible for any mistakes that come from sending an invoice with bad data on it.
  • Tax, VAT & accounting figures (the tax report, VAT-payable, the per-rate breakdown, the Treuhänder / Banana export, etc.) are computed from the data you enter and the defaults you configure. They are guidance only and jurisdiction-specific — tax rules, VAT rates, deduction schemes (e.g. the Liechtenstein 20 % Gewinnungskosten flat rate) and filing duties differ by country and change over time. Every operator must check their own tax / VAT regulations and verify the numbers with their accountant / Treuhänder / tax authority before relying on any figure or export. Picpeak makes no warranty that the output is correct for your jurisdiction or situation.

Read docs/crm-disclaimers.md before enabling the Contracts, Invoices or Accounting features.

📄 License

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

🚀 Ready to Get Started?

  1. Star this repository to show your support
  2. 📖 Read the docs at docs.picpeak.app
  3. 🐛 Report issues or request features
  4. 🤝 Join our community and contribute!

Made with ❤️ by photographers, for photographers
HomepageLive DemoGitHubDocumentationSupport

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%