mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 14:23:14 -06:00
7 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
5b069b9665 |
Probe video asynchronously — the sweep would have blocked the loop per file
The backfill is right, and it lands on a path that could not carry it yet. deriveMediaMetadata spawned ffprobe and ffmpeg with execFileSync, each with a 15s timeout. Synchronously, those two calls stop the whole server for their duration: no heartbeats, no socket traffic, no HTTP. That was survivable while the only caller was a human-initiated upload — one file, someone waiting on it, bounded by their patience. The boot-time sweep removes every one of those mitigations. It walks the entire library, unattended, on a server with live panels, once per boot. A library of video rows therefore becomes a per-file event-loop stall, which is #240's failure mode — blocked loop, missed heartbeats, panels marked offline, reconnect churn — arriving from our own maintenance instead of from a checkpoint. We spent yesterday removing one of those; this would have added another, on a schedule. So both spawns are awaited instead of blocked on. Both callers already awaited deriveMediaMetadata, so this is invisible to them, and the ingest path stops freezing the server for the length of an upload's probe as a side benefit — that sync ffprobe has been known tech debt for a while. Timeouts are unchanged and still asserted: async is not a licence to hang, or one wedged file stops the sweep dead instead of moving on. Also applied the PR's own phantom-path discipline to the video branch, which still named its thumbnail before the encode: a failed ffmpeg left the row claiming a file that was never written, which is the exact bug the image branch was fixed for two commits earlier. The new test measures the property rather than grepping for it — a timer keeps ticking across a real spawn — so a future edit that reintroduces a sync call fails here rather than in a customer's fleet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS |
||
|
|
3f1c044940 |
Return no thumbnailPath when the image thumbnail write fails
deriveMediaMetadata assigned thumbnailPath before sharp wrote the file, so a failed write (corrupt image, disk error) returned a name for a file that was never created. Ingest then stored that phantom thumbnail_path and the dashboard requested it forever as a broken image. Assign only after the write succeeds; the video branch already nulled its path on failure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0131RYmVh8ePEhparD3mXBhU |
||
|
|
3e37d33b80 |
QA: close four ways a control or an asset lied about itself
Found by driving the real server and a real browser, not by reading. Each fix has a
test that fails without it.
1. A missing upload answered 200 with the DASHBOARD. express.static falls through on a
miss and the SPA catch-all caught it, so GET /uploads/content/<gone>.mp4 returned
15KB of index.html as text/html — under the `immutable, max-age=30d` header the mount
sets before it knows the file exists. Every player downloader treats 200 as success,
so a panel stores the HTML page AS the video and caches it for a month, rendering a
black frame with nothing in any log. Reachable exactly when it hurts: a content
replace writes a new random filename and unlinks the old one. The mount now
terminates a miss with a 404 and drops the cache header.
2. Four dashboard->device socket handlers had no capability gate. dashboard:device-command
has always refused a command the panel cannot honour, and the comment above it is right
about why ("hiding the button is not enforcement — this socket is reachable directly").
Every word applied to the four handlers immediately above it, which had none: a display
declaring [] still received screenshot-request, remote-touch, remote-key and
remote-start. Measured, not inferred. They now refuse on remote.screenshot /
remote.input / remote.stream and name the capability in the ack; remote-stop stays
ungated for the same reason set_debug does. The undeclared fleet is unaffected — an
absent declaration still resolves to its platform baseline and keeps everything.
The wall panel list (#235) made this visible: it offered a Screenshot button for every
panel, including a BrightSign, which has no screenshot capability at all, and popped a
toast promising an image that was never coming. GET /api/devices now ships the RESOLVED
capability array rather than the raw column ('[]' as a STRING, which Array.isArray reads
as "pre-capability server, show everything" — wrong in the one case that matters), so
the wall list and the fleet cards can hide what a panel cannot do. The remote pad's
Scrn Off / Scrn On were gated on remote.input while the Info tab gated the same two
commands on display.power; both now agree.
3. A register with no `platform` ERASED the stored one. captureIdentity coerces a missing
field to the literal 'unknown' and persistIdentity wrote it straight over. That column
is load-bearing: platformFamily() reads it, so one reconnect from an older build turned
a Tizen panel into a browser tab and handed it a volume slider the .wgt has no handler
for — the exact control BASELINE.tizen exists to hide — while a BrightSign lost screen
power and reboot and gained screenshots it cannot take. platform and client_type are
now preserved (physical facts); client_version and contract_version still decay, because
there "we no longer know" is the truthful answer. client_type 'wgt' is also read as a
second signal for a Tizen TV.
4. PUT /api/content/:id/replace carried its own shorter copy of the ingest logic. Replacing
a video left duration_sec at the OLD clip's length and nulled width/height, so #237's
brand-new "default an item to the clip's own length" then handed out the wrong number
for every later add — 32s scheduled for a 5s video is 27s of frozen frame. Replacing an
image measured it with raw sharp metadata and thumbnailed without .rotate(),
re-introducing the EXIF-orientation bug #172 had just fixed at ingest. Both paths now
share lib/content-ingest.deriveMediaMetadata.
Verified working and NOT changed: all six item-duration insert paths (a 31.7s clip stores
32 everywhere, an explicit value always wins, and no path can store a 0); the content
revision bump + filepath refresh reaching a real device socket; a landscape wall producing
byte-identical geometry to the pre-#236 expression; a portrait wall reaching the player as
side-by-side halves; cross-workspace isolation across 29 probes.
Full suite green (1319).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
|
||
|
|
6b082cfad0 |
fix(uploads): derive stored type from file content, and never serve uploads as documents
Uploaded files are served from the SAME ORIGIN as the dashboard, so how a browser
interprets them is a security boundary. Two things decided that interpretation, and
both were caller-controlled: the stored extension came from
`path.extname(originalname)`, and the only type check read `file.mimetype` — a request
header. A caller could therefore choose to have their bytes served as an active
document from the app origin.
Two independent invariants now hold the boundary:
1. INGEST — lib/upload-sniff.js sniffs magic bytes after multer writes a neutral
`.part` file (diskStorage names the file before any bytes exist, so the sniff cannot
happen there), maps the result through a hardcoded mime->extension allowlist, renames
accordingly, and stores the sniffed mime. Unsupported bytes are refused with a 400.
2. SERVING — upload responses carry `Content-Security-Policy: sandbox`, so if a response
is ever treated as a document it lands in an opaque origin with scripts disabled.
Anything outside the inline-safe extension set is additionally forced to download.
This holds regardless of how a file reached disk, so a future gap in (1) is contained
rather than exploitable.
Applied at every instance of the pattern, not just the first: lib/content-ingest.js,
the /replace route, the four content-serving paths across server.js and routes/content.js
(the latter pair currently shadowed by mount order, which is not a guarantee), and the
ZIP-import path in routes/status.js, which took its extension from the archive entry.
SVG stays accepted and stays inline: white-label logos are SVG, and octet-stream +
nosniff makes <img> fail. Scripts in an SVG never run in an image context, and the
sandbox CSP covers the one case where they would — a direct navigation. SVG is also no
longer handed to sharp, which removes the librsvg path where the open libvips CVEs live.
Existing rows are untouched — no migration. The four upload fixtures in agency.test.js
uploaded `Buffer.from('x')` declared as image/png; that is the exact "declared type is a
lie" case this closes, so the fixtures now use real PNG bytes. No assertion changed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
837f65e634
|
fix(content+android): rotation-aware media — portrait upright on dashboard AND player (#170) (#172)
* fix(content): rotation-aware media dimensions — portrait no longer stored landscape (#170) Ingest recorded CODED width/height and ignored rotation, so a portrait phone video (coded 1920x1080 + 90° Display-Matrix) or a portrait photo (EXIF orientation 6) was stored LANDSCAPE. The player then rendered it wrong-aspect and letterboxed — the "portrait content degraded + blue bar at the bottom" symptom in #170. The reporter's workaround (pre-rotate + mark Landscape) is exactly what this bug forces. - lib/media-orientation.js (new): pure, unit-tested display-dimension helpers = single source of truth for ingest AND the backfill. videoDisplayDims() reads the modern Display-Matrix side_data rotation (falls back to the legacy tags.rotate, sign-normalized); imageDisplayDims() honors EXIF orientation 5..8. Odd quarter-turns swap W/H. - lib/content-ingest.js: use the helpers for stored dims; add sharp .rotate() so image THUMBNAILS are auto-oriented too (video thumbs were already auto-rotated by ffmpeg). - scripts/backfill-rotation-dims.js (new): idempotent, dry-run-by-default maintenance to correct already-uploaded portrait media (re-probe -> fix dims -> regenerate image thumbs). - test/media-orientation.test.js: 5 bites (tag + Display-Matrix, sign/normalize, EXIF 5..8, the blue-bar landscape->portrait case, null-safety). Scopes #170 to its residual-on-1.9.4 issues; the 1.9.3 "never displays" slice was #162 + the remote_url-null download fix, already shipped in 1.9.4. The slow low-res/orientation- cycling first load is tracked separately in #170 pending repro data. Refs #170. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(android): honor EXIF orientation in ImageLoader so portrait photos render upright (#170) Completes the rotation-aware media fix on the PLAYER side. The server ingest fix (this branch) corrects stored dimensions + auto-orients the thumbnail, but the panel draws the full-res original via BitmapFactory, which ignores EXIF — so a portrait photo (landscape pixels tagged "rotate 90") still rendered sideways on the screen. QA root-cause pass on #170 caught this gap: the Android player reads no stored dims and applied no EXIF. ImageLoader now reads the EXIF orientation (from the file for cached content, from the byte stream for remote_url images — ExifInterface(stream) is API 24+, minSdk is 24) and rotates/ flips the decoded bitmap via a Matrix (all 8 orientations). NORMAL/UNDEFINED is a no-op (no extra allocation); a transformed copy recycles the source; OOM falls back to the source rather than crashing. Videos were already correct (ExoPlayer honors the rotation matrix). Verified: :app:compileDebugKotlin clean. Refs #170. Rides with the server rotation-dims fix on this branch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
2f3dd80881
|
feat(agency): per-token upload folder — auto-created, subtree-confined (#158) (#171)
Agency-portal uploads previously all landed at the workspace library root, unsorted. Instead of the issue's whole-workspace folder dropdown (which would leak every folder name to an external party), bind ONE folder per agency token — admin-controlled and agency-invisible — and scope the portal picker strictly to that folder's own subtree (Hybrid-C). Fully backwards-compatible: no bound folder -> root, exactly as before. Model / multi-workspace: an agency token is bound to ONE workspace at issuance, so the token key IS that workspace's private link and the bound folder lives in that workspace. An admin with N workspaces mints one token per workspace (each with its own auto-folder). No workspace-switcher in the portal — the token is the tenant boundary. Backend: - api_tokens.upload_folder_id (additive; ON DELETE SET NULL -> deleting the folder falls back to root). - lib/agency-targets.folderSubtree(): recursive-CTE helper = the SINGLE confinement source shared by GET /api/agency/folders AND the POST /api/agency/content target check, so the set the agency can SEE and the set it may WRITE to can never drift. Workspace-guarded at the anchor row; descendants inherit the workspace (folders.js forbids cross-ws parents). - routes/agency.js: GET /folders (bound subtree only); POST /content defaults to the bound folder and 403s any folder_id outside the subtree. - routes/tokens.js: create auto-creates "Agency — <name>" (or binds a picked folder, validated same-workspace, respecting the 100-folder cap) inside the token tx; new PUT /:id/upload-folder to rebind; listing surfaces the bound folder name. - middleware/apiToken.js + lib/content-ingest.js: upload_folder_id onto req.apiToken; ingest writes folder_id. Frontend: - Agency portal: folder <select> shown only when a real subfolder choice exists (identifies the "Main folder" root client-side without learning the token's folder id). - Settings: folder pick at token creation, bound-folder display, rebind modal. - i18n: 7 new apitoken.* keys across all 5 locales. Tests (429/429): - test/agency-folder.test.js: 5 folderSubtree confinement bites (subtree in, siblings out, workspace guard, null -> root). - test/agency.test.js (+1 e2e): auto-create, default-to-bound, in-subtree pick lands there, sibling -> 403, admin-pick, unknown-pick -> 400, rebind-to-root. Closes #158. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
a59b53cc25 |
refactor(content): extract the upload ingest into a shared lib (#73)
routes/content.js POST / processing (thumbnail/dimensions/duration) + insert moved to lib/content-ingest.js so the agency router produces byte-identical first-class content. content.js POST / is now a thin caller; behavior-preserving - the 52 content regression tests (api/operator-permissions/config-paths) pass unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |