Commit graph

117 commits

Author SHA1 Message Date
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 dec56506f9 i18n: add Android localized string resources
Adds values-{es,fr,de,pt,hi}/strings.xml mirroring values/strings.xml.
Two strings: app_name (kept as RemoteDisplay across all locales) and
the accessibility service description (translated).

Hindi is a copy of English by design — same approach as the web's
empty hi.js. Native review can replace the en text in place once
done; Android picks the right file based on device language.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:20:14 -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 6d6f901ef4 i18n batch 6: wire teams + activity + help (~62 keys)
- teams.js: list, detail with members + shared devices, invite/role
  controls, all toasts
- activity.js: page chrome, action verb/noun mapping translated through
  t() so the audit log reads naturally in each language
- help.js: page chrome translated; guides and FAQ body content kept
  in English with a comment explaining why (machine-translated docs
  read worse than English source)
- 1008 keys total, parity 100% across en/es/fr/de/pt

All 16 dashboard views now use t(). index.html modal, player overlay,
and Android resources still pending.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:16:21 -05:00
ScreenTinker 7a17bb5079 i18n batch 5: wire layout-editor + video-wall + billing (~85 keys)
- layout-editor.js: list with templates + custom, zone editor with
  drag/resize and properties panel
- video-wall.js: list with grid preview, editor with grid config,
  bezel inputs, drag-and-drop device placement
- billing.js: current plan card, plans grid with checkout buttons,
  Stripe portal integration
- 943 keys total, parity 100% across en/es/fr/de/pt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:13:38 -05:00
ScreenTinker f4a81d7be2 i18n batch 4: wire schedule + reports + kiosk (~95 keys)
- schedule.js: weekly calendar, add/edit modal with target/recurrence,
  hour labels, day-of-week headers
- reports.js: filters, summary cards, top-content + by-device tables,
  daily/hourly charts
- kiosk.js: list + editor, page settings, style controls, button list
  with action types
- 838 keys total, parity 100% across en/es/fr/de/pt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:09:32 -05:00
ScreenTinker 457a2e4dd4 i18n batch 3b: wire onboarding.js + admin.js (~84 keys)
- Onboarding: 5-step wizard (welcome, get player, pair, upload, done)
  with translated step titles, content, prompts, error messages
- Admin: superadmin user table, plans, system info, role/plan
  selectors, delete confirms
- 750 keys total, parity 100% across en/es/fr/de/pt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:04:23 -05:00
ScreenTinker 04891bccee i18n batch 3a: wire playlists.js (~65 keys)
- List view: tags, item/display pluralization, empty state, load errors
- Detail view: draft banner, inline rename/description, items list
- Drag-reorder + up/down buttons, duration editor
- Add-item modal with content/widgets tabs and search
- 671 keys total, parity 100% across en/es/fr/de/pt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:00:52 -05:00
ScreenTinker 103803fb92 i18n batch 2b: wire designer.js (~80 keys)
- All 12 element types (text, heading, image, video, clock, date,
  weather, ticker, shape, qr, countdown, webpage)
- Background swatches, properties panel, layers list
- Translated prompts for video/weather/RSS/QR/countdown/webpage URLs
- Toasts for publish, export, load, invalid file
- 612 keys total, parity 100% across en/es/fr/de/pt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 19:57:12 -05:00
ScreenTinker 0743901e48 i18n batch 2a: wire widgets.js (~107 keys)
- All widget types (clock/weather/rss/text/webpage/social/directory-board)
  with localized names + descriptions
- Full Directory Board editor (categories, entries, logo, backgrounds)
- Content picker overlay
- Confirms, toasts, empty states
- 532 keys total, 100% parity across en/es/fr/de/pt

Designer.js follows in batch 2b.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 19:52:31 -05:00
ScreenTinker eccf4b7af1 i18n batch 1/6: wire device-detail + settings (~242 keys)
- device-detail.js: tabs, draft banner, layout selector, info cards,
  uptime timeline, controls, remote tab, playlist items, copy/assign
  modals, all toasts and confirms
- settings.js: account, change password, license, user management,
  white-label, server info, setup guide, your data export/import,
  language selector, about
- es/fr/de/pt all at 425/425 key parity; hi skeleton untouched
- Native review still recommended before publicizing as fully supported

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 19:47:17 -05:00
ScreenTinker 8e7a093150 i18n: extract all strings, add 6 language translations, restructure i18n module
Session 1 of 2 of the i18n rollout.

- Split i18n module into per-language files under frontend/js/i18n/ so a
  translator can edit one language without touching the others.
- Add Portuguese (pt) and seed Hindi (hi). Hindi is intentionally a skeleton
  -- 0 keys, full English fallback -- because we have an active Indian user
  and would rather ship "no Hindi" than ship machine-quality Hindi that
  could read as unprofessional or get formality/gender register wrong.
- 183 keys, 100% parity across en/es/fr/de/pt; native review still
  recommended before publicizing as "fully supported".
- Add t(key, vars) variable substitution and tn(keyBase, n, vars) plural
  helper for _one/_other key pairs.
- setLanguage() now triggers a CustomEvent + HashChangeEvent so the
  existing hash router naturally re-renders the current view, plus a
  subscriber pattern for nav labels rendered once outside the router.
- Wire t() into 3 high-traffic views end-to-end: dashboard, login,
  content-library. Sidebar nav labels in app.js update on language change.
- The remaining 16 views still ship with hardcoded English; they will be
  wired in session 2. The t() lookup is robust against unwired views, so
  the dashboard works in 5 languages while clicking into e.g. Schedule
  still shows English. No regressions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 19:25:22 -05:00
ScreenTinker a2c8ab4336 Match YouTube oEmbed embed format (revert nocookie, add referrerpolicy) to fix Error 153 2026-04-29 11:32:57 -05:00
ScreenTinker a273e5b2b6 Switch YouTube embed to youtube-nocookie.com to avoid Error 153 from tracking blockers 2026-04-29 11:28:49 -05:00
ScreenTinker 8bfb4584a1 Ignore local video/ directory 2026-04-29 11:26:24 -05:00
ScreenTinker a27738120a Add YouTube video embed to landing page 2026-04-29 11:25:29 -05:00
ScreenTinker 19b62fdc1b Fix landing-page comparison: ScreenTinker 15-device price is \$1,188 not \$989
The Pro plan is \$99/mo flat, so 15 devices for a year = \$1,188. The
landing page's compare table mistakenly showed \$989, which would imply
\$82.42/mo and contradicts every other place the price is quoted (the
comparison pages, the demo video, the pricing cards).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 23:23:58 -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 63dcc2b656 Drag-and-drop devices into groups on the dashboard
Device cards are now draggable. Group sections accept drops to add
membership (mirroring the Manage modal — same confirmation if the
device is already in another group). The Ungrouped section also
accepts drops to remove the device from every group it's in.

The existing Manage modal still works for bulk add/remove and for
finding devices not currently visible. Click-to-open on a card still
works; drag is only triggered on actual mouse movement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:54:33 -05:00
ScreenTinker 9b26b4930b Make breadcrumb a drop target for moving content out of folders
Once inside a folder, the only drop targets shown were that folder's
own subfolders — no way to drag a file back up to root or to a parent
without opening the edit modal. Breadcrumb segments now accept content
drops: drop on 'All Content' to move to root, or onto a parent folder
name to move there. The edit modal still works for cross-branch moves.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:51:02 -05:00
ScreenTinker 66a137cffe Android: bump to 1.7.8 + fix safeOn return type
Released APK 1.7.8 includes the OOM/crash-loop fix, WebSocket crash
hardening, and the http(s)-only ImageLoader scheme guard. Bumped
versionCode 10 -> 11 and versionName 1.7.7 -> 1.7.8 so existing
1.7.7 installs auto-update on the next UpdateChecker poll.

Also fixed the safeOn extension function: Socket.on() returns Emitter,
not Socket, so the original `return on(...)` failed compile with a
type mismatch. Switched to `on(...); return this` for proper chaining.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:45:18 -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 8866e305f0 Fix Android app crash on WebSocket connection loss
Every Socket.IO listener now goes through a safeOn helper that wraps
the body in try/catch(Throwable). Unsafe args[0] as JSONObject and
data.getString() patterns replaced with firstOrNull as? JSONObject
and optString — a malformed payload from the server, or a transient
state error during disconnect, no longer surfaces as an unhandled
exception on the IO thread.

Reconnection now uses explicit exponential backoff with jitter
(1s → 60s, randomizationFactor 0.5) so a fleet doesn't reconnect in
lockstep after a server blip. EVENT_DISCONNECT stops the heartbeat
while disconnected; the player keeps showing cached content. register,
sendHeartbeat, requestPlaylistRefresh, sendScreenshot, sendContentAck,
sendPlaybackState, and disconnect are all wrapped — telemetry / WiFi
service calls can throw on some devices.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:13:26 -05:00
ScreenTinker cd6e39a4a7 Fix Android app OOM crash on 4K images and crash loop recovery
A 4K image assigned to a 1080p display decoded as a ~33 MB ARGB_8888
bitmap and OOM'd. Worse, the cached playlist on disk meant relaunch
hit the same image and crashed again — only a reinstall recovered.

New ImageLoader utility reads bounds via inJustDecodeBounds, computes
inSampleSize against the device screen (or zone size for multi-zone
layouts), and returns null on OOM/Throwable so callers skip the item
instead of crashing. MediaPlayerManager exposes an onImageError
callback wired to playlistController.next() so a bad item advances
the playlist. The cached-playlist restore in onCreate now catches
Throwable (was Exception) and clears the cache on any failure,
breaking the crash loop. android:largeHeap="true" added as belt and
braces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:13:10 -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 05f70b7910 Update ToS: add CSAM policy, fix MIT license conflict, add governing law
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:26:32 -05:00
ScreenTinker c2b1bb20ae Fix stale setup.sh references in Pi installer
Curl-pipe URLs, --help output, clone-and-run path, and the root-check
error message all referenced pi-setup.sh / setup.sh / screentinker/pi,
none of which exist. Point them all at the actual filename and path:
scripts/raspberry-pi-setup.sh.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 18:28:26 -05:00
ScreenTinker 261f74e1e4 Rewrite Pi setup script as all-in-one installer
Turns the Raspberry Pi script from a basic Chromium kiosk launcher
into a full installer with two modes:

- All-in-One: installs Node.js, clones the repo, runs the server
  on port 3001, and launches the kiosk pointing at localhost. One
  Pi does everything.
- Player-Only: connects to an existing server; same kiosk behavior
  as before but with better Chromium flags and crash-flag cleanup.

Other changes:
- Detects Pi OS Lite vs Desktop and adjusts strategy (startx + vt1
  for Lite, plain kiosk launcher for Desktop)
- Auto-login on tty1 for Lite installs
- GPU memory, overscan, console-blanking, and watchdog tweaks
- screentinker-{status,update,logs} management commands
- MOTD with command hints
- Cleans up the legacy remotedisplay.service / kiosk script on
  upgrade so old installs migrate cleanly
- set -euo pipefail, root check, architecture check, tee'd log at
  /var/log/screentinker-setup.log

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 18:26:49 -05:00
ScreenTinker 846d61a1b0 Add Discord link and refresh feature copy
- README + landing page footer now link to the community Discord
- Landing page feature grid gains Playlists, Directory Board,
  Offline Resilience, and Mobile Dashboard cards; Scheduling and
  Self-Hosted copy updated to mention group-level schedules and
  the DISABLE_REGISTRATION env var
- Structured data featureList expanded to match; Organization
  sameAs now includes Discord
- README feature list clarifies scheduling precedence, mobile
  responsiveness scope, and the auth/IDOR/XSS audit work

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 17:47:00 -05:00
ScreenTinker 2959eaa149 Refresh cached user so admin plan/role changes propagate
The JWT only carries { id, email, role } and the server reads plan_id
fresh from the DB per request, but the frontend cached the user object
in localStorage at login and never refreshed it. After an admin changed
a user's plan, the dashboard kept rendering the old plan until the
user logged out and back in.

Added api.getMe() and a refreshCurrentUser() helper that runs at
startup and on every hashchange. Settings page now fetches the user
fresh via api.getMe() on render, with localStorage as fallback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 19:38:46 -05:00
ScreenTinker 281a735e84 Fix white-label settings not applying on page load
Root cause: the Settings page loaded /api/white-label into the form
inputs but never applied the saved values (primary_color, bg_color,
brand_name, favicon, custom_css) to the actual document. Nothing in
app.js bootstrap touched branding. So the save hit the DB correctly,
reload kept the DB value correctly, but the page always rendered the
hardcoded defaults from css/variables.css and the static "ScreenTinker"
label in index.html — which looked like the save had reverted.

Fix: new frontend/js/branding.js module that fetches /api/white-label
once at startup (app.js) and applies values to:
  - --accent and --bg-primary CSS vars
  - document.title and the .sidebar-header .logo span text
  - all <link rel="icon">/apple-touch-icon hrefs
  - a <style id="wl-custom-css"> tag for custom_css
  - the theme-color meta tag

Settings save now calls resetBranding() after POST so changes apply
immediately without a reload.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 19:36:20 -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 ea86d70475 README: update feature list to reflect current capabilities
- Playlists: draft/publish workflow with revert
- Device groups: group playlist assignment and group scheduling
- Scheduling: priority-based conflict resolution, group-level schedules,
  device-level overrides
- Widgets: replace "Content designer" line; list all widget types
  including Directory Board
- Offline resilience: Android ContentCache + web player service worker
- Mobile-responsive dashboard
- Account management: password change, profile, email reset
- Security: JWT, rate limiting, ownership checks, XSS/IDOR audits

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 22:39:07 -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 4e4664b603 Add directory board editor UI with content picker, category/entry management
Inline editor with:
- Collapsible categories, reorder up/down, delete
- Entries with identifier, name, subtitle, available toggle
- Add/remove with auto-focus on new row
- Empty state prompts first category
- Theme, scroll speed, column count selectors
- Reusable content picker (single/multi-select) against user's image library
- Logo picker + background image picker (multi) via that picker
- Preview button posts unsaved config to /widgets/preview and shows the
  returned HTML in a modal iframe (srcdoc + injected <base> so relative
  content URLs resolve against our origin)
- Delete confirms with widget name

Also escapes w.name / typeMeta.name / w.id in the widget grid to prevent
stored XSS against admins viewing other users' widgets.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 22:28:47 -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 a981171c94 SEO: open-source positioning, GitHub links, OG image, semantic <main>
- Retarget primary keywords ("open-source", "CMS") in title, description,
  OG/Twitter tags and hero h1
- Swap OG/Twitter image from icon-512 to dashboard-preview.png with
  width/height/alt metadata
- Add GitHub link in nav (icon), hero (secondary btn), footer, and a
  new Open Source callout section
- Wrap content in <main> landmark; add width/height on screenshot for
  CLS; add third-party license page to sitemap; Organization schema
  sameAs now points to the GitHub repo

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 19:56:22 -05:00