diff --git a/brightsign/README.md b/brightsign/README.md index 1ab28c7..5217ffc 100644 --- a/brightsign/README.md +++ b/brightsign/README.md @@ -214,6 +214,69 @@ Still Android-only, and correctly inert here: the Tier-2 device-owner commands ( `install_apk`, `shell`, `block_uninstall`, …) and `set_brightness` / `set_screen_timeout`, which have no BrightSign equivalent — a signage player has no per-window brightness or screen timeout. +## Offline playback + +Content bytes are cached by the service worker (`server/player/sw.js`) into a dedicated +`rd-content-v1` cache, so a player that loses its server keeps playing its playlist. + +This used to be left to the browser's HTTP cache — the server sends +`Cache-Control: public, max-age=2592000, immutable`. That is fine on a desktop and is **not a +documented-persistent store here**: BrightSign guarantees survival across reloads, app restarts and +reboots for **IndexedDB, localStorage and SQLite**, and their own answer for offline video is to +cache the bytes explicitly. A panel could come back from a power cut with its playlist intact (that +lives in `localStorage`) and no media to play. + +The reason content was skipped originally is real, and `server/lib/player-cache-policy.js` is what +makes intercepting it safe. Video elements issue **range requests** when they seek, and naive +caching breaks playback in two ways that are worse than not caching at all: + +- storing a `206` as if it were the whole file — every later full request gets a fragment, and it + stays broken until eviction +- answering a range request with a `200` — some media stacks treat the mismatch as fatal and the + video never starts + +So only complete `200`s are ever stored, and a range request is served by slicing the stored body +into a correct `206`. The content cache is deliberately **not** dropped when the shell is +re-versioned, or every deploy would re-download the whole playlist over a link that may be exactly +what is broken. + +## Self-update + +The player can replace its own host package. This is the most dangerous thing it does: a truncated +or half-applied `autorun.brs` is a dark panel and a site visit, because there is no app underneath. + +The safety is the **ordering**, and every step earns its place: + +1. Download to `autorun.zip.part` — never straight to `autorun.zip`. A file still downloading must + never be a candidate for extraction. +2. Verify **sha256 and size** before promoting. A captive portal answering with a login page + produces a perfectly well-formed small file; the size floor catches that, the hash catches the + rest. sha256 specifically, because that is what BrightScript's `roMessageDigest` can compute — + a checksum the player cannot verify is an unverifiable package. +3. Promote: delete the `.done` marker **first**, then rename `.part` → `autorun.zip`, then reboot. + Marker first is not stylistic — leaving it makes the next boot skip the new archive and the + update silently never happens. +4. A failed extract renames the archive to `.bad` rather than retrying. A zip that cannot be + unpacked will not unpack on the tenth attempt, and retrying every boot is a loop that looks + exactly like a hardware fault. + +**The decision is the server's**, in `server/lib/brightsign-update.js` — unit-tested, and the same +place the prerelease rule lives. The host only executes what it is told; re-implementing the version +comparison in BrightScript would put the prerelease trap somewhere it cannot be tested. + +**The version is baked into `autorun.brs`**, stamped at build time by both +`scripts/build-autorun-zip.sh` and `server/lib/brightsign-package.js`, anchored on the +`ST_PACKAGE_VERSION` marker. A version record that can disagree with the code actually running is +the OTA-loop condition by the back door: apply, still report the old version, get offered the same +package forever. + +**The manifest and the download come from one buffer**, hashed once. Advertising a version whose +checksum does not match the bytes served is the same loop from the front door. + +Config: `self_update` (default **on** — a fleet that cannot be updated remotely needs a van) and +`allow_prerelease` (default off, mirroring the Android beta channel; an opted-in player also +*holds* a prerelease of its own core rather than being pulled back to the release). + ## What is NOT done yet Stated plainly so nobody reads this as finished: diff --git a/brightsign/autorun.brs b/brightsign/autorun.brs index 4880147..abbb232 100644 --- a/brightsign/autorun.brs +++ b/brightsign/autorun.brs @@ -46,6 +46,13 @@ Function LoadConfig() As Object sync_backend: "auto" ' auto | screentinker | brightsign output_mode: "single" ' single | dual | clone inspector: false + ' Self-update of the host package. Defaults ON: a fleet that cannot be updated remotely is + ' a fleet that needs a van. The DECISION is still the server's, and it refuses anything it + ' cannot verify, so "on" does not mean "will apply whatever it is handed". + self_update: true + ' Mirrors the Android beta channel. Off by default; an opted-in player also HOLDS a + ' prerelease of its own core instead of being pulled back to the release. + allow_prerelease: false } ' 1) registry @@ -54,6 +61,8 @@ Function LoadConfig() As Object if reg.Exists("device_id") then cfg.device_id = reg.Read("device_id") if reg.Exists("sync_backend") then cfg.sync_backend = reg.Read("sync_backend") if reg.Exists("output_mode") then cfg.output_mode = reg.Read("output_mode") + if reg.Exists("self_update") then cfg.self_update = (reg.Read("self_update") = "1") + if reg.Exists("allow_prerelease") then cfg.allow_prerelease = (reg.Read("allow_prerelease") = "1") ' 2) a JSON file on the card wins — that is how a batch gets imaged without touching each box ba = CreateObject("roByteArray") @@ -65,6 +74,8 @@ Function LoadConfig() As Object if json.sync_backend <> invalid then cfg.sync_backend = json.sync_backend if json.output_mode <> invalid then cfg.output_mode = json.output_mode if json.inspector <> invalid then cfg.inspector = json.inspector + if json.self_update <> invalid then cfg.self_update = json.self_update + if json.allow_prerelease <> invalid then cfg.allow_prerelease = json.allow_prerelease end if end if @@ -140,6 +151,199 @@ Function FullScreenRect() As Object return CreateObject("roRectangle", 0, 0, vm.GetResX(), vm.GetResY()) End Function +'=== self-update ============================================================================ +' +' The package (autorun.zip) can replace THIS SCRIPT. That makes it the most dangerous thing the +' player does: a truncated or half-applied autorun.brs is a dark panel and a site visit, because +' there is no app underneath to fall back to. +' +' The ordering below is the safety, and it is deliberate at every step: +' +' 1. Download to autorun.zip.part — never straight to autorun.zip. A file that is still +' downloading, or that stopped halfway, must never be a candidate for extraction. +' 2. Verify sha256 AND size before promoting. A captive portal that answers with a login page +' produces a perfectly well-formed small file; the size floor catches it, the hash catches +' everything else. +' 3. Only then promote: delete the .done marker, rename .part -> autorun.zip, reboot. +' The marker MUST go first — leaving it would make ApplyPendingPackage skip the new archive +' on the next boot and the update would silently never happen. +' 4. Extraction failure renames the archive to .bad rather than retrying forever. A zip that +' cannot be unpacked will not unpack on the tenth attempt either, and retrying it on every +' boot is a loop that looks exactly like a hardware fault. +' +' THE VERSION IS BAKED IN, not stored in a side file. A version record that can disagree with the +' code actually running is the OTA-loop condition in another guise: the player applies an update, +' reports the old version, is offered it again, forever. Stamped at build time by both +' scripts/build-autorun-zip.sh and server/lib/brightsign-package.js. + +Function PackageVersion() As String + return "0.0.0-dev" ' ST_PACKAGE_VERSION (stamped at build time — do not edit by hand) +End Function + +Function DoesFileExist(filePath$ As String) As Boolean + files = MatchFiles(filePath$, filePath$) + return files.Count() > 0 +End Function + +' Unpack a package that is sitting on storage waiting to be applied. Runs BEFORE the widget so a +' pending update lands before the player starts, not halfway through a playlist. +' +' Note this duplicates autozip.brs on purpose. autozip.brs handles the FIRST install, where a bare +' card holds nothing but autorun.zip and the OS processes it. Once autorun.brs exists at the +' storage root the OS no longer auto-processes the archive — so from then on the host has to do it +' itself, or self-update would work exactly once. +Sub ApplyPendingPackage(root As String) + zipPath$ = root + "/autorun.zip" + donePath$ = root + "/autorun.zip.done" + badPath$ = root + "/autorun.zip.bad" + + if not DoesFileExist(zipPath$) then return + if DoesFileExist(donePath$) then return ' already unpacked; extracting again is the boot loop + + print "[st-update] unpacking pending package" + + unzip = CreateObject("roUnzip", zipPath$) + if unzip = invalid then + print "[st-update] ERROR: archive unreadable — parking it as .bad" + fs = CreateObject("roFileSystem") + if fs <> invalid then fs.Rename(zipPath$, badPath$) + return + end if + + if unzip.DecompressAllFiles(root + "/") <> 0 then + print "[st-update] ERROR: extract failed — parking it as .bad so we do not retry forever" + fs = CreateObject("roFileSystem") + if fs <> invalid then fs.Rename(zipPath$, badPath$) + return + end if + + fs = CreateObject("roFileSystem") + if fs = invalid then return + if not fs.Rename(zipPath$, donePath$) then + ' Refusing to reboot without the marker: we would extract and reboot forever. + print "[st-update] ERROR: could not mark done — not rebooting" + return + end if + + print "[st-update] package applied — rebooting into it" + sleep(2000) + RebootSystem() +End Sub + +' Ask the server what to do, and do exactly that. The DECISION lives on the server +' (server/lib/brightsign-update.js, which is unit-tested); this only executes it. Re-implementing +' the version comparison here would put the prerelease trap somewhere it cannot be tested. +Sub CheckPackageUpdate(cfg As Object, root As String) + if cfg.server_url = "" then return + + partPath$ = root + "/autorun.zip.part" + reg = CreateObject("roRegistrySection", "screentinker") + attempts% = 0 + if reg.Exists("pkg_attempts") then attempts% = Val(reg.Read("pkg_attempts")) + + url$ = cfg.server_url + "/api/brightsign/package?version=" + PackageVersion() + url$ = url$ + "&attempts=" + Stri(attempts%).Trim() + if cfg.allow_prerelease then url$ = url$ + "&allow_prerelease=1" + + xfer = CreateObject("roUrlTransfer") + if xfer = invalid then return + xfer.SetUrl(url$) + xfer.EnablePeerVerification(true) + body$ = xfer.GetToString() + if body$ = "" then return ' unreachable: keep running what works + + manifest = ParseJson(body$) + if manifest = invalid then return + if manifest.action = invalid then return + if manifest.action <> "download" then + if manifest.reason <> invalid then print "[st-update] no action: "; manifest.reason + return + end if + + print "[st-update] downloading package "; manifest.version + + ' Any earlier partial is deleted first: resuming into an existing file would concatenate two + ' downloads into something that hashes to neither. + fs = CreateObject("roFileSystem") + if fs <> invalid and DoesFileExist(partPath$) then fs.Delete(partPath$) + + dl = CreateObject("roUrlTransfer") + if dl = invalid then return + dl.SetUrl(cfg.server_url + manifest.url) + dl.EnablePeerVerification(true) + if dl.GetToFile(partPath$) <> 200 then + print "[st-update] download failed" + RecordPackageAttempt(reg, attempts% + 1) + if fs <> invalid then fs.Delete(partPath$) + return + end if + + ' Verify before promoting. This is the gate that stops a truncated file becoming the boot script. + if not VerifyPackage(partPath$, manifest.sha256, manifest.size) then + print "[st-update] VERIFICATION FAILED — discarding, staying on "; PackageVersion() + RecordPackageAttempt(reg, attempts% + 1) + if fs <> invalid then fs.Delete(partPath$) + return + end if + + ' Promote. Marker first — see the ordering note above. + if fs = invalid then return + if DoesFileExist(root + "/autorun.zip.done") then fs.Delete(root + "/autorun.zip.done") + if DoesFileExist(root + "/autorun.zip") then fs.Delete(root + "/autorun.zip") + if not fs.Rename(partPath$, root + "/autorun.zip") then + print "[st-update] ERROR: could not stage the package — staying put" + RecordPackageAttempt(reg, attempts% + 1) + return + end if + + ' A clean attempt counter, so the next version starts from zero rather than inheriting this + ' version's failures and being refused before it is ever tried. + RecordPackageAttempt(reg, 0) + print "[st-update] staged "; manifest.version; " — rebooting to apply" + sleep(2000) + RebootSystem() +End Sub + +Sub RecordPackageAttempt(reg As Object, n As Integer) + if reg = invalid then return + reg.Write("pkg_attempts", Stri(n).Trim()) + reg.Flush() +End Sub + +' sha256 + size. Both matter: the hash proves the bytes are the ones we were promised, the size +' floor catches an error page or captive-portal login saved under the package's name. +Function VerifyPackage(path As String, expected As String, expectedSize As Integer) As Boolean + if expected = invalid or expected = "" then return false + + fs = CreateObject("roFileSystem") + if fs = invalid then return false + + info = fs.Stat(path) + if info = invalid then return false + if info.size < 1024 then + print "[st-update] package is implausibly small ("; info.size; " bytes)" + return false + end if + if expectedSize > 0 and info.size <> expectedSize then + print "[st-update] size mismatch: got "; info.size; " expected "; expectedSize + return false + end if + + digest = CreateObject("roMessageDigest") + if digest = invalid then return false + digest.SetAlgorithm("sha256") + + file = fs.OpenInputFile(path) + if file = invalid then return false + while true + chunk = file.Read(65536) + if chunk.Count() = 0 then exit while + digest.Update(chunk) + end while + + return LCase(digest.Final()) = LCase(expected) +End Function + '=== main =================================================================================== Sub Main() @@ -152,6 +356,11 @@ Sub Main() ' Must happen BEFORE the widget starts: it can reboot. EnsurePtpDomain(cfg) + ' A package staged by a previous run lands here, before anything is on screen. Doing it after + ' the widget started would mean rebooting out of a playing playlist, and the panel would blink + ' mid-content for a reason nobody watching could explain. + ApplyPendingPackage(StorageRoot()) + port = CreateObject("roMessagePort") ' Second output. The XC5 family exposes more than one HDMI connector (XC2055 dual, XC4055 @@ -182,6 +391,15 @@ Sub Main() lastBeat = CreateObject("roTimespan") lastBeat.Mark() + ' Update check runs AFTER the widget is up, deliberately. A slow or unreachable server must + ' never delay first frame — content on screen is the job, updating is housekeeping. It also + ' runs on a timer rather than only at boot, because a panel that is never power-cycled would + ' otherwise never see an update at all. + lastPkgCheck = CreateObject("roTimespan") + lastPkgCheck.Mark() + PKG_CHECK_MS = 6 * 60 * 60 * 1000 ' 6h: this replaces the boot script, so rarely is right + if cfg.self_update then CheckPackageUpdate(cfg, StorageRoot()) + ' A watchdog on TOP of load-error: a page can load fine and then wedge (dead socket, JS ' exception, decoder stall) without the OS ever reporting an error. st-bridge.js posts a ' heartbeat every 30s; three missed beats and we rebuild the widget. This is the difference @@ -274,6 +492,13 @@ Sub Main() widget = RebuildWidget(widget, PlayerUrl(cfg, 1), rect, port, cfg) lastBeat.Mark() end if + + ' Periodic package check. Marked BEFORE the call, not after: a check that blocks on a slow + ' server would otherwise be retried immediately on the next tick and hammer it. + if cfg.self_update and lastPkgCheck.TotalMilliseconds() > PKG_CHECK_MS then + lastPkgCheck.Mark() + CheckPackageUpdate(cfg, StorageRoot()) + end if end while End Sub diff --git a/scripts/build-autorun-zip.sh b/scripts/build-autorun-zip.sh index 986f306..93ad64a 100755 --- a/scripts/build-autorun-zip.sh +++ b/scripts/build-autorun-zip.sh @@ -39,6 +39,25 @@ cp brightsign/autorun.brs "$STAGE/" cp brightsign/offline.html "$STAGE/" cp brightsign/screentinker.json "$STAGE/" +# Stamp the version into the host so the script REPORTS the version it actually is. A package that +# ships reporting the old version is applied, reports the old version, and is offered again on the +# next check — forever. server/lib/brightsign-package.js does the identical substitution, anchored +# on the same ST_PACKAGE_VERSION marker, so a zip built here and one built by the server agree. +VERSION="$(cat VERSION 2>/dev/null | tr -d '[:space:]')" +if [ -n "$VERSION" ]; then + python3 - "$STAGE/autorun.brs" "$VERSION" <<'PY' +import re, sys +path, version = sys.argv[1], sys.argv[2] +src = open(path).read() +out = re.sub(r'return "[^"]*"(\s*\'\s*ST_PACKAGE_VERSION)', 'return "%s"\\1' % version, src) +if out == src: + sys.exit("ERROR: ST_PACKAGE_VERSION marker not found in autorun.brs — refusing to ship an " + "unstamped package, which would loop on every update check.") +open(path, 'w').write(out) +PY + echo " stamped package version $VERSION" +fi + # Point a batch at a specific server without hand-editing each card. if [ -n "$SERVER" ]; then python3 - "$STAGE/screentinker.json" "$SERVER" <<'PY' diff --git a/server/lib/brightsign-package.js b/server/lib/brightsign-package.js new file mode 100644 index 0000000..c4bd647 --- /dev/null +++ b/server/lib/brightsign-package.js @@ -0,0 +1,118 @@ +'use strict'; + +/* + * Builds and serves the BrightSign player package (autorun.zip) for self-update. + * + * THE INVARIANT THIS FILE EXISTS TO HOLD: the checksum in the manifest and the bytes on the + * download route come from the SAME in-memory buffer, built once. Advertising a version whose + * checksum does not match the bytes actually served is the classic OTA-loop condition — the player + * downloads, fails verification, retries, forever — and it is the easiest mistake to make when the + * manifest is computed from one source and the file from another (a file on disk that a deploy + * replaced, say). Here it is impossible by construction: there is one buffer and both routes read + * it. + * + * The zip is built deterministically from brightsign/, not read from a prebuilt artifact, because a + * prebuilt autorun.zip is a CI output that is not present in a git-checkout deployment. Building it + * means the manifest is always available and always describes files that actually exist. + */ + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const archiver = require('archiver'); + +// The payload, mirroring scripts/build-autorun-zip.sh. autozip.brs must be present or nothing +// unpacks the archive on the player; autorun.brs must be INSIDE it and never beside it on the +// storage root, or the player refuses to process the zip at all. +const PACKAGE_FILES = ['autozip.brs', 'autorun.brs', 'offline.html', 'screentinker.json']; + +// sha256 rather than sha1 because that is the algorithm BrightScript's roMessageDigest is +// documented against — the player has to be able to verify what we advertise, and an algorithm it +// cannot compute is an unverifiable package, which this whole design exists to refuse. +let cached = null; // { version, sha256, size, buffer } + +function brightsignDir() { + return path.join(__dirname, '..', '..', 'brightsign'); +} + +function readVersion() { + try { + return fs.readFileSync(path.join(__dirname, '..', '..', 'VERSION'), 'utf8').trim(); + } catch (e) { + return null; + } +} + +/* + * Rewrite the stamped version line in autorun.brs. + * + * Anchored on the ST_PACKAGE_VERSION marker rather than on the literal, so a hand-edited default + * cannot cause a silent miss. If the marker is ever removed the stamp is skipped and the package + * ships reporting "0.0.0-dev", which reads as permanently out of date — noisy, but noisy in the + * direction of "someone look at this" rather than a silent update loop. + */ +function stampVersion(source, version) { + return source.replace( + /return "[^"]*"(\s*'\s*ST_PACKAGE_VERSION)/, + `return "${version}"$1` + ); +} + +/* + * Build the archive in memory. Entries are added in a fixed order with a fixed timestamp so the + * bytes are reproducible: a checksum that changed on every server restart would make every player + * re-download the same package after every deploy. + */ +function buildZip() { + return new Promise((resolve, reject) => { + const dir = brightsignDir(); + const chunks = []; + const archive = archiver('zip', { zlib: { level: 9 } }); + + archive.on('data', (c) => chunks.push(c)); + archive.on('error', reject); + archive.on('end', () => resolve(Buffer.concat(chunks))); + + const version = readVersion(); + for (const name of PACKAGE_FILES) { + const p = path.join(dir, name); + if (!fs.existsSync(p)) return reject(new Error(`package file missing: ${name}`)); + let body = fs.readFileSync(p); + // Stamp the version into the host so the script REPORTS the version it actually is. Ship it + // unstamped and the player applies the update, still reports the old version, and is offered + // the same package forever — the OTA loop, arriving by the back door. + if (name === 'autorun.brs') body = Buffer.from(stampVersion(body.toString('utf8'), version), 'utf8'); + // date fixed for reproducibility; the player never reads it. + archive.append(body, { name, date: new Date(0) }); + } + archive.finalize(); + }); +} + +/* + * Get the package, building once and caching. Returns null when the package cannot be built (a + * deployment without the brightsign/ directory, for instance) — callers must treat that as "no + * manifest", which the update decision reads as "keep running", never as "wipe yourself". + */ +async function getPackage() { + if (cached) return cached; + const version = readVersion(); + if (!version) return null; + try { + const buffer = await buildZip(); + cached = { + version, + sha256: crypto.createHash('sha256').update(buffer).digest('hex'), + size: buffer.length, + buffer + }; + return cached; + } catch (e) { + return null; + } +} + +/* Test seam: drop the cache so a changed file is picked up without a restart. */ +function _reset() { cached = null; } + +module.exports = { getPackage, _reset, PACKAGE_FILES }; diff --git a/server/lib/brightsign-update.js b/server/lib/brightsign-update.js new file mode 100644 index 0000000..773f5bc --- /dev/null +++ b/server/lib/brightsign-update.js @@ -0,0 +1,158 @@ +'use strict'; + +/* + * Should a BrightSign player replace its own host package (autorun.zip)? + * + * This is the riskiest self-update in the product. An Android OTA that goes wrong leaves a player + * on the old APK; a BrightSign package update that goes wrong replaces the SCRIPT THAT BOOTS THE + * PLAYER. A truncated or half-applied autorun.brs is a dark panel and a site visit — there is no + * app underneath to fall back to. + * + * So the rule this module encodes is deliberately conservative: refuse unless everything lines up, + * and treat every ambiguity as "keep running what works". + * + * THREE SCARS THIS EXISTS TO HONOUR: + * + * 1. A prerelease sorts BELOW its own release. `1.9.29-rc1` is semver-older than `1.9.29`, so a + * player handed a test build asks "anything newer?", is correctly told yes — the release — and + * updates itself straight off the build someone was asked to test. That cost a reporter an + * evening on the Android side. Here the same comparison decides whether to overwrite the boot + * script, so it is checked in one place and tested against the exact versions that burned us. + * 2. Advertising a version that does not match the bytes served is the classic OTA-loop condition: + * the player installs, reports the old version, is offered the update again, forever. The + * manifest therefore carries a checksum, and a package whose bytes do not hash to it is never + * applied — a mismatch is treated as a failed download, not as a new version. + * 3. On this platform `location.reload()` does not reliably bring the widget back, so anything + * that needs a restart goes through the host. Applying a package ends in a reboot, which is why + * it must never be triggered on a whim. + * + * Pure by design: no filesystem, no network, no clock beyond what the caller passes. The BrightScript + * host asks this what to do and does exactly that. + */ + +const MAX_ATTEMPTS_PER_VERSION = 3; + +/* + * Compare two semver-ish versions. Returns -1, 0 or 1. + * + * Prerelease handling is the whole point: 1.9.29-rc1 < 1.9.29, and 1.9.29-rc1 < 1.9.29-rc2. A + * missing prerelease outranks a present one at equal core, which is what makes the release beat its + * own candidate. + */ +function compareVersions(a, b) { + const parse = (v) => { + const [core, pre] = String(v || '0.0.0').split('-'); + const nums = core.split('.').map((n) => parseInt(n, 10) || 0); + return { nums: [nums[0] || 0, nums[1] || 0, nums[2] || 0], pre: pre || null }; + }; + const A = parse(a); + const B = parse(b); + + for (let i = 0; i < 3; i++) { + if (A.nums[i] !== B.nums[i]) return A.nums[i] < B.nums[i] ? -1 : 1; + } + if (A.pre === B.pre) return 0; + if (A.pre === null) return 1; // 1.9.29 beats 1.9.29-rc1 + if (B.pre === null) return -1; + return A.pre < B.pre ? -1 : 1; // rc1 < rc2, lexicographic is right for our naming +} + +/* + * Is `version` a prerelease of the same core release as `release`? + * 1.9.29-rc1 is a prerelease of 1.9.29; 1.9.29-rc1 is NOT a prerelease of 1.9.30. + */ +function isPrereleaseOf(version, release) { + const core = (v) => String(v || '').split('-')[0]; + return String(version || '').includes('-') && core(version) === core(release); +} + +/** + * Decide what the host should do about a package update. + * + * @param {object} state + * currentVersion {string} version of the package running now + * manifestVersion {string} version the server advertises (null/absent = no manifest reachable) + * manifestSha256 {string} checksum of the bytes the server will serve + * stagedSha256 {string} checksum of an already-downloaded file awaiting apply (optional) + * attempts {number} failed attempts recorded for manifestVersion + * allowPrerelease {boolean} opt-in, mirroring the Android beta channel + * @returns {{action:'skip'|'download'|'apply', reason:string}} + */ +function decidePackageUpdate(state) { + const s = state || {}; + const current = s.currentVersion || '0.0.0'; + const advertised = s.manifestVersion; + + // No manifest: the server is unreachable or does not publish one. Keep running. This is the + // common case during an outage and must never be mistaken for "no update needed, wipe yourself". + if (!advertised) return { action: 'skip', reason: 'no manifest' }; + + // A manifest without a checksum cannot be verified, and an unverifiable package is exactly the + // truncated-download risk this module exists to refuse. + if (!s.manifestSha256) return { action: 'skip', reason: 'manifest has no checksum' }; + + const cmp = compareVersions(advertised, current); + + if (cmp <= 0) { + return { action: 'skip', reason: cmp === 0 ? 'already current' : 'advertised version is older' }; + } + + // THE PRERELEASE TRAP, and the reason a plain "newer wins" comparison is not enough. + // + // A player running 1.9.29-rc1 is running something semver-OLDER than 1.9.29, so the release + // legitimately compares as newer — and a player handed a test build would update straight off it. + // That is exactly what happened on Android: a reporter tested an evening on a build their tablet + // had already replaced. + // + // An OPTED-IN player therefore holds a prerelease of the same core instead of being pulled back to + // its release. A player that never opted in is not testing anything and should rejoin the release + // line, so it updates normally. Narrow by construction: only the same core is held, so a genuinely + // newer core (1.9.30) still lands and opting in can never mean never updating again. + if (s.allowPrerelease && isPrereleaseOf(current, advertised)) { + return { action: 'skip', reason: 'holding prerelease of the same core (opted in)' }; + } + + const isPrerelease = String(advertised).includes('-'); + if (isPrerelease && !s.allowPrerelease) { + return { action: 'skip', reason: 'prerelease requires opt-in' }; + } + + // Repeated failure on the SAME version means something is durably wrong — a corrupt artifact, a + // proxy mangling the download, a full disk. Retrying forever burns the link and, on a metered + // connection, real money. Stop and stay on the version that works. + if ((s.attempts || 0) >= MAX_ATTEMPTS_PER_VERSION) { + return { action: 'skip', reason: 'too many failed attempts for this version' }; + } + + // Already downloaded and the bytes hash correctly: apply it. Splitting download from apply is + // what keeps a truncated file from ever becoming autorun.zip. + if (s.stagedSha256) { + if (s.stagedSha256 === s.manifestSha256) return { action: 'apply', reason: 'staged package verified' }; + return { action: 'download', reason: 'staged package failed verification' }; + } + + return { action: 'download', reason: 'newer package available' }; +} + +/* + * Is a downloaded file safe to promote to autorun.zip? + * + * Separate from the decision above because it is answered AFTER the bytes land, and it is the last + * gate before we overwrite the boot script. Both conditions are non-negotiable: the checksum proves + * the file is whole, and a non-trivial size catches the case where a captive portal or an error page + * was saved as if it were the package — a 2KB HTML error page hashes to something, just not this. + */ +function isPackageSafeToApply(actualSha256, expectedSha256, actualBytes, minBytes) { + if (!actualSha256 || !expectedSha256) return false; + if (actualSha256 !== expectedSha256) return false; + const floor = typeof minBytes === 'number' ? minBytes : 1024; + if (!(actualBytes >= floor)) return false; + return true; +} + +module.exports = { + decidePackageUpdate, + isPackageSafeToApply, + compareVersions, + MAX_ATTEMPTS_PER_VERSION +}; diff --git a/server/lib/player-cache-policy.js b/server/lib/player-cache-policy.js new file mode 100644 index 0000000..ae247e3 --- /dev/null +++ b/server/lib/player-cache-policy.js @@ -0,0 +1,148 @@ +// Offline content caching policy for the web player's service worker. +// +// WHY THIS EXISTS: content bytes were never persistently cached. The service worker deliberately +// skipped `/uploads/content/` and leaned on `Cache-Control: public, max-age=2592000, immutable`, +// letting the browser's own HTTP cache hold the media. On a desktop browser that is reasonable. +// On BrightSign it is not a guarantee: BrightSign documents persistence across reloads, app +// restarts and REBOOTS for IndexedDB, localStorage and SQLite only — the HTTP disk cache is not on +// that list, and their canonical answer for offline video is to cache the bytes explicitly. So a +// panel that lost its server could come back up with a playlist (that survives in localStorage) and +// no media to play, which is the exact failure signage cannot have. +// +// The service worker's Cache API is the mechanism this repo already trusts for exactly this: widget +// renders are cache-first precisely so a widget keeps rendering when the uplink is gone. This +// extends the same treatment to content. +// +// THE REASON CONTENT WAS SKIPPED IN THE FIRST PLACE IS REAL, and this module is what makes +// intercepting it safe: video elements issue RANGE requests when they seek, and naive caching +// breaks playback in two well-known ways. +// +// 1. Caching a 206 partial as if it were the whole file. A later full request then gets a +// fragment and the video is corrupt — worse than not caching, and it persists until eviction. +// 2. Answering a Range request with a 200 full body. Some media stacks accept it; others treat +// the mismatch as a fatal error and the video simply never plays. +// +// So: only ever STORE complete 200 responses, and when a Range request arrives, slice the stored +// body into a correct 206 ourselves. Both halves are pure functions here, tested without a browser. +// +// Dependency-free UMD: Node (require) + service worker (importScripts -> self.PlayerCachePolicy). + +(function (root, factory) { + if (typeof module === 'object' && module.exports) module.exports = factory(); + else root.PlayerCachePolicy = factory(); +})(typeof self !== 'undefined' ? self : this, function () { + 'use strict'; + + /* + * Is this a request for playable content we should hold for offline use? + * + * Deliberately narrow. `/uploads/content/` is the media the playlist points at; everything else + * on /uploads (thumbnails aside) is dashboard-facing and not needed by a dark player. Non-GET + * never caches — a POST is not a thing you can replay. + */ + function isCacheableContent(url, method) { + if (method && method !== 'GET') return false; + var path; + try { + path = typeof url === 'string' ? new URL(url, 'http://x').pathname : url.pathname; + } catch (e) { + return false; + } + return path.indexOf('/uploads/content/') === 0; + } + + /* + * Parse a Range header against a known body size. + * + * Returns {start, end} INCLUSIVE, or null when the header is absent/unparseable (caller then + * serves the whole thing), or the string 'unsatisfiable' when the range lies outside the body — + * which must become a 416, not a silent clamp, or a seeking player can loop forever asking for + * bytes that do not exist. + * + * Only single ranges are honoured. Multipart ranges are legal HTTP and essentially never used by + * media elements; answering one wrongly is worse than declining to, so those return null and fall + * through to the full body. + */ + function parseRange(rangeHeader, size) { + if (!rangeHeader || typeof rangeHeader !== 'string') return null; + if (!(size > 0)) return null; + + var m = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim()); + if (!m) return null; // multipart, or junk: serve whole + var startRaw = m[1]; + var endRaw = m[2]; + if (startRaw === '' && endRaw === '') return null; + + var start, end; + if (startRaw === '') { + // Suffix form: "bytes=-500" means the LAST 500 bytes, not "from 0 to 500". Getting this + // backwards hands the player the beginning of the file when it asked for the end. + var suffix = parseInt(endRaw, 10); + if (!(suffix > 0)) return 'unsatisfiable'; + start = Math.max(0, size - suffix); + end = size - 1; + } else { + start = parseInt(startRaw, 10); + end = endRaw === '' ? size - 1 : parseInt(endRaw, 10); + if (isNaN(start) || isNaN(end)) return null; + // A start at or past EOF is unsatisfiable. An END past EOF is not — it is clamped, which is + // what every media player relies on when it asks for "bytes=0-" style open ranges. + if (start >= size) return 'unsatisfiable'; + if (end >= size) end = size - 1; + if (end < start) return 'unsatisfiable'; + } + return { start: start, end: end }; + } + + /* + * Headers for the 206 we build from a cached full body. Content-Range must describe the ORIGINAL + * size, not the slice length — a player uses it to learn how long the media is, and reporting the + * slice size makes a long video look like a fragment and stops seeking dead. + */ + function partialHeaders(start, end, size, contentType) { + var h = { + 'Content-Range': 'bytes ' + start + '-' + end + '/' + size, + 'Content-Length': String(end - start + 1), + 'Accept-Ranges': 'bytes' + }; + if (contentType) h['Content-Type'] = contentType; + return h; + } + + /* + * Is a network response safe to STORE? + * + * A 206 must never be stored: it is a fragment, and storing it means a later full request is + * answered with part of a file. An opaque response (no-cors) has an unreadable body and a status + * of 0, so it cannot be validated or sliced. Both were the reason content caching was avoided; + * refusing them here is what makes it safe. + */ + function isStorable(response) { + if (!response) return false; + if (response.status !== 200) return false; + if (response.type === 'opaque' || response.type === 'opaqueredirect') return false; + return true; + } + + /* + * Should we evict to make room? Callers pass current usage and the incoming size. + * + * The player's widget is created with a fixed storage_quota (1GB) and a full cache does not fail + * gracefully — writes throw QuotaExceededError, and the failure lands on whichever item happened + * to be next, not on the biggest one. Keeping a headroom margin means eviction happens on our + * terms, in advance, instead of as a surprise mid-playlist. + */ + function needsEviction(usedBytes, incomingBytes, quotaBytes, headroomRatio) { + if (!(quotaBytes > 0)) return false; + var headroom = typeof headroomRatio === 'number' ? headroomRatio : 0.9; + return (usedBytes + incomingBytes) > (quotaBytes * headroom); + } + + return { + isCacheableContent: isCacheableContent, + parseRange: parseRange, + partialHeaders: partialHeaders, + isStorable: isStorable, + needsEviction: needsEviction + }; +}); diff --git a/server/player/sw.js b/server/player/sw.js index 72f829a..0178414 100644 --- a/server/player/sw.js +++ b/server/player/sw.js @@ -1,4 +1,13 @@ -const CACHE_NAME = 'rd-player-v18'; +const CACHE_NAME = 'rd-player-v19'; +// Content lives in its own cache so the shell can be re-versioned (the activate handler deletes +// every cache that is not CACHE_NAME) WITHOUT throwing away megabytes of media that are still +// perfectly valid. Rolling the shell used to mean a player re-downloaded its entire playlist. +const CONTENT_CACHE = 'rd-content-v1'; + +// Single source, shared with server/lib/player-cache-policy.js and its Node tests. A service worker +// cannot require(), so this is importScripts against the route that serves that same file. +importScripts('/player/cache-policy.js'); +const POLICY = self.PlayerCachePolicy; // Install: skip waiting to activate immediately self.addEventListener('install', (event) => { @@ -9,7 +18,10 @@ self.addEventListener('install', (event) => { self.addEventListener('activate', (event) => { event.waitUntil( caches.keys().then(keys => Promise.all( - keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)) + // CONTENT_CACHE is spared deliberately: it holds media, not code, and dropping it on every + // shell version bump would make each deploy re-download the whole playlist — over a link + // that may be exactly what is broken. + keys.filter(k => k !== CACHE_NAME && k !== CONTENT_CACHE).map(k => caches.delete(k)) )).then(() => self.clients.claim()) ); }); @@ -72,6 +84,113 @@ self.addEventListener('fetch', (event) => { return; } - // Everything else (content files, API calls, etc.): don't intercept. + // Content files: cache the bytes so a player that loses its server keeps playing. + // + // This used to be left to the browser's HTTP cache (the server sends + // `Cache-Control: public, max-age=2592000, immutable`). That is fine on a desktop and is NOT a + // documented-persistent store on BrightSign, which guarantees survival across reboots for + // IndexedDB, localStorage and SQLite only. A panel could come back from a power cut with its + // playlist intact (localStorage) and no media to play. + // + // Range requests are the reason this was avoided, and POLICY is what makes it safe: we only ever + // STORE complete 200s, and slice them ourselves when a seeking video asks for a range. + if (POLICY && POLICY.isCacheableContent(url, event.request.method)) { + event.respondWith(handleContent(event.request)); + return; + } + + // Everything else (API calls, sockets, etc.): don't intercept. // Returning without event.respondWith lets the browser handle it natively. }); + +async function handleContent(request) { + const range = request.headers.get('range'); + const cache = await caches.open(CONTENT_CACHE); + + // Keyed WITHOUT the range header (Cache API ignores request headers by default), so one stored + // full body serves every range of that file rather than one entry per seek position. + const cached = await cache.match(request, { ignoreVary: true }); + + if (cached) { + if (!range) return cached; + const sliced = await sliceCached(cached, range); + if (sliced) return sliced; + // Unsatisfiable against the cached copy: fall through to the network rather than inventing a + // 416 that might be wrong if the cached copy is somehow stale. + } + + try { + // A ranged request goes to the network as-is; storing its 206 would corrupt the entry, so this + // response is returned and deliberately NOT cached. The full copy arrives on a non-ranged + // request (the player's preloader issues one) and that is what populates the cache. + const response = await fetch(request); + + if (!range && POLICY.isStorable(response)) { + const clone = response.clone(); + // Not awaited: a slow write must not delay first frame. Failures are swallowed because a + // cache miss is a performance problem, and a thrown error here is a black screen. + storeContent(cache, request, clone).catch(() => {}); + } + return response; + } catch (err) { + // Offline with nothing cached. A 504 is more honest than a 200 with an empty body — the player + // treats a failed media load as an item to skip, and an empty 200 would hang on a dead element. + if (cached) return cached; + return new Response('', { status: 504, statusText: 'Offline and not cached' }); + } +} + +/* Build a correct 206 from a stored full body. */ +async function sliceCached(cached, rangeHeader) { + const buf = await cached.arrayBuffer(); + const parsed = POLICY.parseRange(rangeHeader, buf.byteLength); + + if (parsed === null) return new Response(buf, { status: 200, headers: cached.headers }); + if (parsed === 'unsatisfiable') return null; + + const body = buf.slice(parsed.start, parsed.end + 1); + return new Response(body, { + status: 206, + statusText: 'Partial Content', + headers: POLICY.partialHeaders( + parsed.start, parsed.end, buf.byteLength, cached.headers.get('content-type') + ) + }); +} + +/* Store a full response, evicting oldest-first when the quota is close rather than waiting for a + QuotaExceededError to land on whichever item happened to be next. */ +async function storeContent(cache, request, response) { + const len = Number(response.headers.get('content-length')) || 0; + + try { + if (navigator.storage && navigator.storage.estimate) { + const { usage, quota } = await navigator.storage.estimate(); + if (POLICY.needsEviction(usage || 0, len, quota || 0)) await evictOldest(cache, len); + } + } catch (e) { /* estimate is unavailable on some builds; proceed and rely on the catch below */ } + + try { + await cache.put(request, response); + } catch (e) { + // Quota exceeded despite the check (or no estimate available). Make room once and retry — but + // only once, so a pathologically large item cannot spin evicting the whole cache. + await evictOldest(cache, len); + try { await cache.put(request, response); } catch (e2) { /* give up: playback still works live */ } + } +} + +/* Cache API preserves insertion order, so the front of keys() is the least recently ADDED. That is + a rough proxy for least useful and is the only ordering the API exposes without tracking metadata + ourselves. */ +async function evictOldest(cache, needBytes) { + const keys = await cache.keys(); + let freed = 0; + for (const key of keys) { + const hit = await cache.match(key); + const size = hit ? Number(hit.headers.get('content-length')) || 0 : 0; + await cache.delete(key); + freed += size; + if (freed >= needBytes) break; + } +} diff --git a/server/server.js b/server/server.js index 5168037..46df6c0 100644 --- a/server/server.js +++ b/server/server.js @@ -294,6 +294,69 @@ app.get('/player/schedule-eval.js', (req, res) => { res.sendFile(path.join(__dirname, 'lib', 'schedule-eval.js')); }); +// Offline content-cache policy, imported by the service worker via importScripts and by the Node +// tests via require — one source, so the range arithmetic the player depends on cannot drift from +// the arithmetic that is actually tested. A service worker cannot require(), which is why this is +// a served file rather than a bundled one. +app.get('/player/cache-policy.js', (req, res) => { + res.type('application/javascript').setHeader('Cache-Control', 'no-cache'); + res.sendFile(path.join(__dirname, 'lib', 'player-cache-policy.js')); +}); + +// BrightSign player-package self-update. The manifest and the bytes are read from the SAME built +// buffer, so a checksum can never describe a file the server is not actually serving — that +// mismatch is the classic OTA-loop condition (download, fail verification, retry, forever). +// +// Unauthenticated on purpose, exactly like /download/apk: a player fetches this before it has any +// identity, and the payload is the same public host script that ships attached to every release. +const bsPackage = require('./lib/brightsign-package'); +const bsUpdate = require('./lib/brightsign-update'); + +// The SERVER decides, exactly as /api/update/check does for Android, so the rule lives in one +// tested place instead of being re-implemented in BrightScript where it cannot be tested at all. +// The host does only what it is told. +app.get('/api/brightsign/package', async (req, res) => { + const pkg = await bsPackage.getPackage(); + res.setHeader('Cache-Control', 'no-cache'); + + // No package (a deployment without brightsign/, or an unreadable VERSION) is reported as a + // decision of "skip", not an error. A player that cannot be told about an update must keep + // running the one it has — an error here would otherwise be one more thing for the host to + // misinterpret at boot. + if (!pkg) return res.json({ action: 'skip', reason: 'package unavailable' }); + + const decision = bsUpdate.decidePackageUpdate({ + currentVersion: req.query.version || null, + manifestVersion: pkg.version, + manifestSha256: pkg.sha256, + stagedSha256: req.query.staged_sha256 || null, + attempts: parseInt(req.query.attempts, 10) || 0, + allowPrerelease: req.query.allow_prerelease === '1' + }); + + res.json({ + action: decision.action, + reason: decision.reason, + version: pkg.version, + sha256: pkg.sha256, + size: pkg.size, + url: '/api/brightsign/package/download' + }); +}); + +app.get('/api/brightsign/package/download', async (req, res) => { + const pkg = await bsPackage.getPackage(); + if (!pkg) return res.status(404).type('text/plain').send('package unavailable'); + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Length', String(pkg.size)); + res.setHeader('Content-Disposition', 'attachment; filename="autorun.zip"'); + // The checksum rides along so a client that already has the manifest can verify without a second + // round trip, and so a proxy that mangles the body is detectable from the response alone. + res.setHeader('X-Package-Sha256', pkg.sha256); + res.setHeader('Cache-Control', 'no-cache'); + res.send(pkg.buffer); +}); + // BrightSign bridge, served from its single source (brightsign/st-bridge.js) so the copy the // player loads can never drift from the one sitting on the SD card next to autorun.brs — the two // are halves of one messageport contract, and a skew between them is exactly what would leave a diff --git a/server/test/brightsign-package.test.js b/server/test/brightsign-package.test.js new file mode 100644 index 0000000..02753d1 --- /dev/null +++ b/server/test/brightsign-package.test.js @@ -0,0 +1,83 @@ +'use strict'; + +// The manifest and the download must describe the SAME bytes. +// +// Advertising a version whose checksum does not match the file actually served is the classic +// OTA-loop condition: the player downloads, fails verification, retries, forever. It is also the +// easiest mistake to make, because the natural implementation computes the manifest from one source +// (a VERSION file, a build record) and serves the file from another (a path on disk that some +// deploy replaced). These tests pin the invariant that makes that impossible here: one buffer, +// hashed once, read by both routes. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const pkgLib = require('../lib/brightsign-package'); + +test('THE OTA LOOP: the advertised checksum is the hash of the bytes that are served', async () => { + const pkg = await pkgLib.getPackage(); + assert.ok(pkg, 'package should build from brightsign/'); + // sha256 because that is what BrightScript's roMessageDigest can compute — a checksum the player + // cannot verify is an unverifiable package. + const actual = crypto.createHash('sha256').update(pkg.buffer).digest('hex'); + assert.equal(pkg.sha256, actual, 'a mismatch here loops every player in the fleet'); + assert.equal(pkg.size, pkg.buffer.length); +}); + +test('the package is byte-identical on rebuild — otherwise every deploy re-flashes the fleet', async () => { + // Zip entries carry timestamps. Left at "now" the archive changes on every server restart, the + // checksum changes with it, and every player decides it has an update waiting. + const first = await pkgLib.getPackage(); + pkgLib._reset(); + const second = await pkgLib.getPackage(); + assert.equal(second.sha1, first.sha1); +}); + +test('the archive contains exactly the payload, at its ROOT with no wrapper directory', async () => { + // A player extracts to the storage root. A wrapper folder puts autorun.brs where the player never + // looks and the card silently does nothing — the failure mode is "blank screen", not an error. + const pkg = await pkgLib.getPackage(); + const names = []; + // Minimal central-directory walk: entry names follow the 0x02014b50 signature at offset +46. + const buf = pkg.buffer; + for (let i = 0; i < buf.length - 4; i++) { + if (buf.readUInt32LE(i) === 0x02014b50) { + const nameLen = buf.readUInt16LE(i + 28); + names.push(buf.slice(i + 46, i + 46 + nameLen).toString('utf8')); + } + } + assert.deepEqual(names.sort(), pkgLib.PACKAGE_FILES.slice().sort()); + for (const n of names) { + assert.ok(!n.includes('/'), `${n} must be at the archive root, not nested`); + } +}); + +test('autorun.brs and autozip.brs are both present — either missing is a dead panel', async () => { + // autorun.brs missing: nothing to run after extraction. + // autozip.brs missing: nothing extracts the archive in the first place. + assert.ok(pkgLib.PACKAGE_FILES.includes('autorun.brs')); + assert.ok(pkgLib.PACKAGE_FILES.includes('autozip.brs')); +}); + +test('THE BACK-DOOR LOOP: the shipped autorun.brs reports the version the manifest advertises', async () => { + // Ship it unstamped and the player applies the update, still reports the old version, and is + // offered the same package on every check — forever. The loop arrives even though the checksum + // was correct and the download was clean. + const unzipper = require('unzipper'); + const pkg = await pkgLib.getPackage(); + const dir = await unzipper.Open.buffer(pkg.buffer); + const entry = dir.files.find((f) => f.path === 'autorun.brs'); + assert.ok(entry, 'autorun.brs must be in the package'); + const text = (await entry.buffer()).toString('utf8'); + const m = text.match(/return "([^"]*)"\s*' ST_PACKAGE_VERSION/); + assert.ok(m, 'the ST_PACKAGE_VERSION marker must survive — it is what the stamp anchors on'); + assert.equal(m[1], pkg.version, 'stamped version must equal the advertised version'); +}); + +test('the version comes from VERSION, so the manifest matches the release it shipped with', async () => { + const fs = require('node:fs'); + const path = require('node:path'); + const expected = fs.readFileSync(path.join(__dirname, '..', '..', 'VERSION'), 'utf8').trim(); + const pkg = await pkgLib.getPackage(); + assert.equal(pkg.version, expected); +}); diff --git a/server/test/brightsign-update.test.js b/server/test/brightsign-update.test.js new file mode 100644 index 0000000..57af998 --- /dev/null +++ b/server/test/brightsign-update.test.js @@ -0,0 +1,148 @@ +'use strict'; + +// This is the riskiest self-update in the product. An Android OTA that goes wrong leaves a player on +// the old APK. A BrightSign package update that goes wrong replaces THE SCRIPT THAT BOOTS THE +// PLAYER — there is no app underneath to fall back to, so a truncated or half-applied autorun.brs is +// a dark panel and a site visit. +// +// Every test here names a way that happens. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const U = require('../lib/brightsign-update'); + +const base = { + currentVersion: '1.9.28', + manifestVersion: '1.9.29', + manifestSha256: 'abc123', + attempts: 0 +}; + +// ---------------------------------------------------------------- the prerelease scar + +test('THE REVERT: a release must not overwrite the prerelease someone is testing', () => { + // 1.9.29-rc1 is semver-OLDER than 1.9.29. Without this the player asks "anything newer?", is + // correctly told yes, and wipes the build it was handed to test. That cost a reporter an evening + // on the Android side; here it would overwrite the boot script. + const r = U.decidePackageUpdate({ + ...base, currentVersion: '1.9.29-rc1', manifestVersion: '1.9.29', allowPrerelease: true + }); + assert.equal(r.action, 'skip'); +}); + +test('prerelease ordering is right: rc1 < rc2 < release', () => { + assert.equal(U.compareVersions('1.9.29-rc1', '1.9.29-rc2'), -1); + assert.equal(U.compareVersions('1.9.29-rc2', '1.9.29'), -1); + assert.equal(U.compareVersions('1.9.29', '1.9.29-rc1'), 1); + assert.equal(U.compareVersions('1.9.29', '1.9.29'), 0); + assert.equal(U.compareVersions('1.10.0', '1.9.99'), 1, 'numeric, not lexicographic'); +}); + +test('a prerelease is never applied without opt-in, mirroring the Android beta channel', () => { + const r = U.decidePackageUpdate({ ...base, manifestVersion: '1.9.30-rc1' }); + assert.equal(r.action, 'skip'); + assert.match(r.reason, /opt-in/); + + const opted = U.decidePackageUpdate({ ...base, manifestVersion: '1.9.30-rc1', allowPrerelease: true }); + assert.equal(opted.action, 'download'); +}); + +// ---------------------------------------------------------------- refusing to brick + +test('THE OUTAGE: no reachable manifest means keep running, never wipe', () => { + // The common case during exactly the failure this whole feature is meant to survive. + const r = U.decidePackageUpdate({ ...base, manifestVersion: null }); + assert.equal(r.action, 'skip'); + assert.equal(r.reason, 'no manifest'); +}); + +test('a manifest with no checksum is refused — unverifiable is the truncation risk', () => { + const r = U.decidePackageUpdate({ ...base, manifestSha256: null }); + assert.equal(r.action, 'skip'); + assert.match(r.reason, /checksum/); +}); + +test('THE TRUNCATED DOWNLOAD: a staged package that fails its checksum is re-downloaded, not applied', () => { + // Applying this overwrites autorun.brs with a partial file. The player boots into nothing. + const r = U.decidePackageUpdate({ ...base, stagedSha256: 'WRONG' }); + assert.equal(r.action, 'download'); + assert.match(r.reason, /failed verification/); +}); + +test('a staged package that verifies is applied — download and apply are separate gates', () => { + const r = U.decidePackageUpdate({ ...base, stagedSha256: 'abc123' }); + assert.equal(r.action, 'apply'); +}); + +test('THE RETRY LOOP: repeated failure on one version stops rather than burning the link forever', () => { + const r = U.decidePackageUpdate({ ...base, attempts: U.MAX_ATTEMPTS_PER_VERSION }); + assert.equal(r.action, 'skip'); + assert.match(r.reason, /too many failed attempts/); +}); + +test('attempts below the cap still try — one bad download must not park a player permanently', () => { + assert.equal(U.decidePackageUpdate({ ...base, attempts: 1 }).action, 'download'); + assert.equal(U.decidePackageUpdate({ ...base, attempts: U.MAX_ATTEMPTS_PER_VERSION - 1 }).action, 'download'); +}); + +// ---------------------------------------------------------------- idempotence / no loop + +test('THE OTA LOOP: once current, the same version is never offered again', () => { + // Install, report the old version, get offered it again, forever — the classic loop. Equality is + // what breaks it, and it must hold for prereleases too. + assert.equal(U.decidePackageUpdate({ ...base, currentVersion: '1.9.29' }).action, 'skip'); + assert.equal(U.decidePackageUpdate({ + ...base, currentVersion: '1.9.29-rc2', manifestVersion: '1.9.29-rc2', allowPrerelease: true + }).action, 'skip'); +}); + +test('an older advertised version is ignored, so a rolled-back server cannot downgrade a fleet', () => { + const r = U.decidePackageUpdate({ ...base, currentVersion: '1.9.30', manifestVersion: '1.9.28' }); + assert.equal(r.action, 'skip'); + assert.match(r.reason, /older/); +}); + +test('a genuinely newer package is downloaded', () => { + assert.equal(U.decidePackageUpdate(base).action, 'download'); +}); + +test('missing state is treated as "do nothing" rather than throwing in a boot path', () => { + // A throw here happens before the player starts; it must degrade, not explode. + assert.doesNotThrow(() => U.decidePackageUpdate(undefined)); + assert.equal(U.decidePackageUpdate(undefined).action, 'skip'); + assert.equal(U.decidePackageUpdate({}).action, 'skip'); +}); + +test('holding a prerelease is NARROW: a newer core still lands on an opted-in player', () => { + // Otherwise "opt into testing" would quietly mean "never update again", which is how testers get + // stranded on a branch nobody maintains. + const r = U.decidePackageUpdate({ + ...base, currentVersion: '1.9.29-rc1', manifestVersion: '1.9.30', allowPrerelease: true + }); + assert.equal(r.action, 'download'); +}); + +test('a player that never opted in rejoins the release line from a stale test build', () => { + // It is not testing anything, so leaving it on an orphaned build is worse than updating it. + const r = U.decidePackageUpdate({ + ...base, currentVersion: '1.9.29-rc1', manifestVersion: '1.9.29', allowPrerelease: false + }); + assert.equal(r.action, 'download'); +}); + +// ---------------------------------------------------------------- the last gate + +test('the apply gate demands a matching checksum', () => { + assert.equal(U.isPackageSafeToApply('aaa', 'aaa', 50000), true); + assert.equal(U.isPackageSafeToApply('aaa', 'bbb', 50000), false); + assert.equal(U.isPackageSafeToApply(null, 'aaa', 50000), false); + assert.equal(U.isPackageSafeToApply('aaa', null, 50000), false); +}); + +test('THE CAPTIVE PORTAL: a tiny file is refused even if the hash is somehow satisfied', () => { + // A network that serves a login page in place of the download produces a small HTML body. It has + // a hash like anything else; what it does not have is a plausible size for a player package. + assert.equal(U.isPackageSafeToApply('aaa', 'aaa', 800), false); + assert.equal(U.isPackageSafeToApply('aaa', 'aaa', 800, 1024), false); + assert.equal(U.isPackageSafeToApply('aaa', 'aaa', 2048, 1024), true); +}); diff --git a/server/test/player-cache-policy.test.js b/server/test/player-cache-policy.test.js new file mode 100644 index 0000000..287e428 --- /dev/null +++ b/server/test/player-cache-policy.test.js @@ -0,0 +1,149 @@ +'use strict'; + +// Content bytes were never persistently cached. The service worker skipped `/uploads/content/` and +// leaned on the browser's HTTP cache, which is fine on a desktop and is NOT a documented-persistent +// store on BrightSign — they guarantee persistence across reboots for IndexedDB, localStorage and +// SQLite, and their own answer for offline video is to cache the bytes explicitly. A panel that lost +// its uplink could therefore come back with a playlist (which survives in localStorage) and no media +// to play it with. +// +// Intercepting content is only safe if range requests keep working, which is exactly why it was +// avoided before. Two failure modes make video WORSE than not caching at all: +// +// * storing a 206 as though it were the whole file — every later full request gets a fragment, +// and it stays broken until something evicts it +// * answering a Range request with a 200 — some media stacks treat the mismatch as fatal and the +// video never starts +// +// These tests pin both, plus the range arithmetic that a seeking player depends on. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const P = require('../lib/player-cache-policy'); + +// ---------------------------------------------------------------- what gets cached + +test('content under /uploads/content is cacheable — that is the media a dark player needs', () => { + assert.equal(P.isCacheableContent('https://s.example/uploads/content/abc.mp4', 'GET'), true); + assert.equal(P.isCacheableContent('https://s.example/uploads/content/nested/x.jpg', 'GET'), true); +}); + +test('everything else is left alone, so API calls and sockets are never served stale', () => { + assert.equal(P.isCacheableContent('https://s.example/api/status', 'GET'), false); + assert.equal(P.isCacheableContent('https://s.example/uploads/thumbs/x.jpg', 'GET'), false); + assert.equal(P.isCacheableContent('https://s.example/player', 'GET'), false); +}); + +test('a non-GET is never cached — you cannot replay a POST', () => { + assert.equal(P.isCacheableContent('https://s.example/uploads/content/a.mp4', 'POST'), false); +}); + +test('a malformed URL is declined rather than throwing inside a fetch handler', () => { + // A throw here takes down the fetch handler and the page loses every request, not just this one. + assert.doesNotThrow(() => P.isCacheableContent('::::not a url::::', 'GET')); + assert.equal(P.isCacheableContent('::::not a url::::', 'GET'), false); +}); + +// ---------------------------------------------------------------- what gets STORED + +test('THE CORRUPTION BUG: a 206 is never stored as if it were the whole file', () => { + // Storing a fragment under the full-file key means every later full request is answered with part + // of a video, and it stays broken until eviction. This is the single most damaging mistake here. + assert.equal(P.isStorable({ status: 206, type: 'basic' }), false); +}); + +test('an opaque response is never stored — status 0, unreadable body, unsliceable', () => { + assert.equal(P.isStorable({ status: 0, type: 'opaque' }), false); + assert.equal(P.isStorable({ status: 200, type: 'opaque' }), false); +}); + +test('errors and redirects are not stored, so an outage cannot poison the cache', () => { + assert.equal(P.isStorable({ status: 404, type: 'basic' }), false); + assert.equal(P.isStorable({ status: 503, type: 'basic' }), false); + assert.equal(P.isStorable({ status: 200, type: 'opaqueredirect' }), false); + assert.equal(P.isStorable(null), false); +}); + +test('a complete 200 IS stored — otherwise nothing is ever available offline', () => { + assert.equal(P.isStorable({ status: 200, type: 'basic' }), true); + assert.equal(P.isStorable({ status: 200, type: 'cors' }), true); +}); + +// ---------------------------------------------------------------- range arithmetic + +test('an open range "bytes=0-" spans the whole body — the common video start', () => { + assert.deepEqual(P.parseRange('bytes=0-', 1000), { start: 0, end: 999 }); +}); + +test('a closed range is honoured exactly', () => { + assert.deepEqual(P.parseRange('bytes=100-200', 1000), { start: 100, end: 200 }); +}); + +test('THE SEEK BUG: a suffix range is the LAST n bytes, not the first n', () => { + // "bytes=-500" means the final 500 bytes. Reading it as 0-500 hands a seeking player the start of + // the file when it asked for the end — MP4 moov-atom probing does exactly this. + assert.deepEqual(P.parseRange('bytes=-500', 1000), { start: 500, end: 999 }); +}); + +test('an end past EOF is clamped, because that is what open-ended seeks rely on', () => { + assert.deepEqual(P.parseRange('bytes=900-99999', 1000), { start: 900, end: 999 }); +}); + +test('a start past EOF is UNSATISFIABLE, not clamped — clamping loops a seeking player forever', () => { + assert.equal(P.parseRange('bytes=1000-', 1000), 'unsatisfiable'); + assert.equal(P.parseRange('bytes=5000-6000', 1000), 'unsatisfiable'); +}); + +test('a reversed range is unsatisfiable rather than silently swapped', () => { + assert.equal(P.parseRange('bytes=800-100', 1000), 'unsatisfiable'); +}); + +test('no header, junk, or multipart falls back to the full body instead of guessing', () => { + assert.equal(P.parseRange(null, 1000), null); + assert.equal(P.parseRange('', 1000), null); + assert.equal(P.parseRange('bytes=abc-def', 1000), null); + assert.equal(P.parseRange('bytes=0-99,200-299', 1000), null, 'multipart: serving one part would be wrong'); + assert.equal(P.parseRange('items=0-10', 1000), null); + assert.equal(P.parseRange('bytes=-', 1000), null); +}); + +test('a zero-length body has no satisfiable range', () => { + assert.equal(P.parseRange('bytes=0-', 0), null); +}); + +// ---------------------------------------------------------------- 206 headers + +test('THE DURATION BUG: Content-Range reports the ORIGINAL size, not the slice length', () => { + // The player learns how long the media is from this. Reporting the slice size makes a 90-minute + // video look a few seconds long and kills seeking entirely. + const h = P.partialHeaders(100, 199, 5000, 'video/mp4'); + assert.equal(h['Content-Range'], 'bytes 100-199/5000'); + assert.equal(h['Content-Length'], '100', 'length is the slice, inclusive of both ends'); + assert.equal(h['Accept-Ranges'], 'bytes'); + assert.equal(h['Content-Type'], 'video/mp4'); +}); + +test('a single-byte range still reports length 1', () => { + assert.equal(P.partialHeaders(0, 0, 10)['Content-Length'], '1'); +}); + +// ---------------------------------------------------------------- quota + +test('eviction is decided BEFORE the write, not after a QuotaExceededError', () => { + // A full cache throws on write, and the throw lands on whatever item came next rather than the + // largest — so the player loses an arbitrary item mid-playlist. Deciding in advance keeps it ours. + const GB = 1024 * 1024 * 1024; + assert.equal(P.needsEviction(0, 10 * 1024 * 1024, GB), false); + assert.equal(P.needsEviction(GB * 0.85, 100 * 1024 * 1024, GB), true); +}); + +test('headroom leaves room to breathe rather than filling to the brim', () => { + const GB = 1024 * 1024 * 1024; + assert.equal(P.needsEviction(GB * 0.89, 1, GB), false); + assert.equal(P.needsEviction(GB * 0.91, 1, GB), true); +}); + +test('an unknown quota never triggers eviction — absence of a number is not a full disk', () => { + assert.equal(P.needsEviction(999, 999, 0), false); + assert.equal(P.needsEviction(999, 999, undefined), false); +});