Short-lived per-device queue covers the TV-flap window (issue #3):
when a device is mid-reconnect, prior code emitted to an empty room
and the event vanished. Now playlist-updates and commands targeting
an offline device are queued and flushed in order on the next
device:register for that device_id.
server/lib/command-queue.js (new):
- pendingPlaylistUpdate: per-device marker (rebuild via builder on
flush -> always fresh DB state, no stale snapshots)
- pendingCommands: per-device Map<type, payload> with last-of-type
dedup (most recent screen_off wins)
- TTL via COMMAND_QUEUE_TTL_MS env (default 30000)
- Active sweep every 30s prunes expired entries
Memory bounds: ~6 entries per device worst case (1 playlist marker
+ 5 command types), unref'd sweep timer.
Wired emit sites (8 total; the four direct socket.emit calls in
deviceSocket register handlers are intentionally NOT queued because
the socket is alive by definition at those points):
- server/routes/video-walls.js (pushWallPayloadToDevice)
- server/routes/device-groups.js (pushPlaylistToDevice)
- server/routes/content.js (content-delete fan-out)
- server/routes/playlists.js (pushToDevices + assign)
- server/services/scheduler.js (scheduled rotations)
- server/ws/deviceSocket.js x2 (wall leader reclaim/reassign)
server/ws/deviceSocket.js register paths now call flushQueue after
heartbeat.registerConnection + socket.join. Existing
socket.emit('device:playlist-update', ...) lines kept - they send
the initial state on register; the flush replays any queued events.
Player's handlePlaylistUpdate fingerprint check dedupes the
overlap.
Refs #3
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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.
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.
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
- 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>
- 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>
- 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>
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>