Commit graph

146 commits

Author SHA1 Message Date
ScreenTinker 3da49ec79c chore(config): env-configurable heartbeat timing
Make HEARTBEAT_INTERVAL and HEARTBEAT_TIMEOUT env-tunable so
self-hosters with slow/jittery networks don't have to edit
config.js (issue #3 reporter did exactly this to confirm the
diagnosis). Defaults unchanged at 10000ms / 45000ms so existing
deployments keep current behavior.

Same parseInt(env) || default pattern as PORT/HTTPS_PORT/PING_*.
README env table extended.

Refs #3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:03:02 -05:00
ScreenTinker 1aee4f2d5b fix(socket): raise Engine.IO ping/pong + prefer WebSocket transport
Connection-stability layer for issue #3. LG webOS WebKit (and other
TV-grade clients) miss Engine.IO pongs under decode load with the
Socket.IO defaults of 25s ping / 20s timeout, causing spurious
transport drops and a connect/reconnect/evict/disconnect loop on
the device. Default polling-first transport adds another fragility
layer via the polling->WebSocket upgrade dance.

- pingInterval / pingTimeout default to 30000 / 30000 (worst-case
  dead-socket detection 60s, up from ~45s). Both env-configurable
  via PING_INTERVAL / PING_TIMEOUT.
- Player Socket.IO client: transports: ['websocket', 'polling'].
  Tries WebSocket first; falls back to polling on the same connect
  attempt if WebSocket fails. Polling fallback preserved for
  firewall-restricted networks.

App-level heartbeat checker is unchanged and remains the safety net
for clients that miss the transport-level ping/pong window.

Tradeoffs documented in inline comments. README env table extended
with PING_INTERVAL and PING_TIMEOUT rows.

Refs #3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:02:34 -05:00
ScreenTinker 1e23335356 fix(player): graceful handling when displayed content is removed
Deleting a content asset that was actively displayed on screens
caused affected players to go black and never recover; deleting an
actively-playing video also failed to stop playback (audio kept
going). Root cause: handlePlaylistUpdate never tore down the current
media element and could drive currentIndex to NaN when a late
onended fired during the playlist swap.

- Add teardownCurrentMedia() - pause, clear src, .load() to actually
  release the decoder and kill audio; null event handlers to prevent
  late onended races
- handlePlaylistUpdate: preserve continuity - if the playing item
  survives the update keep it playing, otherwise walk forward from
  the old position to the next surviving item; empty playlist tears
  down to waiting state
- Guard playCurrentItem against empty playlist / non-finite index
- Remove dead device:content-delete socket handler (never emitted)

Resolves #4

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:17:40 -05:00
ScreenTinker 3dfec5d2f9 feat(config): DISABLE_HOMEPAGE env var to redirect / to the app
Self-hosters running internal-only deployments don't need the
marketing homepage. With DISABLE_HOMEPAGE=true, requests to /
302-redirect to /app instead of serving the landing page.
Unset/false preserves current behavior.

Requested via discord feedback.
2026-05-14 12:03:29 -05:00
ScreenTinker f4d2a0330b chore(email): log successful sends for observability
Previously sendEmail() only logged on error/suppression paths; success
was silent. After prod deploy of c71c401 it was unclear whether the
first alert tick had actually delivered email or not - the answer was
yes but had to be derived from 'no error log + recipient query showed
matching device'. Add a log line on success so future observability
doesn't require detective work.
2026-05-12 18:34:19 -05:00
ScreenTinker c71c4016ca feat(email): Microsoft Graph send + alert spam protection + preferences UI
Replaces the unused EMAIL_WEBHOOK_URL stub with a real Microsoft Graph
Mail.Send pipeline via @azure/msal-node client-credentials flow. Prior
state on prod: every alert email was logged to journalctl and never
sent (21 fallback log lines per hour for the chronic-offline devices).

Four coordinated changes shipped as one commit since they're all part
of making email delivery actually work responsibly:

1. services/email.js (NEW): Graph send via plain HTTPS (no SDK), in-memory
   MSAL token cache (refresh 60s pre-expiry), graceful stdout fallback
   when GRAPH_* env vars absent. Drop-in replacement for the old webhook.

2. services/alerts.js refactored: sequential await around sendEmail (was
   parallel fire-and-forget; first run hit Graph's MailboxConcurrency 429
   ApplicationThrottled on a 30-device backlog). Sequential at ~250ms per
   send takes 5-8s for the full backlog, well within the 60s tick. Also:
   24h long-offline cutoff to stop nagging about chronic-offline devices
   (the 20,000+ minute ones); 2-hour dedup window (was 1h) via a generic
   shouldSendAlert(type, id, windowMs) helper that future alert types
   (payment_failed, plan_limit_hit, etc.) can reuse.

3. Preferences UI: single checkbox in settings.js Account section bound
   to users.email_alerts. Saved via the existing Save Profile button. PUT
   /api/auth/me extended to accept email_alerts. requireAuth middleware
   SELECT now includes email_alerts so it propagates via req.user.

4. Dev safety net: GRAPH_DEV_RESTRICT_TO env var as an allow-list. When
   set, only listed recipients reach Graph; everyone else is suppressed
   with a log line. Prevents local dev (which often runs against fresh
   prod DB copies) from accidentally emailing real prod users. UNSET on
   prod systemd unit so production fans out normally.

Also: package.json scripts use --env-file-if-exists=.env so local dev
picks up .env automatically (Node 20.6+ built-in, no dotenv dep). Prod
runs via systemd ExecStart and is unaffected. server/.gitignore added
to keep .env out of git.

Smoke verified end-to-end:
- Sequential send pattern verified (a prior parallel-send tick had hit
  Graph's MailboxConcurrency 429 on 30 simultaneous sends; sequential
  at ~250ms each completes the same backlog without throttling)
- 24h cutoff silenced 20/21 prod devices on the next tick
- Dev restrict suppressed the 1 within-24h send
- User-preference toggle flipped via UI -> DB -> alert path silently
  continued before reaching even the suppression log
2026-05-12 18:16:40 -05:00
ScreenTinker ce332ead67 feat(switcher): per-workspace device count in dropdown rows
/me's accessible_workspaces query gains a device_count field via a
correlated subquery on workspaces.id - WHERE workspace_id = w.id
strictly excludes the unclaimed pair-pool (workspace_id IS NULL fails
equality). Added to both query branches (platform_admin LEFT JOIN and
regular INNER JOIN); microseconds per row at current scale (~37 rows
worst case), not optimizing.

Frontend appends the count to the muted org-name line with a middle-dot
separator: 'Acme Studios . 2 devices'. Singular/plural respected via the
existing tn() helper convention; 'No devices' for empty workspaces. New
formatResourceCount(n, keyBase, zeroKey) helper is generic so the same
shape can wire users/playlists/schedules counts later without refactor.

New i18n keys: switcher.devices_count_one, switcher.devices_count_other,
switcher.no_devices. Added to en.js only; other locales fall back to en
via the existing lookup chain (verified in i18n.js:19).

API smoke verified: switcher-test sees Studio A=2, Field Crew=2;
dw5304 (platform_admin) sees all 37 workspaces with their device counts
varying 0-4; single-workspace zero-device user (geoff.case) sees 0.
2026-05-12 14:04:21 -05:00
ScreenTinker 42966da973 feat(teams): temporarily disable Teams API while feature is redesigned
Teams in its pre-Workspaces form is being paused while the feature is
redesigned as a user-grouping primitive within the new Workspaces
architecture. The original Teams data model had no workspace-awareness
and was effectively non-functional after Phase 2.2 (every route migrated
away from team_id), but the UI remained reachable and allowed users to
accumulate orphan data while believing they were configuring access
control.

Hide the Teams sidebar nav entry to prevent new entries to the UI.
/api/teams now returns 503 Service Unavailable with a 'feature
redesign in progress' message. Existing teams/team_members/team_invites
table data is preserved indefinitely for forward migration to the
future teams design.

Bonus: requireAuth middleware fires before the catch-all so unauthenticated
callers see the standard 401 instead of the 503 redesign message - avoids
exposing the 'feature being redesigned' signal to unauthenticated probes
or fingerprint scanners.
2026-05-12 13:30:55 -05:00
ScreenTinker 766f02ae5d docs(upload): correct misleading defParamCharset comment
The previous comment claimed defParamCharset:'utf8' fixed multipart
filename header decoding. It doesn't - that option only fires for the
RFC 5987 encoded filename*=utf-8''... form, which clients rarely send.
The actual UTF-8 recovery happens in the storage.filename callback
(added in d679ca8) via Buffer.from(name,'latin1').toString('utf8').
The option is kept set for the rare RFC 5987 case but the comment no
longer overclaims what it does.
2026-05-12 11:57:54 -05:00
ScreenTinker d679ca8d14 fix(upload): re-decode multipart filename header from latin1 to utf8 in multer storage callback
busboy reads the Content-Disposition filename="..." header value as
latin1 by default - even with defParamCharset:'utf8' set, that option
only applies to RFC 5987 encoded filename*=... params, which most
clients (browsers, curl, programmatic HTTP) don't send. Modern clients
send raw UTF-8 bytes for non-ASCII filenames; busboy interprets those
bytes one-byte-per-char as latin1, producing a JS string like 'A-tilde
+ quarter-mark' for 'u-umlaut'. JS then re-encodes that string as UTF-8
on the way to SQLite, yielding 4 bytes (c3 83 c2 bc) for what should be
2 bytes (c3 bc). Classic double-encoding mojibake - shows up in the UI
as 'BegrA-tilde...' instead of 'Begru-umlaut...'.

Fix: in the multer filename callback, re-decode file.originalname from
latin1 to utf8 to recover the original byte sequence. Mutating
originalname here propagates to every route handler reading
req.file.originalname (POST /, PUT /:id/replace, and any future upload
route using the same middleware).

This is the actual visible-mojibake bug semetra22 reported. The prior
commit b677752 (NFC normalize in safeFilename) handles a separate but
related case (macOS NFD clients sending decomposed forms); both fixes
compose correctly - latin1->utf8 first restores the byte sequence,
then NFC normalize collapses NFD into composed form.

Smoke verified by sending raw UTF-8 multipart from a Node https client
(no shell escaping). NFC input 'Begru-umlaut-essungsscreens.jpg' with
bytes c3bc c39f arrives clean (was c383c2bc c383c29f before). NFD input
'u + combining diaeresis' arrives as composed NFC c3bc after both fixes.
2026-05-12 11:55:55 -05:00
ScreenTinker b67775283b fix(server): NFC normalize user-facing filenames in safeFilename
Single line change to safeFilename() in routes/content.js: add
.normalize('NFC') before sanitizeString. Covers all 4 user-facing
filename storage sites (POST /, POST /remote, POST /embed, PUT /:id
rename) since they all flow through safeFilename.

Fixes macOS NFD vs Linux NFC mismatch on filename storage that mangled
umlauts (ae/oe/ue/ss) in displayed filenames. macOS clients send
NFD-decomposed names (e.g. 'u' + combining diaeresis U+0308 instead of
the precomposed U+00FC); Linux + most renderers expect NFC. Without
this, names like 'Begruessungsscreens.jpg' arrive with the combining
char floating and display as mojibake.

Reported by semetra22 in Discord with extraordinarily good debugging
narrowing (rename works, upload doesn't = bug is in upload path).
Single-point fix at the convergence of all user-facing filename flows.

Existing NFD-mangled rows in DB not backfilled; users can re-upload or
rename to repair. Optional one-time UPDATE backfill captured as follow-up
in handoff doc.

Smoke verified by invoking safeFilename directly on NFD + NFC inputs of
'Begruessungsscreens.jpg' - both produce identical NFC-normalized bytes
(42656772c3bcc39f756e677373637265656e732e6a7067).
2026-05-12 11:51:34 -05:00
ScreenTinker fc29843035 feat(socket): Phase 2.3 workspace-scoped dashboard socket rooms + per-command permission gates. Dashboard namespace was previously a flat broadcast - every connected dashboard received every device's status/screenshot/playback events platform-wide (foreign device names + IPs included). Inbound socket commands gated by a legacy admin/superadmin role check that was dead code post-Phase-1 rename.
Fix: at connect, enumerate the user's accessible workspace_ids (direct workspace_members + org_owner/admin paths + platform_admin 'all') via new accessibleWorkspaceIds() helper in lib/tenancy.js; socket.join one room per workspace. All 12 dashboardNs.emit sites across deviceSocket / heartbeat / server.js / devices route / video-walls route now route via dashboardNs.to(workspaceRoom(...)).emit() with the workspace looked up from the relevant device or wall. New lib/socket-rooms.js holds the helpers and breaks a circular dependency (dashboardSocket already requires heartbeat, so heartbeat can't require dashboardSocket).

Inbound 6 commands rewired to canActOnDevice(socket, deviceId, tier): request-screenshot is read tier (workspace_viewer+); remote-touch/key/start/stop and device-command are write tier (workspace_editor+). Platform_admin and org_owner/admin always pass via actingAs. Legacy admin/superadmin branch dropped.

Lifecycle note: workspace-switch already calls window.location.reload (Phase 3 switcher), which forces a fresh socket with updated memberships - no per-emit re-evaluation needed.

Smoke tested with 3 simultaneous socket.io-client connections (switcher-test, swninja, dw5304 platform_admin) + direct canActOnDevice invocation for 6 user/device/tier combinations. All 9 outbound isolation cells and all 6 permission gates pass. Fixture mutation: switcher-test's Field Crew membership flipped from workspace_editor to workspace_viewer to exercise the read/write tier split in one login.
2026-05-12 11:34:24 -05:00
ScreenTinker 56da64d0cd feat(workspaces): rename via switcher dropdown - new PATCH /api/workspaces/:id route, per-row pencil affordance in switcher (visible only when caller can_admin), small rename modal with name + slug fields, validation (name <=80 chars, slug ^[a-z0-9]+(?:-[a-z0-9]+)*$ <=60 chars, blank slug -> NULL), 409 on per-org slug collision. Permission gating via new canAdminWorkspace(db, user, ws) helper in lib/permissions.js - reused-ready for future Phase 3 admin actions. /me query now joins organization_members to compute can_admin per accessible_workspaces entry. Drive-by fixes surfaced: (1) activityLogger method filter was missing PATCH, added; (2) routes that operate on a target workspace by URL param need to stamp req.workspaceId from the param so activityLogger captures the right tenant attribution - documented in the route. Smoke fixture: switcher-test@local.test is workspace_admin of Studio A and workspace_editor of Field Crew (no org_owner) so the can_admin true/false split is exercised in one login. 2026-05-12 11:06:55 -05:00
ScreenTinker 0c91390e56 fix(frontend): workspace switcher (Phase 3 MVP) + SW network-first migration + platform_admin accessible_workspaces expansion + static render CSS cleanup. The switcher adds a sidebar dropdown for users who are members of multiple workspaces, renders as static text with a 'Workspace' label for single-workspace users, and muted 'No workspace' for zero. Uses existing /api/auth/me's accessible_workspaces and POST /api/auth/switch-workspace endpoints. Platform admin / superadmin users now see all workspaces in accessible_workspaces (closing the known regression from 88d91b1) via a LEFT JOIN that preserves workspace_role semantics (null = acting-as, role string = direct member). No cap on the list - deliberate for now, revisit at 50+ workspaces. SW fix bumps rd-admin-v1 -> rd-admin-v2 and switches fetch strategy from cache-first to network-first so the server's existing Cache-Control: no-cache + ETag headers actually get respected; preserves offline fallback. Static render CSS drops the bordered-box chrome that was making single-workspace users think the static text was clickable. Includes test fixture user switcher-test@local.test (credentials in fixture SQL header). Surfaced by semetra22 / Discord report about 'screens jumbled up' post-migration; root cause was the missing workspace switcher UI making devices in non-active workspaces appear missing. 2026-05-12 10:55:09 -05:00
ScreenTinker bc445a0a7c fix(boot): auto-apply Phase 1 multi-tenancy migration on startup if not yet applied; refactor scripts/migrate-multitenancy.js to expose runMigration() with CLI wrapper preserved; pre-migration snapshot to db/remote_display.pre-migration-<timestamp>.db; belt-and-suspenders guards on migrateFolderWorkspaceIds + backfillActivityLogWorkspace so the inline backfills skip cleanly if workspaces table absent. Fixes startup crash on pre-multi-tenancy installs (semetra22 / Discord report) where 'npm start' after pulling latest hit migrateFolderWorkspaceIds and crashed with 'no such table: workspaces'. Self-hosters now get an automatic upgrade path without needing to run 'node scripts/migrate-multitenancy.js' manually. 2026-05-12 08:22:47 -05:00
ScreenTinker 92e26aafcb fix(server): mount activityLogger middleware before workspace routes so POST/PUT/DELETE actually get logged - pre-existing bug, the middleware was a no-op for every API route because route mounts came first in server.js (L305 routes vs L368 middleware). Zero double-log risk: the one inline logActivity caller at routes/auth.js:452 is on /api/auth which mounts before the new middleware position. activity_log row growth will pick up significantly going forward (pruneActivityLog 90-day retention already handles the bound). Surfaced by Phase 2.2 migration discipline. 2026-05-11 23:17:28 -05:00
ScreenTinker 88d91b10af activity_log: stop the bleeding - writer-leak fix on 3 sites (activityLogger middleware, alert service, login route) + one-time backfill of 548 NULL-workspace rows via device.workspace_id or workspace_members lookup; activity.js route migration deferred to its own slice tomorrow.
KNOWN REGRESSION (Phase 3 fix): platform_admin / superadmin no longer has cross-workspace 'see everything' view. Every route migrated tonight (2.2a-2.2m) deliberately removed the role-based bypass per design doc - cross-workspace visibility will come via dedicated admin endpoints in Phase 3, not magic role bypasses. Until Phase 3 ships, platform admins must switch-workspace to see other workspaces' data.
2026-05-11 23:14:06 -05:00
ScreenTinker 0b9aa56e75 Phase 2.2m: schedules.js scoped to workspace_id; schedule.workspace_id inherited from target (device/group); fixes 6 pre-existing cross-tenant leaks (POST content/widget/layout/playlist accepted with no check, PUT verifyOwnership rewrite across all 6 polymorphic targets) 2026-05-11 23:03:54 -05:00
ScreenTinker b6c90d3421 Phase 2.2l: video-walls.js scoped to workspace_id; fixes 4 pre-existing cross-tenant leaks (POST playlist_id, PUT playlist/content/leader_device, PUT /devices, PUT /content); drops dead admin/team_members code paths left over from Phase 2.1 role rename; team_id column noted for future cleanup 2026-05-11 22:56:08 -05:00
ScreenTinker 52e68ac490 fix(playlists): POST /publish returns items with pi.id so post-publish delete works without refresh; future refactor candidate to share SELECT shape with GET /:id 2026-05-11 22:28:59 -05:00
ScreenTinker 833e84578e Phase 2.2k: playlists.js scoped to workspace_id; fixes 3 pre-existing cross-tenant leaks (content add, widget add with NO existing check, device assign); content.js snapshot-scrub bundle; status.js export endpoint deferred to dedicated slice 2026-05-11 22:22:18 -05:00
ScreenTinker 90fe6e0f9a Phase 2.2j: assignments.js scoped to workspace_id via playlist.workspace_id; fixes 3 pre-existing cross-tenant leaks (content add, widget add with NO existing check, copy-to cross-workspace); ensureDevicePlaylist loop-closer; status.js playlist INSERT bundle 2026-05-11 22:12:13 -05:00
ScreenTinker e17538b186 Phase 2.2i: device-groups.js scoped to workspace_id; fixes 3 pre-existing cross-tenant leaks (group device add, bulk content assign, bulk playlist assign); pre-emptive workspace_id stamp on ensureDevicePlaylist helper 2026-05-11 21:58:13 -05:00
ScreenTinker c7f9d014ca Phase 2.2h: layouts.js scoped to workspace_id; templates via is_template path; fixes pre-existing PUT /device/:deviceId cross-tenant layout-assignment leak 2026-05-11 21:45:28 -05:00
ScreenTinker f17d757ba0 Phase 2.2g: reports.js scoped to workspace_id; fixes pre-existing /export and /uptime cross-tenant leaks 2026-05-11 21:36:54 -05:00
ScreenTinker 0d642e4d80 Phase 2.2f: white-label.js scoped to workspace_id; requireWorkspaceAdmin gate; status.js bundle 2026-05-11 21:30:22 -05:00
ScreenTinker 806c931e43 Phase 2.2e: kiosk.js scoped to workspace_id; import kiosk INSERT bundled 2026-05-11 21:20:18 -05:00
ScreenTinker efce13e05d Phase 2.2d: widgets.js scoped to workspace_id; import + widget-reference defense bundled 2026-05-11 21:13:51 -05:00
ScreenTinker a4610e8d0d Phase 2.2c: content_folders gets workspace_id (schema + backfill); folders.js scoped; content.js folder-move strict same-workspace 2026-05-11 21:04:03 -05:00
ScreenTinker a5dbc5d665 Phase 2.2b: content.js + status.js import scoped to workspace_id; uploads stamp workspace_id 2026-05-11 20:50:25 -05:00
ScreenTinker afd2a10df2 Phase 2.2a: devices.js scoped to workspace_id; pair flow stamps workspace_id on claim 2026-05-11 20:33:58 -05:00
ScreenTinker 2954fd1a84 Phase 2.1: tenancy middleware, permission helpers, JWT workspace context, frontend + backend role-rename compat 2026-05-11 20:02:00 -05:00
ScreenTinker 45a6800621 fix: log real client IPs through Cloudflare instead of CF edge
Express's req.ip was resolving to a Cloudflare edge address (e.g.
172.70.x.x) for any request fronted by Cloudflare, because trust proxy
was set to '1' — that trusts the immediate hop, which IS Cloudflare.
All activity_log rows from API paths captured the proxy, not the
client. The WebSocket path was unaffected and recorded the real IP.

Two layers of defense:

1. trust proxy now lists Cloudflare's published v4 + v6 ranges plus
   loopback / linklocal / uniquelocal (config/cloudflareIps.js). With
   this list req.ip resolves to the original client when fronted by
   CF, and X-Forwarded-For from any non-trusted source is ignored —
   so the value can't be spoofed.

2. New getClientIp(req) helper in services/activity.js prefers the
   CF-Connecting-IP header but only honors it when the immediate TCP
   peer is itself a trusted address. Same gate as trust proxy, so a
   visitor who hits the origin directly with a forged header is
   logged at their real address.

Routed all five activity-log call sites (auth login success/failure,
admin password reset, generic activityLogger middleware, and the
in-memory rate-limiter key) through the helper.

Logging-only change. No schema changes. Existing rows are not
modified — fix applies to new entries going forward.

Verified locally:
- Bare loopback hit logs 127.0.0.1 (not a proxy address).
- Helper unit cases including an untrusted peer (203.0.113.7) sending
  a forged CF-Connecting-IP correctly fall back to the real peer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:26:37 -05:00
ScreenTinker 2068bc8833 Video walls: free-form canvas editor, leader-driven sync, group dissolve, progress bars
Wall editor: replaces the small grid with a Figma-style pan/zoom canvas. Each
display is a rectangle that can be dragged/resized to match its physical
arrangement; a separate semi-transparent player rect overlays the screens and
defines what content plays where. Drag empty space to pan, wheel to zoom,
"Center" button auto-fits content. Per-rect numeric x/y/w/h panel; arrow keys
nudge by 1px (10px with shift). Negative coordinates supported for screens
offset above/left of the origin. Coords rounded to integers on save.

Wall rendering: each device receives screen_rect + player_rect, maps the
player into its viewport with vw/vh and object-fit:fill so vertical position
of every source pixel is identical across devices that share viewport height.
Leader emits wall:sync at 4Hz with sent_at timestamp; followers apply
latency-adjusted target and use playbackRate ±3% for sub-300ms drift,
hard-seek for >300ms. Followers stay muted; leader unmutes via gesture with
AudioContext priming and pause+play retry to bypass Firefox autoplay.
"Tap to enable audio" overlay as a final fallback.

Reconnect handling: server re-evaluates leader on device:register so the
top-left tile reclaims leadership when it returns. Followers emit
wall:sync-request on entering wall mode (incl. reconnect) so they snap to
position immediately instead of drifting until the next periodic tick.

Group dissolve: removing a device from its last group clears its playlist
to mirror wall-leave semantics. Leaving a group with playlists on remaining
groups inherits the next group's playlist.

Dashboard: walls render as their own card section (hidden the device cards
they contain). Multi-select checkboxes on cards + "Create Video Wall" toolbar
action that creates the wall, removes devices from groups, and opens the
editor. dashboard:wall-changed broadcast triggers live re-render. Per-card
playback progress bar driven by play_start events forwarded from devices.

Security: PUT /walls/:id/devices verifies caller owns each device (or has
team-owner access via the widgets pattern), preventing cross-tenant device
takeover. wall:sync and wall:sync-request validate that the sending device
is a member of the named wall; relay re-stamps device_id with currentDeviceId
so clients can't spoof or shadow-exclude peers.

Schema: video_walls += player_x/y/width/height, playlist_id;
video_wall_devices += canvas_x/y/width/height. All idempotent migrations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 23:11:16 -05:00
ScreenTinker 388e9e6ab8 Admin password reset + widget visibility fix
Password reset for other users:
- New PUT /api/auth/users/:id/password endpoint
- Superadmin can reset any local user; admin can reset role=user
  members of teams they own only (cannot reset other admins or
  superadmins, cannot self-reset — that goes through PUT /me with
  current_password)
- OAuth users are excluded (no password to reset)
- Rate-limited 20 req/min/IP to cap blast radius if an admin session
  is compromised
- Explicit audit log entry "password_reset_for_user / target: <email>"
  on every reset; activity logger's summarizeAction never reads the
  password field, so the password value is not stored anywhere

Frontend: Reset Password button in the Admin user table and Settings
> User Management table. Shown only for local-auth users that aren't
the current user; prompts for an 8+ char password.

Widgets visibility fix:
- routes/widgets.js had `const isAdmin = req.user.role === 'superadmin'`
  which mislabeled superadmin as admin and silently restricted real
  admins (role=admin) to seeing only their own widgets. Now matches
  /auth/users behavior: superadmin sees all, admin sees own + public
  + widgets owned by members of teams they own, user sees own + public.

7 new i18n keys (admin.reset_password, admin.prompt_reset_password,
admin.toast.password_min_8, admin.toast.password_reset, and the
matching settings.user.* / settings.toast.* trio). 1024 keys total,
parity 100% across en/es/fr/de/pt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:45:25 -05:00
ScreenTinker aebaacf2c1 i18n batch 7: index.html modal + player overlay
- Add-Display modal in index.html: marked translatable elements with
  data-i18n / data-i18n-placeholder / data-i18n-html attributes
- app.js: translateStaticDom() walks data-i18n* on init and on every
  language-changed event so static HTML stays in sync
- server/player/index.html: standalone player gets its own inline
  PLAYER_I18N table (en/es/fr/de/pt) with a tiny _t() helper. Reads
  rd_lang from localStorage (set by dashboard) so the player picks up
  the same language. Translates info overlay, setup screen, and
  status messages.
- 1018 keys total in dashboard locales, parity 100%.

This completes the wiring; Android resources are next.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:19:06 -05:00
ScreenTinker 25ab1c485b SEO: add meta tags, sitemap, robots.txt, comparison pages, guides, internal linking
Landing page (frontend/landing.html):
- Title now includes "Self-Hosted" for that keyword
- Description appended "MIT licensed."
- Keywords aligned to spec (digital signage raspberry pi, digital
  signage android tv, video wall software, kiosk software, etc.)
- SoftwareApplication JSON-LD: added applicationSubCategory
  "DigitalSignage", license URL, refreshed description
- Image alt text + og:image:alt + twitter:image:alt now include
  "open-source digital signage"
- New Resources section above the CTA with 6 cards linking to all
  new guides and comparison pages
- Footer rewritten as a 5-column grid (Brand / Guides / Compare /
  Project / Legal) with the new internal links

New SEO pages, all dark-themed, mobile-responsive, ASCII-only:
- frontend/css/seo-page.css (shared nav/footer/article/table styles)
- frontend/compare/yodeck-alternative.html
- frontend/compare/screencloud-alternative.html
- frontend/compare/optisigns-alternative.html
- frontend/guides/raspberry-pi-digital-signage.html
- frontend/guides/digital-signage-android-tv.html
- frontend/guides/self-hosted-digital-signage.html

Each new page has unique title/description/canonical, OG and Twitter
card tags, BreadcrumbList JSON-LD, single h1, proper h2/h3 nesting,
visible breadcrumb, comparison table or step-by-step ordered list,
"Related guides" cross-link block, and a CTA.

Sitemap (frontend/sitemap.xml): added all 6 new URLs with appropriate
priority (0.8 for compare pages, 0.9 for guides). Existing landing
(1.0) and legal pages preserved.

Robots (frontend/robots.txt): allow /compare/ and /guides/, disallow
/player (was previously allowed by mistake).

Server (server/server.js): added explicit GET /sitemap.xml and
GET /robots.txt routes ahead of the static middleware so the
Content-Type is guaranteed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 20:54:32 -05:00
ScreenTinker b2aa7fab54 Player: keep video playing if unmute is blocked
video.play().catch(() => {}) silently swallowed the rejection from the
browser's autoplay policy, so when a user click triggered the unmute
path the video paused (browser side-effect of unmuting a muted-autoplay
video) and never resumed.

Surface the play() rejection in the log, and fall back to muted playback
if the unmuted play() is blocked. Same for the YT side: explicitly set
volume on unmute. Bumped SW cache to v9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:18:32 -05:00
ScreenTinker a3551a2654 Player: only request fullscreen on real user clicks
The remote-control feature dispatches synthetic click events on the
player when the dashboard forwards touches. The global click handler
called requestFullscreen() on every click, but the browser only honors
that API for trusted user gestures — synthetic events rejected with
"Permissions check failed" / "API can only be initiated by a user
gesture", spamming the console for the duration of any remote session.

Gate the fullscreen request on event.isTrusted. Local user clicks still
trigger fullscreen; remote-control taps no longer try (and fail).
Bumped SW cache to v8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:13:58 -05:00
ScreenTinker a4c85eaabc Remove playerContainer position:relative override that nuked YT iframe
createYoutubeEmbed set container.style.position = 'relative' to anchor
the click-to-unmute overlay. That overrode #playerContainer's
position:fixed/inset:0 — the container fell into normal flow with
zero height (the YT iframe inside has no intrinsic size), so the new
absolute-positioned iframe rendered as 100% of 0 = black screen.

The container is already position:fixed, so absolute children anchor
to it correctly without the override. Removed the line. Bumped SW
cache to v7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:36:39 -05:00
ScreenTinker fb0a7f48dd Force YouTube iframe fullscreen with absolute positioning
The previous CSS fix used 100% width/height but YT.Player can bake in
300x150 fallback pixel dimensions if the placeholder isn't laid out at
construction time. Inline pixel dimensions beat percentage CSS at
equal specificity, so the iframe stayed small.

Use absolute positioning with !important to force fullscreen over
whatever YT set inline. Bumped sw cache to v6 to invalidate the
previously-cached player HTML.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:34:40 -05:00
ScreenTinker ed46011ae4 Pin YouTube iframe to fill the player container
The .zone iframe sizing rule only applies to multi-zone layouts. In
fullscreen single-zone mode the YT IFrame API replaces our placeholder
div with an iframe directly inside #playerContainer, where no CSS rule
sized it — leaving it at the iframe default size (~300x150) and
producing a tiny square in the corner. Added explicit rules so any
iframe child of #playerContainer fills the viewport.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:59:23 -05:00
ScreenTinker fb58256b1c Fix YouTube autoplay block from stale localStorage gesture flag
userHasInteracted was initialized from localStorage('rd_audio_unlocked')
on every page load. Browser autoplay policy is per-document, so a flag
from a prior session does not actually grant autoplay rights — but the
player code used it to decide whether to start the YouTube embed muted
(autoplay-able) or unmuted (blocked). Result: kiosks with the flag set
loaded a YT embed with mute=0 that the browser refused to start.

- userHasInteracted now always starts as false. The cold-load tap
  overlay flips it to true on real gesture; the 5s auto-dismiss leaves
  it false and playback stays muted (still allowed).
- unlockAudio() now also calls activeYtPlayer.unMute() so the muted
  embed unmutes immediately when the user finally taps the overlay.
- Removed the now-unused localStorage writes of rd_audio_unlocked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:56:49 -05:00
ScreenTinker f951d51214 Always show tap overlay on player cold load
Browser autoplay policy is per-document — a previous session's
localStorage flag does not grant the new page autoplay rights. The
'audio previously unlocked, skipping tap overlay' branch was racing
with YouTube's autoplay block, leaving the player stuck on a paused
embed.

Removed the skip-overlay optimization. The existing 5s auto-dismiss
+ muted-connect fallback still handles unattended kiosks, and a real
user only needs to tap once per cold load to get audio.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:54:19 -05:00
ScreenTinker 06ba054898 Fix web player TDZ crash on cached-playlist startup
The cached-playlist restore at the top of the script synchronously calls
playCurrentItem -> renderContent -> createYoutubeEmbed, which references
ytGeneration / activeYtPlayer / ytApiReady / ytApiCallbacks. Those were
declared with `let` further down in the script, so the references hit
the temporal dead zone and threw on every cold start with a YouTube
item in the cached playlist:

  Uncaught ReferenceError: can't access lexical declaration
  'ytGeneration' before initialization

Hoisted the four declarations to the top of the script alongside the
other player state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:51:35 -05:00
ScreenTinker f8cc62308f Fix screenshot fallback query and API 404 hang
Two pre-existing bugs surfaced during deploy:

- /api/devices/:id/screenshot fell back to a query referencing
  screenshots.created_at, but the schema column is captured_at. Threw
  SqliteError 500 whenever the in-memory cache was cold (e.g. just
  after a server restart).

- The SPA catch-all at /* served index.html for non-/api paths but did
  nothing for unmatched /api/ paths — the response hung until the
  upstream timeout (524 from Cloudflare at 15s). Now returns 404 JSON.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:49:10 -05:00
ScreenTinker 8ec33721f7 Security: sanitize notes, add CSP headers, tighten CORS
LOW 1 (notes XSS): device.notes textarea content now goes through
esc(). Notes weren't in the sanitizeBody allow-list at write time, so
HTML in the field would render unescaped on the device-detail page.

LOW 2 (CSP): enabled Helmet contentSecurityPolicy with default-src
'self', script-src 'self', style-src 'self' 'unsafe-inline', plus the
data:/blob:/https: image and media sources the player needs. Strict
script-src blocks <script> injection; script-src-attr 'unsafe-inline'
keeps existing inline onclick handlers working until they can be
refactored to addEventListener (TODO comment in code).

  CSP applies to /app and most other paths. Skipped on the public
  widget and kiosk render endpoints, the landing page, and /player —
  those legitimately need inline scripts/styles. upgrade-insecure-
  requests is explicitly disabled so HTTP-only self-hosted LAN
  deployments aren't broken.

  Refactored two inline onclick handlers in index.html to data-close-
  modal attributes wired by a delegated listener in app.js. Was the
  only blocker for /app under strict script-src.

LOW 3 (CORS): Express CORS now only allows screentinker.com (and
subdomains) + localhost in production. SELF_HOSTED=true bypasses the
allowlist (operator owns their deployment). Development mode stays
open. Same policy applied to the Socket.IO CORS config which was
previously origin: '*'. Native clients (Android, server-to-server,
kiosk iframes) send no Origin and pass through unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:37:31 -05:00
ScreenTinker c105a5941e Security: fix IDORs, XSS, rate limits, SSRF validation
HIGH 1 (teams IDOR): POST/DELETE /api/teams/:id/devices now require the
caller to own the device before assigning or detaching it. Without this
check, any team member could pull any device into their team via UUID
guess and gain remote-control access.

HIGH 2 (schedules IDOR): PUT /api/schedules/:id now re-verifies
ownership of every changed target field — device_id, group_id,
content_id, widget_id, layout_id, playlist_id. Previously only the
schedule owner was checked, letting users fire arbitrary content on
victim devices via update.

HIGH 3 (filename XSS): file.originalname captured by multer bypassed
sanitizeBody. New safeFilename() wraps every INSERT path (multipart
upload, remote URL, YouTube). Frontend sinks now go through esc() in
content-library.js, device-detail.js, video-wall.js. Web player gets
an inline escHtml helper for its info overlay where filenames, device
name, and serverUrl land in innerHTML.

HIGH 4 (kiosk public XSS): config.idleTimeout is now coerced via the
existing safeNumber() helper at both interpolation sites. A crafted
value with a newline can no longer escape the JS line comment to
inject arbitrary code into the public render endpoint.

HIGH 5 (folder DoS): POST /api/folders enforces a per-user cap of 100
folders (429 on overflow). Superadmin exempt.

MED 1 (SSRF): ImageLoader.decodeUrl rejects any URL scheme other than
http(s) so a malicious remote_url can't read local files via file://.
On the server, validateRemoteUrl() is extracted and now also runs on
PUT /api/content/:id remote_url updates — previously the SSRF check
only fired on POST.

MED 2 (fingerprint takeover): the WS device:register fingerprint
reclaim path now rejects takeover while the target device is online or
within 24h of its last heartbeat. A leaked fingerprint can no longer
hijack an active display.

MED 3 (npm audit): bumped uuid 9.x -> 14.0.0 (v3/v5/v6 buffer bounds
CVE; we only use v4 so not exploitable, but clears the audit). path-
to-regexp resolved to 0.1.13 via npm audit fix. 0 vulns remaining.

MED 4 (folder admin consistency): ownedFolder() and the content.js
folder_id move check now both treat only superadmin as privileged,
matching GET /api/folders. Previously a plain "admin" could rename
or delete folders they couldn't see, and could move content into
folders they couldn't list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:37:18 -05:00
ScreenTinker 76a0076b65 Fix UTF-8 encoding for special characters in filenames
multer/busboy decode multipart filename headers as latin1 by default,
which mangled umlauts and other non-ASCII characters end-to-end
(Größe.jpg arrived as Größe.jpg and was stored that way). Setting
defParamCharset: 'utf8' on the multer options makes the entire
upload pipeline consistent UTF-8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:13:41 -05:00
ScreenTinker fcecf805ed Add media folder organization to content library
New content_folders table with hierarchical parent_id and per-user
scoping. content.folder_id added (ON DELETE SET NULL so deleting a
folder drops items back to root). New /api/folders route exposes
list/create/rename/move/delete with cycle detection on move.

Content library UI: breadcrumb navigation, subfolder grid, "+ New
Folder" creates inside the current folder, drag-and-drop content
items onto folder cards to move them, and the edit modal has a
folder dropdown showing each folder's full path.

Per-user scoping is enforced server-side: every folder query
filters by user_id, and folder ownership is checked on both folder
mutations and content.folder_id updates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:13:36 -05:00
ScreenTinker ee6888e737 Fix display duplication on WebSocket reconnect
Server-side: when a device reconnects on a fresh socket while the old
TCP zombie is still around, the old socket's eventual disconnect handler
flipped the device offline and removed the new heartbeat entry. Now we
proactively evict any prior socket on register and ignore disconnects
from sockets that are no longer the registered one for that device_id.

Frontend: dedupe devices by id from the API response and only render
each device in the first group it belongs to (multi-group membership
is still tracked for the Manage modal).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:13:00 -05:00
ScreenTinker 4392bb460a Add DISABLE_REGISTRATION env var to block public sign-ups
When DISABLE_REGISTRATION=true (or 1), POST /api/auth/register returns
403 with a clear error. OAuth endpoints (/google, /microsoft) also
refuse to auto-create new accounts — existing OAuth users can still
sign in. First-user setup (empty users table) is always allowed so a
fresh install can still be initialized.

GET /api/auth/config now returns registration_enabled so the login
view can hide the "Create Account" button and the trial banner when
registration is off. Absence of the flag is treated as enabled for
back-compat with older servers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 19:35:32 -05:00
ScreenTinker 6a0e5a28a9 Fix content file access gate for widget references
Extend the public /api/content/:id/file gate to unlock content referenced
by widgets (previously only playlists unlocked it), so device browsers
and kiosk iframes can fetch logos and background images that widgets
embed.

Security: scope the widget lookup to the content owner's widgets only
(w.user_id = content.user_id). Otherwise a user could unlock another
user's content file by creating their own widget whose config references
the victim's content UUID. The pre-existing playlist gate has the same
shape and is left for a separate fix.

Also adds a 30/min rate limit on POST /api/widgets/preview, which
inlines user content as base64 and is memory-intensive.

Perf note: the widgets.config LIKE scan is O(n). Fine at current scale;
revisit with a content_widget_refs join table if the widget table grows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 22:28:55 -05:00
ScreenTinker 08a83c9ba9 Add directory board widget renderer with scrolling, anti-burn-in, dark/light themes
Lobby-style tenant/room directory with vertical marquee, seamless loop via
content cloning, pixel shift + bg pulse for anti-burn-in, rotating background
images with crossfade. Supports logo, title, footer, subtitles per entry,
and Available (green) state. All user strings rendered via textContent in
browser — no server-side HTML escaping of entries needed.

Also refactors render dispatch into renderWidgetHtml() and adds a POST
/preview endpoint that inlines user-owned image content as base64 data
URIs so the editor can preview unsaved widgets. Preview is gated by:
- image/* MIME only
- 10 MB size cap
- user_id ownership check
- path traversal guard via basename + resolve

Unknown widget_type on /preview returns 400.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 22:28:37 -05:00
ScreenTinker 52297ec618 Settings: add account profile + password change UI
Adds a per-user Account section in Settings with name edit and password
change. Password change requires current password; local auth only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 19:13:20 -05:00
ScreenTinker 772ead28a2 Fix reset-admin.js: honor recovery token in requireAuth
scripts/reset-admin.js signed a JWT with a synthetic id ("recovery-XXX")
and instructed the operator to paste it into localStorage. But the
requireAuth middleware always SELECTs the user row by id, so every
authed API call under the recovery token returned 401 "User not found"
and the recovery flow was effectively dead.

Fix:
- reset-admin.js now sets a `recovery: true` claim on the JWT.
- requireAuth / optionalAuth short-circuit the DB lookup when
  decoded.recovery === true and synthesize a req.user record in
  memory (role: admin, plan_id: enterprise). The synthetic user is
  never persisted, so FK-constrained writes that expect a real
  user (creating devices, etc.) will still fail — which is fine,
  recovery is only meant to let the operator reset a password or
  create a fresh admin via the Settings UI.

Security: a recovery token still requires the jwtSecret to sign,
so only someone with filesystem access to the server can mint one.
Token TTL remains 1h.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 19:08:49 -05:00
ScreenTinker 2d3bb55db4 Fix startup crash on existing DB: defer group_id index to migration
The CREATE INDEX on schedules(group_id) in schema.sql ran before the
phase4 migration added the group_id column, crashing on existing databases.
Move the index creation to the migration which already handles it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 07:59:49 -05:00
ScreenTinker 52dd44a3e8 Add group-level scheduling, group playlist assignment, and persist audio unlock
Phase 4 group scheduling: schema migration adds group_id to schedules with
CHECK constraint, scheduler evaluates group+device schedules with priority,
group deletion converts schedules to per-device copies. Dashboard gets
playlist assignment dropdown and current playlist label on group headers.
Player persists audio unlock state in localStorage so version reloads
don't lose audio on unattended displays.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 20:22:42 -05:00
ScreenTinker 2104c9cc9f Auto-reload web player when server code changes
Player polls /api/version every 30s and reloads if the hash changes.
Server hash now includes player/index.html and sw.js so player code
updates are detected without requiring a hard refresh.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 22:44:47 -05:00
ScreenTinker ad3095cdf5 Fix player video cycling bug and connecting overlay during cached playback
Clear pending advance timers when switching content items to prevent stale
image/widget duration timers from interrupting video playback. Also skip
showing "Connecting..." overlay when cached playlist is already playing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 22:34:48 -05:00
ScreenTinker d73abc809d Simplify service worker: stop intercepting content requests
The SW was causing "unexpected error" on video/image fetches due to
range request handling, opaque response caching, and stale SW races.

Fix: SW now ONLY caches player page + socket.io JS for offline boot.
Content files are left to browser native HTTP cache (server already
sets Cache-Control: public, max-age=2592000, immutable).

Also: auto-reload player when new SW activates so deploys take effect
immediately without manual hard refresh.

Bumped cache to v5 — activate purges all old caches (including the
broken rd-content-v1 content cache).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 22:24:15 -05:00
ScreenTinker b4ac2fb821 Fix broken service worker + device auth rejection on playlist refresh
Bug 1 (SW): Rewrote service worker fetch handler:
- Skip range requests (video seeking) to avoid caching partial responses
- Skip non-GET requests entirely
- Use ignoreSearch on cache match to avoid query-param misses
- Don't cache opaque cross-origin responses
- Outer catch on Cache API failures
- Don't intercept catch-all requests (let browser handle natively)
- Bump cache version to v4 to purge broken cached responses

Bug 2 (auth): Playlist refresh register was missing device_token,
causing auth rejection every 5 minutes. Fixed by including token
in the refresh-register emit. Added diagnostic logging on both
client and server for token validation failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 22:18:08 -05:00
ScreenTinker dc7450b6a7 Offline resilience: persist playlist cache for cold-start recovery
Web player:
- Cache playlist JSON to localStorage on every update
- Restore and start playing immediately on boot before connecting
- Clear cache on unpair/reset

Android app:
- Cache playlist JSON to EncryptedSharedPreferences on every update
- Restore cached playlist on cold-start, play from disk-cached content
- Update cache on content deletion, clear on unpair

Server (device socket):
- Fingerprint reconnect: issue fresh token instead of rejecting
- Send device:paired on fingerprint recovery for claimed devices
- Add status logging and dashboard notification on fingerprint reconnect

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 21:49:45 -05:00
ScreenTinker 470197d203 Fix 8 security findings from Phase 3 audit + device-detail banner refresh
Security fixes:
- Critical: Add ownership checks to assignments PUT/:id and DELETE/:id (IDOR)
- Critical: Add ownership checks to assignments copy-to endpoint for both devices
- High: Validate device ownership when adding to device groups
- High: UUID-validate content ID before LIKE query + scope to owner's playlists
- Low: Handle FK violations gracefully in playlist discard (deleted content/widgets)
- Low: Escape mime_type with esc() in playlist item display (XSS)

Bug fix:
- Device-detail mutation handlers now reload full page to show draft banner

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 21:36:16 -05:00
ScreenTinker f30d8b82cd Unify publish behavior: all edits go to draft, require explicit publish
Remove autoPublish from assignments.js and device-groups.js. All item
mutations (add, update, delete, reorder, copy) now call markDraft
regardless of which UI the edit comes from. Users must explicitly
click Publish to push changes to devices.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 21:22:44 -05:00
ScreenTinker 436a3be7f6 Phase 3: playlist publish/draft state with auto-publish from device detail
Schema: add status and published_snapshot columns to playlists table.
Migration snapshots all existing playlists as published (idempotent via schema_migrations).

Devices always receive the published_snapshot, not live playlist_items.
Edits from device-detail/groups auto-publish immediately (display updates instantly).
Edits from playlist detail page go to draft (requires explicit publish).
POST /playlists/:id/publish snapshots and pushes to all devices.
POST /playlists/:id/discard reverts playlist_items from published snapshot.
Content deletion scrubs references from all published snapshots.

Frontend: draft badge in playlist list, prominent yellow banner with publish/discard
buttons on playlist detail and device detail pages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 20:52:29 -05:00
ScreenTinker 1d253c4cae Android + web player: handle device_token authentication
Follows up on the security audit remediation (afbe113) which added
device_token auth to the WebSocket /device namespace.

Android player (ServerConfig.kt, WebSocketService.kt):
- Persist device_token in EncryptedSharedPreferences alongside device_id
- Send device_token in device:register on reconnect and playlist refresh
- Save/overwrite token from device:registered response (handles legacy
  devices getting their first token)
- Handle device:auth-error by clearing credentials and showing pairing screen
- clearDeviceCredentials() method wipes device_id, device_token, is_paired

Web player (player/index.html):
- Save deviceToken in localStorage config from device:registered response
- Send device_token in register() payload on reconnect
- Handle device:auth-error and device:unpaired events — clear config and
  show re-pair UI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 22:52:52 -05:00
ScreenTinker afbe113acf Security audit remediation: auth, IDOR, XSS, hardening
- Device WebSocket authentication: devices get a device_token on
  registration, must present it on reconnect. All WS events require
  prior auth. Timing-safe token comparison.
- IDOR fixes: ownership checks on schedules (device, week), layouts
  (all CRUD, zones, duplicate, device assign), video-walls (content,
  device-config).
- XSS prevention: shared esc() helper in utils.js, fixed 13 innerHTML
  injection points across 9 frontend files.
- OAuth hardening: no longer silently overwrites auth_provider on
  accounts with local passwords (returns 409).
- JWT pinned to HS256 for sign and verify.
- Password policy: change endpoint now requires 8 chars (was 6).
- HSTS header enabled (max-age 1 year, includeSubDomains).
- Stripe webhook rejects unsigned payloads when no secret configured.
- Screenshot size validation (max 2MB base64).
- Rate limiting on exports, imports, content operations.
- Content file serving checks playlist_items instead of old assignments.
- Content ownership verified in device-groups assign-content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 22:48:07 -05:00
ScreenTinker b87904c326 Add schema_migrations table for run-once migration tracking
New schema_migrations table (id TEXT PK, ran_at INTEGER) tracks which
one-time migrations have executed. The Phase 2 playlist migration now
checks for 'phase2_playlist_migration' in this table instead of
inferring state from devices.playlist_id. Records the migration ID
after successful completion. Eliminates ffprobe overhead on subsequent
startups.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 22:28:10 -05:00
ScreenTinker df7919f84f Migration: add detailed logging for device, item, and video probe counts
Reports devices migrated, total playlist items created, videos probed
via ffprobe, and existing schedules. Helps verify the migration ran
correctly on first startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 22:25:42 -05:00
ScreenTinker 1483500458 Probe video durations during migration and v1 import
Migration (database.js): switched from sync execFileSync to async execFile
with promise wrapper, matching the pattern in playlists.js. Probes each
video content item, backfills content.duration_sec, and uses the real
duration in playlist_items. Falls back to the assignment's original
duration_sec if the probe fails or content isn't a video.

V1 import (status.js): moved assignment-to-playlist conversion out of the
synchronous db.transaction() so async ffprobe can run. Content files are
already on disk from the transaction, so probing works. Same fallback logic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 22:24:56 -05:00
ScreenTinker aee6766c4c Migration: probe video durations with ffprobe during assignment-to-playlist conversion
Videos were getting the default 10s from the assignments table. Now ffprobe
runs for each video content item during migration, backfills the content
table, and uses the real duration in playlist_items.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 22:21:40 -05:00
ScreenTinker 6f01d319f5 Phase 2: playlists API returns display_count, is_auto_generated + assign endpoint
List and detail endpoints now include display_count (devices using this playlist).
New POST /:id/assign endpoint sets a playlist on a device and pushes the update.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 22:09:32 -05:00
ScreenTinker 33a5be39ed Phase 2: export v2 format with playlists, backward-compat v1 import
Export now includes playlists, playlist_items, and device.playlist_id.
Format bumped to screentinker-export-v2. Schedules include playlist_id.
V1 import converts old assignments array into per-device playlists inline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 22:08:44 -05:00
ScreenTinker 19a08ef5bc Phase 2: schedules accept playlist_id, scheduler overrides device playlist
Schedule CRUD now includes playlist_id field. List queries join playlist name.
Scheduler tracks active overrides per device and reverts to original
playlist/layout when no schedule is active.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 22:07:36 -05:00
ScreenTinker f35894d17a Phase 2: device-groups assign-content uses playlists + new assign-playlist endpoint
assign-content now adds content to each device's playlist (auto-creating if needed).
New POST /:id/assign-playlist sets a shared playlist on all group devices.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 22:06:26 -05:00
ScreenTinker a9aaebc08d Phase 2: devices.js returns playlist items instead of assignments
GET /:id now queries playlist_items via device.playlist_id.
DELETE no longer cleans up assignments table (playlist survives device deletion).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 22:05:48 -05:00
ScreenTinker 55c8e354b4 Phase 2: assignments.js operates on device playlists instead of assignments table
All CRUD routes now read/write playlist_items via device.playlist_id.
Auto-creates a playlist for a device on first content add if none exists.
PUT/DELETE push updates to ALL devices sharing the same playlist.
Copy-to-device creates a playlist on the target if needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 22:05:19 -05:00
ScreenTinker 6d4d39c2d8 Phase 2: buildPlaylistPayload reads from playlist_items via device.playlist_id
Replaces the assignments-table query with a playlist_items query keyed on
device.playlist_id. Also eliminates the duplicate payload builder in
scheduler.js — it now calls the shared buildPlaylistPayload.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 22:04:26 -05:00
ScreenTinker c8dffab5ad Phase 2 migration: convert existing assignments to per-device playlists
Runs once at startup. For each device with assignments, creates an
auto-generated playlist (is_auto_generated=1) containing those items
and sets device.playlist_id. Skips if any device already has a playlist.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 22:03:41 -05:00
ScreenTinker 2af3cec8a6 Phase 2 schema: add playlist_id to devices/schedules, is_auto_generated to playlists
Every device will point to exactly one playlist. Schedules can temporarily
override a device's playlist. Auto-generated playlists (from migration) are
flagged so the UI can filter them.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 22:00:56 -05:00
ScreenTinker 19fc38a59e Make ffprobe re-probe async to avoid blocking the event loop
Swap execFileSync to execFile with promise wrapper in
probeAndUpdateDuration(). Wrap the add-item handler in try/catch
for Express 4.x async safety (Express 4 doesn't catch rejected
promises from async handlers).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 21:30:11 -05:00
ScreenTinker 1ad390229b Re-probe video duration with ffprobe when adding to playlist
If a video's duration_sec is NULL in the content table (e.g. ffprobe
wasn't available at upload time), re-probe it when the content is
added to a playlist. Backfills the content table so subsequent adds
skip the probe. Non-video content and probe failures fall back to
the 10s default.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 21:24:41 -05:00
ScreenTinker adb107f228 Use content's native duration for videos added to playlists
When adding a content item to a playlist without an explicit
duration_sec, use the content's own duration (from ffprobe at upload
time) instead of defaulting to 10s. Falls back to 10s for images
or content without a detected duration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 21:23:07 -05:00
ScreenTinker 94f48e76b0 Register playlist routes in server.js
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 21:10:24 -05:00
ScreenTinker e262216c58 Add playlist API routes with full CRUD and item management
Routes: GET/POST /playlists, GET/PUT/DELETE /playlists/:id,
GET/POST /playlists/:id/items, PUT/DELETE /playlists/:id/items/:itemId,
POST /playlists/:id/items/reorder.

Follows device-groups.js patterns: ownership middleware, parameterized
queries, content/widget ownership validation, input validation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 21:09:58 -05:00
ScreenTinker 1fbeccff7c Add playlists and playlist_items tables to schema
Phase 1 of playlist refactor: standalone playlist entities with ordered
items. No changes to existing tables or display behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 21:09:12 -05:00
ScreenTinker f57fc5ad81 Security hardening: auth checks, XSS escaping, input validation
- Add requireGroupOwnership middleware to all group endpoints
- Whitelist allowed command types (screen_on/off, launch, update, reboot, shutdown)
- Validate color format as #RRGGBB
- Escape all user-controlled strings (device/group names, emails) in dashboard HTML
- Restrict trust proxy to first hop only (prevents IP spoofing + rate limit bypass)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 22:09:40 -05:00
ScreenTinker faa437881f Add device groups UI, group commands, proxy IP fix, and web player detection
- Dashboard now organizes devices by group with colored section headers
- Group command endpoint (POST /groups/:id/command) sends to all members
- Manage modal with multi-group confirmation prompt
- Destructive commands (reboot/shutdown) require confirmation
- Ungrouped devices shown separately at bottom
- trust proxy + X-Forwarded-For for real client IPs behind Nginx
- Hide Android-only telemetry (battery/storage/RAM/CPU/WiFi) for web players

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 22:03:44 -05:00
ScreenTinker e7081a579c Fix widget assignments, designer scaling, and cache strategy
- Make assignments.content_id nullable so widgets can be assigned to playlists
- Fix designer publish to use vw units matching preview (was hardcoded px)
- Add px-to-vw conversion in text widget renderer for backward compat
- Fix webpage widget zoom scaling
- Add widget rendering support in fullscreen player mode
- Set no-cache headers on JS/CSS/HTML for instant updates (ETag/304)
- Set 30-day cache on media files and uploaded content for Cloudflare

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 16:25:05 -05:00
ScreenTinker e2879fff58 Instant playlist push, fix YouTube looping, auto-fetch video titles
- Push playlist updates to devices instantly via WebSocket on all
  assignment mutations (add, update, delete, reorder, copy)
- Fix YouTube videos skipping early: remove duration_sec timeout (was
  defaulting to 10s), use generation counter to ignore stale player
  callbacks, disable YouTube loop param for multi-item playlists
- Auto-fetch YouTube video title via oEmbed API when no name provided
- Show actual video duration in M:SS format in playlist instead of
  misleading assignment duration_sec
- Pre-fill server URL from origin on web player setup
- Bump playlist poll interval to 5min (fallback only, push is primary)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 15:42:41 -05:00
ScreenTinker 8a84923d72 Fix YouTube playback: use IFrame API, fix playlist change detection, network-first caching
- Replace raw iframe YouTube embeds with official YT IFrame Player API for proper
  error handling (150/153/100/101) and unmute support
- Fix playlist not updating when single item changes by comparing full content
  fingerprint (id + url + filepath + filename) instead of just content_id
- Add click-to-unmute overlay for YouTube since iframe swallows click events
- Remove hardcoded origin param from server-side YouTube URLs (caused Error 153
  when player domain differs from server)
- Switch service worker to network-first for player assets so deploys take effect
  without hard refresh; keep cache-first for uploaded content

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 14:56:49 -05:00
ScreenTinker af371b9d89 Fix YouTube embed error 153 - add mute, origin, and enablejsapi params
- Add mute=1, enablejsapi=1, and origin params to YouTube embed URLs
- Fix applies at creation time (content route) and playback time (player)
- Existing YouTube content gets fixed params via fixYoutubeUrl() helper
- Also fixes content library preview iframe

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 14:25:44 -05:00
ScreenTinker 4ae7533b85 Hide unclaimed devices from dashboard, add unassigned API, add upgrade docs
- Filter out devices with no user_id from the main device listing
- Add GET /api/devices/unassigned endpoint (admin only) for unclaimed devices
- Add "Updating" section to README with upgrade instructions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 13:13:46 -05:00
ScreenTinker d18045a386 Fix web player single-video loop and service worker cache errors
- Loop single-video playlists natively instead of destroying/recreating the element
- Skip caching HTTP 206 partial responses in service worker (video range requests)
- Bump service worker cache version to invalidate old cache

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 12:58:43 -05:00
ScreenTinker 1594a9d4a4 Initial open source release
ScreenTinker - open source digital signage management software.
MIT License, all features included, no license gates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 12:14:53 -05:00