Compare commits

...

8 Commits

Author SHA1 Message Date
Paul Nothaft 7974b9c6d7 chore(stable): release 3.45.10 (#923)
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-30 21:20:10 +02:00
Paul Nothaft 60cbda5b22 fix(security): close GHSA-g94x (cross-gallery photo read) + GHSA-pv6w (admin DB export) (stable) (#925)
* fix(security): close two access-control advisories (GHSA-g94x, GHSA-pv6w) (stable)

GHSA-g94x-8vv8-3c9f (HIGH) — the secure-image VIEW route
(/secure-images/:slug/secure/:photoId/:token) validated only the token
signature and took the gallery/photo from the URL, so a token minted on
any PUBLIC gallery read every other gallery's photos with no password
(its download sibling has verifyGalleryAccess; the view route can't —
it serves via <img src> with no header). Bind the token to its scope
instead: the URL photoId must equal the token's minted photoId (photos
belong to exactly one gallery, and minting is gallery-scoped), and the
gallery embedded in the token's sessionId must equal the URL gallery.

GHSA-pv6w-rj34-wj9v (MEDIUM) — GET /admin/backup/picpeak/export dumps
every table unredacted (bcrypt hashes, 2FA, SMTP/SSO/WhatsApp/webhook/S3
secrets) and was gated only by backup.create, which the built-in admin
role holds. Gate it behind super_admin, matching the restore side
(backup.restore, already admin-denied) and the masked config APIs.

Regression tests pin both: cross-gallery token reads 403 (photo and
gallery checks), backup export 403 for admin / passes for super_admin.

Stable port of #924. secureImages on stable has no reveal-mode block, so
only the token-binding checks are added; the backup export gate is
identical.

* fix(security): review follow-ups on the export gate (GHSA-pv6w)

- test: place the mocked export in its own mkdtemp dir. The route
  recursively deletes path.dirname(filePath) after download, so a stub
  in bare os.tmpdir() made the super_admin test wipe the whole temp
  root — other jest workers' DB files included (latent CI flake).
- ui: hide PicpeakExportCard from non-super_admins. The role keeps
  settings.view + backup.create, so after the gate its Download button
  always 403'd with a generic toast; gate the card on role super_admin
  to match the endpoint.

* fix(security): keep the token-mismatch audit values within varchar(20) (GHSA-g94x review)

image_access_logs.access_type is varchar(20) (migration 038), but
'token_gallery_mismatch' is 22 chars — on Postgres the audit write
threw value-too-long and logImageAccess swallowed it, so the security
event went unrecorded (the 403 still fired; log is best-effort). Shorten
to 'photo_mismatch' / 'gallery_mismatch' (14/16).

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-30 14:24:34 +02:00
Paul Nothaft a27d19b4d1 fix(admin): expose view/download counters in the admin photos list (#895 follow-up) (stable) (#915)
* fix(admin): expose view/download counters in the admin photos list (#895 follow-up) (stable)

st-ivan's re-test after #904: statistics panel and event summary now
agree, but the per-image Engagement column still shows 0. Root cause:
the admin photos LIST endpoint maps rows to an explicit response object
that includes like/comment/rating/favorite counts but never included
view_count or download_count — the grid reads photo.view_count ?? 0,
so the column showed 0 regardless of what the DB counted. This mapper,
not stale data, is also why per-image downloads always displayed 0 in
the original report.

Suite extended with a list-endpoint assertion (beacon + download, then
the admin list reflects 1/1 and untouched photos 0/0). The skip test now
neutralizes the route's background pre-zip build, whose async ENOENT
against the intentionally missing file could land mid-suite.

Includes the one-line chunkedUploadService unref from #911 so the test
suite can mount adminPhotos regardless of merge order (identical change,
merges cleanly either way).

* test: widen the fire-and-forget settle window (#895 follow-up)

The 100ms settle was marginal on loaded CI runners — the counter
increments are deliberately fire-and-forget, and the 909 PRs flaked on
exactly these assertions. 400ms keeps the suite fast while giving slow
runners room.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-30 12:15:38 +02:00
Paul Nothaft d68d84e5c8 fix(admin): serve videos with their real MIME type in the admin photo view (#908) (stable) (#911)
* fix(admin): serve videos with their real MIME type in the admin photo view (#908) (stable)

The admin view route built Content-Type from the filename extension —
image/<ext> — which is invalid for videos (image/mp4). The admin player
fetches this URL into a blob that inherits the type, and browsers
refuse to play a <video> blob labeled image/*: blank/grey preview,
while download (which already uses photo.mime_type) worked fine.

Stored mime_type now wins; videos without one fall back to video/mp4,
images to the extension, and extensionless files to image/jpeg instead
of the equally invalid bare 'image/'.

Also unrefs chunkedUploadService's module-level hourly cleanup interval:
it kept Jest from exiting for any suite requiring adminPhotos (it's why
adminPhotos.reference sits on the CI ignore list). Production behavior
unchanged — the HTTP listener keeps the process alive.

New adminPhotoContentType suite pins all four MIME cases.

* fix(admin): harden admin photo Content-Type resolution (#908 review round)

External review findings, all verified:

- The header is now ALWAYS image/* or video/*. photos.mime_type is
  never echoed verbatim unless it is a video/ type — the chunked-upload
  path stores the client-sent MIME unvalidated, so a stored text/html
  served inline under the app origin was a same-origin XSS hazard.
- MIME-less videos map from the extension via the shared
  EXTENSION_TO_MIME (.mov → video/quicktime, .webm → video/webm)
  instead of a blanket video/mp4 that would mislabel them.
- Images ignore the stored MIME entirely: migration 039 backfilled
  image/jpeg onto every legacy row (PNGs included), so trusting it
  would regress previously-correct extension-derived types. Extension
  wins, normalized (jpg → image/jpeg).

Suite extended to 8 MIME cases including the XSS guard and the
039-backfill immunity.

* fix(admin): validate stored video MIME as a full header-safe token (#908 review round 2)

A prefix check let malformed client-stored values through:
'video/mp4\r\nX: y' makes res.setHeader throw ERR_INVALID_CHAR — a
permanent 500 for that photo — and a bare 'video/' is an invalid type.
Strict /^video\/[\w.+-]+$/ now gates the stored value; anything else
falls back to the extension map. Two new tests pin both shapes.

* fix(admin): map-only image Content-Type — no raw extension interpolation (#908 review round 3)

image/${ext} could synthesize image/svg+xml (scriptable when served
inline) or header-invalid values from client-controlled chunked-upload
filenames. The shared EXTENSION_TO_MIME map is now the allowlist on the
image side too; unmapped extensions serve as image/jpeg — browsers
sniff image bytes in img/blob contexts, so a mislabel is harmless where
an injected type is not.

* fix(admin): own-property lookup in the extension MIME map (#908 review round)

A client-controlled filename ending in .constructor / .__proto__ /
.toString made EXTENSION_TO_MIME[ext] return an inherited Object.prototype
member (truthy), and the downstream extMime.startsWith threw —
a permanent 500 on the admin view for that photo instead of the JPEG /
mp4 fallback. hasOwnProperty-gated now; test pins both a .constructor
image and a .__proto__ video.

* fix(admin): honor safe stored image MIME for auto-imported formats (#908 review round 2)

My previous round made the image side map-only to dodge the migration
039 image/jpeg backfill and image/svg+xml — but that regressed the S3
auto-importer (STORAGE_AUTO_IMPORT), which stores correct types for
avif/bmp/tiff/heic whose extensions aren't in EXTENSION_TO_MIME. Those
now served as image/jpeg (JPEG-labelled non-JPEG bytes).

Precedence is now mapped-extension (still corrects the 039 backfill on
PNGs) -> stored MIME IF in a safe raster allowlist (avif/bmp/tiff/heic
+ the mapped ones) -> image/jpeg. Allowlist, not a regex: image/svg+xml
stays excluded (scriptable inline). Tests pin avif preserved and svg
degraded to jpeg.

* fix(admin): allow any header-safe raster MIME, deny svg/xml (#908 review round 3)

The round-2 hand-listed Set kept missing formats the S3 auto-importer
stores (apng/ico/jxl beyond avif/bmp/tiff). Replace it with a regex:
honor image/<token> EXCEPT the scriptable svg / *+xml family. Covers
every current and future raster type in one rule while still blocking
inline-scriptable svg and header injection. Tests pin apng + x-icon
preserved, svg still degraded to jpeg.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-30 12:14:58 +02:00
Paul Nothaft 6891769124 fix(admin): stop marking events expired up to 24h early (#909) (stable) (#917)
* fix(admin): stop marking events expired up to 24h early (#909) (stable)

differenceInDays truncates to whole days, so an event expiring in a few
hours returned 0 and three admin surfaces treated it as gone:

- EventsListPage: status chip said 'Expired' (days <= 0) while the
  public gallery — which compares real timestamps — correctly showed
  'expires in X hours'. This is the reporter's exact symptom.
- EventDetailsPage: same isExpired math on the detail view.
- AdminDashboard: the expiring-soon card showed '0 days left' on the
  final day.

Expired is now gated on the actual timestamp (expires_at <= now), and
the countdown chips use ceiling days so the last day reads '1 day
left' instead of flipping to Expired/0.

* fix(admin): drop already-expired events from the dashboard card (#909 review round)

The expiring-soon card ran Math.max(1, ceil(delta)), so an event that
expired while the dashboard sat open (its query isn't polled) showed
'1 day left' indefinitely from the stale cached row. Expired rows are
now filtered out before render; the delta is therefore always positive
and the clamp is gone.

* fix(admin): refresh expiry status live at the boundary (#909 review round 2)

Two review findings on the admin expiry surfaces:

- The dashboard 'expiring soon' card, list badges, and detail banner are
  all computed inline from Date.now() at render, so a page left open
  across an event's expiry kept showing 'active'/'1 day left' until an
  unrelated render — which for editor/viewer roles (no health poll)
  never happens.
- My round-1 client-side filter on the dashboard desynced the visible
  list from the cached total/stat ('no events expiring' beside 'view
  all N').

Both are fixed by new useExpiryRefresh: it fires once at the soonest
future expiry (setTimeout, overflow-guarded). The dashboard refetches
its expiring + stats queries — the backend already excludes expired
events, so rows/total/stats come back consistent (filter removed). The
list and detail pages bump a tick so the inline badges recompute. Hooks
are placed above the loading early-returns (rules-of-hooks is disabled
in eslint, so this was a latent crash otherwise).

* fix(admin): expiry-refresh precision + filtered refetch (#909 review round 3)

Three refinements to round-2's live-expiry work:

- useExpiryRefresh now re-arms past setTimeout's ~24.8-day overflow
  limit (capped wake-up that re-evaluates) instead of dropping the timer,
  so a page mounted for weeks still updates.
- The dashboard requests the expiring list ordered by expires_at asc, so
  the five shown rows ARE the soonest to expire — the timer schedules
  against the true next boundary even when >5 events are expiring
  (getEvents gains optional sortBy/sortOrder; backend already whitelists
  expires_at).
- EventsListPage refetches instead of only re-rendering at the boundary:
  under the 'expiring' filter the backend drops expired rows, so a plain
  tick would leave a stale 'Expired' row + total. refetch keeps rows and
  totals correct under every filter.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-30 12:14:54 +02:00
Paul Nothaft b32ba1ed6b ci: batch stable releases into one daily version (stable) (#920)
* ci: batch stable releases into one daily version (stable)

The stable release PR was auto-merged the instant it went green, so a
day with N bugfixes produced N patch releases (3.45.8 AND 3.45.9 on
2026-07-29 alone) — N upgrade notifications for stable users and N
full Docker build cycles.

Fixes now accumulate in release-please's rolling release PR and are
cut as ONE version per day by release-stable-daily.yml (18:00 UTC).
Approval/merge mechanics are unchanged from the inline step (#719):
approve as github-actions[bot], auto-merge as the PAT so the merge
triggers the tag-cutting run.

- Urgent fix? workflow_dispatch the daily job or merge the release PR
  by hand — the schedule is a default, not a gate.
- Beta is untouched: instant beta releases are load-bearing for
  same-day reporter verification.
- schedule only fires from the default branch; the stable copy of the
  new workflow is inert and exists to keep branches in sync.

* ci: harden the daily stable-release cut (review round) (stable)

Mirror of the #919 hardening — fork-PR head-name spoof (require
--base stable + same-repo head) and no longer swallowing the
auto-merge-enable failure on the sole automatic stable cut.

* ci: accept an immediately-merged release PR as success (review round 2) (stable)

Mirror of #919: MERGED state = success (the normal 18:00 case where
checks were already green and --auto merges immediately), pending
auto-merge = success, still-open-no-auto-merge = real failure.

* ci: read release-PR state + auto-merge in one snapshot (review round 3) (stable)

Mirror of #919 — collapse the two racing gh pr view calls into one.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-30 12:14:31 +02:00
Paul Nothaft f99357460f chore(stable): release 3.45.9 (#907)
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-29 16:02:51 +00:00
Paul Nothaft 90b589a88e fix(analytics): make per-photo view/download counters actually count (#895) (stable) (#905)
* fix(analytics): make per-photo view/download counters actually count (#895) (stable)

Three stacked defects behind 'per-image stats stay 0':

- photos.view_count had NO writer anywhere — the admin IMAGES table and
  photo viewer display it, so it was permanently 0. It now increments
  when the full-size photo or its preview tier is served, excluding the
  slideshow kiosk (migration 138 design) and follow-up video Range
  requests (seeks are not views). Fire-and-forget so analytics can
  never fail the byte-serving path.
- Zip downloads (download-all, presigned download-all,
  download-selected) never incremented per-photo download_count — only
  single-photo downloads did, so zip-heavy galleries showed 0 forever.
  The zip routes now bump exactly the photos that went into the archive
  (the prebuilt-zip path mirrors the archive builders' category filter).
- Every admin surface used a different definition of 'downloads', which
  is the reporter's 46 vs 45 vs 0: event details counted only
  action='download' (no zips at all), the dashboard counted
  download+download_all but silently EXCLUDED download_selected and
  download_all_presigned. All queries now share one action set:
  download, download_all, download_all_presigned, download_selected.

New photoEngagementCounters suite pins all of it (7 tests).

* fix(analytics): count views via an explicit lightbox beacon (#895 review round)

External review flagged that request-level view counting is wrong in
both directions: the lightbox preloads prev/next neighbours (3 fetches
per open) while a preloaded neighbour promoted by a swipe is never
re-fetched (#505 keeps the DOM node), and enhanced/maximum galleries
never hit /photo at all (bytes come from /api/secure-images).

- Views now count via POST /:slug/photo/:photoId/view, fired by the
  lightbox exactly when a photo becomes the visible slide; the
  serving-route increments are removed. Covers protected galleries and
  the preview tier uniformly; slideshow kiosk stays excluded.
- bumpEventDownloadCounts mirrors downloadZipService._build (ALL event
  photos) — the category filter mismatched the prebuilt zip's actual
  contents. (That the builder ignores per-category allow_downloads is a
  separate pre-existing issue.)
- Zip loops count only successfully appended entries, with a pre-append
  storage stat: a lazy stream's async error bypassed the per-photo
  catch and hung the whole response — pre-existing bug, now fixed.

Suite extended to 9 tests (beacon semantics, serve-does-not-count,
skipped-entry exclusion).

* fix(analytics): fire the view beacon from the premium lightbox too (#895 review round 2)

gallery-premium events use yet-another-react-lightbox inside
GalleryPremiumLayout instead of PhotoLightbox, so the layout never
counted views. yarl's on.view fires on open and on every slide change —
identical semantics to the PhotoLightbox beacon.

Also documents the accepted prebuilt-zip approximation: _build can skip
entries whose watermark step fails and still publish the archive;
counting those exactly would need a persisted zip manifest.

* perf(analytics): skip the per-entry zip preflight on S3 (#895 review round 3)

The pre-append source check exists for LocalFs's lazy createReadStream
(async error would kill the whole zip response). S3's get() awaits
GetObject and rejects inside the loop's try/catch on a missing key, so
a HEAD per entry was a redundant serial round trip — 500 extra HEADs
on a 500-photo zip.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-29 17:59:06 +02:00
26 changed files with 1260 additions and 58 deletions
+6 -26
View File
@@ -27,32 +27,12 @@ jobs:
manifest-file: .release-please-manifest.json
target-branch: stable
# Auto-approve + auto-merge the open stable release PR. See the beta
# workflow for the full rationale. Skipped on the release-cutting run and
# whenever no PAT is configured.
- name: Auto-approve and enable auto-merge on the release PR
if: ${{ steps.release.outputs.release_created != 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_PAT: ${{ secrets.RELEASE_PLEASE_TOKEN }}
# No checkout in this job — set the repo explicitly so gh works
# without a git remote (same pattern as whatsnew, 2a5f0a8).
GH_REPO: ${{ github.repository }}
run: |
if [ -z "$RELEASE_PAT" ]; then
echo "RELEASE_PLEASE_TOKEN not set — skipping auto-merge (manual review still required)."
exit 0
fi
pr=$(gh pr list --head release-please--branches--stable --state open --json number --jq '.[0].number // empty')
if [ -n "$pr" ]; then
# Approve as github-actions[bot] (GITHUB_TOKEN, ≠ the PAT author) so it
# is a valid review; enable auto-merge as the PAT so the merge commit is
# attributed to a real identity and triggers the tag-cutting run (#719).
gh pr review "$pr" --approve --body "Automated approval — release-please version bump + changelog (#719)." || true
GH_TOKEN="$RELEASE_PAT" gh pr merge "$pr" --squash --auto || true
else
echo "No open release PR to auto-merge."
fi
# NOTE: stable release PRs are intentionally NOT auto-merged here
# anymore. Fixes accumulate in the rolling release PR and are cut as
# ONE patch version per day by release-stable-daily.yml (18:00 UTC,
# or on demand via workflow_dispatch / a manual merge of the release
# PR). Beta keeps instant releases — see release-please-beta.yml —
# because same-day reporter verification depends on it.
- name: Output Release Info
if: ${{ steps.release.outputs.release_created }}
@@ -0,0 +1,86 @@
name: Cut Stable Release (daily batch)
# Stable fixes accumulate in release-please's rolling release PR instead of
# each cutting its own patch version (the old per-merge auto-merge produced
# e.g. 3.45.8 AND 3.45.9 on the same day). This workflow merges the open
# stable release PR once a day, so a day of N bugfixes ships as ONE version
# with all N changelog entries — and one Docker build instead of N.
#
# - schedule only fires from the default branch (main); the stable copy of
# this file is inert and exists to keep the branches in sync.
# - Need a release NOW? Run this via workflow_dispatch, or merge the
# release PR by hand — the schedule is a default, not a gate.
# - Approval/merge mechanics mirror the old inline step (#719): approve as
# github-actions[bot] (GITHUB_TOKEN, a valid distinct reviewer), enable
# auto-merge as the PAT so the merge attributes to a real identity and
# triggers the tag-cutting run. --auto waits for green checks.
on:
schedule:
- cron: '0 18 * * *'
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
merge-stable-release-pr:
runs-on: ubuntu-latest
steps:
- name: Approve and enable auto-merge on the open stable release PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_PAT: ${{ secrets.RELEASE_PLEASE_TOKEN }}
# No checkout — set the repo explicitly so gh works without a
# git remote (same pattern as whatsnew, 2a5f0a8).
GH_REPO: ${{ github.repository }}
run: |
if [ -z "$RELEASE_PAT" ]; then
echo "RELEASE_PLEASE_TOKEN not set — skipping (manual review required)."
exit 0
fi
# Strict selection (review P1): this job runs daily even without a
# stable push, and `gh pr list --head` matches the branch NAME only
# — a fork PR can spoof `release-please--branches--stable`. Pin the
# base to stable AND require a same-repo head (isCrossRepository
# == false); a fork PR is cross-repository, so it can never be
# picked and auto-merged with the privileged PAT.
pr=$(gh pr list \
--base stable \
--head release-please--branches--stable \
--state open \
--json number,isCrossRepository \
--jq '[.[] | select(.isCrossRepository == false)] | .[0].number // empty')
if [ -z "$pr" ]; then
echo "No open same-repo stable release PR — nothing to cut today."
exit 0
fi
# Approve is tolerant — a pre-existing approval already satisfies
# branch protection and re-approving can return non-zero.
gh pr review "$pr" --approve --body "Automated approval — daily stable release batch (release-please version bump + changelog)." || echo "::warning::approve returned non-zero (PR may already be approved)"
# But the auto-merge enable is the load-bearing step: this scheduled
# job is the ONLY automatic stable cut, so DON'T swallow its failure
# (review P2) — an expired/under-scoped PAT would otherwise stop
# releases while the workflow stays green.
GH_TOKEN="$RELEASE_PAT" gh pr merge "$pr" --squash --auto
# `gh pr merge --auto` merges IMMEDIATELY when the required checks
# are already green — the normal case at 18:00, since the fixes
# merged hours earlier and CI passed. So success is EITHER the PR is
# already merged OR an auto-merge request is now pending; only a PR
# that is still open with no auto-merge request is a real failure
# (expired/under-scoped PAT) worth failing the job on (review round 2).
# One snapshot of both fields (review round 3): querying state and
# autoMergeRequest separately races — auto-merge can complete
# between the two calls, so the first sees OPEN and the second sees
# the request already cleared on the now-merged PR → false failure.
read -r state automerge < <(gh pr view "$pr" --json state,autoMergeRequest \
--jq '[.state, (.autoMergeRequest != null)] | @tsv')
if [ "$state" = "MERGED" ]; then
echo "Stable release PR #$pr merged immediately (checks were already green)."
elif [ "$automerge" = "true" ]; then
echo "Auto-merge enabled on stable release PR #$pr — merges when checks are green."
else
echo "::error::stable release PR #$pr is still open with no auto-merge — check RELEASE_PLEASE_TOKEN scope/expiry."
exit 1
fi
+1 -1
View File
@@ -1 +1 @@
{".":"3.45.8"}
{".":"3.45.10"}
+17
View File
@@ -5,6 +5,23 @@ 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.10](https://github.com/PicPeak/picpeak/compare/v3.45.9...v3.45.10) (2026-07-30)
### Bug Fixes
* **admin:** expose view/download counters in the admin photos list ([#895](https://github.com/PicPeak/picpeak/issues/895) follow-up) (stable) ([#915](https://github.com/PicPeak/picpeak/issues/915)) ([a27d19b](https://github.com/PicPeak/picpeak/commit/a27d19b4d147c33166b47a6bac12b8d1be32daf1))
* **admin:** serve videos with their real MIME type in the admin photo view ([#908](https://github.com/PicPeak/picpeak/issues/908)) (stable) ([#911](https://github.com/PicPeak/picpeak/issues/911)) ([d68d84e](https://github.com/PicPeak/picpeak/commit/d68d84e5c8cfcd123c95e474d2b87d153764f710))
* **admin:** stop marking events expired up to 24h early ([#909](https://github.com/PicPeak/picpeak/issues/909)) (stable) ([#917](https://github.com/PicPeak/picpeak/issues/917)) ([6891769](https://github.com/PicPeak/picpeak/commit/6891769124f77d5af1bbe8eb0932a86753d89e7d))
* **security:** close GHSA-g94x (cross-gallery photo read) + GHSA-pv6w (admin DB export) (stable) ([#925](https://github.com/PicPeak/picpeak/issues/925)) ([60cbda5](https://github.com/PicPeak/picpeak/commit/60cbda5b2228e0bddf5356eae279d9e7916e9ac2))
## [3.45.9](https://github.com/PicPeak/picpeak/compare/v3.45.8...v3.45.9) (2026-07-29)
### Bug Fixes
* **analytics:** make per-photo view/download counters actually count ([#895](https://github.com/PicPeak/picpeak/issues/895)) (stable) ([#905](https://github.com/PicPeak/picpeak/issues/905)) ([90b589a](https://github.com/PicPeak/picpeak/commit/90b589a88e4c56ccac6dba86c48a604f2abcc008))
## [3.45.8](https://github.com/PicPeak/picpeak/compare/v3.45.7...v3.45.8) (2026-07-29)
@@ -0,0 +1,238 @@
/**
* Admin photo view route Content-Type (#908).
*
* The route built `image/<ext>` from the filename, producing invalid
* types like image/mp4 for videos. AdminAuthenticatedVideo fetches this
* URL into a blob whose type inherits the header, and browsers refuse to
* play a <video> blob labeled image/* — blank/grey admin video preview.
*
* Pins (incl. external-review hardening):
* - the header is ALWAYS image/* or video/*: a stored non-media MIME
* (chunked uploads store the client-sent type unvalidated) is never
* echoed — text/html inline under the app origin would be XSS
* - stored video/ MIME wins; MIME-less videos map from the extension
* (.mov → video/quicktime), unknown video extensions get video/mp4
* - images IGNORE the stored MIME (migration 039 backfilled image/jpeg
* onto every legacy row, PNGs included) and use the extension,
* normalized (jpg → image/jpeg); extensionless files get image/jpeg
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-admin-ct-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'admin-ct-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-admin-ct-storage-'));
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const SLUG = 'admin-ct-test-event';
describe('admin photo view Content-Type (#908)', () => {
let db;
let cleanup;
let app;
let eventId;
let adminToken;
const addPhoto = async (filename, extra = {}) => {
const dir = path.join(process.env.STORAGE_PATH, 'events/active', SLUG);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, filename), Buffer.from(`bytes-${filename}`));
const r = await db('photos').insert({
event_id: eventId,
filename,
path: `${SLUG}/${filename}`,
type: 'individual',
uploaded_at: new Date().toISOString(),
...extra,
}).returning('id');
return r[0]?.id ?? r[0];
};
const getPhotoRes = (photoId) => request(app)
.get(`/api/admin/photos/${eventId}/photo/${photoId}`)
.set('Authorization', `Bearer ${adminToken}`);
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Admin CT Test',
event_date: '2026-08-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share`,
share_token: 'admin-ct-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
const superRole = await db('roles').where({ name: 'super_admin' }).first();
const [rootId] = await db('admin_users').insert({
username: 'admin-ct-admin',
email: 'admin-ct-admin@example.com',
password_hash: await bcrypt.hash('AdminCt123', 4),
role_id: superRole.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id').then((r) => [r[0]?.id || r[0]]);
adminToken = jwt.sign(
{ id: rootId, username: 'admin-ct-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
app = express();
app.use(express.json());
app.use('/api/admin/photos', require('../../src/routes/adminPhotos'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('serves a video with its stored mime_type, not image/<ext>', async () => {
const id = await addPhoto('clip.mp4', { media_type: 'video', mime_type: 'video/mp4' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('video/mp4');
});
it('maps MIME-less videos from their extension (.mov → video/quicktime)', async () => {
const id = await addPhoto('clip-nomime.mov', { media_type: 'video' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('video/quicktime');
});
it('falls back to video/mp4 for a video with an unknown extension', async () => {
const id = await addPhoto('clip-unknown.xyz', { media_type: 'video' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('video/mp4');
});
it('rejects malformed video/ MIME values that would break setHeader', async () => {
// Header-invalid chars in the stored value must not 500 the route —
// fall back to the extension map instead.
const id = await addPhoto('crlf.mp4', {
media_type: 'video',
mime_type: 'video/mp4\r\nX-Evil: 1',
});
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('video/mp4');
expect(res.headers['x-evil']).toBeUndefined();
const bare = await addPhoto('bare.webm', { media_type: 'video', mime_type: 'video/' });
const res2 = await getPhotoRes(bare);
expect(res2.status).toBe(200);
expect(res2.headers['content-type']).toBe('video/webm');
});
it('preserves an auto-imported avif via the safe stored-MIME allowlist', async () => {
// .avif isn't in EXTENSION_TO_MIME; s3AutoImporter stores image/avif.
// Map-only would mislabel it image/jpeg — the allowlist keeps it.
const id = await addPhoto('imported.avif', { mime_type: 'image/avif' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/avif');
});
it('preserves other importer raster types too (apng, x-icon)', async () => {
const apng = await addPhoto('anim.apng', { mime_type: 'image/apng' });
expect((await getPhotoRes(apng)).headers['content-type']).toBe('image/apng');
const ico = await addPhoto('fav.ico', { mime_type: 'image/x-icon' });
expect((await getPhotoRes(ico)).headers['content-type']).toBe('image/x-icon');
});
it('does NOT honor a stored scriptable image type (image/svg+xml)', async () => {
// svg is inline-scriptable and must never be echoed — allowlist excludes it.
const id = await addPhoto('vector.svg', { mime_type: 'image/svg+xml' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/jpeg');
});
it('never echoes a stored non-media MIME type (inline XSS guard)', async () => {
const id = await addPhoto('evil.png', { mime_type: 'text/html' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/png');
});
it('ignores the migration-039 image/jpeg backfill on legacy PNG rows', async () => {
const id = await addPhoto('legacy.png', { mime_type: 'image/jpeg' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/png');
});
it('normalizes jpg to the canonical image/jpeg', async () => {
const id = await addPhoto('shot.jpg');
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/jpeg');
});
it('keeps the extension fallback for images without a stored mime_type', async () => {
const id = await addPhoto('shot.png');
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/png');
});
it('handles Object.prototype key extensions without a 500 (.constructor)', async () => {
// The extension-to-MIME lookup must be own-property only — a raw
// index access returns an inherited function for these keys and the
// downstream startsWith throws. Serve image/jpeg instead of 500.
const id = await addPhoto('payload.constructor');
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/jpeg');
const id2 = await addPhoto('payload.__proto__', { media_type: 'video' });
const res2 = await getPhotoRes(id2);
expect(res2.status).toBe(200);
expect(res2.headers['content-type']).toBe('video/mp4');
});
it('does not synthesize types from unmapped image extensions', async () => {
// Raw interpolation would produce image/svg+xml (scriptable inline)
// or arbitrary strings from client-controlled filenames — the shared
// map is the allowlist, everything else is served as image/jpeg.
const svg = await addPhoto('vector.svg+xml');
const res = await getPhotoRes(svg);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/jpeg');
const weird = await addPhoto('weird.xyz');
const res2 = await getPhotoRes(weird);
expect(res2.status).toBe(200);
expect(res2.headers['content-type']).toBe('image/jpeg');
});
it('extensionless files get image/jpeg, never a bare image/', async () => {
const id = await addPhoto('noext');
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/jpeg');
});
});
@@ -0,0 +1,95 @@
/**
* Full-instance export is super_admin only (GHSA-pv6w-rj34-wj9v).
*
* GET /api/admin/backup/picpeak/export dumps every table unredacted (bcrypt
* hashes, 2FA, SMTP/SSO/WhatsApp/webhook/S3 secrets). It was gated only by
* requirePermission('backup.create'), which the built-in `admin` role holds —
* so any non-super_admin admin could download the whole database. Pins that
* `admin` now gets 403 and `super_admin` passes the gate.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-bkexport-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'bkexport-test-secret';
// The export otherwise walks the whole DB and writes a zip — stub it so the
// super_admin happy path is fast and deterministic; the gate is what's tested.
// The route deletes path.dirname(filePath) recursively after download, so the
// stub MUST live in its own dir — a bare os.tmpdir() file would make the route
// wipe the whole temp root (and other jest workers' DB files).
const mockExportDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-export-stub-'));
const mockExportPath = path.join(mockExportDir, 'export.picpeak');
fs.writeFileSync(mockExportPath, 'stub');
jest.mock('../../src/services/picpeakExportService', () => ({
createPicpeak: jest.fn(async () => ({ filePath: mockExportPath })),
}));
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('backup export super_admin gate (GHSA-pv6w)', () => {
let db;
let cleanup;
let app;
let adminToken; let superToken;
const mkUser = async (username, roleName) => {
const role = await db('roles').where({ name: roleName }).first();
const r = await db('admin_users').insert({
username,
email: `${username}@example.com`,
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
const id = r[0]?.id ?? r[0];
return jwt.sign(
{ id, username, type: 'admin', role: roleName, loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' },
);
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
adminToken = await mkUser('limited-admin', 'admin');
superToken = await mkUser('root-admin', 'super_admin');
app = express();
app.use(express.json());
app.use('/api/admin/backup', require('../../src/routes/adminBackup'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
fs.rmSync(mockExportDir, { recursive: true, force: true });
});
it('denies the built-in admin role (was: full DB dump)', async () => {
const res = await request(app)
.get('/api/admin/backup/picpeak/export')
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(403);
});
it('allows super_admin', async () => {
const res = await request(app)
.get('/api/admin/backup/picpeak/export')
.set('Authorization', `Bearer ${superToken}`);
expect(res.status).not.toBe(403);
expect(res.status).toBeLessThan(500);
});
});
@@ -0,0 +1,312 @@
/**
* Per-photo engagement counters (#895).
*
* Pins the contract that the admin EVENT > IMAGES table depends on:
* - photos.view_count increments when the full-size photo is served
* (it existed in the schema + admin UI but had NO writer at all)
* - the slideshow kiosk never increments views (migration 138 design)
* - single-photo downloads increment download_count (regression pin)
* - zip downloads (download-all, download-selected) increment
* download_count for the contained photos — previously they didn't,
* so zip-heavy galleries showed 0 per-photo downloads forever
* - the admin event-detail total_downloads counts singles AND zips
* (it counted action='download' only, disagreeing with the dashboard)
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-engagement-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'engagement-test-secret';
// Real files on disk so /photo and the zip routes actually stream bytes.
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-engagement-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const SLUG = 'engagement-test-event';
describe('photo engagement counters (#895)', () => {
let db;
let cleanup;
let app;
let eventId;
let photoIds;
let adminToken;
const galleryToken = (extra = {}) => jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery', ...extra },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const getPhoto = async (id) => db('photos').where('id', id).first();
// The counter writes are fire-and-forget on purpose — give the event
// loop a beat before asserting.
const settle = () => new Promise((r) => setTimeout(r, 400));
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Engagement Test',
event_date: '2026-08-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share`,
share_token: 'engagement-test-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
allow_downloads: 1,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
const photoDir = path.join(process.env.STORAGE_PATH, 'events/active', SLUG);
fs.mkdirSync(photoDir, { recursive: true });
photoIds = [];
for (let i = 0; i < 3; i++) {
const filename = `photo-${i}.jpg`;
fs.writeFileSync(path.join(photoDir, filename), Buffer.from(`fake-jpeg-bytes-${i}`));
const p = await db('photos').insert({
event_id: eventId,
filename,
path: `${SLUG}/${filename}`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
photoIds.push(p[0]?.id ?? p[0]);
}
const superRole = await db('roles').where({ name: 'super_admin' }).first();
const [rootId] = await db('admin_users').insert({
username: 'engagement-admin',
email: 'engagement-admin@example.com',
password_hash: await bcrypt.hash('EngagementAdmin123', 4),
role_id: superRole.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id').then((r) => [r[0]?.id || r[0]]);
adminToken = jwt.sign(
{ id: rootId, username: 'engagement-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
app.use('/api/admin/photos', require('../../src/routes/adminPhotos'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
beforeEach(async () => {
await db('photos').where('event_id', eventId).update({ view_count: 0, download_count: 0 });
await db('access_logs').where('event_id', eventId).del();
});
describe('view_count via the view beacon (#895 — previously never written)', () => {
const beacon = (photoId, token = galleryToken()) => request(app)
.post(`/api/gallery/${SLUG}/photo/${photoId}/view`)
.set('Authorization', `Bearer ${token}`);
it('increments exactly the beaconed photo', async () => {
expect((await beacon(photoIds[0])).status).toBe(204);
expect((await getPhoto(photoIds[0])).view_count).toBe(1);
expect((await beacon(photoIds[0])).status).toBe(204);
expect((await getPhoto(photoIds[0])).view_count).toBe(2);
// Other photos untouched
expect((await getPhoto(photoIds[1])).view_count).toBe(0);
});
it('serving the image bytes does NOT count (preloads must not inflate)', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/photo/${photoIds[0]}`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(res.status).toBe(200);
await settle();
expect((await getPhoto(photoIds[0])).view_count).toBe(0);
});
it('rejects the slideshow kiosk (migration 138 design)', async () => {
const res = await beacon(photoIds[0], galleryToken({ accessLevel: 'slideshow' }));
expect(res.status).toBeGreaterThanOrEqual(400);
expect((await getPhoto(photoIds[0])).view_count).toBe(0);
});
it("404s a photo that isn't in the event", async () => {
const res = await beacon(999999);
expect(res.status).toBe(404);
});
});
describe('download_count', () => {
it('single-photo download increments (regression pin)', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/download/${photoIds[0]}`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(res.status).toBe(200);
await settle();
expect((await getPhoto(photoIds[0])).download_count).toBe(1);
expect((await getPhoto(photoIds[1])).download_count).toBe(0);
});
it('download-selected increments exactly the selected photos (#895)', async () => {
const res = await request(app)
.post(`/api/gallery/${SLUG}/download-selected`)
.set('Authorization', `Bearer ${galleryToken()}`)
.send({ photo_ids: [photoIds[0], photoIds[1]] });
expect(res.status).toBe(200);
await settle();
expect((await getPhoto(photoIds[0])).download_count).toBe(1);
expect((await getPhoto(photoIds[1])).download_count).toBe(1);
expect((await getPhoto(photoIds[2])).download_count).toBe(0);
});
it('download-all increments every downloadable photo (#895)', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/download-all`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(res.status).toBe(200);
await settle();
for (const id of photoIds) {
expect((await getPhoto(id)).download_count).toBe(1);
}
});
it('skipped archive entries do not count (missing source file)', async () => {
// Own event so the on-the-fly archiver path is guaranteed — the
// main event may have a cached zip from the previous test's
// background generation, and racing its build/invalidate hangs.
// The route also fires a background pre-zip build after streaming;
// against this event's intentionally missing file it crashes with
// an async ENOENT that jest attributes to whatever test is running
// by then — neutralize it, it's not under test here.
const downloadZipService = require('../../src/services/downloadZipService');
const generateZipSpy = jest.spyOn(downloadZipService, 'generateZip')
.mockResolvedValue({ success: false, error: 'disabled in test' });
const slug2 = `${SLUG}-skip`;
const ev = await db('events').insert({
slug: slug2,
event_type: 'wedding',
event_name: 'Engagement Skip Test',
event_date: '2026-08-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${slug2}/share`,
share_token: 'engagement-skip-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
allow_downloads: 1,
created_at: new Date().toISOString(),
}).returning('id');
const eventId2 = ev[0]?.id ?? ev[0];
const dir2 = path.join(process.env.STORAGE_PATH, 'events/active', slug2);
fs.mkdirSync(dir2, { recursive: true });
const ids2 = [];
for (let i = 0; i < 2; i++) {
// Only photo 0 gets a real file — photo 1's source is missing.
if (i === 0) fs.writeFileSync(path.join(dir2, `photo-${i}.jpg`), Buffer.from('skip-test-bytes'));
const p = await db('photos').insert({
event_id: eventId2,
filename: `photo-${i}.jpg`,
path: `${slug2}/photo-${i}.jpg`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
ids2.push(p[0]?.id ?? p[0]);
}
const token2 = jwt.sign(
{ eventId: eventId2, eventSlug: slug2, type: 'gallery' },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const res = await request(app)
.get(`/api/gallery/${slug2}/download-all`)
.set('Authorization', `Bearer ${token2}`);
expect(res.status).toBe(200);
await settle();
expect((await db('photos').where('id', ids2[0]).first()).download_count).toBe(1);
// photo-1's source was missing → skipped from the zip → not counted
expect((await db('photos').where('id', ids2[1]).first()).download_count).toBe(0);
generateZipSpy.mockRestore();
});
});
describe('admin photos list exposes the counters (#895 follow-up)', () => {
it('returns view_count and download_count so the Engagement column can render them', async () => {
// The list mapper builds an explicit object — before this fix it
// omitted both fields, so the admin table showed 0 forever even
// though the DB counted correctly.
await request(app)
.post(`/api/gallery/${SLUG}/photo/${photoIds[0]}/view`)
.set('Authorization', `Bearer ${galleryToken()}`);
await request(app)
.get(`/api/gallery/${SLUG}/download/${photoIds[0]}`)
.set('Authorization', `Bearer ${galleryToken()}`);
await settle();
const res = await request(app)
.get(`/api/admin/photos/${eventId}/photos`)
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(200);
const row = res.body.photos.find((p) => p.id === photoIds[0]);
expect(row.view_count).toBe(1);
expect(row.download_count).toBe(1);
const untouched = res.body.photos.find((p) => p.id === photoIds[1]);
expect(untouched.view_count).toBe(0);
expect(untouched.download_count).toBe(0);
});
});
describe('admin event-detail total_downloads (#895 — one definition everywhere)', () => {
it('counts singles and every zip variant, one row each', async () => {
const row = (action) => ({
event_id: eventId,
ip_address: '127.0.0.1',
user_agent: 'jest',
action,
});
await db('access_logs').insert([
row('download'),
row('download_all'),
row('download_all_presigned'),
row('download_selected'),
row('view'), // not a download
]);
const res = await request(app)
.get(`/api/admin/events/${eventId}`)
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(200);
expect(res.body.total_downloads).toBe(4);
});
});
});
@@ -0,0 +1,136 @@
/**
* Secure-image view route token binding (GHSA-g94x-8vv8-3c9f).
*
* The view route GET /api/secure-images/:slug/secure/:photoId/:token serves
* via <img src> with the token in the URL, so it can't carry a gallery-token
* header like the download sibling. Before the fix it validated only the
* token signature and took the gallery/photo from the URL — so a token minted
* on any PUBLIC gallery read every other gallery's photos with no password.
*
* Pins that the route now enforces the scope inside the token:
* - the URL photoId must equal the token's minted photoId
* - the gallery embedded in the token's sessionId must equal the URL gallery
* A token minted on gallery A cannot read gallery B under either check; a
* token used on its own gallery+photo passes the binding.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-secimg-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'secimg-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-secimg-storage-'));
// Stub the anti-bot/rate-limit middleware so the fingerprint is deterministic
// — the token below is minted with the same fingerprint, so verifySecureToken
// passes and the binding logic under test is what decides the outcome.
jest.mock('../../src/middleware/secureImageMiddleware', () => ({
secureImageAccess: (req, _res, next) => {
req.clientInfo = { fingerprint: 'test-fp', ip: '127.0.0.1', userAgent: 'jest' };
next();
},
getSecurityStatus: (_req, res) => res.json({ ok: true }),
}));
const request = require('supertest');
const express = require('express');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const secureImageService = require('../../src/services/secureImageService');
describe('secure-image view route token binding (GHSA-g94x)', () => {
let db;
let cleanup;
let app;
let galleryA; let galleryB;
let photoA; let photoB;
const mkEvent = async (slug, requirePassword) => {
const r = await db('events').insert({
slug,
event_type: 'wedding',
event_name: slug,
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
require_password: requirePassword ? 1 : 0,
share_link: `/gallery/${slug}/share`,
share_token: `${slug}-share`,
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
const mkPhoto = async (eventId, slug, filename) => {
const dir = path.join(process.env.STORAGE_PATH, 'events/active', slug);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, filename), Buffer.from('img'));
const r = await db('photos').insert({
event_id: eventId,
filename,
path: `${slug}/${filename}`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
// Mint a token exactly as the mint route does — bound to (photoId, gallery
// sessionId, fingerprint) — bypassing the anti-bot HTTP path.
const mint = (photoId, eventId) => secureImageService.generateSecureToken(
photoId,
`gallery_public_${eventId}_${Date.now()}`,
{ clientFingerprint: 'test-fp', maxUses: 100, expiresIn: 3600 },
);
const view = (slug, photoId, token) => request(app)
.get(`/api/secure-images/${slug}/secure/${photoId}/${token}`);
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
galleryA = await mkEvent('secimg-public-a', false); // public — token source
galleryB = await mkEvent('secimg-private-b', true); // password-protected — victim
photoA = await mkPhoto(galleryA, 'secimg-public-a', 'a.jpg');
photoB = await mkPhoto(galleryB, 'secimg-private-b', 'b.jpg');
app = express();
app.use(express.json());
app.use('/api/secure-images', require('../../src/routes/secureImages'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('rejects a gallery-A token used against gallery B (cross-photo)', async () => {
const token = mint(photoA, galleryA);
const res = await view('secimg-private-b', photoB, token);
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/not valid for this photo/i);
});
it('rejects a gallery-A token replayed on gallery B with A\'s photoId', async () => {
const token = mint(photoA, galleryA);
// URL photoId matches the token, so the photo check passes — the gallery
// check (sessionId gallery A != URL gallery B) must catch it.
const res = await view('secimg-private-b', photoA, token);
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/not valid for this gallery/i);
});
it('lets a token read its own gallery + photo (binding passes)', async () => {
const token = mint(photoA, galleryA);
const res = await view('secimg-public-a', photoA, token);
// Binding passes; serving may 200/404/500 depending on the pipeline, but
// it must NOT be rejected as a token mismatch.
expect(res.status).not.toBe(403);
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "3.45.8",
"version": "3.45.10",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"engines": {
+7 -2
View File
@@ -1,7 +1,7 @@
const express = require('express');
const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { requirePermission, requireSuperAdmin } = require('../middleware/permissions');
const { clearAdminAuthCookie } = require('../utils/tokenUtils');
const { revokeToken } = require('../utils/tokenRevocation');
const { triggerManualBackup, getBackupStatus, cleanupOldBackupRuns, getBackupManifest, validateBackupManifest } = require('../services/backupService');
@@ -139,7 +139,12 @@ router.post('/run', adminAuth, requirePermission('backup.create'), async (req, r
// SECURITY: the file contains plaintext secrets (SMTP password, admin password
// hashes, API keys). The download UI must warn before offering it. We surface
// the flag as a response header too so the client can double-confirm.
router.get('/picpeak/export', adminAuth, requirePermission('backup.create'), async (req, res) => {
// Full-instance export dumps every table unredacted — bcrypt password
// hashes, 2FA columns, and all integration secrets (SMTP/SSO/WhatsApp/
// webhook/S3) in cleartext. The built-in `admin` role holds backup.create,
// but is denied this data everywhere else (config APIs mask secrets as
// ********). Gate the raw dump behind super_admin (GHSA-pv6w-rj34-wj9v).
router.get('/picpeak/export', adminAuth, requireSuperAdmin(), async (req, res) => {
const fsSync = require('fs');
try {
const includePhotos = req.query.includePhotos === 'true' || req.query.includePhotos === '1';
+5 -5
View File
@@ -69,7 +69,7 @@ router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req,
// Get total downloads (last 30 days) - include both single and bulk downloads
const totalDownloads = await db('access_logs')
.whereIn('action', ['download', 'download_all'])
.whereIn('action', ['download', 'download_all', 'download_all_presigned', 'download_selected'])
.where('timestamp', '>=', thirtyDaysAgo.toISOString())
.count('id as count')
.first();
@@ -99,7 +99,7 @@ router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req,
.first();
const previousDownloads = await db('access_logs')
.whereIn('action', ['download', 'download_all'])
.whereIn('action', ['download', 'download_all', 'download_all_presigned', 'download_selected'])
.where('timestamp', '>=', sixtyDaysAgo.toISOString())
.where('timestamp', '<', thirtyDaysAgo.toISOString())
.count('id as count')
@@ -271,7 +271,7 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
// Get downloads per day - include both single and bulk downloads
const downloadsData = await db('access_logs')
.select(db.raw('DATE(timestamp) as date'), db.raw('COUNT(*) as count'))
.whereIn('action', ['download', 'download_all'])
.whereIn('action', ['download', 'download_all', 'download_all_presigned', 'download_selected'])
.where('timestamp', '>=', startDateStr)
.groupByRaw('DATE(timestamp)');
@@ -307,7 +307,7 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
.select('events.id', 'events.event_name', 'events.slug')
.select(db.raw('COUNT(CASE WHEN action = \'view\' THEN 1 END) as views'))
.select(db.raw('COUNT(DISTINCT CASE WHEN action = \'view\' THEN ip_address END) as uniqueVisitors'))
.select(db.raw('COUNT(CASE WHEN action IN (\'download\', \'download_all\') THEN 1 END) as downloads'))
.select(db.raw('COUNT(CASE WHEN action IN (\'download\', \'download_all\', \'download_all_presigned\', \'download_selected\') THEN 1 END) as downloads'))
.join('events', 'access_logs.event_id', 'events.id')
.where('access_logs.timestamp', '>=', startDateStr)
.groupBy('events.id', 'events.event_name', 'events.slug')
@@ -377,7 +377,7 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
.first();
const totalDownloadsCount = await db('access_logs')
.whereIn('action', ['download', 'download_all'])
.whereIn('action', ['download', 'download_all', 'download_all_presigned', 'download_selected'])
.where('timestamp', '>=', startDateStr)
.count('id as count')
.first();
+3 -1
View File
@@ -771,9 +771,11 @@ module.exports = (router) => {
.where('action', 'view')
.count('* as totalViews');
// One row per download event: singles AND zips (#895). Must stay in
// sync with adminDashboard's definition or the two surfaces disagree.
const [{ totalDownloads }] = await db('access_logs')
.where('event_id', id)
.where('action', 'download')
.whereIn('action', ['download', 'download_all', 'download_all_presigned', 'download_selected'])
.count('* as totalDownloads');
const [{ uniqueVisitors }] = await db('access_logs')
+66 -2
View File
@@ -1091,7 +1091,13 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), requ
average_rating: photo.average_rating || 0,
comment_count: commentMap[photo.id] || 0,
like_count: photo.like_count || 0,
favorite_count: photo.favorite_count || 0
favorite_count: photo.favorite_count || 0,
// Engagement counters (#895 follow-up): the grid reads these, but
// this explicit mapper never included them — so the Engagement
// column showed 0 regardless of what the DB counted. This, not
// stale data, was why per-image downloads always displayed 0.
view_count: photo.view_count || 0,
download_count: photo.download_count || 0
}))
});
} catch (error) {
@@ -1126,7 +1132,65 @@ router.get('/:eventId/photo/:photoId', adminAuth, requirePermission('photos.view
const event = await db('events').where('id', eventId).first();
const storageKey = resolvePhotoStorageKey(event, photo);
res.setHeader('Content-Type', `image/${path.extname(photo.filename).slice(1)}`);
// Content-Type resolution (#908 + external review). Invariant: the
// header is ALWAYS image/* or video/*.
// - photos.mime_type is never echoed verbatim unless it is a video/
// type: the chunked-upload path stores the client-sent MIME
// unvalidated, so a stored text/html served inline under the app
// origin would be a same-origin XSS gift.
// - Images ignore the stored value entirely — migration 039
// backfilled image/jpeg onto every legacy row (PNGs included), so
// the extension is the more trustworthy signal; normalized via the
// shared map (image/jpg → image/jpeg), jpeg fallback when unknown.
// - Videos prefer a stored video/ type, then the extension map
// (.mov → video/quicktime, .webm → video/webm, …), then video/mp4.
// The old ext-derived image/<ext> (image/mp4) is what made the
// admin player's blob unplayable (#908).
const { EXTENSION_TO_MIME } = require('../services/uploadSettings');
const ext = path.extname(photo.filename).slice(1).toLowerCase();
// Own-property lookup (review): a client-controlled filename ending in
// .constructor / .__proto__ / .toString would otherwise return an
// inherited Object.prototype member, and the extMime.startsWith below
// would throw — a permanent 500 for that photo instead of the fallback.
const extMime = Object.prototype.hasOwnProperty.call(EXTENSION_TO_MIME, ext)
? EXTENSION_TO_MIME[ext]
: null;
// Full-token validation, not just a prefix check: the stored value is
// client-controlled, and header-invalid characters (video/mp4\r\nX: y)
// would make setHeader throw — a permanent 500 for that photo. Bare
// 'video/' is equally invalid; both fall back to the extension map.
const storedVideoMime = photo.mime_type && /^video\/[\w.+-]+$/.test(photo.mime_type)
? photo.mime_type
: null;
// Honor a stored image MIME for any header-safe RASTER type (#908
// review): the S3 auto-importer accepts arbitrary image/* from
// mime-types and stores it (avif/bmp/tiff/heic/apng/ico/jxl/…), and a
// hand-listed allowlist kept missing formats. Allow image/<token> but
// NEVER the scriptable svg / *+xml family (image/svg+xml executes
// inline). The strict token + anchors also block header injection
// (image/x\r\nY:). Migration 039's blanket image/jpeg backfill on
// legacy rows is why the mapped extension still wins ahead of this.
const storedImageMime =
photo.mime_type &&
/^image\/[\w.+-]+$/.test(photo.mime_type) &&
!/^image\/svg|xml/i.test(photo.mime_type)
? photo.mime_type
: null;
const isVideo = photo.media_type === 'video' ||
Boolean(storedVideoMime) ||
Boolean(extMime && extMime.startsWith('video/'));
// Never interpolate the raw extension on the image side: it would
// synthesize image/svg+xml (scriptable inline) or header-invalid values
// from client-controlled chunked-upload filenames. Precedence is
// mapped-extension (also corrects the 039 legacy-jpeg backfill on PNGs)
// -> safe stored raster MIME (auto-imported avif/bmp/tiff) -> image/jpeg.
// A stored type outside the allowlist degrades to image/jpeg; browsers
// sniff image bytes in <img>/blob contexts, so a mislabel is harmless
// where an injected type is not.
const contentType = isVideo
? storedVideoMime || (extMime && extMime.startsWith('video/') ? extMime : null) || 'video/mp4'
: (extMime && extMime.startsWith('image/') ? extMime : null) || storedImageMime || 'image/jpeg';
res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'private, max-age=3600');
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
+99 -2
View File
@@ -933,6 +933,22 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken,
});
// Download all photos as ZIP
// Zip downloads count toward each contained photo's download_count (#895)
// — previously only single-photo downloads did, so galleries whose guests
// grab the zip showed 0 per-photo downloads forever. Used by the
// pre-generated-zip branches only: it mirrors downloadZipService._build,
// which zips EVERY event photo with no per-category allow_downloads
// filter — the counter has to reflect what actually shipped. (That the
// prebuilt zip ignores per-category download opt-outs is a separate,
// pre-existing issue.) Known approximation: _build skips entries whose
// WATERMARK step fails and still publishes the zip; counting those
// would need a persisted archive manifest, which isn't worth it for
// that tail case. Fire-and-forget at the call sites: counters must
// never fail a download.
async function bumpEventDownloadCounts(eventId) {
await db('photos').where('event_id', eventId).increment('download_count', 1);
}
router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async (req, res) => {
try {
// Check if downloads are allowed for this event
@@ -962,6 +978,7 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
user_agent: req.headers['user-agent'],
action: 'download_all_presigned'
}).catch(() => {});
bumpEventDownloadCounts(req.event.id).catch(() => {});
res.redirect(302, url);
return;
} catch (err) {
@@ -985,6 +1002,7 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
user_agent: req.headers['user-agent'],
action: 'download_all'
}).catch(() => {});
bumpEventDownloadCounts(req.event.id).catch(() => {});
return;
}
@@ -1044,6 +1062,10 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
// get a deterministic `_1` suffix before the entries hit the archive.
const useOriginalBulk = await getUseOriginalFilenames();
const bulkEntryNames = getZipEntryNames(photos, useOriginalBulk);
// Only photos whose append succeeded count as downloaded (#895) — the
// catch below deliberately skips missing/corrupt sources, and those
// never make it into the archive.
const appendedIds = [];
for (let i = 0; i < photos.length; i += 1) {
const photo = photos[i];
const storageKey = resolvePhotoStorageKey(req.event, photo);
@@ -1057,6 +1079,22 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
}
try {
// Verify the source exists BEFORE appending — but only for local
// sources: fs.createReadStream is lazy, so its error fires outside
// this try/catch and the archive 'error' handler then kills the
// whole response instead of skipping one photo (#895 review). S3's
// get() awaits GetObject and rejects right here on a missing key,
// so a preflight HEAD per entry would just be a redundant serial
// round trip (500-photo zip = 500 extra HEADs).
if (storageKey && storage.kind() === 'local') {
const srcStat = await storage.stat(storageKey);
if (!srcStat) {
throw new Error(`Photo missing in storage: ${storageKey}`);
}
} else if (!storageKey && !fs.existsSync(resolvePhotoFilePath(req.event, photo))) {
throw new Error('Photo file missing on disk');
}
if (shouldApplyWatermark && effectiveSettings) {
// Watermark service operates on a local path. For managed photos in
// S3 mode, materialize a tmp local copy first.
@@ -1076,9 +1114,9 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
const stream = await storage.get(storageKey);
archive.append(stream, { name: archiveName });
} else {
const filePath = resolvePhotoFilePath(req.event, photo);
archive.file(filePath, { name: archiveName });
archive.file(resolvePhotoFilePath(req.event, photo), { name: archiveName });
}
appendedIds.push(photo.id);
} catch (err) {
logger.warn('Skipping photo in bulk download due to error', {
slug: req.params.slug,
@@ -1098,6 +1136,12 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
user_agent: req.headers['user-agent'],
action: 'download_all'
});
// Exactly the photos that made it into this archive (#895) — skipped
// (missing/corrupt) sources don't count.
if (appendedIds.length > 0) {
db('photos').whereIn('id', appendedIds)
.increment('download_count', 1).catch(() => {});
}
} catch (error) {
errorResponse(res, error, 500, 'Failed to create download archive');
}
@@ -1179,11 +1223,26 @@ router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken,
// #493: same display-name resolution as bulk download, with dedup.
const useOriginalSelected = await getUseOriginalFilenames();
const selectedEntryNames = getZipEntryNames(photos, useOriginalSelected);
// Only photos whose append succeeded count as downloaded (#895).
const appendedIds = [];
for (let i = 0; i < photos.length; i += 1) {
const photo = photos[i];
const name = selectedEntryNames[i] || `photo-${photo.id}.jpg`;
const storageKey = resolveSelectedKey(req.event, photo);
try {
// Same pre-append source check as download-all (#895 review),
// local backend only: a lazy fs stream's async error would kill
// the response instead of skipping the photo; S3's get() rejects
// at the await below, so no redundant per-entry HEAD there.
if (storageKey && selectedStorage.kind() === 'local') {
const srcStat = await selectedStorage.stat(storageKey);
if (!srcStat) {
throw new Error(`Photo missing in storage: ${storageKey}`);
}
} else if (!storageKey && !fs.existsSync(resolvePhotoFilePath(req.event, photo))) {
throw new Error('Photo file missing on disk');
}
if (shouldApplyWatermark && effectiveSettings) {
const buf = storageKey
? await withSelectedLocalCopy(storageKey, (lp) =>
@@ -1197,6 +1256,7 @@ router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken,
} else {
archive.file(resolvePhotoFilePath(req.event, photo), { name });
}
appendedIds.push(photo.id);
} catch (err) {
logger.warn('Skipping selected photo due to error', {
slug: req.params.slug,
@@ -1215,12 +1275,49 @@ router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken,
user_agent: req.headers['user-agent'],
action: 'download_selected'
});
// Exactly the photos that made it into this archive (#895) — skipped
// (missing/corrupt) sources don't count.
if (appendedIds.length > 0) {
db('photos').whereIn('id', appendedIds)
.increment('download_count', 1).catch(() => {});
}
} catch (error) {
errorResponse(res, error, 500, 'Failed to download selected photos');
}
});
// Explicit per-photo view beacon (#895). Counting views on the image-
// serving routes is wrong in both directions: the lightbox preloads the
// prev/next neighbours (three fetches per open), while a preloaded
// neighbour that becomes the current slide is never re-fetched (#505
// keeps the DOM node alive across the swipe) — so request-level counters
// overcount preloads AND undercount swipe-throughs. Instead the lightbox
// pings this endpoint exactly when a photo becomes the visible slide.
// This also covers enhanced/maximum-protection galleries, whose bytes
// are served by /api/secure-images and never pass the routes below.
// The slideshow kiosk is excluded (denySlideshowToken; migration 138).
router.post('/:slug/photo/:photoId/view',
verifyGalleryAccess,
denySlideshowToken,
async (req, res) => {
try {
const photo = await db('photos')
.where({ id: req.params.photoId, event_id: req.event.id })
.first('id', 'visibility');
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Photo not available' });
}
await db('photos').where('id', photo.id).increment('view_count', 1);
res.status(204).end();
} catch (error) {
errorResponse(res, error, 500, 'Failed to record view');
}
});
// View single photo (with watermark if enabled)
router.get('/:slug/photo/:photoId',
verifyGalleryAccess,
+28
View File
@@ -137,6 +137,34 @@ router.get('/:slug/secure/:photoId/:token',
return res.status(404).json({ error: 'Gallery not found' });
}
// Bind the token to the gallery + photo it was minted for
// (GHSA-g94x-8vv8-3c9f). This route serves via <img src> with the
// token in the URL, so it can't require verifyGalleryAccess like the
// download sibling does. Instead enforce the scope already inside the
// token: it is minted for one photoId (and photos belong to exactly
// one gallery), and its sessionId records the minting gallery's id.
// Without this, a token minted on any PUBLIC gallery reads every other
// gallery's photos with no password.
const tokenPhotoId = Number(tokenValidation.data?.photoId);
if (!Number.isInteger(tokenPhotoId) || tokenPhotoId !== Number(photoId)) {
await secureImageService.logImageAccess(
photoId, event.id, req.clientInfo, 'photo_mismatch'
);
return res.status(403).json({ error: 'Token not valid for this photo' });
}
// Defense in depth: the sessionId embeds the gallery the token was
// minted for (`gallery_public_<id>_...` / `gallery_<id>_...`). Reject a
// token whose gallery is parseable and differs from this one.
const sessionEventId = Number(
(String(tokenValidation.data?.sessionId || '').match(/^gallery_(?:public_)?(\d+)_/) || [])[1]
);
if (Number.isInteger(sessionEventId) && sessionEventId !== Number(event.id)) {
await secureImageService.logImageAccess(
photoId, event.id, req.clientInfo, 'gallery_mismatch'
);
return res.status(403).json({ error: 'Token not valid for this gallery' });
}
// Verify photo exists and belongs to event
const photo = await db('photos')
.where({ id: photoId, event_id: event.id })
+6 -2
View File
@@ -281,8 +281,12 @@ async function cleanupExpiredUploads() {
return expiredIds.length;
}
// Run cleanup every hour
setInterval(cleanupExpiredUploads, 60 * 60 * 1000);
// Run cleanup every hour. unref so this module-level housekeeping timer
// never holds the process open on its own — in production the HTTP
// listener keeps the loop alive, and in Jest this exact handle kept the
// runner from exiting for every suite that requires adminPhotos (#908;
// it is why adminPhotos.reference sits on the CI ignore list).
setInterval(cleanupExpiredUploads, 60 * 60 * 1000).unref();
module.exports = {
initializeUpload,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "3.45.8",
"version": "3.45.10",
"type": "module",
"scripts": {
"dev": "vite",
@@ -6,6 +6,7 @@ import { useSavePhotoToDevice } from '../../hooks/useGallery';
import { AuthenticatedImage } from '../common';
import { PhotoFeedback } from './PhotoFeedback';
import { feedbackService } from '../../services/feedback.service';
import { galleryService } from '../../services/gallery.service';
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
import { VideoPlayer } from './VideoPlayer';
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
@@ -102,6 +103,17 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
return () => window.removeEventListener('resize', onResize);
}, []);
// View beacon (#895): count exactly the photo that became the visible
// slide. The image fetches themselves can't be counted — preloaded
// neighbours would inflate, and a neighbour promoted by a swipe is
// never re-fetched (#505).
const currentPhotoId = photos[currentIndex]?.id;
useEffect(() => {
if (currentPhotoId !== undefined) {
galleryService.trackPhotoView(slug, currentPhotoId);
}
}, [slug, currentPhotoId]);
// Save-aware download. On mobile (where Web Share + files is supported)
// this opens the OS share sheet so "Save to Photos" actually lands in
@@ -563,6 +563,14 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
close={() => setLightboxIndex(-1)}
index={lightboxIndex}
slides={slides}
// View beacon (#895): yarl fires `view` on open and on every
// slide change — same semantics as PhotoLightbox's beacon.
on={{
view: ({ index }) => {
const photo = filteredPhotos[index];
if (photo) galleryService.trackPhotoView(slug, photo.id);
},
}}
plugins={[
Thumbnails,
Zoom,
+43
View File
@@ -0,0 +1,43 @@
import { useEffect, useState } from 'react';
// setTimeout stores its delay in a signed 32-bit int; anything larger
// overflows and fires immediately. Events expiring weeks out don't need a
// live tick anyway, so we simply don't schedule past this horizon.
const MAX_TIMEOUT_MS = 2 ** 31 - 1;
/**
* Fire `onExpiry` once, at the soonest future timestamp in `timestamps`
* (#909 review). Admin expiry badges are computed inline from Date.now()
* at render time, so without this a page left mounted across an event's
* expiry keeps showing the stale "active"/"1 day left" state until an
* unrelated render happens — which for editor/viewer roles (no health
* poll) may never occur. When the callback updates state/data, the next
* expiry reschedules automatically.
*/
export function useExpiryRefresh(
timestamps: Array<string | null | undefined>,
onExpiry: () => void,
): void {
const next = timestamps
.map((t) => (t ? new Date(t).getTime() : NaN))
.filter((n) => Number.isFinite(n) && n > Date.now())
.sort((a, b) => a - b)[0];
// Bumped by a capped wake-up so the effect re-evaluates and re-arms when
// the target is further out than a single setTimeout can represent.
const [rearm, setRearm] = useState(0);
useEffect(() => {
if (next === undefined) return;
// +1s so the timer lands just past the boundary, not exactly on it.
const delay = next - Date.now() + 1000;
if (delay > MAX_TIMEOUT_MS) {
// Too far for one timer (setTimeout overflows past ~24.8 days and
// fires immediately). Wake at the cap and re-arm with a now-smaller
// remaining delay, so a page left mounted for weeks still updates.
const id = window.setTimeout(() => setRearm((n) => n + 1), MAX_TIMEOUT_MS);
return () => window.clearTimeout(id);
}
const id = window.setTimeout(onExpiry, Math.max(0, delay));
return () => window.clearTimeout(id);
}, [next, onExpiry, rearm]);
}
+29 -4
View File
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Calendar,
@@ -15,7 +15,9 @@ import {
Check,
X
} from 'lucide-react';
import { differenceInDays, parseISO } from 'date-fns';
import { parseISO } from 'date-fns';
import { useQueryClient } from '@tanstack/react-query';
import { useExpiryRefresh } from '../../hooks/useExpiryRefresh';
import { useTranslation } from 'react-i18next';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { useMutationWithToast } from '../../hooks';
@@ -76,9 +78,29 @@ export const AdminDashboard: React.FC = () => {
// which silently missed any expiring event outside the first 100 rows.
const { data: expiringEventsData, isLoading: eventsLoading } = useQuery({
queryKey: ['admin-events-summary', 'expiring'],
queryFn: () => eventsService.getEvents(1, 5, 'expiring'),
// Order by soonest expiry so the five shown rows ARE the earliest to
// expire — useExpiryRefresh then schedules against the true next boundary
// even when >5 events are expiring (#909 review round 3).
queryFn: () => eventsService.getEvents(1, 5, 'expiring', undefined, 'expires_at', 'asc'),
});
// Keep the "expiring soon" card honest when a row crosses its expiry while
// the dashboard sits open (#909 review). Filtering client-side desynced the
// list from the cached total/stat; instead we refetch the whole set at the
// boundary — the backend returns rows/total/stats that already exclude the
// now-expired event, so everything stays consistent. Fixes the stale
// "1 day left" for roles without the health poll (editor/viewer). Placed
// with the other top-level hooks, above the loading early-return.
const queryClient = useQueryClient();
const refreshExpiring = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ['admin-events-summary', 'expiring'] });
queryClient.invalidateQueries({ queryKey: ['admin-dashboard-stats'] });
}, [queryClient]);
useExpiryRefresh(
(expiringEventsData?.events ?? []).map((e: any) => e.expires_at),
refreshExpiring,
);
// Pending workflow approvals — only when the workflow engine is live. These
// are the human-in-the-loop gates (e.g. "review invoice before sending").
const { flags } = useFeatureFlags();
@@ -237,7 +259,10 @@ export const AdminDashboard: React.FC = () => {
) : (
<div className="space-y-3">
{expiringEvents.map((event) => {
const daysLeft = differenceInDays(parseISO(event.expires_at!), new Date());
// Ceiling so the final partial day reads "1 day", not "0"
// (#909); clamped since a row can sit at the boundary for the
// instant before useExpiryRefresh refetches it away.
const daysLeft = Math.max(1, Math.ceil((parseISO(event.expires_at!).getTime() - Date.now()) / 86400000));
return (
<div
@@ -19,6 +19,7 @@ import { useTranslation } from 'react-i18next';
import { Button, Card, Loading } from '../../components/common';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { useMutationWithToast } from '../../hooks';
import { useAdminAuth } from '../../contexts/AdminAuthContext';
import { BackupDashboard } from '../../components/admin/BackupDashboard';
import { BackupConfiguration } from '../../components/admin/BackupConfiguration';
import { BackupHistory } from '../../components/admin/BackupHistory';
@@ -33,6 +34,11 @@ type TabId = 'dashboard' | 'configuration' | 'history' | 'restore' | 'integrity'
export const BackupManagement: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabId>('dashboard');
const { t } = useTranslation();
// Full-instance export contains every secret, so the endpoint is
// super_admin-only (GHSA-pv6w) — hide the card for other roles instead
// of showing a button that always 403s.
const { user } = useAdminAuth();
const isSuperAdmin = user?.role?.name === 'super_admin';
const { formatDateTime: fmtDateTime } = useLocalizedDate();
const tabs = [
@@ -201,7 +207,7 @@ export const BackupManagement: React.FC = () => {
onRunBackup={() => manualBackupMutation.mutate()}
isBackupRunning={backupStatus?.isRunning || manualBackupMutation.isPending}
/>
<PicpeakExportCard />
{isSuperAdmin && <PicpeakExportCard />}
</div>
)}
+17 -5
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useMemo } from 'react';
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useExpiryRefresh } from '../../hooks/useExpiryRefresh';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { differenceInDays } from 'date-fns';
import { toast } from 'react-toastify';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
@@ -101,6 +101,13 @@ export const EventDetailsPage: React.FC = () => {
enabled: !!id,
});
// Flip the expiry banner live when the timestamp passes with the page open
// (#909 review) — isExpired further down is computed inline from Date.now().
// Kept here with the other hooks, above the loading early-return.
const [, setExpiryTick] = useState(0);
const bumpExpiryTick = useCallback(() => setExpiryTick((n) => n + 1), []);
useExpiryRefresh([event?.expires_at], bumpExpiryTick);
// Fetch feedback settings
const { data: eventFeedbackSettings } = useQuery({
queryKey: ['admin-event-feedback-settings', id],
@@ -277,9 +284,14 @@ export const EventDetailsPage: React.FC = () => {
}
const expiresAtDate = safeParseDate(event.expires_at);
const daysUntilExpiration = expiresAtDate ? differenceInDays(expiresAtDate, new Date()) : null;
const isExpired = daysUntilExpiration !== null && daysUntilExpiration <= 0;
const isExpiring = daysUntilExpiration !== null && daysUntilExpiration > 0 && daysUntilExpiration <= 7;
// Timestamp comparison, not truncated whole days (#909): the old
// differenceInDays <= 0 marked events "expired" up to 24h early.
// Ceiling keeps the countdown at "1 day" through the final day.
const isExpired = expiresAtDate !== null && expiresAtDate.getTime() <= Date.now();
const daysUntilExpiration = expiresAtDate
? Math.ceil((expiresAtDate.getTime() - Date.now()) / 86400000)
: null;
const isExpiring = !isExpired && daysUntilExpiration !== null && daysUntilExpiration > 0 && daysUntilExpiration <= 7;
const handleStartEdit = () => {
setEditForm({
+19 -4
View File
@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import { useExpiryRefresh } from '../../hooks/useExpiryRefresh';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
Plus,
@@ -18,7 +19,7 @@ import {
ChevronLeft,
ChevronRight
} from 'lucide-react';
import { parseISO, differenceInDays } from 'date-fns';
import { parseISO } from 'date-fns';
import { toast } from 'react-toastify';
import { useModal, useMutationWithToast } from '../../hooks';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
@@ -141,7 +142,7 @@ export const EventsListPage: React.FC = () => {
// Fetch events — fully server-side: pagination, status filter, and search
// (#346 — counters and search were previously bounded to the first 100 rows).
const { data, isLoading, error } = useQuery({
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['admin-events', statusFilter ?? 'all', debouncedSearchTerm, page],
queryFn: () => eventsService.getEvents(page, PAGE_SIZE, statusFilter, debouncedSearchTerm || undefined),
placeholderData: (prev) => prev,
@@ -230,6 +231,14 @@ export const EventsListPage: React.FC = () => {
// Filtering and searching now happen server-side. Use the response directly,
// ordered as the backend returned them (created_at desc by default).
const events: Event[] = data?.events ?? [];
// Refetch when the soonest event expiry passes (#909 review): the status
// badge is computed inline from Date.now(), and under the "expiring" filter
// the backend drops the row once expires_at <= now — so a plain re-render
// would leave a stale "Expired" row (and total) in that filtered view.
// refetch() re-runs with the current page/filter/search: rows and totals
// both correct under every filter.
useExpiryRefresh(events.map((e) => e.expires_at), refetch);
const pagination = data?.pagination;
const totalPages = pagination?.totalPages ?? 1;
const filteredCount = pagination?.total ?? 0;
@@ -258,8 +267,14 @@ export const EventsListPage: React.FC = () => {
if (!event.expires_at) return { label: t('events.active'), color: 'text-green-600 dark:text-green-400 bg-green-100 dark:bg-green-900/40' };
const days = differenceInDays(parseISO(event.expires_at), new Date());
if (days <= 0) return { label: t('events.expired'), color: 'text-red-600 dark:text-red-400 bg-red-100 dark:bg-red-900/40' };
// Expired means the timestamp has actually passed (#909):
// differenceInDays truncates to whole days, so an event expiring in a
// few hours returned 0 and showed "Expired" while the public gallery
// (which compares real timestamps) correctly showed it active.
const expiresAt = parseISO(event.expires_at);
if (expiresAt.getTime() <= Date.now()) return { label: t('events.expired'), color: 'text-red-600 dark:text-red-400 bg-red-100 dark:bg-red-900/40' };
// Ceiling so the last day reads "1 day left", never "0 days".
const days = Math.ceil((expiresAt.getTime() - Date.now()) / 86400000);
if (days <= 7) return { label: t('events.daysLeft', { count: days }), color: 'text-orange-600 dark:text-orange-400 bg-orange-100 dark:bg-orange-900/40' };
return { label: t('events.active'), color: 'text-green-600 dark:text-green-400 bg-green-100 dark:bg-green-900/40' };
+9 -1
View File
@@ -91,7 +91,9 @@ export const eventsService = {
page: number = 1,
limit: number = 20,
status?: EventStatusFilter,
search?: string
search?: string,
sortBy?: string,
sortOrder?: 'asc' | 'desc'
): Promise<EventsListResponse> {
const params = new URLSearchParams({
page: page.toString(),
@@ -104,6 +106,12 @@ export const eventsService = {
if (search) {
params.append('search', search);
}
if (sortBy) {
params.append('sortBy', sortBy);
}
if (sortOrder) {
params.append('sortOrder', sortOrder);
}
const response = await api.get<EventsListResponse>(`/admin/events?${params}`);
const data: any = response.data;
+9
View File
@@ -198,6 +198,15 @@ export const galleryService = {
this.triggerBrowserDownload(fetched.blob, fetched.serverFilename || filename);
},
// Per-photo view beacon (#895). Fired by the lightbox when a photo
// becomes the visible slide — request-level counting on the image
// endpoints can't tell the current slide from its preloaded
// neighbours. Fire-and-forget: view counting must never surface an
// error to the guest.
trackPhotoView(slug: string, photoId: number): void {
api.post(`/gallery/${slug}/photo/${photoId}/view`).catch(() => {});
},
// Download all photos as ZIP
// When a pre-generated zip is available, use native browser download (Content-Length → progress bar).
// Otherwise fall back to blob download.