Compare commits

..

13 Commits

Author SHA1 Message Date
Paul Nothaft a7885846ac chore(stable): release 3.45.7 (#881)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-27 07:58:07 +00:00
Paul Nothaft d868aac703 fix(security): close 5 Trivy alerts — postcss/tar bumps + drop npm from the runtime image (stable) (#879)
* fix(security): close the 5 open Trivy alerts — dep bumps + drop npm from the runtime image

Backend deps:
- postcss 8.5.10 -> 8.5.18 (CVE-2026-45623, GHSA-r28c-9q8g-f849; the pin
  exists to force sanitize-html's transitive copy onto a fixed version)
- tar pin/override >=7.5.16 -> >=7.5.21, resolves 7.5.22
  (GHSA-r292-9mhp-454m)

Runtime image:
- Remove the npm CLI from the final stage instead of upgrading it: npm's
  bundled node_modules ship tar 7.5.19 and brace-expansion 5.0.7 (no npm
  release bundles the fixed versions — checked 11.18.0 and 12.0.1), and
  npm never runs in production. wait-for-db.sh now invokes the migration
  runners via node directly. This ends the recurring npm-bundled-CVE
  alert class; the previous 'npm install -g npm@11' line was itself a
  patch for the last batch. (stable)

* fix(restore): run post-restore migrations via node — the image ships no npm

restoreService still shelled out to 'npm run migrate:safe' after a
restore; with npm removed from the runtime image that would ENOENT into
the non-fatal catch, silently leaving a restored older backup on a
schema behind the running code until the next container restart. Invoke
migrations/run-migrations-safe.js through node directly, matching
wait-for-db.sh. The PR #596 source-contract test now pins the new
invocation. (stable)
2026-07-27 09:54:43 +02:00
Paul Nothaft 577b7fa6ae chore(stable): release 3.45.6 (#877)
Build and Push Docker Images / summary (push) Blocked by required conditions
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
2026-07-27 07:10:18 +00:00
Paul Nothaft a27c705e39 fix(backup): make backup settings actually apply (#871) (stable) (#875)
* fix(backup): make backup settings actually apply (#871) (stable)

- Wire the What-to-Backup toggles into the walker: honor
  backup_include_thumbnails / backup_include_photos (opt-out,
  default ON) and accept the UI's backup_include_archives spelling
  for the archived gate (the engine expected _archived, so the
  Archives checkbox silently never worked).
- Fix the 167.6 TB dashboard size: file_size_bytes is a bigint that
  node-postgres returns as a string, and the S3 path concatenated it
  onto the byte counter; coerce to Number at the source.
- Compute the real next scheduled run (cron-parser) and return it as
  nextBackup; the UI read a field the API never sent and rendered a
  hardcoded 'Not scheduled'. A named schedule label now beats the
  stray default cron the UI always sent, which silently turned
  weekly schedules into daily 03:00 runs.
- Never back up filesystem noise (.nfs* silly-renames, .DS_Store,
  Thumbs.db) and honor backup_exclude_patterns in the walker
  (previously rsync-only).
- Remove the compression/encryption toggles from the configuration
  UI: no backend implementation exists, and collecting an encryption
  passphrase while uploading plaintext is a false promise.

* fix(backup): close the review gaps in the settings wiring (stable)

- The UI's backup_include_archives now beats the migration-seeded
  backup_include_archived: every install has the singular key seeded
  true, so the alias-only-when-absent lookup made unchecking Archives
  a no-op.
- rsync destinations now receive the de-selected What-to-Backup paths
  and the noise filters as anchored --exclude args; previously rsync
  synced the whole storage root and the walker's selection only shaped
  the manifest, which then misreported what was actually transferred.
- Escape regex metacharacters in the walker's glob matcher: '.nfs*'
  compiled to /^.nfs.*$/ whose leading dot matched any character, so
  files like anfs-photo.jpg were silently dropped from backups.
- The Backup Coverage report now uses the same gate as the walker
  (new 'skipped-by-setting' status) instead of re-implementing it
  without the opt-out toggles and the archives alias.

* fix(backup): make the coverage diagnostics agree with the walker

- The coverage table shows the alias-aware flag value the gate actually
  used, instead of the seeded backup_include_archived shadowed by the
  UI's plural key (true next to a 'Gated off' badge).
- skipped-by-setting paths are now counted in the coverage summary
  (backend, TS contract, summary card, EN/DE locales) so the totals
  reconcile again when Photos or Thumbnails is unchecked.
- The form's thumbnail default now matches the backend's never-saved
  fallback (include): the checkbox no longer shows 'off' while
  thumbnails are being backed up, and saving an unrelated setting no
  longer flips the backup scope. (stable)

* fix(backup): keep custom crons, exclude disabled rows from rsync, normalize flag display

- Saving a named schedule no longer wipes the stored custom cron: the
  backend already prefers the label, so the cron field stays inert for
  named schedules and is preserved for switching back to Custom. A
  custom schedule now validates the 5-field expression before saving
  (the backend silently fell back to daily 02:00 on a blank value).
- resolveExcludedBackupPaths now also returns rows disabled via
  include_in_default, so rsync excludes them; the enabled-only loader
  hid them and rsync transferred their contents anyway.
- The coverage table normalizes flag values like the walker does —
  Boolean('false') displayed true beside a gated-off badge. (stable)
2026-07-27 09:06:50 +02:00
Paul Nothaft b0e9145bba chore(stable): release 3.45.5 (#873)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-26 18:41:48 +00:00
Paul Nothaft 39696d42fe fix(security): bump backend deps to close all 14 open Trivy code-scanning alerts (stable) (#870)
* fix(security): bump backend deps to close all open Trivy code-scanning alerts (stable)

- axios 1.16.0 -> 1.18.1 (GHSA-gcfj-64vw-6mp9 high + 10 medium advisories)
- sharp 0.34.3 -> 0.35.3 (GHSA-f88m-g3jw-g9cj, inherited libvips CVEs)
- mailparser 3.9.9 -> 3.9.14 (pulls linkify-it 5.0.2, CVE-2026-59887)
- brace-expansion override >=5.0.6 -> >=5.0.7 (CVE-2026-13149)
- body-parser 1.20.4 -> 1.20.6 via lockfile refresh (CVE-2026-12590)

* fix(images): migrate removed sharp failOnError option and enforce Node >=20.9 (stable)

sharp 0.35 drops the deprecated failOnError constructor option, so
recoverably corrupt images would start failing upload validation and
thumbnail generation; use the failOn: 'none' equivalent instead.

sharp 0.35 also requires Node >=20.9: declare it in engines and make
picpeak-setup.sh compare the full version instead of only the major,
so native installs on Node 20.3-20.8 upgrade instead of breaking.

* fix(setup): align the Node floor with the whole dependency tree and gate native updates (stable)

html-to-text@10 needs Node >=20.19 and the glob/minimatch family excludes
Node 21, so declare engines as ^20.19.0 || >=22 and enforce the same range
in picpeak-setup.sh. Also run install_nodejs at the start of
update_native_installation so existing native installs on an old Node get
upgraded before the service is stopped, instead of restarting broken.

* fix(setup): make the update-path Node gate actually work (stable)

--update dispatches before detect_os, so install_nodejs saw an empty
PACKAGE_MANAGER, matched no install branch, and reported success on the
old runtime. Detect the OS on demand and re-verify the installed version
afterwards, failing loudly (before the service is stopped) when the
runtime still misses the engines range, e.g. a Node 21 that package
managers refuse to downgrade.
2026-07-26 20:38:16 +02:00
Paul Nothaft 50f5ca1d5b fix(security): read the password-complexity key the settings UI writes (stable) (#844)
* fix(security): read the password-complexity key the settings UI writes

The settings UI saves the admin's complexity choice as
security_password_complexity (useSettingsState.ts prefixes security_ to
password_complexity), but getPasswordComplexitySettings() queried
security_password_complexity_level — written by nothing — so the setting
was silently ignored and password validation always used the 'moderate'
default. Spotted in the filpgame fork (their main, 2026-07-14).

* fix(security): accept the Postgres json-column shape of the complexity value (codex review of #843)

On SQLite the TEXT column returns the JSON-stringified value
('"very_strong"'), but on Postgres (production default) setting_value
is a json column and arrives already decoded ('very_strong') — the bare
JSON.parse threw and the outer catch silently fell back to 'moderate'
again. Parse with fallback, mirroring getAppSetting's documented
pattern; test now covers both driver shapes + the empty-value default.
2026-07-19 20:04:35 +02:00
Paul Nothaft 11b6490e4c chore(stable): release 3.45.4 (#831)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-17 19:43:45 +00:00
Paul Nothaft 1cff576439 Merge pull request #829 from PicPeak/fix/hero-logo-visible-null-validation-stable
fix(events): accept hero_logo_visible: null on create/update (#822) (stable)
2026-07-17 21:39:26 +02:00
Paul Nothaft 8978acdb49 fix(events): accept hero_logo_visible: null on create/update (#822)
hero_logo_visible is nullable — null means "inherit the global
branding_logo_display_hero toggle" (#756, migration 152). But the create and
update validators used `.optional()` without `{ nullable: true }`, which only
skips `undefined`; an explicit `null` still ran `.isBoolean()` and failed with
HTTP 400 "Invalid value". Saving an event with `hero_logo_visible: null` (the
inherit state the frontend sends) was rejected on v3.45.2.

- Both routes: `body('hero_logo_visible').optional({ nullable: true }).isBoolean()`,
  matching the already-correct `hero_logo_size` rule next to it.
- Create handler: guard on `!= null` instead of `!== undefined` so an explicit
  null stores NULL (inherit) rather than being coerced to 0/false by
  formatBoolean on SQLite. The update handler already did `=== null ? null`.

Left hero_logo_position on plain `.optional()` on purpose: its column is NOT
NULL (no inherit migration) and its handler always resolves to a concrete value
via `|| brandingDefaults`, so null is genuinely invalid there — allowing it
would trade the 400 for a 500.

Adds smoke tests: PUT accepts hero_logo_visible: null and stores NULL; a
non-boolean value is still rejected.
2026-07-17 21:14:12 +02:00
Paul Nothaft 0d8123ed4a chore(stable): release 3.45.3 (#827)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-17 19:08:55 +00:00
Paul Nothaft db1d28a75b Merge pull request #825 from PicPeak/fix/update-instructions-production-compose-stable
fix(update): target docker-compose.production.yml in dashboard update steps + gate mailhog (stable)
2026-07-17 21:03:21 +02:00
Paul Nothaft 64bcd0ab9f fix(update): target docker-compose.production.yml in dashboard update steps
Production installs use docker-compose.production.yml (the README's documented
path, pinned GHCR images, no dev services), but the dashboard's update
instructions emitted bare `docker compose pull` / `up -d`. Bare `docker compose`
operates on docker-compose.yml — a different, build-based stack — so a
production user who followed the steps:
  - never pulled/recreated their real containers (stayed on the old version,
    e.g. stuck on 3.44.0 after "updating" to 3.45.2), and
  - started the dev-only mailhog service that docker-compose.yml defines
    (reported restart-looping).

The backend runs inside a container and can't stat the host's compose files, but
docker-compose.production.yml passes PICPEAK_RELEASE_CHANNEL into the backend env
and docker-compose.yml does not. detectEnvironment() now derives
isProductionCompose from it, and the Docker update steps prepend
`-f docker-compose.production.yml` when set. The non-production branch keeps the
bare commands but the warning now tells users to add `-f docker-compose.production.yml`
if they installed with it.

Also gates the mailhog service in docker-compose.yml behind a `dev` compose
profile so a plain `docker compose up -d` never starts it (opt in with
`docker compose --profile dev up -d`). Nothing depends on it (SMTP_HOST comes
from .env), so gating is safe. Verified: `docker compose config` lists mailhog
only with `--profile dev`; production compose is unchanged.

Adds unit tests for the production-vs-default command generation.
2026-07-17 20:56:18 +02:00
28 changed files with 1125 additions and 445 deletions
+1 -1
View File
@@ -1 +1 @@
{".":"3.45.2"}
{".":"3.45.7"}
+38
View File
@@ -5,6 +5,44 @@ All notable changes to PicPeak will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.45.7](https://github.com/PicPeak/picpeak/compare/v3.45.6...v3.45.7) (2026-07-27)
### Bug Fixes
* **security:** close 5 Trivy alerts — postcss/tar bumps + drop npm from the runtime image (stable) ([#879](https://github.com/PicPeak/picpeak/issues/879)) ([d868aac](https://github.com/PicPeak/picpeak/commit/d868aac70300149e77fb9568735b6481f88a644f))
## [3.45.6](https://github.com/PicPeak/picpeak/compare/v3.45.5...v3.45.6) (2026-07-27)
### Bug Fixes
* **backup:** make backup settings actually apply ([#871](https://github.com/PicPeak/picpeak/issues/871)) (stable) ([#875](https://github.com/PicPeak/picpeak/issues/875)) ([a27c705](https://github.com/PicPeak/picpeak/commit/a27c705e392ec1e6b8d8be945127de9ff11d5db0))
## [3.45.5](https://github.com/PicPeak/picpeak/compare/v3.45.4...v3.45.5) (2026-07-26)
### Bug Fixes
* **security:** bump backend deps to close all 14 open Trivy code-scanning alerts (stable) ([#870](https://github.com/PicPeak/picpeak/issues/870)) ([39696d4](https://github.com/PicPeak/picpeak/commit/39696d42fe22478aee1b8d777d8bcfd5ef986fe4))
* **security:** read the password-complexity key the settings UI writes (stable) ([#844](https://github.com/PicPeak/picpeak/issues/844)) ([50f5ca1](https://github.com/PicPeak/picpeak/commit/50f5ca1d5bbe6f03397b3dc66ea8c3bea538466e))
## [3.45.4](https://github.com/PicPeak/picpeak/compare/v3.45.3...v3.45.4) (2026-07-17)
### Bug Fixes
* **events:** accept hero_logo_visible: null on create/update ([#822](https://github.com/PicPeak/picpeak/issues/822)) ([8978acd](https://github.com/PicPeak/picpeak/commit/8978acdb492f085fbe92186d9dbddfb35b07b676))
* **events:** accept hero_logo_visible: null on create/update ([#822](https://github.com/PicPeak/picpeak/issues/822)) (stable) ([1cff576](https://github.com/PicPeak/picpeak/commit/1cff576439bce06536f7d308d4ef6d52bdc9bb12))
## [3.45.3](https://github.com/PicPeak/picpeak/compare/v3.45.2...v3.45.3) (2026-07-17)
### Bug Fixes
* **update:** target docker-compose.production.yml in dashboard update steps ([64bcd0a](https://github.com/PicPeak/picpeak/commit/64bcd0ab9f35b207ed21f41cfa05c1499ad4cbae))
* **update:** target docker-compose.production.yml in dashboard update steps + gate mailhog (stable) ([db1d28a](https://github.com/PicPeak/picpeak/commit/db1d28a75ba9036e5bd5d87930bcac704c83341b))
## [3.45.2](https://github.com/PicPeak/picpeak/compare/v3.45.1...v3.45.2) (2026-07-17)
+10 -8
View File
@@ -37,14 +37,16 @@ ARG CACHEBUST=1
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
RUN echo "cachebust=${CACHEBUST}" && apk upgrade --no-cache
# Upgrade the npm CLI in the final image so its bundled deps are patched
# (sigstore 4.x, tar) — closes CVE-2026-48815 and the older @sigstore/core / tar
# Trivy alerts. Safe here: only the CLI present in the image changes. Runtime
# dependencies come from the builder stage (COPY --from=builder node_modules
# below) and the entrypoint runs node, not npm — so npm 11's install behaviour
# (the reason 10.x was pinned) never executes in this stage. npm 11 needs
# Node >=22.9, satisfied by node:22-alpine.
RUN npm install -g npm@11
# Remove the npm CLI from the final image. Nothing runs npm here: the
# entrypoint is node, runtime deps are COPY'd from the builder stage, and
# wait-for-db.sh invokes the migration runners via node directly. npm's
# bundled node_modules kept tripping Trivy (sigstore, tar 7.5.19,
# brace-expansion 5.0.7 — even npm 12.0.1 still ships the vulnerable
# copies), so shipping no npm ends that alert class instead of chasing
# per-release patches. Note: `docker exec … npm run <script>` no longer
# works in the container — use `node migrations/run-migrations-safe.js`
# and friends instead.
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
# Install dumb-init for proper signal handling, postgresql-client for database
# checks, ffmpeg for video upload support, and su-exec for the root → nodejs
@@ -177,4 +177,203 @@ describe('backupService — configurable walker (backup_paths)', () => {
const filesOn = await backupService.getFilesToBackup(true);
expect(filesOn.map((f) => f.relativePath)).toContain('events/archived/E3/legacy.jpg');
});
// Issue #871 — the "What to Backup" checkboxes were stored but never read.
describe('UI opt-out toggles (issue #871)', () => {
it('unchecking Thumbnails excludes thumbnails/', async () => {
seedFile('thumbnails/E1/thumb.jpg');
seedFile('events/active/E1/photo.jpg');
const files = await backupService.getFilesToBackup({
backup_include_thumbnails: false,
});
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('events/active/E1/photo.jpg');
expect(rels).not.toContain('thumbnails/E1/thumb.jpg');
});
it('unchecking Photos excludes events/active', async () => {
seedFile('thumbnails/E1/thumb.jpg');
seedFile('events/active/E1/photo.jpg');
const files = await backupService.getFilesToBackup({
backup_include_photos: false,
});
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('thumbnails/E1/thumb.jpg');
expect(rels).not.toContain('events/active/E1/photo.jpg');
});
it('defaults to including everything when the keys were never saved', async () => {
seedFile('thumbnails/E1/thumb.jpg');
seedFile('events/active/E1/photo.jpg');
const files = await backupService.getFilesToBackup({});
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('thumbnails/E1/thumb.jpg');
expect(rels).toContain('events/active/E1/photo.jpg');
});
it("accepts the UI's plural backup_include_archives for the archived gate", async () => {
seedFile('events/archived/E4/archived.jpg');
const files = await backupService.getFilesToBackup({
backup_include_archives: true,
});
expect(files.map((f) => f.relativePath)).toContain('events/archived/E4/archived.jpg');
});
it('the UI plural key beats the migration-seeded singular key', async () => {
// Migration seeds backup_include_archived=true on every install; the
// form only ever writes the plural key, so unchecking Archives must
// win over the stale seeded value.
seedFile('events/archived/E5/archived.jpg');
const files = await backupService.getFilesToBackup({
backup_include_archived: true, // seeded default
backup_include_archives: false, // what the admin actually chose
});
expect(files.map((f) => f.relativePath)).not.toContain('events/archived/E5/archived.jpg');
});
it('rsync gets the de-selected paths and noise filters as --exclude args', async () => {
const excluded = await backupService.resolveExcludedBackupPaths({
backup_include_thumbnails: false,
backup_include_archives: false,
});
expect(excluded.map((r) => r.path)).toEqual(
expect.arrayContaining(['thumbnails', 'events/archived'])
);
const args = backupService.buildRsyncArgs(
{ backup_rsync_host: 'backup.example.com', backup_rsync_path: '/srv/backups' },
excluded.map((r) => `/${r.path}/`)
);
const excludes = args
.map((a, i) => (a === '--exclude' ? args[i + 1] : null))
.filter(Boolean);
expect(excludes).toEqual(expect.arrayContaining([
'.nfs*',
'/thumbnails/',
'/events/archived/',
]));
});
it('rows toggled off via include_in_default also become rsync excludes', async () => {
// The enabled-only loader hides these rows from the walker, but rsync
// syncs the whole storage root, so they must still appear as excludes.
await db('backup_paths').where('path', 'previews').update({
include_in_default: false,
});
const excluded = await backupService.resolveExcludedBackupPaths({});
expect(excluded.map((r) => r.path)).toContain('previews');
});
});
// Issue #871 — .nfs* silly-rename artifacts were uploaded to S3.
it('never backs up filesystem noise (.nfs*, .DS_Store)', async () => {
seedFile('thumbnails/E1/.nfs000000000000006600000008');
seedFile('events/active/E1/.DS_Store');
seedFile('events/active/E1/photo.jpg');
const files = await backupService.getFilesToBackup({});
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('events/active/E1/photo.jpg');
expect(rels.some((r) => r.includes('.nfs'))).toBe(false);
expect(rels.some((r) => r.includes('.DS_Store'))).toBe(false);
});
it('the walker honors backup_exclude_patterns (previously rsync-only)', async () => {
seedFile('events/active/E1/photo.jpg');
seedFile('events/active/E1/scratch.tmp');
const files = await backupService.getFilesToBackup({
backup_exclude_patterns: ['*.tmp'],
});
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('events/active/E1/photo.jpg');
expect(rels).not.toContain('events/active/E1/scratch.tmp');
});
it('glob patterns are literal outside the star (.nfs* must not eat anfs-…)', async () => {
seedFile('events/active/E1/anfs-photo.jpg');
seedFile('events/active/E1/notes-tmp');
const files = await backupService.getFilesToBackup({
backup_exclude_patterns: ['*.tmp'],
});
const rels = files.map((f) => f.relativePath);
// '.nfs*' used to compile to /^.nfs.*$/ whose dot matched any char;
// '*.tmp' used to compile to /^.*.tmp$/ which also matched 'notes-tmp'.
expect(rels).toContain('events/active/E1/anfs-photo.jpg');
expect(rels).toContain('events/active/E1/notes-tmp');
});
// Issue #871 — weekly schedules silently ran daily, and the dashboard's
// "next backup" was a hardcoded "tomorrow 02:00".
describe('schedule resolution + next run (issue #871)', () => {
it('a named label beats the stray default cron the UI used to send', () => {
expect(backupService.resolveScheduleCron({
backup_schedule: 'weekly',
backup_schedule_cron: '0 3 * * *', // old UI default, sent unconditionally
})).toBe('0 3 * * 0');
});
it('custom schedules use the cron field', () => {
expect(backupService.resolveScheduleCron({
backup_schedule: 'custom',
backup_schedule_cron: '15 5 * * 2',
})).toBe('15 5 * * 2');
});
it('falls back to the default daily cron', () => {
expect(backupService.resolveScheduleCron({})).toBe('0 2 * * *');
});
it('getNextScheduledRun is null when backups are disabled', () => {
expect(backupService.getNextScheduledRun(null)).toBeNull();
expect(backupService.getNextScheduledRun({ backup_enabled: false })).toBeNull();
});
it('getNextScheduledRun returns the real next weekly fire time', () => {
const iso = backupService.getNextScheduledRun({
backup_enabled: true,
backup_schedule: 'weekly',
backup_schedule_cron: '0 3 * * *',
});
const next = new Date(iso);
expect(Number.isNaN(next.getTime())).toBe(false);
expect(next.getTime()).toBeGreaterThan(Date.now());
expect(next.getDay()).toBe(0); // Sunday
expect(next.getHours()).toBe(3); // 03:00
});
});
// Issue #871 — "Backup Size: 167.6 TB": file_size_bytes is a bigInteger
// column, node-postgres returns int8 as a string, and the S3 path did
// `backedUpSize += size` — string concatenation.
it('getDatabaseBackupInfo coerces file_size_bytes to a number', async () => {
await db('database_backup_runs').del();
await db('database_backup_runs').insert({
backup_type: 'full',
status: 'completed',
file_path: '/backups/db/dump.sql.gz',
// Simulate the PG int8-as-string driver behaviour (sqlite stores
// whatever it is handed, so the string round-trips).
file_size_bytes: '421988',
started_at: new Date().toISOString(),
completed_at: new Date().toISOString(),
});
const info = await backupService.getDatabaseBackupInfo();
expect(typeof info.size).toBe('number');
expect(info.size).toBe(421988);
});
});
@@ -183,22 +183,24 @@ describe('restoreService — PG branch scope contract (PR #596 review)', () => {
expect(window).toMatch(/was_successful:\s*true/);
});
it('npm run migrate:safe is invoked after the replay in restore()', () => {
it('the safe migration runner is invoked after the replay in restore()', () => {
// Contract from PR #596 round 4: backups taken on older picpeak
// versions must restore COMPLETELY on a newer image — even if new
// migrations have been added since the backup was taken. The
// restore() flow shells out to `npm run migrate:safe` AFTER the
// restore() flow shells out to the safe migration runner AFTER the
// operator-meta replay so the schema catches up to the running
// code WITHIN the restore boundary (not on the next container
// restart).
// restart). Invoked as `node migrations/run-migrations-safe.js` —
// the runtime image ships no npm, so the former `npm run
// migrate:safe` would ENOENT into the non-fatal catch.
//
// Contract:
// 1. A `migrate:safe` shell-out exists somewhere in restoreService
// 1. A run-migrations-safe shell-out exists somewhere in restoreService
// 2. It sits AFTER the replay drain — verification → replay →
// migrations is the documented order
// 3. It does NOT sit inside performDatabaseRestore (must run
// against the reinit'd pool from the parent restore())
const migrateLine = findFirst(/['"]migrate:safe['"]/);
const migrateLine = findFirst(/run-migrations-safe\.js/);
expect(migrateLine).toBeGreaterThan(0);
const replayLine = findLast(/this\.preservedMetaSnapshot\.length\s*>\s*0/);
@@ -180,6 +180,27 @@ describe('admin events CRUD endpoints (smoke)', () => {
});
expect(res.status).toBe(404);
});
// #822 — hero_logo_visible/position are nullable (null = "inherit the global
// branding toggle"), but the validator used .optional() without
// { nullable: true }, so an explicit null was rejected with 400.
it('accepts hero_logo_visible: null and stores NULL (inherit)', async () => {
const id = await insertEvent(db, adminId, { hero_logo_visible: 1 });
const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({
hero_logo_visible: null,
});
expect(res.status).toBe(200);
const row = await db('events').where({ id }).first();
expect(row.hero_logo_visible).toBeNull();
});
it('still rejects a non-boolean hero_logo_visible', async () => {
const id = await insertEvent(db, adminId);
const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({
hero_logo_visible: 'maybe',
});
expect(res.status).toBe(400);
});
});
describe('DELETE /:id', () => {
@@ -0,0 +1,58 @@
/**
* Regression tests for the Docker update instructions (environmentService).
*
* A production install (docker-compose.production.yml) must get `-f
* docker-compose.production.yml` in every update command — bare `docker compose`
* targets docker-compose.yml, a different build-based stack that also starts the
* dev-only mailhog, which left production users stranded on the old version
* (reported against 3.44.0 → 3.45.2).
*/
const { detectEnvironment, generateUpdateInstructions } = require('../../src/services/environmentService');
describe('detectEnvironment — production compose detection', () => {
const orig = process.env.PICPEAK_RELEASE_CHANNEL;
afterEach(() => {
if (orig === undefined) delete process.env.PICPEAK_RELEASE_CHANNEL;
else process.env.PICPEAK_RELEASE_CHANNEL = orig;
});
it('flags isProductionCompose when PICPEAK_RELEASE_CHANNEL is set', async () => {
process.env.PICPEAK_RELEASE_CHANNEL = 'stable';
const env = await detectEnvironment();
expect(env.isProductionCompose).toBe(true);
});
it('does not flag it when the var is absent (default docker-compose.yml)', async () => {
delete process.env.PICPEAK_RELEASE_CHANNEL;
const env = await detectEnvironment();
expect(env.isProductionCompose).toBe(false);
});
});
describe('generateUpdateInstructions — Docker commands', () => {
const cmds = (env) => generateUpdateInstructions(env, '3.45.2').steps.map((s) => s.command);
it('targets docker-compose.production.yml for a production install', () => {
const commands = cmds({ isDocker: true, isProductionCompose: true });
expect(commands).toEqual([
'docker compose -f docker-compose.production.yml pull',
'docker compose -f docker-compose.production.yml up -d',
'docker compose -f docker-compose.production.yml logs -f backend',
]);
// And the warning tells them where to run it.
const { warnings } = generateUpdateInstructions({ isDocker: true, isProductionCompose: true }, '3.45.2');
expect(warnings.join(' ')).toMatch(/docker-compose\.production\.yml/);
});
it('uses bare commands + a hint when not a production compose', () => {
const commands = cmds({ isDocker: true, isProductionCompose: false });
expect(commands).toEqual([
'docker compose pull',
'docker compose up -d',
'docker compose logs -f backend',
]);
const { warnings } = generateUpdateInstructions({ isDocker: true, isProductionCompose: false }, '3.45.2');
// Still nudges production users to add -f in case detection missed.
expect(warnings.join(' ')).toMatch(/-f docker-compose\.production\.yml/);
});
});
@@ -0,0 +1,61 @@
/**
* Regression tests for the password-complexity setting read path.
*
* Bug 1 (key mismatch): the settings UI saves the admin's choice as
* `security_password_complexity` (useSettingsState.ts prefixes every
* security field with `security_`), but getPasswordComplexitySettings()
* queried `security_password_complexity_level` — a key nothing writes —
* so the configured level was silently ignored.
*
* Bug 2 (driver shape, codex review of #843): on SQLite the TEXT column
* returns the JSON-stringified value ('"very_strong"'), but on Postgres
* (production default) `setting_value` is a json column and comes back
* already decoded ('very_strong'). A bare JSON.parse throws on the
* decoded shape and the outer catch fell back to 'moderate' — the
* setting stayed unenforced on Postgres even with the right key.
*/
const mockQueriedKeys = [];
let mockStoredValue;
jest.mock('../../src/database/db', () => ({
db: () => ({
where(_col, key) {
mockQueriedKeys.push(key);
return this;
},
first() {
return Promise.resolve(
mockQueriedKeys[mockQueriedKeys.length - 1] === 'security_password_complexity'
? { setting_key: 'security_password_complexity', setting_value: mockStoredValue }
: undefined
);
},
}),
withRetry: (fn) => fn(),
}));
const { getPasswordComplexitySettings } = require('../../src/utils/passwordValidation');
describe('getPasswordComplexitySettings', () => {
beforeEach(() => { mockQueriedKeys.length = 0; });
it('reads the key the settings UI actually writes (SQLite shape: JSON-stringified)', async () => {
mockStoredValue = JSON.stringify('very_strong'); // '"very_strong"'
const level = await getPasswordComplexitySettings();
expect(mockQueriedKeys).toContain('security_password_complexity');
expect(level).toBe('very_strong');
});
it('accepts the Postgres json-column shape (already decoded, no quotes)', async () => {
mockStoredValue = 'very_strong'; // pg driver auto-parses the json column
const level = await getPasswordComplexitySettings();
expect(level).toBe('very_strong');
});
it('falls back to moderate on an empty value', async () => {
mockStoredValue = '';
const level = await getPasswordComplexitySettings();
expect(level).toBe('moderate');
});
});
+394 -254
View File
File diff suppressed because it is too large Load Diff
+11 -7
View File
@@ -1,8 +1,11 @@
{
"name": "picpeak-backend",
"version": "3.45.2",
"version": "3.45.7",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"engines": {
"node": "^20.19.0 || >=22"
},
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
@@ -18,11 +21,12 @@
"@aws-sdk/lib-storage": "^3.850.0",
"@aws-sdk/s3-request-presigner": "^3.850.0",
"archiver": "^5.3.1",
"axios": "1.16.0",
"axios": "1.18.1",
"bcrypt": "6.0.0",
"chokidar": "4.0.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"cron-parser": "^4.9.0",
"dotenv": "^16.0.3",
"exifr": "^7.1.3",
"express": "^4.18.2",
@@ -50,16 +54,16 @@
"pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2",
"pg": "^8.16.3",
"postcss": "8.5.10",
"postcss": "8.5.18",
"qrcode": "^1.5.4",
"react-i18next": "^15.6.0",
"sanitize-html": "^2.17.0",
"sharp": "0.34.3",
"sharp": "0.35.3",
"sqlite3": "^5.1.6",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"swissqrbill": "^4.3.0",
"tar": ">=7.5.16",
"tar": ">=7.5.21",
"uuid": "^11.1.1",
"winston": "^3.8.2",
"zxcvbn": "^4.4.2"
@@ -79,8 +83,8 @@
"js-yaml": "^4.2.0",
"fast-xml-parser": ">=5.7.0",
"qs": ">=6.15.2",
"tar": ">=7.5.16",
"brace-expansion": ">=5.0.6",
"tar": ">=7.5.21",
"brace-expansion": ">=5.0.7",
"minimatch": ">=9.0.7",
"path-to-regexp": "0.1.13",
"lodash": ">=4.18.1",
+2 -2
View File
@@ -23,7 +23,7 @@ async function validateUploadedFile(filePath) {
let metadata;
try {
metadata = await sharp(filePath, {
failOnError: false, // Don't fail on recoverable errors
failOn: 'none', // Don't fail on recoverable errors
limitInputPixels: 268402689 // ~16k x 16k max
}).metadata();
} catch (metadataError) {
@@ -43,7 +43,7 @@ async function validateUploadedFile(filePath) {
// Additional check: verify we can actually decode a small portion of the image
try {
await sharp(filePath, {
failOnError: false,
failOn: 'none',
limitInputPixels: 268402689
})
.resize(10, 10) // Try to resize to very small size
+6 -4
View File
@@ -94,7 +94,7 @@ module.exports = (router) => {
body('allow_presigned_download').optional().isBoolean(),
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(),
// Hero logo settings
body('hero_logo_visible').optional().isBoolean(),
body('hero_logo_visible').optional({ nullable: true }).isBoolean(),
body('hero_logo_size').optional({ nullable: true }).isIn(['small', 'medium', 'large', 'xlarge']),
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']),
// Header style settings (decoupled from layout)
@@ -342,8 +342,10 @@ module.exports = (router) => {
// hero_logo_visible: store NULL ("inherit") unless the admin explicitly
// set it, so the global branding_logo_display_hero toggle keeps
// controlling this gallery afterwards (#756). Only an explicit per-event
// choice overrides the global.
const effectiveHeroLogoVisible = req.body.hero_logo_visible !== undefined
// choice overrides the global. `!= null` treats an explicit null the same
// as omitted (both → inherit); otherwise formatBoolean(null) would coerce
// to 0/false on SQLite instead of NULL (the PUT handler already does this).
const effectiveHeroLogoVisible = req.body.hero_logo_visible != null
? formatBoolean(hero_logo_visible)
: null;
// NULL = inherit the global branding_logo_size (#756), resolved at read
@@ -1224,7 +1226,7 @@ module.exports = (router) => {
}),
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(),
// Hero logo settings
body('hero_logo_visible').optional().isBoolean(),
body('hero_logo_visible').optional({ nullable: true }).isBoolean(),
body('hero_logo_size').optional({ nullable: true }).isIn(['small', 'medium', 'large', 'xlarge']),
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']),
// Header style settings (decoupled from layout)
+10 -6
View File
@@ -212,17 +212,20 @@ async function buildConfiguredPathReport(configuredRows, config) {
const includedInDefault = Boolean(row.include_in_default);
let featureFlagValue = null;
if (row.feature_flag) {
const v = config[row.feature_flag];
featureFlagValue = v === undefined ? null : Boolean(v);
// Alias-aware: show the value the gate actually used, not a seeded
// canonical key shadowed by the UI's spelling. Normalize like the
// walker does — Boolean('false') is true.
const v = backupService.effectiveFlagValue(row, config);
featureFlagValue = v === undefined || v === null ? null : backupService.normalizeBoolean(v);
}
let coverage;
if (!includedInDefault) {
coverage = 'skipped-by-toggle';
} else if (row.feature_flag && featureFlagValue !== true) {
// null (unset) and explicit false both gate the path off — matches
// the walker's normalizeBoolean semantics
coverage = 'skipped-by-feature-flag';
} else if (!backupService.backupPathIncluded(row, config)) {
// Same gate the walker uses — feature flags (incl. the UI's
// backup_include_archives alias) and the What-to-Backup opt-outs.
coverage = row.feature_flag ? 'skipped-by-feature-flag' : 'skipped-by-setting';
} else if (!stat.exists) {
coverage = 'missing-on-disk';
} else {
@@ -313,6 +316,7 @@ async function getCoverageReport() {
willScanCount: paths.filter((p) => p.coverage === 'will-scan').length,
skippedByToggleCount: paths.filter((p) => p.coverage === 'skipped-by-toggle').length,
skippedByFeatureFlagCount: paths.filter((p) => p.coverage === 'skipped-by-feature-flag').length,
skippedBySettingCount: paths.filter((p) => p.coverage === 'skipped-by-setting').length,
missingOnDiskCount: paths.filter((p) => p.coverage === 'missing-on-disk').length,
driftCount: unconfiguredOnDisk.length,
tableMissingFallbackInUse: fallback,
+192 -68
View File
@@ -7,6 +7,7 @@ const os = require('os');
const { promisify } = require('util');
const cron = require('node-cron');
const cronParser = require('cron-parser');
const { db } = require('../database/db');
const { queueEmail } = require('./emailProcessor');
const logger = require('../utils/logger');
@@ -348,7 +349,10 @@ async function getDatabaseBackupInfoInternal() {
return {
type: recent.backup_type || 'unknown',
backupFile: recent.file_path,
size: recent.file_size_bytes,
// file_size_bytes is a bigInteger column — node-postgres returns int8
// as a STRING, and `backedUpSize += size` then concatenates instead of
// adding (issue #871: "167.6 TB" dashboard size). Coerce at the source.
size: Number(recent.file_size_bytes) || 0,
checksum: recent.checksum,
hasChanged,
backupTime: recent.completed_at,
@@ -389,7 +393,11 @@ async function scanDirectory(dirPath, fileList, basePath, excludePatterns = [])
const isExcluded = excludePatterns.some(pattern => {
if (pattern.includes('*')) {
const regex = new RegExp(`^${pattern.replace(/\*/g, '.*')}$`);
// Escape regex metacharacters before expanding the glob star — the
// raw replace turned '.nfs*' into /^.nfs.*$/ whose leading dot
// matched any character (e.g. 'anfs-photo.jpg' was excluded too).
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
const regex = new RegExp(`^${escaped}$`);
return regex.test(entry.name);
}
return entry.name === pattern;
@@ -437,6 +445,26 @@ const LEGACY_BACKUP_PATHS = [
{ path: 'business-docs', feature_flag: null },
];
// "What to Backup" opt-OUT toggles written by BackupConfiguration.tsx.
// Default-ON semantics: only an explicit false excludes the path, so
// installs that never saved the backup form keep backing up everything
// (issue #871: unchecking Thumbnails had no effect because these keys
// were stored but never read).
const OPT_OUT_FLAGS = {
'events/active': 'backup_include_photos',
'thumbnails': 'backup_include_thumbnails',
};
// The UI "Archives" checkbox writes backup_include_archives (plural) while
// the feature_flag rows use backup_include_archived — accept both.
const FLAG_ALIASES = {
backup_include_archived: 'backup_include_archives',
};
// Filesystem noise that must never land in a backup: NFS silly-rename
// artifacts (issue #871 showed .nfs* files uploaded to S3) and OS metadata.
const DEFAULT_EXCLUDE_PATTERNS = ['.nfs*', '.DS_Store', 'Thumbs.db'];
/**
* Resolve the walker's target subdirectories from `backup_paths`.
*
@@ -456,34 +484,84 @@ const LEGACY_BACKUP_PATHS = [
* Used to evaluate feature_flag gates.
* @returns {Promise<Array<{ path: string, feature_flag: string|null }>>}
*/
async function resolveBackupPaths(config) {
let rows;
async function loadBackupPathRows({ includeDisabled = false } = {}) {
try {
if (!(await db.schema.hasTable('backup_paths'))) {
logger.warn('backup_paths table missing — falling back to LEGACY_BACKUP_PATHS');
rows = LEGACY_BACKUP_PATHS;
} else {
rows = await db('backup_paths')
.where('include_in_default', formatBoolean(true))
.orderBy('display_order', 'asc')
.select('path', 'feature_flag');
if (!rows.length) {
logger.warn('backup_paths has no rows with include_in_default=true — falling back to LEGACY_BACKUP_PATHS');
rows = LEGACY_BACKUP_PATHS;
}
return LEGACY_BACKUP_PATHS;
}
let query = db('backup_paths')
.orderBy('display_order', 'asc')
.select('path', 'feature_flag', 'include_in_default');
if (!includeDisabled) {
query = query.where('include_in_default', formatBoolean(true));
}
const rows = await query;
if (!rows.filter((r) => normalizeBoolean(r.include_in_default)).length) {
logger.warn('backup_paths has no rows with include_in_default=true — falling back to LEGACY_BACKUP_PATHS');
return LEGACY_BACKUP_PATHS;
}
return rows;
} catch (err) {
logger.warn(`Failed to query backup_paths (${err.message}) — falling back to LEGACY_BACKUP_PATHS`);
rows = LEGACY_BACKUP_PATHS;
return LEGACY_BACKUP_PATHS;
}
}
// Apply feature_flag gating. A row with feature_flag='backup_include_archived'
// requires config.backup_include_archived to be truthy (same semantics as
// the historical `includeArchived` parameter).
// Per-row gate. Applies the UI opt-out toggles first, then feature_flag
// gating: a row with feature_flag='backup_include_archived' requires the
// corresponding config key to be truthy (same semantics as the historical
// `includeArchived` parameter).
function backupPathIncluded(row, config) {
const optOutKey = OPT_OUT_FLAGS[row.path];
if (optOutKey && config) {
const optOutValue = config[optOutKey];
if (optOutValue !== undefined && optOutValue !== null && normalizeBoolean(optOutValue) === false) {
return false;
}
}
if (!row.feature_flag) return true;
let flagValue;
if (config) {
// The alias (backup_include_archives) is what the current UI writes;
// the canonical singular key is seeded true by migration on every
// install, so the UI value must take precedence or the checkbox can
// never turn the flag off.
const alias = FLAG_ALIASES[row.feature_flag];
if (alias && config[alias] !== undefined && config[alias] !== null) {
flagValue = config[alias];
} else {
flagValue = config[row.feature_flag];
}
}
return normalizeBoolean(flagValue);
}
// The raw config value the gate actually consulted for a row's feature
// flag (alias-aware) — the coverage report shows it next to the status,
// so it must not display the shadowed seeded key.
function effectiveFlagValue(row, config) {
if (!row.feature_flag || !config) return undefined;
const alias = FLAG_ALIASES[row.feature_flag];
if (alias && config[alias] !== undefined && config[alias] !== null) {
return config[alias];
}
return config[row.feature_flag];
}
async function resolveBackupPaths(config) {
return (await loadBackupPathRows()).filter((row) => backupPathIncluded(row, config));
}
// The rows the admin de-selected — the rsync destination needs them as
// --exclude filters because it syncs the whole storage root rather than
// the walker's file list. Includes rows with include_in_default=false,
// which the enabled-only loader would otherwise hide from rsync entirely.
async function resolveExcludedBackupPaths(config) {
const rows = await loadBackupPathRows({ includeDisabled: true });
return rows.filter((row) => {
if (!row.feature_flag) return true;
const flagValue = config ? config[row.feature_flag] : undefined;
return normalizeBoolean(flagValue);
const disabled = row.include_in_default !== undefined && !normalizeBoolean(row.include_in_default);
return disabled || !backupPathIncluded(row, config);
});
}
@@ -574,6 +652,14 @@ async function getFilesToBackupInternal(configOrIncludeArchived = true) {
const targets = await resolveBackupPaths(config);
// backup_exclude_patterns was only honored by the rsync destination
// (as --exclude args); the local/S3 walker ignored it. Merge it with
// the always-on noise filters here so every destination agrees.
const configuredExcludes = Array.isArray(config.backup_exclude_patterns)
? config.backup_exclude_patterns
: [];
const excludePatterns = [...new Set([...DEFAULT_EXCLUDE_PATTERNS, ...configuredExcludes])];
for (const target of targets) {
// CRM document estate is special-cased in the comment block below
// because it's the most expensive omission to recover from:
@@ -589,7 +675,7 @@ async function getFilesToBackupInternal(configOrIncludeArchived = true) {
// those values refer to do not, leaving every CRM *_path column a
// broken FK. scanDirectory short-circuits on ENOENT so installs
// that never used CRM features won't error.
await scanDirectory(path.join(storagePath, target.path), files, storagePath);
await scanDirectory(path.join(storagePath, target.path), files, storagePath, excludePatterns);
}
return files;
@@ -686,7 +772,7 @@ function validateRsyncParam(value, label) {
return value;
}
function buildRsyncArgs(config) {
function buildRsyncArgs(config, extraExcludes = []) {
const storagePath = getStoragePath();
const host = validateRsyncParam(config.backup_rsync_host, 'host');
const remotePath = validateRsyncParam(config.backup_rsync_path, 'remote path');
@@ -713,7 +799,14 @@ function buildRsyncArgs(config) {
args.push('-e', `ssh -i ${sshKey} -o StrictHostKeyChecking=no`);
}
const excludePatterns = config.backup_exclude_patterns || [];
// Same noise filters as the walker, plus the de-selected backup paths
// (extraExcludes) — rsync syncs the whole storage root, so this is the
// only place the What-to-Backup selection can take effect for rsync.
const excludePatterns = [...new Set([
...DEFAULT_EXCLUDE_PATTERNS,
...(Array.isArray(config.backup_exclude_patterns) ? config.backup_exclude_patterns : []),
...extraExcludes,
])];
excludePatterns.forEach(pattern => args.push('--exclude', pattern));
const source = `${storagePath}/`;
@@ -752,7 +845,11 @@ function parseRsyncStats(output) {
async function performRsyncBackup(config, files) {
const { spawnAsync } = require('../utils/safeExec');
const rsyncArgs = buildRsyncArgs(config);
// Anchored excludes for the de-selected What-to-Backup paths; rsync
// otherwise transfers the whole storage root regardless of the walker's
// file list (which only feeds manifests and file state).
const excludedPaths = await resolveExcludedBackupPaths(config);
const rsyncArgs = buildRsyncArgs(config, excludedPaths.map((row) => `/${row.path}/`));
const { stdout } = await spawnAsync('rsync', rsyncArgs);
const stats = parseRsyncStats(stdout);
@@ -1175,6 +1272,50 @@ async function runBackupInternal(isManual = false) {
}
}
// Two settings cooperate here:
// - backup_schedule — UI label like "daily" / "weekly" / "custom"
// - backup_schedule_cron — actual cron expression (custom schedules)
// Older startup code read backup_schedule and crashed when it found a label
// instead of a cron expression. Resolution order: explicit cron field, then
// map known labels, then fall back to default.
const NAMED_SCHEDULES = {
hourly: '0 * * * *',
daily: '0 2 * * *',
weekly: '0 3 * * 0', // Sunday 03:00
monthly: '0 4 1 * *',
};
function resolveScheduleCron(config) {
const isCronExpression = (s) => typeof s === 'string' && /^\s*\S+(\s+\S+){4}\s*$/.test(s);
const readSetting = (key) => {
if (config && Object.prototype.hasOwnProperty.call(config, key)) {
return String(config[key] ?? '').trim();
}
if (config?.__raw && Object.prototype.hasOwnProperty.call(config.__raw, key)) {
return String(parseSettingValue(config.__raw[key]) ?? '').trim();
}
return '';
};
let schedule = '0 2 * * *';
const cronCandidate = readSetting('backup_schedule_cron');
const labelCandidate = readSetting('backup_schedule');
// A named label wins over the cron field: the UI always used to send its
// default cron ('0 3 * * *') alongside e.g. backup_schedule='weekly', which
// silently turned weekly schedules into daily ones (issue #871). The cron
// field only applies for 'custom' (or when no known label is set).
if (labelCandidate && labelCandidate.toLowerCase() !== 'custom' && NAMED_SCHEDULES[labelCandidate.toLowerCase()]) {
schedule = NAMED_SCHEDULES[labelCandidate.toLowerCase()];
} else if (cronCandidate && isCronExpression(cronCandidate)) {
schedule = cronCandidate;
} else if (labelCandidate && isCronExpression(labelCandidate)) {
// Back-compat: a deployment that wrote a cron expression directly into
// backup_schedule (no _cron field) still works.
schedule = labelCandidate;
}
return schedule;
}
async function startBackupService() {
try {
const config = await resolveConfigWithFallback();
@@ -1192,42 +1333,7 @@ async function startBackupService() {
backupJob = null;
}
// Two settings cooperate here:
// - backup_schedule — UI label like "daily" / "weekly" / "custom"
// - backup_schedule_cron — actual cron expression
// The frontend writes both (BackupConfiguration.jsx). Older startup code
// here read backup_schedule and crashed when it found a label instead of
// a cron expression. Resolution order: explicit cron field, then map known
// labels, then fall back to default.
const NAMED_SCHEDULES = {
hourly: '0 * * * *',
daily: '0 2 * * *',
weekly: '0 3 * * 0', // Sunday 03:00
monthly: '0 4 1 * *',
};
const isCronExpression = (s) => typeof s === 'string' && /^\s*\S+(\s+\S+){4}\s*$/.test(s);
const readSetting = (key) => {
if (config && Object.prototype.hasOwnProperty.call(config, key)) {
return String(config[key] ?? '').trim();
}
if (config?.__raw && Object.prototype.hasOwnProperty.call(config.__raw, key)) {
return String(parseSettingValue(config.__raw[key]) ?? '').trim();
}
return '';
};
let schedule = '0 2 * * *';
const cronCandidate = readSetting('backup_schedule_cron');
const labelCandidate = readSetting('backup_schedule');
if (cronCandidate && isCronExpression(cronCandidate)) {
schedule = cronCandidate;
} else if (labelCandidate && NAMED_SCHEDULES[labelCandidate.toLowerCase()]) {
schedule = NAMED_SCHEDULES[labelCandidate.toLowerCase()];
} else if (labelCandidate && isCronExpression(labelCandidate)) {
// Back-compat: a deployment that wrote a cron expression directly into
// backup_schedule (no _cron field) still works.
schedule = labelCandidate;
}
const schedule = resolveScheduleCron(config);
backupJob = cron.schedule(schedule, async () => {
logger.info('Starting scheduled backup');
@@ -1319,6 +1425,8 @@ async function getBackupStatus(limit = 10) {
// ago looked identical to a successful one. Same "silent failure
// not surfaced" class Stage A was designed to fight.
const lastSuccessful = runs.find(r => r.status === 'completed') || null;
const config = await getBackupConfigInternal();
const nextRun = getNextScheduledRun(config);
// Detect zombie running rows (started >30min ago, never updated)
// — these are processes that died without writing a completed_at.
// Surface them so the admin can tell at a glance vs a live run.
@@ -1339,7 +1447,8 @@ async function getBackupStatus(limit = 10) {
recentRuns: runs,
recentBackups: runs, // Alias for frontend compatibility
totalBackups: runs.filter(r => r.status === 'completed').length,
nextScheduledRun: getNextScheduledRun()
nextScheduledRun: nextRun,
nextBackup: nextRun // BackupManagement.tsx reads this name
};
} catch (error) {
logger.error('Failed to get backup status:', error);
@@ -1351,12 +1460,19 @@ async function getBackupStatus(limit = 10) {
}
}
function getNextScheduledRun() {
const now = new Date();
const next = new Date(now);
next.setDate(now.getDate() + 1);
next.setHours(2, 0, 0, 0);
return next.toISOString();
function getNextScheduledRun(config) {
// null → the UI shows "Not scheduled". Only a real, enabled schedule
// produces a date (issue #871: this used to be a hardcoded "tomorrow
// 02:00" that ignored the configured schedule entirely).
if (!config || !normalizeBoolean(config.backup_enabled)) {
return null;
}
try {
return cronParser.parseExpression(resolveScheduleCron(config)).next().toISOString();
} catch (error) {
logger.warn(`Could not compute next backup run: ${error.message}`);
return null;
}
}
async function cleanupOldBackupRuns(retentionDays = 30) {
@@ -1569,5 +1685,13 @@ service.getBackupStatus = getBackupStatus;
service.cleanupOldBackupRuns = cleanupOldBackupRuns;
service.getBackupManifest = getBackupManifest;
service.validateBackupManifest = validateBackupManifest;
service.resolveBackupPaths = resolveBackupPaths;
service.resolveExcludedBackupPaths = resolveExcludedBackupPaths;
service.backupPathIncluded = backupPathIncluded;
service.effectiveFlagValue = effectiveFlagValue;
service.normalizeBoolean = normalizeBoolean;
service.buildRsyncArgs = buildRsyncArgs;
service.resolveScheduleCron = resolveScheduleCron;
service.getNextScheduledRun = getNextScheduledRun;
module.exports = service;
+27 -4
View File
@@ -47,11 +47,23 @@ async function detectEnvironment() {
type = 'standalone';
}
// Detect a production compose install. The backend runs INSIDE a container and
// cannot see the host's compose files (the image only carries backend/), so we
// can't stat docker-compose.production.yml. Instead we key off an env var the
// production compose sets in the backend environment (PICPEAK_RELEASE_CHANNEL)
// and the default docker-compose.yml does not. When present, the update
// instructions must target that file explicitly — bare `docker compose`
// operates on docker-compose.yml, a different (build-based) stack that also
// starts the dev-only mailhog and leaves the real production containers on the
// old version.
const isProductionCompose = Boolean(process.env.PICPEAK_RELEASE_CHANNEL);
return {
type,
isDocker,
isGit,
hasDockerCompose,
isProductionCompose,
platform: process.platform,
nodeVersion: process.version,
appVersion
@@ -94,25 +106,36 @@ function generateUpdateInstructions(env, targetVersion) {
if (env.isDocker) {
instructions.environmentName = 'Docker';
// Production installs use docker-compose.production.yml (the file the README
// documents and the only one with pinned GHCR images + no dev-only mailhog).
// Bare `docker compose` targets docker-compose.yml instead, so a production
// user who runs it stays on the old version and gets a stray mailhog. When we
// detect a production compose (PICPEAK_RELEASE_CHANNEL set), point every
// command at that file with `-f`.
const composeFile = env.isProductionCompose ? '-f docker-compose.production.yml ' : '';
instructions.steps = [
{
description: 'Pull latest images',
command: 'docker compose pull',
command: `docker compose ${composeFile}pull`,
note: 'Downloads the new version images'
},
{
description: 'Recreate containers with new images',
command: 'docker compose up -d',
command: `docker compose ${composeFile}up -d`,
note: 'Restarts containers with new version'
},
{
description: 'Watch logs for startup (optional)',
command: 'docker compose logs -f backend',
command: `docker compose ${composeFile}logs -f backend`,
note: 'Press Ctrl+C to exit logs',
optional: true
}
];
instructions.warnings.push('Make sure you are in the directory containing your docker-compose.yml file');
if (env.isProductionCompose) {
instructions.warnings.push('Run these from the directory containing your docker-compose.production.yml file.');
} else {
instructions.warnings.push('Make sure you are in the directory containing your compose file. If you installed with docker-compose.production.yml, add `-f docker-compose.production.yml` to each command.');
}
} else if (env.isGit) {
instructions.environmentName = 'Git (Development)';
instructions.steps = [
+3 -3
View File
@@ -149,7 +149,7 @@ async function generateThumbnail(imagePath, options = {}) {
let sharpInstance = sharp(imagePath, {
limitInputPixels: 268402689, // ~16k x 16k max
sequentialRead: true,
failOnError: false
failOn: 'none'
});
// Strip EXIF/metadata from thumbnails (privacy: prevent GPS leak etc.)
@@ -389,7 +389,7 @@ async function generateHeroImage(imagePath, options = {}) {
let sharpInstance = sharp(imagePath, {
limitInputPixels: 268402689,
sequentialRead: true,
failOnError: false
failOn: 'none'
});
// Strip EXIF/metadata from hero images (privacy: prevent GPS leak etc.)
@@ -519,7 +519,7 @@ async function generatePreviewImage(imagePath, options = {}) {
let sharpInstance = sharp(imagePath, {
limitInputPixels: 268402689, // ~16k x 16k max
sequentialRead: true,
failOnError: false,
failOn: 'none',
});
// Strip EXIF — same privacy reasoning as thumbnails/heroes.
+4 -1
View File
@@ -298,7 +298,10 @@ class RestoreService {
this.log('info', 'Applying post-restore migrations to restored database...');
this.updateProgress('Applying any post-backup migrations...');
const backendRoot = path.join(__dirname, '..', '..');
const { stderr } = await spawnAsync('npm', ['run', 'migrate:safe'], {
// Invoked via node directly — the runtime image ships no npm
// (see Dockerfile), and an ENOENT here would be swallowed by the
// non-fatal catch below, silently skipping post-restore migrations.
const { stderr } = await spawnAsync('node', ['migrations/run-migrations-safe.js'], {
cwd: backendRoot,
env: { ...process.env },
});
+19 -7
View File
@@ -123,20 +123,32 @@ async function getPasswordComplexitySettings() {
// Use retry wrapper to handle connection failures
const settings = await withRetry(async () => {
// Key must match what the settings UI writes: `security_` prefix +
// `password_complexity` (useSettingsState.ts saveSecurityMutation).
// The old `security_password_complexity_level` key is written by
// nothing, so the admin's choice was silently ignored.
return await db('app_settings')
.where('setting_key', 'security_password_complexity_level')
.where('setting_key', 'security_password_complexity')
.first();
});
if (!settings || !settings.setting_value) {
return 'moderate'; // Default
}
const value = typeof settings.setting_value === 'string'
? JSON.parse(settings.setting_value)
: settings.setting_value;
return value;
// Parse with fallback, mirroring getAppSetting: on SQLite the TEXT
// column returns the JSON-stringified value ('"very_strong"'), but on
// Postgres the json column comes back already decoded ('very_strong')
// — a bare JSON.parse would throw there and the outer catch would
// silently fall back to 'moderate' again.
let value = settings.setting_value;
if (typeof value === 'string') {
try {
value = JSON.parse(value);
} catch (_) { /* already-decoded plain string — keep as-is */ }
}
return value || 'moderate';
} catch (error) {
logger.error('Failed to get password complexity settings:', error);
return 'moderate'; // Default on error - ensures app continues working
+5 -3
View File
@@ -120,12 +120,14 @@ echo "Ensuring storage directories exist..."
STORAGE_BASE="${STORAGE_PATH:-/app/storage}"
mkdir -p "$STORAGE_BASE/events/active" "$STORAGE_BASE/events/archived" "$STORAGE_BASE/thumbnails" 2>/dev/null || true
# Run migrations (use safe runner in production)
# Run migrations (use safe runner in production). Invoked via node directly —
# the runtime image no longer ships npm (see Dockerfile: its bundled deps kept
# tripping CVE scanners while npm itself never runs in production).
echo "Running database migrations..."
if [ "$NODE_ENV" = "production" ]; then
npm run migrate:safe
node migrations/run-migrations-safe.js
else
npm run migrate
node migrations/run-migrations.js
fi
# Execute the main command
+6
View File
@@ -141,10 +141,16 @@ services:
networks:
- picpeak-network
# Local mail catcher for development/testing only — never wanted in a real
# deployment. Gated behind the `dev` profile so a plain `docker compose up -d`
# does NOT start it; opt in with `docker compose --profile dev up -d`. Nothing
# depends on it (SMTP_HOST comes from .env), so gating is safe.
mailhog:
image: mailhog/mailhog:latest
container_name: picpeak-mailhog
restart: unless-stopped
profiles:
- dev
ports:
- "${MAILHOG_SMTP_PORT:-1025}:1025"
- "${MAILHOG_UI_PORT:-8025}:8025"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "3.45.2",
"version": "3.45.7",
"type": "module",
"scripts": {
"dev": "vite",
@@ -5,7 +5,6 @@ import {
Server,
Cloud,
HardDrive,
AlertCircle,
Eye,
EyeOff,
Wifi,
@@ -102,7 +101,9 @@ export const BackupConfiguration: React.FC<BackupConfigurationProps> = ({ config
backup_include_database: true,
backup_include_photos: true,
backup_include_archives: true,
backup_include_thumbnails: false,
// Matches the backend never-saved fallback (include everything) so the
// form does not show "off" while thumbnails are in fact being backed up.
backup_include_thumbnails: true,
backup_include_temp: false,
backup_compression: true,
backup_encryption: false,
@@ -111,8 +112,7 @@ export const BackupConfiguration: React.FC<BackupConfigurationProps> = ({ config
const [showSecrets, setShowSecrets] = useState({
s3_secret_key: false,
ssh_key: false,
encryption_passphrase: false
ssh_key: false
});
const [testingConnection, setTestingConnection] = useState(false);
@@ -152,6 +152,15 @@ export const BackupConfiguration: React.FC<BackupConfigurationProps> = ({ config
return;
}
// A custom schedule needs a real 5-field cron — the backend silently
// falls back to daily 02:00 otherwise. For named schedules the stored
// cron is kept (the backend prefers the label), so switching back to
// Custom keeps the previously saved expression.
if (formData.backup_schedule === 'custom' && !/^\s*\S+(\s+\S+){4}\s*$/.test(formData.backup_schedule_cron)) {
toast.error(t('backup.configuration.messages.invalidCron', 'Please enter a valid cron expression (5 fields)'));
return;
}
onSave(formData);
};
@@ -546,69 +555,6 @@ export const BackupConfiguration: React.FC<BackupConfigurationProps> = ({ config
</div>
</Card>
{/* Advanced Options */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('backup.configuration.advancedOptions.title')}</h3>
<div className="space-y-4">
<label className="flex items-center">
<input
type="checkbox"
checked={formData.backup_compression}
onChange={(e) => handleChange('backup_compression', e.target.checked)}
className="h-4 w-4 text-primary focus:ring-primary border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700"
/>
<div className="ml-3">
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('backup.configuration.advancedOptions.compression')}</span>
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('backup.configuration.advancedOptions.compressionHelp')}</p>
</div>
</label>
<div>
<label className="flex items-center mb-3">
<input
type="checkbox"
checked={formData.backup_encryption}
onChange={(e) => handleChange('backup_encryption', e.target.checked)}
className="h-4 w-4 text-primary focus:ring-primary border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700"
/>
<div className="ml-3">
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('backup.configuration.advancedOptions.encryption')}</span>
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('backup.configuration.advancedOptions.encryptionHelp')}</p>
</div>
</label>
{formData.backup_encryption && (
<div className="ml-7">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('backup.configuration.advancedOptions.encryptionPassphrase')}
</label>
<div className="relative">
<Input
type={showSecrets.encryption_passphrase ? 'text' : 'password'}
value={formData.backup_encryption_passphrase}
onChange={(e) => handleChange('backup_encryption_passphrase', e.target.value)}
placeholder={t('backup.configuration.advancedOptions.encryptionPassphraseHelp')}
required={formData.backup_encryption}
/>
<button
type="button"
onClick={() => setShowSecrets(prev => ({ ...prev, encryption_passphrase: !prev.encryption_passphrase }))}
className="absolute top-1/2 -translate-y-1/2 right-2 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
>
{showSecrets.encryption_passphrase ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
<p className="mt-1 text-xs text-red-600">
<AlertCircle className="inline h-3 w-3 mr-1" />
{t('backup.configuration.advancedOptions.encryptionPassphraseHelp')}
</p>
</div>
)}
</div>
</div>
</Card>
{/* Save Button */}
<div className="flex justify-end">
<Button
@@ -258,6 +258,12 @@ const SummaryCard: React.FC<{
value={String(summary.skippedByFeatureFlagCount)}
/>
)}
{summary.skippedBySettingCount > 0 && (
<Row
label={t('backup.coverage.summary.skippedBySetting', 'Skipped (backup settings)')}
value={String(summary.skippedBySettingCount)}
/>
)}
{summary.missingOnDiskCount > 0 && (
<Row
label={t('backup.coverage.summary.missingOnDisk', 'Missing on disk')}
@@ -380,6 +386,10 @@ const CoverageBadge: React.FC<{ coverage: BackupPathCoverage }> = ({ coverage })
tone: 'neutral',
label: t('backup.coverage.coverage.skippedByFlag', 'Gated off'),
},
'skipped-by-setting': {
tone: 'neutral',
label: t('backup.coverage.coverage.skippedBySetting', 'Off (backup settings)'),
},
'missing-on-disk': {
tone: 'amber',
label: t('backup.coverage.coverage.missingOnDisk', 'Missing on disk'),
+2
View File
@@ -416,6 +416,7 @@
"willScan": "Wird gescannt",
"skippedByToggle": "Übersprungen (Schalter aus)",
"skippedByFlag": "Übersprungen (Feature-Flag)",
"skippedBySetting": "Übersprungen (Backup-Einstellungen)",
"missingOnDisk": "Auf Festplatte fehlend",
"drift": "Nicht konfiguriert auf Festplatte (Drift)"
},
@@ -430,6 +431,7 @@
"willScan": "Wird gescannt",
"skippedByToggle": "Aus",
"skippedByFlag": "Per Flag aus",
"skippedBySetting": "In Backup-Einstellungen aus",
"missingOnDisk": "Auf Festplatte fehlend"
},
"drift": {
+2
View File
@@ -2862,6 +2862,7 @@
"willScan": "Will scan",
"skippedByToggle": "Skipped (toggle off)",
"skippedByFlag": "Skipped (feature flag)",
"skippedBySetting": "Skipped (backup settings)",
"missingOnDisk": "Missing on disk",
"drift": "Unconfigured on disk (drift)"
},
@@ -2876,6 +2877,7 @@
"willScan": "Will scan",
"skippedByToggle": "Off",
"skippedByFlag": "Gated off",
"skippedBySetting": "Off (backup settings)",
"missingOnDisk": "Missing on disk"
},
"drift": {
@@ -128,7 +128,7 @@ export const BackupManagement: React.FC = () => {
<div className="flex items-center space-x-2">
<Clock className="h-5 w-5 text-neutral-400" />
<span className="text-sm text-neutral-600 dark:text-neutral-400">
{t('backup.status.nextBackup')}: {backupStatus?.nextBackup || t('backup.status.notScheduled')}
{t('backup.status.nextBackup')}: {backupStatus?.nextBackup ? fmtDateTime(backupStatus.nextBackup) : t('backup.status.notScheduled')}
</span>
</div>
)}
+2
View File
@@ -251,6 +251,7 @@ export type BackupPathCoverage =
| 'will-scan'
| 'skipped-by-toggle'
| 'skipped-by-feature-flag'
| 'skipped-by-setting'
| 'missing-on-disk';
export interface BackupCoveragePath {
@@ -289,6 +290,7 @@ export interface BackupCoverageReport {
willScanCount: number;
skippedByToggleCount: number;
skippedByFeatureFlagCount: number;
skippedBySettingCount: number;
missingOnDiskCount: number;
driftCount: number;
tableMissingFallbackInUse: boolean;
+20 -3
View File
@@ -15,6 +15,7 @@ readonly SCRIPT_VERSION="2.1.0"
readonly APP_NAME="PicPeak"
readonly REPO_URL="https://github.com/PicPeak/picpeak.git"
readonly NODE_VERSION="20"
readonly NODE_MIN_VERSION="20.19.0" # backend engines: ^20.19.0 || >=22 (sharp 0.35, html-to-text 10)
readonly MIN_RAM_DOCKER=2048
readonly MIN_RAM_NATIVE=1024
readonly MIN_DISK_GB=2
@@ -721,7 +722,15 @@ EOF
################################################################################
install_nodejs() {
if command_exists node && [[ $(node -v | cut -d'v' -f2 | cut -d'.' -f1) -ge $NODE_VERSION ]]; then
# --update dispatches here before main() runs detect_os, so detect on demand
if [[ -z "$PACKAGE_MANAGER" ]]; then
detect_os
fi
local node_ver
node_ver=$(command_exists node && node -v | cut -d'v' -f2 || echo "0")
# backend engines range is ^20.19.0 || >=22 (Node 21 is excluded by the glob/minimatch family)
if [[ "$(printf '%s\n' "$NODE_MIN_VERSION" "$node_ver" | sort -V | head -1)" == "$NODE_MIN_VERSION" && "${node_ver%%.*}" != "21" ]]; then
log_success "Node.js $(node -v) is already installed"
return
fi
@@ -738,7 +747,12 @@ install_nodejs() {
$PACKAGE_MANAGER install -y nodejs
;;
esac
# Package managers won't downgrade a newer Node (e.g. 21), so re-verify before continuing
node_ver=$(command_exists node && node -v | cut -d'v' -f2 || echo "0")
if [[ "$(printf '%s\n' "$NODE_MIN_VERSION" "$node_ver" | sort -V | head -1)" != "$NODE_MIN_VERSION" || "${node_ver%%.*}" == "21" ]]; then
die "Node.js v$node_ver does not satisfy the backend requirement (^$NODE_MIN_VERSION || >=22); remove the current Node.js, install a supported version, then re-run this script"
fi
log_success "Node.js installed: $(node -v)"
}
@@ -1254,7 +1268,10 @@ update_docker_installation() {
update_native_installation() {
log_step "Updating native installation..."
# Make sure the runtime satisfies the backend engines range before taking the service down
install_nodejs
# Stop services
systemctl stop picpeak-backend || true
if systemctl list-unit-files | grep -q '^picpeak-workers.service'; then