The stable release-please workflow (release-please.yml, triggered on
push to stable) had no `target-branch`, so it defaulted to the repo
default branch (main) and computed the next version from main's stale
`.release-please-manifest.json` (2.6.1) — cutting a spurious **v2.7.0**
stable release (a version regression from 3.44.0) when #771 landed on
stable, and bumping main's package.json + manifest to 2.7.0.
- release-please.yml: add `target-branch: stable` so it releases from
the stable branch (3.44.0 → 3.45.0), like release-please-beta.yml
already pins `target-branch: main`.
- Restore main's version to 3.83.0-beta.0 (backend + frontend
package.json), set `.release-please-manifest.json` to 3.44.0, and drop
the bogus 2.7.0 CHANGELOG section.
The v2.7.0 tag/release is deleted separately; the real v3.45.0 stable is
cut by re-running release-please on the stable branch after this lands.
Backend for #738. Real TOTP 2FA for admin accounts, all roles incl.
super_admin (closes#735).
- mfaService: otplib TOTP; AES-256-GCM encryption of the secret at rest
(key derived from MFA_ENCRYPTION_KEY or JWT_SECRET); bcrypt-hashed,
single-use recovery codes; otpauth URI + QR.
- Migration 151: adds two_factor_recovery_codes + two_factor_enrolled_at
(secret/enabled columns already existed from legacy 016).
- Enrollment endpoints (behind adminAuth, per-user): GET /mfa/status,
POST /mfa/{setup,enable,disable,recovery-codes}. Disable/regenerate
require a current code so a hijacked session can't strip 2FA.
- Login challenge: /admin/login returns {mfaRequired, mfaToken} (no
session) when 2FA is on; /admin/login/mfa exchanges a TOTP or recovery
code for the session. Lockout counter is NOT reset until the second
factor passes, so MFA brute-force is rate-limited too.
- CLI break-glass: scripts/reset-admin-mfa.js --email <e> | --all --yes,
audit-logged, matches reset-admin-password.js convention.
- Docs + optional MFA_ENCRYPTION_KEY env.
Verified end-to-end on a live backend: enroll (super_admin), challenge,
TOTP + single-use recovery login, disable, and CLI reset.
Code-scanning Trivy alerts on the open beta (PR #641). Of the 10 open
alerts, 6 are stale (lockfile already past the fix) or live in
floating-tag base images (`nginx:1.28-alpine`, `node:22-alpine`) which
auto-update on the next CI rebuild — no code change needed for those.
The 3 actually present in the current `backend/package-lock.json`:
- `qs 6.15.0 → 6.15.2` (CVE-2026-8723, alert #266). Bump override from
`>=6.14.2` to `>=6.15.2`.
- `brace-expansion 5.0.5 → 5.0.6` (CVE-2026-45149, alert #264). Bump
override from `>=5.0.5` to `>=5.0.6`.
- `uuid 8.3.2` transitively via `node-cron@3.0.3` (CVE-2026-41907,
alert #265). Add top-level `uuid: ^11.1.1` override so node-cron's
nested resolution collapses into our root uuid version. node-cron
uses only `uuid.v4()` — API-stable across v8 → v11. Verified the
scheduler still constructs tasks under the override.
Lockfile regenerated; net -9 lines (one fewer uuid copy).
Stale alerts that will close on next code-scan rebuild:
- #205 postcss (frontend lockfile already at 8.5.14)
- #221 i18next-http-backend (backend lockfile already at 3.0.6)
Auto-resolved on next image rebuild (no Dockerfile change — floating
tags):
- #267 nginx (frontend `nginx:1.28-alpine`)
- #223 ip-address, #156/#155 picomatch, #140 brace-expansion (all in
the npm CLI shipped inside `node:22-alpine`)
Refs: code-scanning alerts #264, #265, #266
Two related backup-integrity fixes from 8digit's fork (issue #640 items
#3 + #4), bundled because they touch the same two files and ship better
together than apart.
### Stream-extract restore for >2 GiB archives
`adminArchives.js:170` was using `adm-zip`, which loads the entire ZIP
into a Node Buffer before extracting. Node has a hard 2 GiB Buffer cap,
so any restore over that limit fails with `ERR_FS_FILE_TOO_LARGE` — and
since the frontend `onError` toast is the generic "Something went wrong",
the cause stays invisible. Real-world wedding archives routinely cross
2 GiB; affected restores have likely been silent failures.
Swapped `adm-zip` for `node-stream-zip` which streams each entry to disk
as it's processed — no full-file Buffer, no 2 GiB ceiling. API shape:
```js
const zip = new StreamZip.async({ file: archivePath });
const entries = Object.values(await zip.entries());
await zip.extract(null, eventDir);
await zip.close();
```
Re-import logic (photos, categories, sizes) unchanged; only field rename
`entry.entryName` → `entry.name`. Credit: 8digit/picpeak@69033c6.
### Preserve `original_filename` via photos manifest
Archive → restore round-trip currently loses `original_filename` (the
post-#508 column tracking the camera-side name) because the gallery
filenames are renamed on upload and can't be derived from the extracted
files. This matters now that the Lightroom export (#623) depends on
`original_filename` — a restored event lost that signal.
- **`archiveService.js`**: writes `photos_manifest.json` into the archive
containing per-photo `{filename, original_filename, type, uploaded_at,
category_name}`. Non-fatal: a manifest write failure falls through to
legacy behaviour (filename used as original_filename, same as before).
- **`adminArchives.js`**: reads the manifest on restore, builds a
`Map<filename → manifest>`, and assigns
`original_filename = manifest?.original_filename || filename`.
Archives produced before this lands have no manifest — restore logs a
one-shot notice and falls back to filename, preserving backward compat.
Credit: 8digit/picpeak@eb018aa.
### Deps
- Removed `adm-zip ^0.5.16`
- Added `node-stream-zip ^1.15.0`
### What's NOT in this PR
8digit's commit also fixed the production compose healthcheck (`curl`
isn't in our Alpine image); that's already been addressed upstream in
the meantime. The frontend `onError` swallow on the restore toast is a
separate small follow-up.
### Test plan
- [x] `node -c` on both files clean
- [x] `node-stream-zip` async API verified at load time
- [ ] Manual: archive a multi-GB event → restore → confirm photos
re-import with original_filename preserved
- [ ] Manual: restore an archive produced before this lands → confirm
fallback to filename works (no manifest path crashes)
- [ ] Manual: confirm the new photos_manifest.json is inside the
generated archive (`unzip -l <archive>.zip | grep manifest`)