mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-16 15:23:16 -06:00
Compare commits
14 commits
v1.9.34-al
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f9459139f | ||
|
|
94a81b6896 | ||
|
|
86db5929c1 | ||
|
|
3ec06b663c | ||
|
|
8cb67122ad | ||
|
|
b13f11af13 | ||
|
|
dd7295792e | ||
|
|
114dc453bb | ||
|
|
955a691bcd | ||
|
|
702e107972 | ||
|
|
04a2ad99d1 | ||
|
|
243fc6688c | ||
|
|
741bc7b6a3 | ||
|
|
60dacad303 |
54
.github/workflows/ci.yml
vendored
54
.github/workflows/ci.yml
vendored
|
|
@ -86,6 +86,39 @@ jobs:
|
|||
working-directory: android
|
||||
run: ./gradlew :app:testDebugUnitTest --no-daemon
|
||||
|
||||
# Every artifact that can enter the APK must have a licence on file. This runs here
|
||||
# rather than in its own job because the Gradle cache and Android SDK are already warm.
|
||||
- name: Licence gate (APK runtime classpath)
|
||||
run: node scripts/android-license-check.js
|
||||
|
||||
licenses:
|
||||
name: Licence gate + SBOM (production deps)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: npm
|
||||
cache-dependency-path: server/package-lock.json
|
||||
|
||||
# --omit=dev on purpose, and it is the whole point of the job. A developer checkout
|
||||
# carries sharp, whose @img/sharp-wasm32 declares LGPL-3.0-or-later; it is a test
|
||||
# fixture generator that never reaches a server. Auditing anything other than a
|
||||
# production install would report a licence we do not actually ship.
|
||||
- name: Install production dependencies only
|
||||
working-directory: server
|
||||
run: npm ci --omit=dev
|
||||
|
||||
- name: Licence gate
|
||||
run: node scripts/license-check.js --sbom sbom/screentinker-server.cdx.json
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: sbom
|
||||
path: sbom/
|
||||
if-no-files-found: error
|
||||
|
||||
smoke:
|
||||
name: Boot smoke + version check
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -109,6 +142,12 @@ jobs:
|
|||
working-directory: server
|
||||
env:
|
||||
SELF_HOSTED: 'true'
|
||||
# Boot WITH the collector on. This block is config-gated and only the
|
||||
# statistics-collecting deployment sets the flag, so it had never executed in CI,
|
||||
# on alpha, or in any test - and a load-time crash inside it took production down
|
||||
# while every check was green. Code only one deployment runs is exactly the code
|
||||
# CI has to execute.
|
||||
TELEMETRY_COLLECTOR: '1'
|
||||
run: |
|
||||
node server.js > "$RUNNER_TEMP/server.log" 2>&1 &
|
||||
echo $! > "$RUNNER_TEMP/server.pid"
|
||||
|
|
@ -131,6 +170,21 @@ jobs:
|
|||
test "$REPORTED" = "$EXPECTED"
|
||||
echo "OK: status ok, version $REPORTED matches VERSION"
|
||||
|
||||
# Booting is not enough on its own - the collector could be mounted and broken. Prove
|
||||
# the routes it adds actually answer, so a fault inside that block fails here rather
|
||||
# than on the single deployment that turns it on.
|
||||
- name: Assert the collector routes answer when enabled
|
||||
run: |
|
||||
STATS="$(curl -sf http://localhost:3001/api/public/stats)"
|
||||
echo "stats: $STATS"
|
||||
test "$(echo "$STATS" | jq -r 'has("screens") and has("installs")')" = "true"
|
||||
REPORT="$(curl -s -o /dev/null -w '%{http_code}' -X POST \
|
||||
-H 'Content-Type: application/json' -d '{"bad":1}' \
|
||||
http://localhost:3001/api/telemetry/report)"
|
||||
echo "malformed report -> HTTP $REPORT"
|
||||
test "$REPORT" = "400"
|
||||
echo "OK: collector mounted and answering"
|
||||
|
||||
- name: Stop server
|
||||
if: always()
|
||||
run: kill "$(cat "$RUNNER_TEMP/server.pid")" 2>/dev/null || true
|
||||
|
|
|
|||
48
.github/workflows/release.yml
vendored
48
.github/workflows/release.yml
vendored
|
|
@ -85,6 +85,15 @@ jobs:
|
|||
./scripts/build-autorun-zip.sh -o autorun.zip
|
||||
ls -la autorun.zip
|
||||
|
||||
# A published SBOM is what turns "we track licences" into something a customer or an
|
||||
# underwriter can check for themselves. Built from a PRODUCTION install — a dev tree
|
||||
# would list packages (sharp and its LGPL-bearing wasm variant) that never ship.
|
||||
- name: Generate SBOM (production dependencies)
|
||||
run: |
|
||||
( cd server && npm ci --omit=dev )
|
||||
node scripts/license-check.js --sbom "screentinker-sbom-${{ steps.ver.outputs.version }}.cdx.json"
|
||||
ls -la screentinker-sbom-*.cdx.json
|
||||
|
||||
- name: Build source tarball (bundles the .wgt; the signed apk is added by scripts/finalize-release.sh)
|
||||
run: |
|
||||
OUT="screentinker-${{ steps.ver.outputs.version }}.tar.gz"
|
||||
|
|
@ -100,15 +109,42 @@ jobs:
|
|||
- name: Generate release notes
|
||||
run: |
|
||||
PREV="${{ steps.ver.outputs.prev }}"
|
||||
VERSION="${{ steps.ver.outputs.version }}"
|
||||
|
||||
# Prefer the hand-written CHANGELOG section for this version.
|
||||
#
|
||||
# The generated list is commit SUBJECTS, which describe the work, not the release: cutting
|
||||
# 1.9.34 produced a page reading "chore(release): v1.9.34" and one changelog commit, while
|
||||
# the entry describing single sign-on, the native-dependency removal, the update fixes and
|
||||
# every outside contributor sat in CHANGELOG.md and was never published. The notes on the
|
||||
# release page are what most people actually read, so they should be the written ones.
|
||||
#
|
||||
# awk rather than sed: the body contains regex metacharacters and markdown that a sed range
|
||||
# would mangle. This takes everything between `## <version>` and the next `## ` heading.
|
||||
CHANGELOG_BODY="$(awk -v v="## $VERSION" '
|
||||
$0 == v {found=1; next}
|
||||
found && /^## / {exit}
|
||||
found {print}
|
||||
' CHANGELOG.md)"
|
||||
|
||||
{
|
||||
echo "## ScreenTinker ${{ steps.ver.outputs.tag }}"
|
||||
echo
|
||||
echo "### Changes"
|
||||
if [ -n "$PREV" ]; then
|
||||
git log --no-merges --pretty='- %s' "${PREV}..${{ steps.ver.outputs.tag }}"
|
||||
if [ -n "$(printf '%s' "$CHANGELOG_BODY" | tr -d '[:space:]')" ]; then
|
||||
echo "$CHANGELOG_BODY"
|
||||
else
|
||||
echo "_First tagged release. Most recent changes:_"
|
||||
git log --no-merges --pretty='- %s' -n 30 "${{ steps.ver.outputs.tag }}"
|
||||
# No entry for this version — fall back to commit subjects rather than publish a
|
||||
# release with no notes at all. scripts/bump-version.sh already warns when the
|
||||
# CHANGELOG has no matching heading; this is the same gap showing up downstream.
|
||||
echo "_No CHANGELOG entry for $VERSION; listing commits instead._"
|
||||
echo
|
||||
echo "### Changes"
|
||||
if [ -n "$PREV" ]; then
|
||||
git log --no-merges --pretty='- %s' "${PREV}..${{ steps.ver.outputs.tag }}"
|
||||
else
|
||||
echo "_First tagged release. Most recent changes:_"
|
||||
git log --no-merges --pretty='- %s' -n 30 "${{ steps.ver.outputs.tag }}"
|
||||
fi
|
||||
fi
|
||||
echo
|
||||
echo "### Artifacts"
|
||||
|
|
@ -127,6 +163,7 @@ jobs:
|
|||
echo "- Docker image: \`ghcr.io/screentinker/screentinker:${{ steps.ver.outputs.version }}\` (also \`:latest\`)."
|
||||
fi
|
||||
echo "- \`ScreenTinker.apk\` - signed Android player (attached during release finalization)."
|
||||
echo "- \`screentinker-sbom-${{ steps.ver.outputs.version }}.cdx.json\` - CycloneDX 1.5 software bill of materials for the server's production dependencies, with the licence of every component."
|
||||
} > RELEASE_NOTES.md
|
||||
cat RELEASE_NOTES.md
|
||||
|
||||
|
|
@ -144,6 +181,7 @@ jobs:
|
|||
--notes-file RELEASE_NOTES.md \
|
||||
"${TARBALL}" \
|
||||
autorun.zip \
|
||||
"screentinker-sbom-${{ steps.ver.outputs.version }}.cdx.json" \
|
||||
tizen/ScreenTinker.wgt
|
||||
|
||||
docker:
|
||||
|
|
|
|||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -59,3 +59,7 @@ audit/
|
|||
# Local SQLite artifacts (any extension the tooling might produce)
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Generated by scripts/license-check.js --sbom (CI publishes it as a release asset)
|
||||
sbom/
|
||||
*.cdx.json
|
||||
|
|
|
|||
675
CHANGELOG.md
675
CHANGELOG.md
|
|
@ -1,405 +1,392 @@
|
|||
# Changelog
|
||||
|
||||
## 1.9.34-alpha14
|
||||
## 1.9.36
|
||||
|
||||
The update fix from alpha13, confirmed over the air. A panel that had failed every update attempt
|
||||
for hours took this one unattended, in about seventy seconds, with no prompt on screen — the first
|
||||
successful over-the-air update on that hardware since the staging bug was introduced.
|
||||
A single fix. **1.9.36 replaces 1.9.35** — see below for whether that affects you.
|
||||
|
||||
### Added — Raspberry Pi notes in the operations runbook
|
||||
Three traps from a Pi 5 report, two of which are not Pi-specific: a piped installer cannot really
|
||||
ask you anything (the pipe is its input, so every prompt takes the default), X11 tools fail silently
|
||||
on Wayland (so screen blanking and cursor hiding can be entirely absent while appearing configured),
|
||||
and overlay filesystems protect an SD card by discarding writes — safe for a player, quietly
|
||||
destructive for a server whose database is written continuously.
|
||||
### Fixed — 1.9.35 would not start on a server collecting install statistics
|
||||
|
||||
## 1.9.34-alpha13
|
||||
A server with install-statistics collection switched on could not start 1.9.35. It threw
|
||||
`ReferenceError: Cannot access 'db' before initialization` while loading, before it began listening,
|
||||
and a service manager configured to restart it would do so in a loop.
|
||||
|
||||
### Fixed — a player that could not update, while downloading content perfectly well
|
||||
Updates were always written to external storage. On a player where that location exists but cannot
|
||||
be written to, the download failed the instant it began — before any data arrived — and reported
|
||||
only that it had failed to download or verify. Nothing pointed at a directory, and the same player
|
||||
was downloading and caching content without trouble the whole time, because content goes to
|
||||
internal storage.
|
||||
**Almost nobody is affected.** The fault is inside a block that only runs when a server is configured
|
||||
to *collect* statistics from other installs — not when it merely reports its own. That is a single
|
||||
deployment, not a normal install. If you have never set `TELEMETRY_COLLECTOR`, 1.9.35 runs correctly
|
||||
and this release changes nothing for you.
|
||||
|
||||
Updates now go to the first location that genuinely accepts them, starting with the player's own
|
||||
internal storage, which is always available. External storage is still used when it works, since
|
||||
the file remains there for a manual install. Each location is tested by writing to it rather than
|
||||
by asking whether it is writable — the previous check asked, was told yes, and the write then
|
||||
failed anyway.
|
||||
The cause was a reference to the database resolved when the file loaded rather than when the request
|
||||
arrived, in code that had been moved earlier in the same release.
|
||||
|
||||
If no location works, the player now names every one it tried and why.
|
||||
### Changed — the startup check now covers configuration only one deployment uses
|
||||
|
||||
⚠️ **A player already stuck cannot be rescued by this release.** The broken path is how updates
|
||||
arrive, and the "Push an APK" button used it too, so a player in that state needs one update
|
||||
installed by hand. Afterwards it recovers on its own and stays fixed.
|
||||
The fault above shipped through a full test suite and every CI job green, because the affected block
|
||||
is switched on by configuration that no test set. It had never executed anywhere except the one
|
||||
server that turns it on.
|
||||
|
||||
## 1.9.34-alpha12
|
||||
The startup smoke check now boots with that configuration enabled and confirms the routes it adds
|
||||
actually answer. Code that only one deployment runs is exactly the code an automated check has to
|
||||
exercise, and it now does.
|
||||
|
||||
### Fixed — updates were refused as "already newer" from alpha10 onward
|
||||
The update check compared the prerelease part of a version as plain text, so "alpha11" sorted below
|
||||
"alpha8" — because "1" comes before "8". Every build from alpha10 on was therefore treated as older
|
||||
than alpha8 and alpha9, and the server refused to offer it while naming it as the latest version in
|
||||
the same reply. A player on those builds could not be moved forward at all, and nothing about the
|
||||
failure pointed at version ordering.
|
||||
### Upgrading
|
||||
|
||||
Prerelease numbers are now compared as numbers. Ordinary precedence is unchanged: beta still comes
|
||||
after alpha, rc after beta, and a finished release still beats any prerelease of the same version.
|
||||
No migrations, no configuration changes, and no dependency changes from 1.9.35 — this release only
|
||||
alters when one value is read. Upgrading from 1.9.34 or earlier, the 1.9.35 note still applies:
|
||||
`npm ci --omit=dev` is required in both directions, which `scripts/upgrade.sh` already runs.
|
||||
|
||||
The BrightSign host package carried the same comparison and is fixed with it — there, a wrong
|
||||
answer replaces the script that starts the player.
|
||||
## 1.9.35
|
||||
|
||||
## 1.9.34-alpha11
|
||||
A maintenance release. Two faults where the product was working correctly and still looked broken to
|
||||
whoever was standing in front of the screen, plus the dependency advisories that could reach a running
|
||||
server.
|
||||
|
||||
### Fixed — an update that will not install now says why
|
||||
A panel that failed to update reported only "failed to download or failed signature verification" —
|
||||
one sentence covering seven distinct causes, three of which are download failures where
|
||||
verification never runs. Every specific reason went to logcat, which an unprivileged app cannot
|
||||
read on Android 9, so in the field the message named a symptom shared by unrelated problems.
|
||||
No migrations and no configuration changes. See the upgrade note at the end of this entry.
|
||||
|
||||
The player now reports the actual cause: the HTTP status the server returned, how many bytes
|
||||
arrived, whether the signing certificates could be read at all, whether the key genuinely differs,
|
||||
or that the download could not be written and where.
|
||||
### Fixed — a player could get stuck on an update it was never able to install
|
||||
|
||||
### Fixed — the destination is checked before downloading
|
||||
Nothing verified that the player could actually store the file. A directory path can be returned
|
||||
and still be missing, unwritable, on a volume that has gone away, or full — and all of those
|
||||
surfaced as the same opaque failure as a network fault. The player now proves the directory is
|
||||
writable (by writing to it, not by asking) and that there is room for the APK plus the copy the
|
||||
installer stages, before it starts.
|
||||
A staged update is saved under a filename built from the version the server advertised. If a server
|
||||
advertised one version while still serving the file for an older one, the player saved the old file
|
||||
under the new name — and from then on found it, verified its signature, accepted it, and installed
|
||||
something that changed nothing. The version never moved, so the same update was offered again, and the
|
||||
player retried the same no-op until it hit its attempt limit.
|
||||
|
||||
### Fixed — a readable APK is no longer refused on Android 9 and 10
|
||||
On those versions the archive's signing certificate comes from a legacy path that can return
|
||||
nothing, and the update was then refused with no way to distinguish that from a real mismatch. The
|
||||
player now reads the signature itself before giving up. Verification is unchanged: the certificate
|
||||
is still compared against the installed app, and anything unsigned, tampered with, or signed by a
|
||||
different key is still rejected.
|
||||
The signature check passed the whole time, correctly: the file was genuine, it was simply the wrong
|
||||
one. Worse, fixing the server did not help, because the bad file was reused before anything was
|
||||
downloaded. Recovery meant deleting the file on the device by hand.
|
||||
|
||||
## 1.9.34-alpha10
|
||||
A staged update is now reused only when the version inside the file matches the version being
|
||||
installed, and a fresh download is checked the same way before it is applied. A server serving the
|
||||
wrong file now says so — *"server served 1.9.33 but advertised 1.9.34 — the update on the server is
|
||||
stale"* — and the file is deleted instead of kept. That makes this self-healing: once the server is
|
||||
corrected, the player recovers on its own.
|
||||
|
||||
### Changed — install statistics report as soon as you opt in, and say when they cannot
|
||||
Turning sharing on now sends a report immediately rather than waiting for the next daily tick, and
|
||||
a failed attempt is recorded and explained. A server whose outbound traffic is filtered previously
|
||||
looked identical to one where the feature was broken: sharing on, nothing arriving, no way to tell
|
||||
which. Settings now names the address that did not answer and why, and a later success clears the
|
||||
warning.
|
||||
**Clear update cache** on the device page discards every staged update on a player. The version check
|
||||
should make it rarely necessary; it exists because a player already holding a bad file predates this
|
||||
release and cannot benefit from the check, and because the alternative is a cable and a laptop.
|
||||
|
||||
### Added — keep your own copy of the statistics
|
||||
`TELEMETRY_EXTRA_ENDPOINT` posts the same three fields to a collector you run.
|
||||
### Fixed — directory search showed the system keyboard on top of its own
|
||||
|
||||
It is **additional, not a redirect** — the shared report still goes to ScreenTinker, which is why
|
||||
it is named EXTRA rather than ENDPOINT, and Settings lists every destination a report goes to. It
|
||||
is also independent of the sharing switch, because it is your server posting to your host: if you
|
||||
want your own statistics and nothing sent to us, set it and leave sharing off. Each destination is
|
||||
attempted separately, so one being unreachable never stops the other.
|
||||
The directory-search widget draws its own on-screen keyboard, sized and themed to the panel and on by
|
||||
default. On Android it was never visible. The page puts the cursor in a real text field, which is the
|
||||
signal for the device to raise its system keyboard — over the bottom of the screen, exactly where the
|
||||
widget's keyboard is.
|
||||
|
||||
## 1.9.34-alpha9
|
||||
So a wall-mounted directory showed the phone keyboard: split across the screen, with microphone, GIF,
|
||||
emoji and a settings key that opens the keyboard vendor's own interface on a kiosk. On one panel the
|
||||
only keyboard installed was voice input, so touching the search box opened a microphone. The widget's
|
||||
own keyboard had been underneath the whole time.
|
||||
|
||||
### Added — opt-in install statistics
|
||||
ScreenTinker cannot see how widely it is deployed, because self-hosted installs are private by
|
||||
design and should stay that way. A platform administrator is now asked, once, whether this install
|
||||
will share how many screens it runs.
|
||||
When the widget draws a keyboard, it now tells the device not to raise one. Turn the built-in keyboard
|
||||
off and the system keyboard behaves as before — with nothing to cover, it is the only way left to type.
|
||||
|
||||
The whole payload is three fields — a random instance ID, the version, and the screen count — and
|
||||
nothing else: no hostnames, addresses, organization or user names, device names, content, or
|
||||
configuration. Settings → Install statistics shows the **actual payload this server would send**,
|
||||
generated live from its own data, alongside what it last really sent and when, so the claim can be
|
||||
checked rather than taken on trust. Full detail in [docs/telemetry.md](docs/telemetry.md).
|
||||
### Changed — the dependency advisories that could reach a running server are cleared
|
||||
|
||||
Off until enabled, and both answers are remembered — declining is permanent, so the prompt does not
|
||||
return after an update.
|
||||
Every high-severity advisory affecting a production install is resolved, including eight in the mail
|
||||
library covering SMTP command injection and header injection. The remaining advisories are in
|
||||
development-only tooling that is not installed on a server and cannot be reached from one.
|
||||
|
||||
The random ID exists only so repeat reports from one server count as one server. It makes a report
|
||||
pseudonymous rather than anonymous, which the wording says plainly. Because sharing is opt-in, any
|
||||
total published from this is a floor — "at least N screens" — never an estimate of the install base.
|
||||
The real-time connection to players is deliberately untouched: the fix there was a patch to the message
|
||||
parser with no change to the format players speak, so nothing about an existing player's connection
|
||||
changes.
|
||||
|
||||
## 1.9.34-alpha8
|
||||
Sending mail was previously covered only by tests that substituted the mail library for a stand-in,
|
||||
which would have stayed green through any change in the library itself. It is now also tested against
|
||||
the real one.
|
||||
|
||||
### Fixed — an APK download with no external storage went nowhere, silently
|
||||
A panel could not install an update by OTA **or** by the dashboard's "Push an APK" button. Both
|
||||
staged the download in `getExternalFilesDir()`, which returns null when external storage is
|
||||
unavailable — not exotic on signage hardware: no emulated volume, a vendor ROM that never mounts
|
||||
one, an ejected card, storage still unmounted early in boot. A null parent silently produces a
|
||||
*relative* path, so the download was written to the process working directory, which is not
|
||||
writable. The write failed and the panel reported only "failed to download or failed signature
|
||||
verification".
|
||||
### Added — an install that collects statistics can show the total on its landing page
|
||||
|
||||
Every signal pointed away from the cause: the HTTP request succeeds (the server records a served
|
||||
download at the exact second of each failure), the signing key is fine, and because nothing is ever
|
||||
written there is no partial file to find. It also never recovers — every attempt fails the same way.
|
||||
Where install statistics are being collected, the landing page can show how many screens have been
|
||||
deployed in total. It is an aggregate across every install that chooses to report, so it says nothing
|
||||
about any single one.
|
||||
|
||||
Downloads now fall back to internal storage, which cannot be unmounted.
|
||||
This does nothing on a normal install: the figure is served only where collection is switched on, so a
|
||||
private server never publishes its own screen count, and the line is hidden entirely rather than
|
||||
showing a zero.
|
||||
|
||||
⚠️ **This cannot repair a panel already affected.** The broken download path is the delivery
|
||||
mechanism, and the Push APK button shared the bug, so a panel in this state needs one manual install
|
||||
to escape it. The release protects panels that are not yet affected.
|
||||
### Changed — release notes are the written ones
|
||||
|
||||
## 1.9.34-alpha7
|
||||
Published release notes now come from this file rather than from a list of commit subjects. The
|
||||
previous release announced itself as one commit titled "chore(release)" while the entry describing it
|
||||
sat here unread.
|
||||
|
||||
### ⚠️ Upgrading to this build requires reinstalling dependencies
|
||||
### ⚠️ Upgrading from 1.9.34 reinstalls dependencies
|
||||
|
||||
The two dependency changes below — dropping `sharp` and pinning `better-sqlite3` — alter
|
||||
`server/package.json`, so **`npm ci --omit=dev` is required, not optional** — in both directions.
|
||||
The runbook's rollback step marks that command "only if dependencies changed"; for this release
|
||||
they did.
|
||||
This release changes `server/package.json`, so **`npm ci --omit=dev` is required, not optional** — in
|
||||
both directions. `scripts/upgrade.sh` already runs it, and the server repairs a missed install at
|
||||
startup where it can reach the npm registry.
|
||||
|
||||
- **Upgrading**: `scripts/upgrade.sh` already runs it. A hand-rolled deploy that skips it leaves a
|
||||
`better-sqlite3` that no longer matches `package.json`.
|
||||
- **Rolling back past this build**: the reinstall is **mandatory**. Earlier builds import `sharp` at
|
||||
runtime to thumbnail images, and this build removes it from the production dependencies — so
|
||||
rolling back the code without reinstalling leaves a server whose image ingest cannot load its
|
||||
decoder. `lib/preflight-deps.js` catches this at boot and repairs it, but do not rely on that as
|
||||
the plan.
|
||||
Docker deployments need no action; dependencies are installed inside the image.
|
||||
|
||||
Docker deployments need no action either way: dependencies are installed inside the image.
|
||||
## 1.9.34
|
||||
|
||||
No migrations, no configuration changes, no player-side changes.
|
||||
Single sign-on is the headline, rebuilt rather than extended — because of a vulnerability in what
|
||||
was there before. Alongside it: the last native image dependency is gone, several players that
|
||||
could not install updates now can, and an install can optionally report how many screens it runs.
|
||||
|
||||
No migrations and no configuration changes. See the upgrade note at the end of this entry.
|
||||
|
||||
### Fixed — the old sign-in path could be replayed by any site you had signed into
|
||||
What shipped as "OAuth" verified almost nothing. It asked whether an **access** token was valid and
|
||||
then trusted the email address that came back, never asking the only question that matters: *who was
|
||||
this token issued for?* Any other site a user had signed into — anything holding a token with the
|
||||
right scope — could replay it against ScreenTinker and receive a session as that user. No password,
|
||||
no interaction from the victim.
|
||||
|
||||
Identity now comes from an **ID token only**, with the signature checked against the provider's
|
||||
keys and `iss`, `aud`, `azp`, `exp` and `nonce` all verified. One flow for every provider:
|
||||
Authorization Code with PKCE, completed server-side. Google and Microsoft became ordinary entries
|
||||
rather than hand-written special cases, which is what removed the two paths that were wrong.
|
||||
|
||||
### Added — organizations bring their own single sign-on
|
||||
Instance-wide providers stay the default and are now unlimited in number. On top of that, an
|
||||
organization can connect its own identity provider — Entra, Okta, Auth0, Keycloak, anything speaking
|
||||
OpenID Connect — configured by that organization's own admins in Settings, with no operator
|
||||
involvement and no restart.
|
||||
|
||||
A provider may only assert addresses at domains the organization has **proved it controls**, via a
|
||||
TXT record at `_screentinker-verify.<domain>`. An unverified claim lapses after eight hours and
|
||||
releases the domain, so a typo cannot park someone else's domain indefinitely. A domain belongs to
|
||||
one organization only. Proof by delegated name (CNAME) is refused outright: it would need a wildcard
|
||||
zone we do not operate, and it would turn a subdomain takeover into an apex takeover.
|
||||
|
||||
An organization's provider never appears publicly. The login page reveals it only after someone
|
||||
enters an address at a verified domain, so a guessed domain cannot confirm who your customers are.
|
||||
|
||||
**Require single sign-on** is available per organization: passwords refused, other providers
|
||||
refused, the instance's own Google and Microsoft buttons refused — otherwise "requires SSO" would
|
||||
just be renaming the bypass. Turning it *off* again needs a platform administrator to approve the
|
||||
request, so one compromised org admin cannot quietly reopen password login. Break-glass for a
|
||||
platform administrator is the correct password and nothing else, and a wrong password returns the
|
||||
same refusal everyone else gets, so it cannot be used to discover whether an account exists.
|
||||
|
||||
⚠️ **Enabling it clears the passwords** of members at verified domains. That is not reversible
|
||||
without a reset.
|
||||
|
||||
Entra sends no `email_verified` claim, which is why a Microsoft provider is trusted on other
|
||||
grounds: an instance-wide Microsoft entry is pinned to a single directory chosen by the operator,
|
||||
and an organization's own provider is believed once it has verified a domain — the DNS proof stands
|
||||
in for the claim, since whoever controls a domain's DNS controls its mail. A provider that has
|
||||
verified nothing assumes nothing, and an explicit `email_verified: false` is refused from anyone.
|
||||
Other providers that verify addresses without saying so can opt in with
|
||||
`OIDC_<SLUG>_ASSUME_EMAIL_VERIFIED=true`.
|
||||
|
||||
**With no SSO environment variables set, the product behaves exactly as it did before.**
|
||||
|
||||
### Added — an existing account can move to single sign-on
|
||||
Signing in with a provider has always refused to take over an account that already has a password,
|
||||
and that refusal is right — otherwise anyone who could persuade a provider to assert your address
|
||||
would inherit your account. But the way out had never been built, so an account created with a
|
||||
password simply could not use single sign-on.
|
||||
|
||||
**Settings → Sign-in method** now offers it, in both directions. An account has exactly **one**
|
||||
credential: linking **deletes** the password, and the confirmation says so, because a password left
|
||||
behind is a second way in that you believe you replaced. Unlinking asks for the new password first
|
||||
and applies both changes together, so the account is never left without a way in.
|
||||
|
||||
The account being linked is the one you are **signed in as**, never whichever account matches the
|
||||
address the provider returns — that is what separates linking from the takeover the login page
|
||||
refuses. Only providers configured on this server can be linked; an organization's own provider
|
||||
cannot attach itself to an account.
|
||||
|
||||
### Changed — the login page asks who you are before how you sign in
|
||||
The password box appears once you have entered your address and continued, rather than sitting there
|
||||
from the start. That is what lets the page check whether your organization uses single sign-on
|
||||
*before* offering you a credential, so someone whose company requires it is shown that rather than a
|
||||
password box that was always going to be refused. Correcting your address takes you back a step.
|
||||
|
||||
The address is no longer looked up on every keystroke — it answered for half-finished domains,
|
||||
changed the form under you mid-address, and could exhaust a shared office network's lookup budget
|
||||
before anyone had tried to sign in. The instance's own provider buttons stay visible throughout, so
|
||||
the page no longer changes shape while you type.
|
||||
|
||||
Setup instructions for both operators and organization admins are in
|
||||
[docs/sso-setup.md](docs/sso-setup.md), written from configuring real Google and Entra applications
|
||||
— including the one that catches everyone: the Microsoft tenant setting names the directory that
|
||||
*authenticates the user*, which for personal accounts is not the directory the application is
|
||||
registered in.
|
||||
|
||||
### Changed — image processing no longer needs a native library
|
||||
Thumbnails and image measurement are now pure JavaScript, with WebAssembly decoders for webp and
|
||||
avif, running on a worker thread. Nothing in the image path is a compiled binary any more, and
|
||||
`better-sqlite3` is the only native module left.
|
||||
|
||||
A native module needs a prebuilt binary matching both the platform and the Node version; when there
|
||||
isn't one the server fails at load with an error that reads like database corruption rather than a
|
||||
missing image library. That class of failure is gone from this half of the product.
|
||||
|
||||
Format support is unchanged in practice: jpeg, png, gif, tiff and bmp decode directly, webp and avif
|
||||
through WebAssembly. `.heic` still produces no thumbnail — it never did, because the image library
|
||||
in use decodes AV1 but refuses HEVC.
|
||||
|
||||
Decoding moved off the main thread deliberately. Pure JavaScript costs about a second for a
|
||||
12-megapixel photo, which in-process would stall everything else — and the thumbnail backfill walks
|
||||
an entire library at startup, which is exactly how a maintenance task turns into missed heartbeats
|
||||
and players marked offline. Thumbnailing is slower in wall-clock terms and no longer competes with
|
||||
serving requests.
|
||||
|
||||
### Fixed — players that could not install an update
|
||||
Three separate faults, each able to strand a player on an old version.
|
||||
|
||||
**Updates were written to external storage.** Where that location is absent, or exists but cannot be
|
||||
written to, the download failed the instant it began — before any data arrived — and reported only
|
||||
that it had failed to download or verify. The same player could be caching content perfectly well
|
||||
throughout, because content goes to internal storage. Updates now go to the first location that
|
||||
genuinely accepts them, starting with internal storage, and each candidate is tested by *writing to
|
||||
it* rather than by asking whether it is writable — the previous check asked, was told yes, and the
|
||||
write failed anyway.
|
||||
|
||||
**Prerelease versions were ordered as text**, so a build numbered 10 or higher sorted below one
|
||||
numbered 8 or 9. A player on such a build was told it was already up to date and could not be moved
|
||||
forward, while the server named the newer build as latest in the same reply. Numbers in version
|
||||
names are now compared as numbers. The BrightSign host package carried the same comparison and is
|
||||
fixed with it — there, a wrong answer replaces the script that starts the player.
|
||||
|
||||
**A readable update was refused on Android 9 and 10**, where a downloaded file's signing certificate
|
||||
comes from a legacy path that can return nothing. The player now reads the signature itself before
|
||||
giving up. Verification is unchanged: the certificate is still compared against the installed app,
|
||||
and anything unsigned, tampered with, or signed by a different key is still rejected.
|
||||
|
||||
A failed update now also says which of those things went wrong, instead of one message covering
|
||||
every possible cause.
|
||||
|
||||
⚠️ **A player already stuck cannot be rescued by this release**, because the broken path is how
|
||||
updates arrive and the "Push an APK" button used it too. Such a player needs one update installed by
|
||||
hand, after which it recovers on its own and stays fixed.
|
||||
|
||||
### Fixed — the Android player could leave a band down one edge of the screen
|
||||
A panel would sometimes not fill its display: content rendered into a stage sized from a window that
|
||||
no longer existed, leaving a bar the exact size of the hidden system bar. It was intermittent
|
||||
because it depended on whether the app was measured before or after the system UI was hidden — the
|
||||
same screen could come up correct after a reboot and wrong after an app restart.
|
||||
|
||||
The stage is now measured from the current window and re-measured when focus changes, so a late
|
||||
change to the usable area is picked up instead of being baked in at startup.
|
||||
A panel would sometimes not fill its display, leaving a bar the exact size of the hidden system bar.
|
||||
It was intermittent because it depended on whether the app was measured before or after the system
|
||||
UI was hidden — the same screen could come up correct after a reboot and wrong after an app restart.
|
||||
The stage is now measured from the current window and re-measured when focus changes.
|
||||
|
||||
Reported on an RK356x Android box, where it was compounded by an unrelated HDMI mode problem;
|
||||
pinning the output resolution fixed the corruption, and this fixes the band that remained.
|
||||
|
||||
### Added — opt-in install statistics
|
||||
ScreenTinker cannot see how widely it is deployed, because self-hosted installs are private by
|
||||
design and should stay that way. A platform administrator is asked, once, whether this install will
|
||||
share how many screens it runs.
|
||||
|
||||
The whole payload is three fields — a random instance ID, the version, and the screen count — and
|
||||
nothing else: no hostnames, addresses, organization or user names, device names, content or
|
||||
configuration. Settings shows the **actual payload this server would send**, generated live from its
|
||||
own data, alongside what it last really sent and when, so the claim can be checked rather than taken
|
||||
on trust. Turning it on reports immediately, and a blocked outbound connection is named along with
|
||||
the address to allow, rather than failing silently.
|
||||
|
||||
Off until enabled, and both answers are remembered — declining is permanent, so the prompt does not
|
||||
return after an update. `TELEMETRY_EXTRA_ENDPOINT` posts the same three fields to a collector you
|
||||
run; it is **additional, not a redirect**, and independent of the sharing switch, so an operator who
|
||||
wants their own numbers and nothing sent to us can set it and leave sharing off.
|
||||
|
||||
The random ID exists only so repeat reports from one server count as one server, which makes a
|
||||
report pseudonymous rather than anonymous — the wording says so plainly. Because sharing is opt-in,
|
||||
any total published from it is a floor, never an estimate of the install base. Full detail in
|
||||
[docs/telemetry.md](docs/telemetry.md).
|
||||
|
||||
### Added — organizations may re-enable same-origin widgets, deliberately
|
||||
Widget isolation removed `allow-same-origin`, which also broke embedding for sites that enforce
|
||||
strict CORS. There is now an organization-level switch to put it back, behind a modal requiring a
|
||||
typed acknowledgement, with a persistent banner while it is on. It needs an organization owner or
|
||||
admin — a workspace admin is deliberately not enough — and the change is written to the activity
|
||||
log. Contributed by @ChrisChrome.
|
||||
|
||||
The widget editor's **Preview is excluded** from that switch. Preview renders inside the dashboard
|
||||
where the admin's session token lives, so honouring the setting there would let anyone who can
|
||||
author a widget lift the session of whichever admin clicked Preview. The setting exists so
|
||||
*displays* can embed origin-strict sites; a display holds a device token, an admin's browser does
|
||||
not.
|
||||
|
||||
### Fixed — RSS tickers ran at a speed that depended on how much news there was
|
||||
Scroll speed set a fixed total time for the whole strip to cross the screen regardless of length, so
|
||||
a feed with twenty items was dragged past in the same seconds as a feed with one — too fast to read,
|
||||
and it appeared to jump back to the start. It now holds a constant rate, so more items simply take
|
||||
proportionally longer and every item scrolls fully into and out of view. Contributed by @ChrisChrome.
|
||||
|
||||
### Fixed — user-controlled text is escaped where it reaches the page
|
||||
An audit pass over the frontend's HTML sinks, escaping the ones that receive user-controlled data.
|
||||
Also here: dashboard banners no longer overlap the sidebar, shift the layout, or vanish when
|
||||
switching views, and the main content no longer collapses to a narrow column.
|
||||
|
||||
### Added — an operations runbook
|
||||
`docs/operations.md`. How to deploy, verify, and roll back an instance in both shapes it runs in
|
||||
(git + systemd, and Docker), including what to back up first, how to tell a deploy actually worked,
|
||||
and the traps that are only obvious once they have bitten you.
|
||||
|
||||
### Changed — image processing no longer uses a native module
|
||||
`sharp` is gone. Thumbnailing and image measurement are pure JavaScript (Jimp) with WebAssembly
|
||||
codecs for webp and avif, running on a worker thread. `sharp` remains as a development dependency
|
||||
for test fixtures, so `--omit=dev` excludes it from a deployed install entirely.
|
||||
|
||||
The motivation is that a native module needs a prebuilt binary matching both the platform and the
|
||||
Node ABI; when there isn't one, the failure arrives at load time and reads like database corruption
|
||||
rather than a missing image library. `better-sqlite3` is now the only native module left.
|
||||
|
||||
Format support is unchanged in practice. jpeg, png, gif, tiff and bmp decode natively; webp and avif
|
||||
via WebAssembly. `.heic` still produces no thumbnail — it never did, because the `sharp` builds in
|
||||
use decode AV1 but refuse HEVC.
|
||||
|
||||
Images are decoded on a worker thread rather than in-process. Pure-JavaScript decoding costs about a
|
||||
second for a 12-megapixel photo, which in-process would block the event loop — and the thumbnail
|
||||
backfill walks an entire library at boot, which is exactly how a maintenance task turns into missed
|
||||
heartbeats and players marked offline. Thumbnailing is slower in wall-clock terms than the native
|
||||
library was, and no longer competes with serving requests.
|
||||
[docs/operations.md](docs/operations.md): how to deploy, verify and roll back an instance in both
|
||||
shapes it runs in, what to back up first, how to upgrade Node.js safely, and the traps that are only
|
||||
obvious once they have bitten you — including three from a Raspberry Pi 5 report, two of which are
|
||||
not Pi-specific. A piped installer cannot really ask you anything, because the pipe is its input and
|
||||
every prompt takes the default. X11 tools fail silently on Wayland, so screen blanking and cursor
|
||||
hiding can be entirely absent while appearing configured. And an overlay filesystem protects an SD
|
||||
card by discarding writes — safe for a player, quietly destructive for a server whose database is
|
||||
written continuously.
|
||||
|
||||
### Changed — `better-sqlite3` pinned to 12.9.0
|
||||
Preparation for a future Node 22 upgrade, landed separately so the runtime move and the database
|
||||
driver move stay independently reversible.
|
||||
Preparation for a future Node.js 22 upgrade, landed separately so the runtime and the database
|
||||
driver can move independently rather than as one flag day.
|
||||
|
||||
The pin is **exact on purpose**. 12.9.0 is the last release publishing prebuilt binaries for both
|
||||
the current and the next Node major; later 12.x releases dropped the older one while still
|
||||
advertising support for it in `engines`. A caret range would resolve to one of those and silently
|
||||
turn installation into a from-source compile. `lib/preflight-deps.js` explains this at the point
|
||||
anyone debugging the resulting failure would be reading.
|
||||
advertising support for it. A caret range would resolve to one of those and silently turn
|
||||
installation into a source build. Nothing in the query API changed.
|
||||
|
||||
Nothing in the query API changed — every major since 9.x was bumped only to drop end-of-life Node
|
||||
and Electron versions.
|
||||
### ⚠️ Upgrading from 1.9.33 reinstalls dependencies
|
||||
This release changes `server/package.json`, so **`npm ci --omit=dev` is required, not optional** —
|
||||
in both directions.
|
||||
|
||||
## 1.9.34-alpha6
|
||||
- **Upgrading**: `scripts/upgrade.sh` already runs it, and the server repairs a missed install at
|
||||
startup where it can reach the npm registry.
|
||||
- **Rolling back past this release**: mandatory. Earlier builds load a native image library at
|
||||
runtime that this release removes, so rolling back the code without reinstalling leaves a server
|
||||
whose image ingest cannot load its decoder.
|
||||
|
||||
### Added — a setup guide for single sign-on
|
||||
`docs/sso-setup.md`. The README explained what SSO is and listed the settings; it did not say where
|
||||
to click, which of several plausible values to use, or what a given failure means. The guide walks
|
||||
both the operator configuring Google or Microsoft for the whole instance and an organization admin
|
||||
bringing their own provider, and ends with a table of every error the sign-in can produce alongside
|
||||
what usually causes it.
|
||||
Docker deployments need no action either way; dependencies are installed inside the image.
|
||||
|
||||
Written from configuring real Google and Entra applications rather than from the code, so the
|
||||
pitfalls in it are the ones that actually cost time — most of all that the Microsoft tenant setting
|
||||
names the directory that *authenticates the user*, which for personal accounts is not the directory
|
||||
the application is registered in.
|
||||
### Known limitations
|
||||
Deliberately unresolved, and worth knowing:
|
||||
|
||||
## 1.9.34-alpha5
|
||||
|
||||
### Fixed — the Link button in Settings always said "Authentication required"
|
||||
`alpha4` shipped account linking with a button that could never work. It navigated the browser
|
||||
straight at the link endpoint, and this app's session is a token held in the page rather than a
|
||||
cookie — so the request arrived with no credentials and was refused, every time.
|
||||
|
||||
The page now asks the server for the sign-in URL first, using its session, and follows that. Nothing
|
||||
about the link itself changed.
|
||||
|
||||
## 1.9.34-alpha4
|
||||
|
||||
Two changes to how signing in works, both found by configuring real Google and Microsoft sign-in
|
||||
rather than by reading the code.
|
||||
|
||||
### Added — an existing account can move to single sign-on
|
||||
Signing in with a provider has always refused to take over an account that already has a password.
|
||||
That refusal is right: otherwise anyone who could get a provider to assert your address would inherit
|
||||
your account. But the way out — sign in with your password, then link from Settings — had never been
|
||||
built, so it was a dead end. An account created with a password simply could not use single sign-on.
|
||||
|
||||
**Settings → Sign-in method** now offers it. An account with a password can link one of the
|
||||
providers this server offers; an account on a provider can unlink back to a password.
|
||||
|
||||
An account has exactly **one** credential. Linking **deletes** the password, and the confirmation
|
||||
says so plainly, because a password left behind is a second way in that you believe you replaced.
|
||||
Unlinking asks for the new password first and applies both changes together, so the account is never
|
||||
left, even briefly, with no way to sign in.
|
||||
|
||||
The account being linked is the one you are **signed in as** — never whichever account matches the
|
||||
email the provider returns. That is what separates linking from the takeover the login page refuses.
|
||||
Only the providers configured on this server can be linked; an organization's own provider cannot
|
||||
attach itself to an account.
|
||||
|
||||
### Changed — the login page asks who you are before how you sign in
|
||||
The password box now appears once you have entered your email address and continued, rather than
|
||||
sitting there from the start. That is what lets the page ask whether your organization uses single
|
||||
sign-on *before* offering you a credential: if it does, you are shown that instead of a password box
|
||||
that was going to be refused. Correcting your address takes you back a step so the answer matches
|
||||
what you actually typed.
|
||||
|
||||
The address is no longer looked up on every keystroke. It answered for half-finished domains, changed
|
||||
the form under you mid-address, and could exhaust a shared office IP's lookup budget before anyone
|
||||
had tried to sign in.
|
||||
|
||||
Google and Microsoft buttons now stay visible throughout, including for organizations that require
|
||||
their own provider. Such a login is still refused by the server; the page no longer changes shape
|
||||
while you type.
|
||||
|
||||
## 1.9.34-alpha3
|
||||
|
||||
Completes what `alpha2` half-fixed. That release made the operator's own Microsoft button work and
|
||||
left customers' Entra tenants broken, which is the wrong way round.
|
||||
|
||||
### Fixed — a customer's own Entra tenant was refused after its domain went green
|
||||
An organization that brings its own Microsoft tenant publishes the DNS record, watches its domain
|
||||
verify, and was then refused at login with `email_unverified`. Entra sends no such claim, and an
|
||||
organization's provider was never allowed to assume one.
|
||||
|
||||
The refusal fired *after* domain confinement had already passed, so it was not the domain check doing
|
||||
its job — it was a second check asking for something Microsoft does not emit. Requiring a claim a
|
||||
provider cannot send is not a security control; it is an outage.
|
||||
|
||||
An organization's provider is now believed **once it has verified a domain**. The DNS proof is what
|
||||
stands in for the claim: whoever controls a domain's DNS controls its mail, which is the same trust
|
||||
that makes a verification link meaningful. A provider that has verified nothing still assumes
|
||||
nothing, domain confinement is unchanged, and an explicit `email_verified: false` is still refused
|
||||
from anyone.
|
||||
|
||||
The assumption follows from the proof and is never a stored setting — an organization cannot switch
|
||||
it on for itself.
|
||||
|
||||
## 1.9.34-alpha2
|
||||
|
||||
Everything in `1.9.34-alpha1`, plus one fix without which the headline feature could not be used with
|
||||
Microsoft at all.
|
||||
|
||||
### Fixed — Microsoft sign-in could never complete
|
||||
The login callback required the identity provider to assert `email_verified: true`. **Entra ID v2
|
||||
does not send that claim**, so a Microsoft login would authenticate correctly against the tenant and
|
||||
then be refused on the way back with `email_unverified`. Found while configuring a real Entra
|
||||
application, before a single sign-in was attempted; the SSO tests checked how the Microsoft issuer
|
||||
string is built but never put a Microsoft-shaped token through the policy.
|
||||
|
||||
The strict check was itself a fix — an earlier version accepted an omitted claim — and it stays
|
||||
exactly as strict for a provider a **customer** configures, because such a provider is chosen by the
|
||||
party it vouches for and its bare assertion is worth nothing. What changed is recognising this as a
|
||||
question about *who was trusted* rather than about what the token contained: an instance-wide
|
||||
provider is chosen by the operator, and a Microsoft entry is pinned to a single tenant GUID, so only
|
||||
that directory can issue a token this server will accept.
|
||||
|
||||
An explicit `email_verified: false` is still refused from anyone, and an organization's own provider
|
||||
can never make the assumption. Google is unchanged — it does send the claim.
|
||||
|
||||
Other identity providers that verify addresses without saying so in the token can opt in with
|
||||
`OIDC_<SLUG>_ASSUME_EMAIL_VERIFIED=true`.
|
||||
|
||||
### Fixed — documentation that would have cost you an afternoon
|
||||
`MICROSOFT_CLIENT_SECRET` is read by the server but was missing from the README table. The redirect
|
||||
URI must be registered under Entra's **Web** platform, not **SPA** — a SPA registration is rejected
|
||||
at the token endpoint, because this exchange runs server-side and sends no browser `Origin`. And the
|
||||
`email` optional claim has to be added under *Token configuration → ID*, or the token arrives with no
|
||||
address at all.
|
||||
|
||||
## 1.9.34-alpha1
|
||||
|
||||
**A prerelease, for the alpha instance.** It is not a production build and no display should be
|
||||
pulled onto it except deliberately: a prerelease sorts *below* its own release in semver, so a player
|
||||
that takes `1.9.34-alpha1` without being opted in will see `1.9.33` as newer and roll itself back.
|
||||
Opting a display in is what stops that.
|
||||
|
||||
The headline is single sign-on, rebuilt from nothing — and the reason it was rebuilt rather than
|
||||
extended is a vulnerability in what was there before.
|
||||
|
||||
### Fixed — the old sign-in path could be replayed by any site you had signed into
|
||||
What shipped as "OAuth" verified almost nothing. The Google path asked `tokeninfo` whether an
|
||||
**access** token was valid and then trusted the email address in the reply. The Microsoft path handed
|
||||
a bearer token to Graph `/me` and trusted that. Neither asked the only question that matters: *who
|
||||
was this token issued for?*
|
||||
|
||||
So any other site a user had signed into — anything that had requested `email` or `User.Read` — held
|
||||
a token it could replay against ScreenTinker and receive a session as that user. No password, no
|
||||
interaction from the victim.
|
||||
|
||||
Identity now comes from an **ID token only**, with signature checked against the provider's JWKS and
|
||||
`iss`, `aud`, `azp`, `exp` and `nonce` all verified. One flow for every provider: Authorization Code
|
||||
with PKCE, completed server-side. Google and Microsoft became ordinary entries rather than special
|
||||
cases, which is what removed the two hand-written paths that were wrong.
|
||||
|
||||
### Added — organizations bring their own identity provider
|
||||
Instance-wide providers stay the default and are now unlimited in number. On top of that an
|
||||
organization may configure its own provider, but only for domains it has **proved it controls** — a
|
||||
TXT record at `_screentinker-verify.<domain>`. An unverified claim lapses after eight hours and
|
||||
releases the domain, so a typo cannot park someone else's domain indefinitely.
|
||||
|
||||
Proof by delegated name (CNAME) is refused outright. It would have required a wildcard zone we do not
|
||||
operate, and worse, it would turn a subdomain takeover into an apex takeover.
|
||||
|
||||
**SSO-only** is available per organization: passwords refused, other providers refused, the instance
|
||||
Google button refused. Turning it *off* again needs a platform admin to approve the request, so one
|
||||
compromised org admin cannot quietly reopen password login. Break-glass for a platform admin is the
|
||||
correct password and nothing else — and a wrong password returns the same 403 everyone else gets, so
|
||||
it cannot be used to discover whether an account exists.
|
||||
|
||||
With no SSO environment variables set, the product behaves exactly as it did before. That was
|
||||
verified in a browser, not merely reasoned about.
|
||||
|
||||
### Added — organizations may re-enable same-origin widgets, deliberately
|
||||
Widget isolation removed `allow-same-origin`, which also broke embedding for sites that enforce strict
|
||||
CORS. There is now an org-level switch to put it back, behind a modal that requires the operator to
|
||||
type an acknowledgement, with a persistent banner while it is on. Enabling it needs an organization
|
||||
owner or admin — a workspace admin is deliberately not enough — and the change is written to the
|
||||
activity log. Contributed by @ChrisChrome.
|
||||
|
||||
The **widget editor's Preview is excluded** from that switch. Preview renders inside the dashboard,
|
||||
where the admin's session token lives, so honouring the setting there would have let anyone who can
|
||||
author a widget lift the session of whichever admin clicked Preview. The setting exists so *displays*
|
||||
can embed origin-strict sites; a display holds a device token, an admin's browser does not.
|
||||
|
||||
### Fixed — RSS tickers ran at a speed that depended on how much news there was
|
||||
`scroll_speed` was wired straight into `animation-duration`, so it set a fixed total time for the
|
||||
whole strip to cross the screen regardless of length. A feed with twenty items was dragged past in
|
||||
the same seconds as a feed with one — too fast to read, and it appeared to jump back to the start.
|
||||
It now calibrates a constant pixels-per-second rate, so more items simply take proportionally longer
|
||||
and every item scrolls fully into and out of view. Contributed by @ChrisChrome.
|
||||
|
||||
### Fixed — user-controlled text is escaped where it actually reaches HTML
|
||||
An audit pass over the frontend's HTML sinks, escaping the ones that receive user-controlled data.
|
||||
Also in this release: dashboard banners no longer overlap the sidebar, shift the layout or vanish
|
||||
when switching views, and the main content no longer collapses to a narrow column.
|
||||
|
||||
### Known limitations in this alpha
|
||||
Deliberately not resolved yet, and worth knowing before testing against them:
|
||||
|
||||
- Enabling SSO-only **clears the passwords** of members at verified domains. That is irreversible
|
||||
- Requiring single sign-on **clears the passwords** of members at verified domains, irreversibly
|
||||
without a reset.
|
||||
- The SSO-only removal queue is an availability dependency on the operator: if nobody approves, the
|
||||
organization stays SSO-only.
|
||||
- Turning that requirement back off depends on a platform administrator approving the request; if
|
||||
nobody does, the organization stays on single sign-on.
|
||||
- `landing.html` still interpolates plan names into HTML without escaping. Those values come from
|
||||
the plans table rather than from end users, so it is a loose end rather than an exposure.
|
||||
- `/api/provision` is limited to 5/min, so a twenty-display install day takes four minutes of waiting.
|
||||
Pre-existing, unchanged by this release.
|
||||
- `/api/provision` is limited to 5 requests per minute, so a twenty-display install day involves
|
||||
some waiting. Pre-existing and unchanged by this release.
|
||||
|
||||
### Thanks
|
||||
This release — and a good deal of what came before it — exists because people outside the project
|
||||
reported problems and sent patches. Credit was recorded inconsistently at the time, so it is
|
||||
collected here rather than left scattered.
|
||||
|
||||
**Code contributed**
|
||||
|
||||
- **@ChrisChrome** — the organization-level widget sandbox toggle (#254) and the RSS ticker rate fix,
|
||||
both in this release. Earlier: the Debian player/server install script (#137) and web player
|
||||
auto-connect (#6).
|
||||
- **@BlazzzPlay** — eight merged pull requests across 1.9.4 to 1.9.13: server-side preview sessions
|
||||
to work around CSP (#151), the Android hidden settings menu (#152), sending device identity on
|
||||
reconnect before pairing (#164), the dashboard version indicator and update check (#165, #181),
|
||||
authenticated thumbnail loading (#182), the server URL in the Add Display modal and the Releases
|
||||
link on the APK download page (#210), and uploads respecting the current folder (#211).
|
||||
- **@a10kiloham** — boot-time thumbnail healing with ffmpeg diagnostics and packaging (#244), the
|
||||
screenshot-request verdict toast and the reverse-proxy header pitfall it documented (#243), and a
|
||||
configurable maximum upload size (#233).
|
||||
- **@albanobattistella** — the Italian translation, and its updates since (#2, #145, #232).
|
||||
|
||||
**Reported**
|
||||
|
||||
- **@carloblu74** — the Raspberry Pi 5 report behind #245, which found five defects in the installer
|
||||
and kiosk launcher that nothing in this repository would have caught, because nothing here had ever
|
||||
executed those scripts on a Pi. The runbook notes above come from it.
|
||||
- **@bold-media-group** — by a wide margin the largest source of field reports, across roughly fifty
|
||||
issues: the OTA rollout and version-advertising problems, event-loop lag under long uptime, video
|
||||
wall behaviour, Tizen playback regressions, and the content-loading failures that led to resumable
|
||||
downloads.
|
||||
- **@Smiley-k**, **@Semetra22**, **@patrickfinardi09**, **@hapishyguy**, **@Nikhil12656**,
|
||||
**@gittyguy92** and **@Obe-BoldMediaGroup** — bug reports and feature requests across the 1.9.x
|
||||
line, including SMTP transport, playlist item scheduling, and the Android playlist-order fault
|
||||
behind #234.
|
||||
|
||||
Several of the hardest faults this year were found by someone running the product on hardware the
|
||||
project does not own. That is worth saying plainly.
|
||||
|
||||
## 1.9.33
|
||||
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ android {
|
|||
targetSdk = 34
|
||||
// Env-overridable so device-owner reinstalls (which require an ever-increasing
|
||||
// versionCode — downgrades are blocked) don't churn this file each build.
|
||||
versionCode = (System.getenv("VERSION_CODE") ?: findProperty("VERSION_CODE") as String? ?: "120").toInt()
|
||||
versionName = System.getenv("VERSION_NAME") ?: findProperty("VERSION_NAME") as String? ?: "1.9.34-alpha14"
|
||||
versionCode = (System.getenv("VERSION_CODE") ?: findProperty("VERSION_CODE") as String? ?: "123").toInt()
|
||||
versionName = System.getenv("VERSION_NAME") ?: findProperty("VERSION_NAME") as String? ?: "1.9.36"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
|
@ -87,8 +87,24 @@ dependencies {
|
|||
implementation("androidx.media3:media3-exoplayer:1.2.1")
|
||||
implementation("androidx.media3:media3-ui:1.2.1")
|
||||
|
||||
// Socket.IO client
|
||||
implementation("io.socket:socket.io-client:2.1.0")
|
||||
// Socket.IO client.
|
||||
//
|
||||
// org.json is excluded deliberately. socket.io-client pulls org.json:json:20090211
|
||||
// transitively, and that artifact was being packaged into the APK in full — 19 classes,
|
||||
// including CDL, XML, JSONML and its own Test class. It carries the JSON License, whose
|
||||
// "shall be used for Good, not Evil" clause is not OSI-approved, is treated as non-free by
|
||||
// Debian and Fedora, and is Category X at Apache. Shipping it in a commercially distributed
|
||||
// binary is an avoidable licensing problem: it is not copyleft, but it is not a licence we
|
||||
// want to have to explain.
|
||||
//
|
||||
// Nothing is lost. Android provides org.json in the platform (since API 1, and minSdk is 24),
|
||||
// and the only classes either side actually touches are JSONObject, JSONArray and JSONTokener.
|
||||
// The full method surface used — by socket.io/engine.io and by our own Kotlin — is
|
||||
// get/getString/getLong/getJSONArray/getJSONObject/has/keys/length/isNull/put/NULL,
|
||||
// the opt* family, and JSONTokener.nextValue. Every one is platform API.
|
||||
implementation("io.socket:socket.io-client:2.1.0") {
|
||||
exclude(group = "org.json", module = "json")
|
||||
}
|
||||
|
||||
// WorkManager for background downloads
|
||||
implementation("androidx.work:work-runtime-ktx:2.9.0")
|
||||
|
|
|
|||
|
|
@ -941,6 +941,14 @@ class MainActivity : AppCompatActivity() {
|
|||
if (::updateChecker.isInitialized) updateChecker.checkForUpdate(forced = true)
|
||||
}
|
||||
// #161 device-owner tooling: push + silently install an arbitrary APK from a URL.
|
||||
// Escape hatch for a panel holding a stale/bad staged APK: drop every cached file
|
||||
// so the next check downloads afresh. Only ever deletes caches.
|
||||
"clear_update_cache" -> {
|
||||
if (::updateChecker.isInitialized) {
|
||||
val n = updateChecker.clearUpdateCache()
|
||||
Log.i("MainActivity", "clear_update_cache removed $n file(s)")
|
||||
}
|
||||
}
|
||||
"install_apk" -> {
|
||||
val url = payload?.optString("url", "") ?: ""
|
||||
if (url.isNotBlank() && ::updateChecker.isInitialized) {
|
||||
|
|
|
|||
|
|
@ -415,7 +415,7 @@ class UpdateChecker(private val context: Context) {
|
|||
// #139: reuse a previously-downloaded, verified APK for this version instead of
|
||||
// re-pulling ~8.7 MB every cycle. The file also stays on disk as the artifact for a
|
||||
// manual install when silent install isn't possible.
|
||||
if (apkFile.exists() && verifyApkSignature(apkFile)) {
|
||||
if (apkFile.exists() && cachedApkIs(apkFile, version) && verifyApkSignature(apkFile)) {
|
||||
Log.i(TAG, "Reusing cached verified APK: ${apkFile.absolutePath} (${apkFile.length()} bytes)")
|
||||
handler.post { installApk(apkFile) }
|
||||
return true
|
||||
|
|
@ -448,6 +448,17 @@ class UpdateChecker(private val context: Context) {
|
|||
// Verify the downloaded APK is our package AND signed by the same key as
|
||||
// the currently-installed app before installing. An attacker can't forge
|
||||
// our signature, so this holds even over an untrusted transport.
|
||||
// The server advertises a version and separately serves a file; the two can drift. A
|
||||
// stale APK behind a current version number installs as a NO-OP, so the version never
|
||||
// changes, the update is attempted again, and the panel loops until its attempts are
|
||||
// spent — reporting a download failure, which it is not. Say what actually happened.
|
||||
if (!cachedApkIs(apkFile, version)) {
|
||||
val got = apkVersionName(apkFile) ?: "unreadable"
|
||||
lastFailure = "server served $got but advertised $version — the update on the server is stale"
|
||||
Log.e(TAG, "Version mismatch: advertised $version, downloaded $got")
|
||||
apkFile.delete()
|
||||
return false
|
||||
}
|
||||
if (!verifyApkSignature(apkFile)) {
|
||||
// lastFailure was set precisely inside verifyApkSignature; keep it, and add the
|
||||
// size so a truncated download is distinguishable from a genuine cert mismatch.
|
||||
|
|
@ -585,6 +596,52 @@ class UpdateChecker(private val context: Context) {
|
|||
|
||||
// True only if the downloaded APK is this same package and shares a signing
|
||||
// certificate with the installed app. Fail-closed on any error.
|
||||
/* The versionName inside an APK file, or null if it cannot be read. */
|
||||
private fun apkVersionName(apkFile: File): String? = try {
|
||||
context.packageManager.getPackageArchiveInfo(apkFile.absolutePath, 0)?.versionName
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "Could not read version from ${apkFile.name}: ${e.message}")
|
||||
null
|
||||
}
|
||||
|
||||
/*
|
||||
* Is this file actually the version we mean to install?
|
||||
*
|
||||
* The cache is keyed by FILENAME, and the filename is built from the version the server
|
||||
* advertised — so a file called ScreenTinker-1.9.34.apk containing 1.9.33 passes a signature
|
||||
* check (same key), gets reused on every attempt, and installs as a no-op forever. Fixing the
|
||||
* server does not clear it; only deleting the file does. Checking the version inside makes that
|
||||
* self-healing instead of needing a hand on the device.
|
||||
*/
|
||||
private fun cachedApkIs(apkFile: File, version: String): Boolean {
|
||||
val got = apkVersionName(apkFile) ?: return false
|
||||
if (got == version) return true
|
||||
Log.w(TAG, "Cached ${apkFile.name} contains $got, expected $version — discarding")
|
||||
return false
|
||||
}
|
||||
|
||||
/*
|
||||
* Delete every staged APK. The escape hatch for a panel holding a bad download: it forces the
|
||||
* next check to fetch again rather than reuse. Safe at any time — these are only ever caches,
|
||||
* re-fetched on demand.
|
||||
*/
|
||||
fun clearUpdateCache(): Int {
|
||||
var n = 0
|
||||
for (dir in listOfNotNull(
|
||||
File(context.filesDir, "Download"),
|
||||
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
|
||||
File(context.cacheDir, "Download"),
|
||||
)) {
|
||||
val files = try { dir.listFiles() } catch (_: Throwable) { null } ?: continue
|
||||
for (f in files) {
|
||||
if (!f.name.endsWith(".apk")) continue
|
||||
if (f.delete()) n++
|
||||
}
|
||||
}
|
||||
report("info", "Update cache cleared ($n file(s)) — the next check will download afresh")
|
||||
return n
|
||||
}
|
||||
|
||||
private fun verifyApkSignature(apkFile: File): Boolean {
|
||||
return try {
|
||||
val pm = context.packageManager
|
||||
|
|
|
|||
45
android/licenses.json
Normal file
45
android/licenses.json
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"_comment": [
|
||||
"Licence policy for everything on the Android release runtime classpath — i.e. everything that",
|
||||
"can end up inside the APK customers install.",
|
||||
"",
|
||||
"scripts/android-license-check.js resolves the real classpath and checks it against this file.",
|
||||
"An artifact that appears in neither 'artifacts' nor 'groups' FAILS: a new transitive dependency",
|
||||
"must be looked at by a person before it ships, which is exactly how org.json:json:20090211 got",
|
||||
"into the APK unnoticed in the first place.",
|
||||
"",
|
||||
"Record what you verified in 'evidence' — the point is to be able to answer 'how do you know?'"
|
||||
],
|
||||
|
||||
"groups": {
|
||||
"androidx": { "license": "Apache-2.0", "evidence": "AndroidX / Jetpack, Apache-2.0 across the board" },
|
||||
"com.google.android.material": { "license": "Apache-2.0", "evidence": "Material Components for Android" },
|
||||
"com.google.code.gson": { "license": "Apache-2.0", "evidence": "google/gson LICENSE" },
|
||||
"com.google.crypto.tink": { "license": "Apache-2.0", "evidence": "google/tink LICENSE" },
|
||||
"com.google.errorprone": { "license": "Apache-2.0", "evidence": "google/error-prone LICENSE" },
|
||||
"com.google.guava": { "license": "Apache-2.0", "evidence": "google/guava LICENSE" },
|
||||
"com.google.j2objc": { "license": "Apache-2.0", "evidence": "google/j2objc LICENSE" },
|
||||
"com.squareup.okhttp3": { "license": "Apache-2.0", "evidence": "square/okhttp LICENSE" },
|
||||
"com.squareup.okio": { "license": "Apache-2.0", "evidence": "square/okio LICENSE" },
|
||||
"org.checkerframework": { "license": "MIT", "evidence": "checker-framework, MIT for the qualifiers" },
|
||||
"org.jetbrains": { "license": "Apache-2.0", "evidence": "JetBrains annotations" },
|
||||
"org.jetbrains.kotlin": { "license": "Apache-2.0", "evidence": "Kotlin stdlib" },
|
||||
"org.jetbrains.kotlinx": { "license": "Apache-2.0", "evidence": "kotlinx coroutines" },
|
||||
"io.socket": { "license": "MIT", "evidence": "socket.io-client-java LICENSE (MIT)" }
|
||||
},
|
||||
|
||||
"artifacts": {},
|
||||
|
||||
"denied": {
|
||||
"org.json:json": {
|
||||
"why": "JSON Licence — the 'shall be used for Good, not Evil' clause. Not OSI-approved, non-free per Debian and Fedora, Apache Category X. Arrives transitively via socket.io-client and was previously packaged into the APK in full (19 classes). Excluded in app/build.gradle.kts; Android provides org.json in the platform from API 1 and minSdk is 24, so nothing is lost."
|
||||
}
|
||||
},
|
||||
|
||||
"denied_licenses": [
|
||||
{ "match": "AGPL", "why": "network copyleft" },
|
||||
{ "match": "GPL", "why": "strong copyleft in a commercially distributed binary" },
|
||||
{ "match": "SSPL", "why": "not OSI-approved, service-scope obligations" },
|
||||
{ "match": "JSON", "why": "field-of-use restriction" }
|
||||
]
|
||||
}
|
||||
103
docs/licensing.md
Normal file
103
docs/licensing.md
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
# Licensing
|
||||
|
||||
ScreenTinker is MIT. This page records how we know what our dependencies are licensed under,
|
||||
so the answer to "do you track licences?" is something you can check rather than something you
|
||||
have to take on trust.
|
||||
|
||||
## The short answer
|
||||
|
||||
**No GPL or AGPL anywhere in the product.** Neither the server nor the Android player links,
|
||||
bundles, or ships anything under strong or network copyleft.
|
||||
|
||||
## Where the answer comes from
|
||||
|
||||
Two gates run in CI on every push, and both fail closed — a dependency whose licence nobody has
|
||||
recorded fails the build rather than shipping unnoticed.
|
||||
|
||||
| Gate | Covers | Script |
|
||||
|---|---|---|
|
||||
| Licence gate + SBOM (production deps) | the server's npm tree | `scripts/license-check.js` |
|
||||
| Licence gate (APK runtime classpath) | everything that can enter the APK | `scripts/android-license-check.js` |
|
||||
|
||||
Run either locally:
|
||||
|
||||
```sh
|
||||
cd server && npm ci --omit=dev && cd ..
|
||||
node scripts/license-check.js # server
|
||||
node scripts/android-license-check.js # APK
|
||||
node scripts/license-check.js --sbom sbom/x.json # also write an SBOM
|
||||
```
|
||||
|
||||
Neither script has dependencies of its own. A gate that needs its own supply chain audited is
|
||||
worth less than one that doesn't.
|
||||
|
||||
## ⚠️ Audit the production install, not the checkout
|
||||
|
||||
**A licence scanner pointed at a developer checkout will report LGPL, and it will be wrong about
|
||||
what we ship.**
|
||||
|
||||
`sharp` is a `devDependency` — a fixture generator for the image tests — and one of its platform
|
||||
binaries, `@img/sharp-wasm32`, declares `Apache-2.0 AND LGPL-3.0-or-later AND MIT`. It is never
|
||||
installed on a server: production installs with `npm ci --omit=dev`, which both the CI gate and
|
||||
`scripts/upgrade.sh` use.
|
||||
|
||||
If someone challenges the answer with a scan of the repo, this is the discrepancy they have found.
|
||||
|
||||
`sharp` is kept deliberately: it is the *independent* implementation used to generate fixtures for
|
||||
the pure-JavaScript image path that replaced it. Generating those fixtures with the library under
|
||||
test would mean a decode bug could produce a fixture that hides the same bug.
|
||||
|
||||
## Policy
|
||||
|
||||
**Allowed** — MIT, MIT-0, ISC, 0BSD, BSD-2-Clause, BSD-3-Clause, Apache-2.0, BlueOak-1.0.0,
|
||||
Unlicense, CC0-1.0, Python-2.0, WTFPL, Zlib, CC-BY-4.0.
|
||||
|
||||
**Denied** — AGPL, GPL, SSPL, Commons Clause, BUSL, and the JSON Licence.
|
||||
|
||||
**Reported but not failed** — LGPL, MPL, EPL, CDDL, OSL, EUPL. Weak copyleft is file- or
|
||||
library-scoped and usually fine when merely linked, but it is a judgement, and the judgement should
|
||||
be made by someone who knows they are making it.
|
||||
|
||||
**Unrecognised — fails.** A package with no licence we can identify is not a package we ship. Where
|
||||
a dependency ships a real licence *file* but declares no `license` field, it is recorded as an
|
||||
exception in the script with the evidence that was read off disk (currently `exif-parser` and
|
||||
`thirty-two`, both MIT).
|
||||
|
||||
### Why the JSON Licence is denied
|
||||
|
||||
`org.json:json:20090211` arrived transitively through `socket.io-client` and was **packaged into the
|
||||
APK in full** — 19 classes, including ones nothing referenced. Its licence carries the clause *"The
|
||||
Software shall be used for Good, not Evil"*: not OSI-approved, treated as non-free by Debian and
|
||||
Fedora, and Category X at Apache. Not copyleft, but not a term to accept in a binary distributed
|
||||
commercially.
|
||||
|
||||
It is now excluded in `android/app/build.gradle.kts`. Nothing is lost — Android has provided
|
||||
`org.json` in the platform since API 1 and `minSdk` is 24 — and `android/licenses.json` denies it by
|
||||
name so it cannot return quietly.
|
||||
|
||||
## SBOM
|
||||
|
||||
Every release publishes `screentinker-sbom-<version>.cdx.json`: **CycloneDX 1.5**, listing every
|
||||
production dependency with its version, package URL, and licence. Generated from a production
|
||||
install, so it describes what actually runs.
|
||||
|
||||
CI also uploads one as a build artifact on every run.
|
||||
|
||||
## Vendored code
|
||||
|
||||
Anything committed under `frontend/vendor/` **ships in the release tarball** and must carry its
|
||||
licence notice as a separate file — minifiers strip headers, which is exactly when the notice has to
|
||||
be kept alongside. See `frontend/vendor/README.md`.
|
||||
|
||||
## The GLSL transitions
|
||||
|
||||
The 14 shaders in `shared/Transitions/` are original work. Each carries its author and licence in
|
||||
the file header, and none derives from Shadertoy, gl-transitions, glslsandbox or similar. "GL
|
||||
Transitions v1" in those headers refers to the *interface convention* — the function signature the
|
||||
renderer calls — not to borrowed code.
|
||||
|
||||
## Limits
|
||||
|
||||
These gates identify licences from declared metadata and recorded evidence. They are not a
|
||||
clean-room provenance review, and they do not detect code copied into the repository without
|
||||
attribution.
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
openapi: 3.1.0
|
||||
info:
|
||||
title: ScreenTinker Public API
|
||||
version: 1.9.34
|
||||
version: 1.9.36
|
||||
description: |
|
||||
Public, token-scoped REST API for ScreenTinker digital signage.
|
||||
|
||||
|
|
|
|||
|
|
@ -683,6 +683,8 @@ export default {
|
|||
'device.ctl.screen_on': 'Screen On',
|
||||
'device.ctl.launch_player': 'Launch Player',
|
||||
'device.ctl.force_update': 'Force Update',
|
||||
'device.ctl.clear_update_cache': 'Clear Update Cache',
|
||||
'device.ctl.clear_update_cache_tip': 'Delete any update file this display has already downloaded, so the next check fetches a fresh copy. Use if updates keep failing.',
|
||||
'device.ctl.shutdown': 'Shutdown',
|
||||
// Remote tab
|
||||
'device.remote.start_prompt': 'Click "Start Remote" to begin',
|
||||
|
|
@ -773,6 +775,7 @@ export default {
|
|||
'device.toast.screen_on_sent': 'Screen on command sent',
|
||||
'device.toast.launch_sent': 'Launch command sent',
|
||||
'device.toast.update_triggered': 'Update check triggered',
|
||||
'device.toast.update_cache_cleared': 'Update cache cleared — the next check will download afresh',
|
||||
'device.toast.remote_started': 'Remote session started',
|
||||
'device.toast.command_queued': '{cmd} — device offline, will deliver on reconnect',
|
||||
'device.toast.command_undeliverable': '{cmd} — device offline and queue unavailable',
|
||||
|
|
|
|||
|
|
@ -501,6 +501,12 @@ async function loadDevice(deviceId, activeTab = null) {
|
|||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
${t('device.ctl.force_update')}
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" id="clearUpdateCacheBtn" title="${t('device.ctl.clear_update_cache_tip')}">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
</svg>
|
||||
${t('device.ctl.clear_update_cache')}
|
||||
</button>` : ''}
|
||||
${can('system.reboot') ? `
|
||||
<button class="btn btn-danger btn-sm" id="shutdownBtn">
|
||||
|
|
@ -1593,6 +1599,12 @@ function setupActions(device) {
|
|||
sendWithFeedback('update', 'Update', 'device.toast.update_triggered');
|
||||
});
|
||||
|
||||
// Drops every staged APK on the panel so the next check downloads afresh. The escape hatch for a
|
||||
// player holding a bad download — a cached file that cannot install but is reused every attempt.
|
||||
document.getElementById('clearUpdateCacheBtn')?.addEventListener('click', () => {
|
||||
sendWithFeedback('clear_update_cache', 'Clear update cache', 'device.toast.update_cache_cleared');
|
||||
});
|
||||
|
||||
// #109: PiP overlay tester — pushes/clears an overlay via the public API (POST /api/pip).
|
||||
document.getElementById('sendPipBtn')?.addEventListener('click', async () => {
|
||||
const uri = (document.getElementById('pipUri')?.value || '').trim();
|
||||
|
|
|
|||
|
|
@ -210,6 +210,14 @@
|
|||
</a>
|
||||
<a href="#compare" class="btn btn-outline" style="padding:14px 28px;font-size:16px">See How We Compare</a>
|
||||
</div>
|
||||
|
||||
<!-- Live deployment count. Hidden until the number arrives, so a self-hosted instance (where
|
||||
the endpoint does not exist) and a brand-new one (where the count is 0) show nothing at
|
||||
all rather than an empty frame or a zero. -->
|
||||
<p id="deployed-stat" hidden style="margin-top:28px;color:var(--muted);font-size:15px">
|
||||
<strong id="deployed-count" style="color:var(--text,inherit);font-variant-numeric:tabular-nums"></strong>
|
||||
screens deployed with ScreenTinker
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Intro video -->
|
||||
|
|
@ -630,6 +638,19 @@
|
|||
// replace it on the public marketing page with a hardcoded Contact Us
|
||||
// card. Other consumers of /api/subscription/plans (billing.js,
|
||||
// settings.js, admin.js) get the full list as before.
|
||||
/* Screens deployed. Only the deployment that collects install statistics answers this;
|
||||
everywhere else it 404s and the line stays hidden. Failures are silent by design —
|
||||
a marketing page must not show a broken stat, and there is nothing a visitor could
|
||||
do about it. */
|
||||
fetch('/api/public/stats')
|
||||
.then(r => (r.ok ? r.json() : null))
|
||||
.then(s => {
|
||||
if (!s || !(s.screens > 0)) return;
|
||||
document.getElementById('deployed-count').textContent = s.screens.toLocaleString();
|
||||
document.getElementById('deployed-stat').hidden = false;
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
fetch('/api/subscription/plans').then(r => r.json()).then(plans => {
|
||||
const grid = document.getElementById('pricingGrid');
|
||||
const publicPlans = plans.filter(p => p.active && p.name !== 'enterprise');
|
||||
|
|
|
|||
7
frontend/vendor/README.md
vendored
7
frontend/vendor/README.md
vendored
|
|
@ -4,9 +4,16 @@ Third-party libraries committed directly to the repo (not fetched from a CDN or
|
|||
from npm) so self-hosted / air-gapped instances work with no external dependency and no
|
||||
build step.
|
||||
|
||||
**Anything added here ships in the release tarball**, so it must carry its licence notice —
|
||||
a minified bundle usually has its headers stripped, which is exactly when the notice has to
|
||||
be kept as a separate file next to it. Record the licence below and add a `<name>.LICENSE`.
|
||||
|
||||
## redoc.standalone.js
|
||||
- **Library:** Redoc — renders the OpenAPI reference served at `/docs`.
|
||||
- **Version:** 2.3.9
|
||||
- **Licence:** MIT — Copyright (c) 2015-present, Rebilly, Inc. Full text in
|
||||
[`redoc.LICENSE`](redoc.LICENSE). The bundle itself carries no header (stripped by the
|
||||
upstream minifier), which is why the notice is kept separately.
|
||||
- **Source:** https://cdn.redoc.ly/redoc/v2.3.9/bundles/redoc.standalone.js
|
||||
- **Why committed:** the API reference must render on offline instances — no CDN, no build step.
|
||||
- **Regenerate / update:**
|
||||
|
|
|
|||
31
frontend/vendor/redoc.LICENSE
vendored
Normal file
31
frontend/vendor/redoc.LICENSE
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
Redoc — https://github.com/Redocly/redoc
|
||||
Version vendored here: 2.3.9 (see redoc.standalone.js)
|
||||
|
||||
The bundle in this directory is minified and its license headers were stripped upstream, so
|
||||
the notice is kept alongside it instead. MIT requires this notice to accompany the software
|
||||
wherever it is distributed, and redoc.standalone.js is included in the ScreenTinker release
|
||||
tarball.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015-present, Rebilly, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
112
scripts/android-license-check.js
Normal file
112
scripts/android-license-check.js
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* Licence gate for the APK.
|
||||
*
|
||||
* node scripts/android-license-check.js [--sbom <path>]
|
||||
*
|
||||
* Resolves the real `releaseRuntimeClasspath` — every artifact that can end up inside the APK a
|
||||
* customer installs, transitive ones included — and checks each against android/licenses.json.
|
||||
*
|
||||
* Fails on an artifact nobody has recorded a licence for. That is the case worth catching:
|
||||
* org.json:json:20090211 reached customers because it arrived as a transitive dependency of
|
||||
* socket.io-client and nothing ever asked what licence it carried.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const ANDROID = path.join(ROOT, 'android');
|
||||
const POLICY = JSON.parse(fs.readFileSync(path.join(ANDROID, 'licenses.json'), 'utf8'));
|
||||
const SBOM_OUT = process.argv.includes('--sbom') ? process.argv[process.argv.indexOf('--sbom') + 1] : null;
|
||||
|
||||
function resolveClasspath() {
|
||||
const out = execFileSync('./gradlew', ['-q', 'app:dependencies', '--configuration', 'releaseRuntimeClasspath'],
|
||||
{ cwd: ANDROID, maxBuffer: 64 * 1024 * 1024, encoding: 'utf8' });
|
||||
const found = new Map();
|
||||
for (const raw of out.split('\n')) {
|
||||
// Gradle prints "group:name:requested -> resolved" when a version is upgraded; the resolved
|
||||
// one is what ships, so prefer the right-hand side.
|
||||
const m = raw.match(/([a-zA-Z0-9._-]+):([a-zA-Z0-9._-]+):([0-9][a-zA-Z0-9._-]*)(?:\s*->\s*([0-9][a-zA-Z0-9._-]*))?/);
|
||||
if (!m) continue;
|
||||
const [, group, name, requested, upgraded] = m;
|
||||
found.set(`${group}:${name}`, { group, name, version: upgraded || requested });
|
||||
}
|
||||
return [...found.values()].sort((a, b) => `${a.group}:${a.name}`.localeCompare(`${b.group}:${b.name}`));
|
||||
}
|
||||
|
||||
function licenceFor(a) {
|
||||
const coord = `${a.group}:${a.name}`;
|
||||
if (POLICY.denied[coord]) return { verdict: 'DENY', why: POLICY.denied[coord].why };
|
||||
if (POLICY.artifacts[coord]) return { verdict: 'ALLOW', ...POLICY.artifacts[coord] };
|
||||
// Longest matching group prefix wins, so a specific rule beats a broad one.
|
||||
const groups = Object.keys(POLICY.groups)
|
||||
.filter(g => a.group === g || a.group.startsWith(g + '.'))
|
||||
.sort((x, y) => y.length - x.length);
|
||||
if (groups.length) return { verdict: 'ALLOW', ...POLICY.groups[groups[0]] };
|
||||
return { verdict: 'UNKNOWN' };
|
||||
}
|
||||
|
||||
const artifacts = resolveClasspath();
|
||||
if (!artifacts.length) {
|
||||
console.error('Resolved no artifacts — the gradle task did not run properly. Refusing to pass.');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const results = artifacts.map(a => ({ ...a, ...licenceFor(a) }));
|
||||
const denied = results.filter(r => r.verdict === 'DENY');
|
||||
const unknown = results.filter(r => r.verdict === 'UNKNOWN');
|
||||
|
||||
for (const r of results.filter(r => r.verdict === 'ALLOW')) {
|
||||
for (const d of POLICY.denied_licenses) {
|
||||
if (new RegExp(d.match, 'i').test(r.license)) { denied.push({ ...r, why: `${r.license}: ${d.why}` }); }
|
||||
}
|
||||
}
|
||||
|
||||
const counts = results.reduce((m, r) => (m[r.license || '(unrecorded)'] = (m[r.license || '(unrecorded)'] || 0) + 1, m), {});
|
||||
console.log(`\nScope: ${results.length} artifacts on releaseRuntimeClasspath (everything that can enter the APK)\n`);
|
||||
Object.entries(counts).sort((a, b) => b[1] - a[1]).forEach(([l, n]) => console.log(` ${String(n).padStart(4)} ${l}`));
|
||||
|
||||
if (SBOM_OUT) {
|
||||
const sbom = {
|
||||
bomFormat: 'CycloneDX',
|
||||
specVersion: '1.5',
|
||||
version: 1,
|
||||
metadata: {
|
||||
component: {
|
||||
type: 'application',
|
||||
name: 'screentinker-android-player',
|
||||
version: fs.readFileSync(path.join(ROOT, 'VERSION'), 'utf8').trim(),
|
||||
licenses: [{ license: { id: 'MIT' } }],
|
||||
},
|
||||
},
|
||||
components: results.map(r => ({
|
||||
type: 'library',
|
||||
name: `${r.group}:${r.name}`,
|
||||
version: r.version,
|
||||
purl: `pkg:maven/${r.group}/${r.name}@${r.version}`,
|
||||
licenses: r.license ? [{ license: { id: r.license } }] : [],
|
||||
})),
|
||||
};
|
||||
fs.mkdirSync(path.dirname(SBOM_OUT), { recursive: true });
|
||||
fs.writeFileSync(SBOM_OUT, JSON.stringify(sbom, null, 2));
|
||||
console.log(`\nSBOM: ${SBOM_OUT} (${sbom.components.length} components, CycloneDX 1.5)`);
|
||||
}
|
||||
|
||||
let failed = false;
|
||||
if (denied.length) {
|
||||
failed = true;
|
||||
console.log('\nDENIED');
|
||||
denied.forEach(r => console.log(` ${r.group}:${r.name}:${r.version}\n ${r.why}`));
|
||||
}
|
||||
if (unknown.length) {
|
||||
failed = true;
|
||||
console.log('\nUNRECORDED — a new dependency reached the APK with no licence on file.');
|
||||
console.log('Look it up, then add it to android/licenses.json with evidence, or exclude it.');
|
||||
unknown.forEach(r => console.log(` ${r.group}:${r.name}:${r.version}`));
|
||||
}
|
||||
console.log(failed ? '\nFAIL: licence policy violated.\n' : '\nOK: every artifact in the APK has a recorded, permitted licence.\n');
|
||||
process.exit(failed ? 1 : 0);
|
||||
184
scripts/license-check.js
Normal file
184
scripts/license-check.js
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* Licence gate for the dependencies that actually SHIP.
|
||||
*
|
||||
* node scripts/license-check.js [--sbom <path>] [--include-dev]
|
||||
*
|
||||
* Run from a PRODUCTION install (`npm ci --omit=dev`). That is the whole point: a developer
|
||||
* checkout carries `sharp`, whose `@img/sharp-wasm32` declares LGPL-3.0-or-later. It is a test
|
||||
* fixture generator, it is devDependencies-only, and it never reaches a server — but a scanner
|
||||
* pointed at a dev tree reports LGPL and contradicts the answer we give customers. Auditing the
|
||||
* installed production tree is what makes the answer defensible.
|
||||
*
|
||||
* Exits non-zero on anything denied or unresolved, so CI fails before a licence can arrive
|
||||
* unnoticed through a transitive bump.
|
||||
*
|
||||
* No dependencies, deliberately — a gate that needs its own supply chain audited is worth less.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const INCLUDE_DEV = args.includes('--include-dev');
|
||||
const SBOM_OUT = args.includes('--sbom') ? args[args.indexOf('--sbom') + 1] : null;
|
||||
const SERVER_DIR = path.join(__dirname, '..', 'server');
|
||||
|
||||
/* ── policy ───────────────────────────────────────────────────────────────────
|
||||
* ALLOW: permissive, no distribution obligation beyond keeping the notice.
|
||||
* DENY: strong/network copyleft, plus licences we will not ship for other reasons.
|
||||
* Anything matching neither is REVIEW — it fails, and a human decides. Failing closed
|
||||
* matters more than being clever: the risk is a licence arriving that nobody looked at.
|
||||
*/
|
||||
const ALLOW = [
|
||||
/^MIT$/i, /^MIT-0$/i, /^ISC$/i, /^0BSD$/i, /^BSD-2-Clause$/i, /^BSD-3-Clause$/i,
|
||||
/^Apache-2\.0$/i, /^BlueOak-1\.0\.0$/i, /^Unlicense$/i, /^CC0-1\.0$/i, /^Python-2\.0$/i,
|
||||
/^WTFPL$/i, /^Zlib$/i, /^CC-BY-4\.0$/i,
|
||||
];
|
||||
|
||||
const DENY = [
|
||||
{ re: /\bAGPL/i, why: 'network copyleft — obligations trigger on serving, not distributing' },
|
||||
{ re: /\bGPL-[123]|\bGPLv[123]|(^|[^L])\bGPL\b/i, why: 'strong copyleft — links into a product we distribute commercially' },
|
||||
{ re: /\bSSPL/i, why: 'server-side public licence — not OSI-approved, service-scope obligations' },
|
||||
{ re: /\bCommons-Clause/i, why: 'commercial-use restriction' },
|
||||
{ re: /\bBUSL|Business Source/i, why: 'source-available, not open source' },
|
||||
{ re: /Good, not Evil|^JSON$/i, why: 'JSON Licence — field-of-use clause, Apache Category X, non-free per Debian/Fedora' },
|
||||
];
|
||||
|
||||
// Weak copyleft: file- or library-scoped, generally fine when merely linked, but never silently.
|
||||
const REVIEW = [/\bLGPL/i, /\bMPL/i, /\bEPL/i, /\bCDDL/i, /\bOSL/i, /\bEUPL/i, /\bCPL/i];
|
||||
|
||||
/*
|
||||
* Packages that ship a real licence FILE but declare no `license` field in package.json.
|
||||
* Each entry records what was read off disk, so this is a documented finding rather than a
|
||||
* blanket exemption. Re-verify if the version changes.
|
||||
*/
|
||||
const EXCEPTIONS = {
|
||||
'exif-parser': { license: 'MIT', evidence: 'LICENSE.md — "The MIT License"' },
|
||||
'thirty-two': { license: 'MIT', evidence: 'LICENSE.txt — MIT, Copyright (c) 2011 Chris Umbel' },
|
||||
'screentinker': { license: 'MIT', evidence: 'repository root LICENSE' },
|
||||
};
|
||||
|
||||
function classify(id) {
|
||||
if (!id) return { verdict: 'UNKNOWN' };
|
||||
for (const d of DENY) if (d.re.test(id)) return { verdict: 'DENY', why: d.why };
|
||||
// A GPL-with-exception (Classpath, linking) is not the thing we are guarding against.
|
||||
if (/WITH .*exception/i.test(id)) return { verdict: 'REVIEW', why: 'copyleft with a linking exception' };
|
||||
for (const r of REVIEW) if (r.test(id)) return { verdict: 'REVIEW', why: 'weak copyleft' };
|
||||
// Composite expressions: every term must be allowed.
|
||||
const terms = id.split(/\s+(?:OR|AND)\s+|[()]/).map(s => s.trim()).filter(Boolean);
|
||||
if (terms.length && terms.every(t => ALLOW.some(a => a.test(t)))) return { verdict: 'ALLOW' };
|
||||
return { verdict: 'UNKNOWN' };
|
||||
}
|
||||
|
||||
function readLicense(dir) {
|
||||
let pkg;
|
||||
try { pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); } catch { return null; }
|
||||
let lic = pkg.license;
|
||||
if (lic && typeof lic === 'object') lic = lic.type;
|
||||
if (!lic && Array.isArray(pkg.licenses)) lic = pkg.licenses.map(l => l.type || l).join(' OR ');
|
||||
return { name: pkg.name, version: pkg.version, license: lic || null };
|
||||
}
|
||||
|
||||
/*
|
||||
* `npm ls` exits non-zero for any tree problem — an extraneous package, a peer-dep complaint —
|
||||
* while still printing the full listing. Treating that as fatal would turn a routine tree quirk
|
||||
* into an unexplained CI failure, and worse, a licence check that never actually ran. Read the
|
||||
* output either way; a genuinely empty result is the only thing worth aborting on.
|
||||
*/
|
||||
function listInstalled() {
|
||||
const argv = ['ls', ...(INCLUDE_DEV ? [] : ['--omit=dev']), '--all', '--parseable'];
|
||||
const opts = { cwd: SERVER_DIR, maxBuffer: 64 * 1024 * 1024, encoding: 'utf8' };
|
||||
try {
|
||||
return execFileSync('npm', argv, opts);
|
||||
} catch (e) {
|
||||
if (e.stdout && e.stdout.trim()) return e.stdout;
|
||||
console.error('npm ls produced no output:\n' + (e.stderr || e.message));
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
const dirs = listInstalled().split('\n').filter(Boolean);
|
||||
|
||||
const pkgs = [];
|
||||
const seen = new Set();
|
||||
for (const d of dirs) {
|
||||
const info = readLicense(d);
|
||||
if (!info || !info.name) continue;
|
||||
const key = `${info.name}@${info.version}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
let license = info.license;
|
||||
let note = null;
|
||||
if (!license && EXCEPTIONS[info.name]) {
|
||||
license = EXCEPTIONS[info.name].license;
|
||||
note = `no license field; ${EXCEPTIONS[info.name].evidence}`;
|
||||
}
|
||||
pkgs.push({ ...info, license, note, ...classify(license) });
|
||||
}
|
||||
|
||||
const denied = pkgs.filter(p => p.verdict === 'DENY');
|
||||
const review = pkgs.filter(p => p.verdict === 'REVIEW');
|
||||
const unknown = pkgs.filter(p => p.verdict === 'UNKNOWN');
|
||||
|
||||
const counts = pkgs.reduce((m, p) => (m[p.license || '(none)'] = (m[p.license || '(none)'] || 0) + 1, m), {});
|
||||
console.log(`\nScope: ${pkgs.length} packages (${INCLUDE_DEV ? 'INCLUDING dev' : 'production only, --omit=dev'})\n`);
|
||||
Object.entries(counts).sort((a, b) => b[1] - a[1]).forEach(([l, n]) => console.log(` ${String(n).padStart(4)} ${l}`));
|
||||
|
||||
if (SBOM_OUT) {
|
||||
// CycloneDX 1.5, hand-built. A standard format customers and underwriters recognise, without
|
||||
// taking a dependency on a generator to produce it.
|
||||
const sbom = {
|
||||
bomFormat: 'CycloneDX',
|
||||
specVersion: '1.5',
|
||||
version: 1,
|
||||
metadata: {
|
||||
component: {
|
||||
type: 'application',
|
||||
name: 'screentinker',
|
||||
version: fs.readFileSync(path.join(__dirname, '..', 'VERSION'), 'utf8').trim(),
|
||||
licenses: [{ license: { id: 'MIT' } }],
|
||||
},
|
||||
properties: [{ name: 'screentinker:scope', value: INCLUDE_DEV ? 'all' : 'production' }],
|
||||
},
|
||||
components: pkgs
|
||||
.filter(p => p.name !== 'screentinker')
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map(p => ({
|
||||
type: 'library',
|
||||
name: p.name,
|
||||
version: p.version,
|
||||
purl: `pkg:npm/${p.name.replace('@', '%40')}@${p.version}`,
|
||||
licenses: p.license ? [{ license: /[()]| OR | AND /.test(p.license) ? { name: p.license } : { id: p.license } }] : [],
|
||||
})),
|
||||
};
|
||||
fs.mkdirSync(path.dirname(SBOM_OUT), { recursive: true });
|
||||
fs.writeFileSync(SBOM_OUT, JSON.stringify(sbom, null, 2));
|
||||
console.log(`\nSBOM: ${SBOM_OUT} (${sbom.components.length} components, CycloneDX 1.5)`);
|
||||
}
|
||||
|
||||
let failed = false;
|
||||
if (denied.length) {
|
||||
failed = true;
|
||||
console.log('\nDENIED');
|
||||
denied.forEach(p => console.log(` ${p.name}@${p.version} -> ${p.license}\n ${p.why}`));
|
||||
}
|
||||
if (unknown.length) {
|
||||
failed = true;
|
||||
console.log('\nUNRESOLVED — no recognised licence. Read the package, then add it to EXCEPTIONS');
|
||||
console.log('with the evidence, or remove the dependency.');
|
||||
unknown.forEach(p => console.log(` ${p.name}@${p.version} -> ${p.license || '(no license field)'}`));
|
||||
}
|
||||
if (review.length) {
|
||||
// Not fatal, but never silent — weak copyleft is a judgement call, and the judgement should be
|
||||
// made by a person who knows it is being made.
|
||||
console.log('\nREVIEW (not failing)');
|
||||
review.forEach(p => console.log(` ${p.name}@${p.version} -> ${p.license} ${p.why}`));
|
||||
}
|
||||
|
||||
console.log(failed ? '\nFAIL: licence policy violated.\n' : '\nOK: no denied or unresolved licences.\n');
|
||||
process.exit(failed ? 1 : 0);
|
||||
|
|
@ -312,6 +312,9 @@ const COMMAND_CAPABILITY = {
|
|||
launch: 'system.restart_player',
|
||||
refresh: 'system.restart_player',
|
||||
update: 'system.self_update',
|
||||
// Clearing the staged-APK cache is part of the same self-update surface: a player that can
|
||||
// update itself is a player that can hold a bad download and needs a way to drop it.
|
||||
clear_update_cache: 'system.self_update',
|
||||
|
||||
// display
|
||||
screen_on: 'display.power',
|
||||
|
|
|
|||
37
server/package-lock.json
generated
37
server/package-lock.json
generated
|
|
@ -1,12 +1,13 @@
|
|||
{
|
||||
"name": "screentinker",
|
||||
"version": "1.9.34-alpha14",
|
||||
"version": "1.9.36",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "screentinker",
|
||||
"version": "1.9.34-alpha14",
|
||||
"version": "1.9.36",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@azure/msal-node": "^5.2.1",
|
||||
"@jsquash/avif": "^1.3.0",
|
||||
|
|
@ -21,7 +22,7 @@
|
|||
"jimp": "^1.6.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nodemailer": "^6.9.16",
|
||||
"nodemailer": "^9.0.5",
|
||||
"otplib": "^12.0.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"socket.io": "^4.7.2",
|
||||
|
|
@ -1941,9 +1942,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
|
|
@ -3434,9 +3435,9 @@
|
|||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz",
|
||||
"integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
|
|
@ -3544,9 +3545,9 @@
|
|||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -3913,9 +3914,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "6.10.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
|
||||
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz",
|
||||
"integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
|
|
@ -4931,9 +4932,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
"version": "4.2.6",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
||||
"version": "4.2.7",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz",
|
||||
"integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"name": "screentinker",
|
||||
"version": "1.9.34-alpha14",
|
||||
"version": "1.9.36",
|
||||
"license": "MIT",
|
||||
"description": "ScreenTinker - Digital Signage Management Server",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
|
@ -23,7 +24,7 @@
|
|||
"jimp": "^1.6.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nodemailer": "^6.9.16",
|
||||
"nodemailer": "^9.0.5",
|
||||
"otplib": "^12.0.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"socket.io": "^4.7.2",
|
||||
|
|
|
|||
68
server/routes/telemetry-collector.js
Normal file
68
server/routes/telemetry-collector.js
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
'use strict';
|
||||
|
||||
/*
|
||||
* Opt-in install statistics — the COLLECTOR side, plus the public aggregate the marketing
|
||||
* page reads.
|
||||
*
|
||||
* Mounted only when TELEMETRY_COLLECTOR=1, so a normal self-hosted install exposes neither
|
||||
* route. That gate is doing real work on both: the report endpoint is unauthenticated, and
|
||||
* the aggregate would otherwise let any anonymous visitor read a private instance's screen
|
||||
* count off its own landing page.
|
||||
*
|
||||
* A factory rather than a bare router so the database can be injected — which is what lets
|
||||
* this be tested at all. It was previously inline in server.js and had no tests.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
|
||||
module.exports = function createTelemetryCollectorRouter(db) {
|
||||
const router = express.Router();
|
||||
|
||||
/*
|
||||
* Deliberately unauthenticated: a self-hosted instance has no credential with us, and
|
||||
* issuing one would mean an enrolment handshake for what is a three-integer postcard.
|
||||
*
|
||||
* Upsert keyed on instance_id, so an install that reports daily occupies one row forever
|
||||
* rather than 365 a year. Nothing here reads or stores the request IP — receiving one is
|
||||
* unavoidable, logging it would quietly make a pseudonymous report an identifiable one.
|
||||
*/
|
||||
router.post('/telemetry/report', express.json({ limit: '2kb' }), (req, res) => {
|
||||
const { instance_id: id, version, screen_count: screens } = req.body || {};
|
||||
// Validate rather than trust: this endpoint is open, so a malformed or hostile body must
|
||||
// land as a 400, never as a row that poisons the count it exists to produce.
|
||||
if (typeof id !== 'string' || !/^[0-9a-f-]{36}$/i.test(id)) return res.status(400).json({ error: 'bad instance_id' });
|
||||
if (version != null && (typeof version !== 'string' || version.length > 40)) return res.status(400).json({ error: 'bad version' });
|
||||
if (!Number.isInteger(screens) || screens < 0 || screens > 100000) return res.status(400).json({ error: 'bad screen_count' });
|
||||
db.prepare(`INSERT INTO telemetry_reports (instance_id, version, screen_count, first_seen, last_seen)
|
||||
VALUES (?, ?, ?, strftime('%s','now'), strftime('%s','now'))
|
||||
ON CONFLICT(instance_id) DO UPDATE SET
|
||||
version = excluded.version, screen_count = excluded.screen_count, last_seen = excluded.last_seen`)
|
||||
.run(id, version || null, screens);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
/*
|
||||
* The aggregate, for the marketing page. An aggregate across every install that reports,
|
||||
* so it discloses nothing about any one of them.
|
||||
*
|
||||
* Cached, because this sits on a public landing page and the number moves in hours, not
|
||||
* milliseconds. A scraper hitting it in a loop costs one query per interval, not per request.
|
||||
*/
|
||||
const STATS_TTL_MS = 5 * 60 * 1000;
|
||||
let statsCache = { at: 0, body: null };
|
||||
|
||||
router.get('/public/stats', (req, res) => {
|
||||
const now = Date.now();
|
||||
if (!statsCache.body || now - statsCache.at > STATS_TTL_MS) {
|
||||
const row = db.prepare(
|
||||
'SELECT COUNT(*) AS installs, COALESCE(SUM(screen_count), 0) AS screens FROM telemetry_reports'
|
||||
).get();
|
||||
statsCache = { at: now, body: { screens: row.screens, installs: row.installs } };
|
||||
}
|
||||
// Public and cacheable, but never for long by a shared cache: the number is meant to climb.
|
||||
res.set('Cache-Control', 'public, max-age=300');
|
||||
res.json(statsCache.body);
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
|
|
@ -1206,6 +1206,14 @@ function renderDirectorySearch(c) {
|
|||
|
||||
// ----- on-screen keyboard (drives the same filter path as typing) -----
|
||||
if (cfg.show_onscreen_keyboard) {
|
||||
/* Tell the platform not to raise ITS keyboard for this field. We autofocus a real
|
||||
<input>, which on Android is the signal to throw the system IME over the bottom of
|
||||
the screen - directly on top of the keyboard we draw below, so a directory panel
|
||||
showed Google's keyboard (mic, GIF and emoji keys included) and never showed its
|
||||
own. The buttons write input.value directly, so suppressing the platform keyboard
|
||||
costs nothing here. Ignored by browsers that don't know inputmode, which is the
|
||||
right fallback: a desktop preview keeps behaving exactly as before. */
|
||||
input.setAttribute('inputmode', 'none');
|
||||
var kb = document.getElementById('keyboard');
|
||||
function press(ch) { input.value += ch; try { input.focus(); } catch(e){} onInput(); }
|
||||
['1234567890','qwertyuiop','asdfghjkl','zxcvbnm'].forEach(function(r){
|
||||
|
|
|
|||
|
|
@ -978,31 +978,19 @@ app.get('/api/version', (req, res) => {
|
|||
app.use('/api/status', require('./routes/status'));
|
||||
|
||||
/*
|
||||
* Opt-in install statistics — COLLECTOR side. Inert unless TELEMETRY_COLLECTOR=1, so a normal
|
||||
* self-hosted install never exposes this at all; only the deployment that gathers the numbers
|
||||
* turns it on. Deliberately unauthenticated: a self-hosted instance has no credential with us,
|
||||
* and issuing one would mean an enrolment handshake for what is a three-integer postcard.
|
||||
*
|
||||
* Upsert keyed on instance_id, so a install that reports daily occupies one row forever rather
|
||||
* than 365 a year. Nothing here reads or stores the request IP — receiving one is unavoidable,
|
||||
* logging it would quietly make a pseudonymous report an identifiable one.
|
||||
* Opt-in install statistics — the COLLECTOR side, plus the public aggregate the marketing
|
||||
* page reads. Both live in routes/telemetry-collector.js; both are mounted only when
|
||||
* TELEMETRY_COLLECTOR=1, so a normal self-hosted install exposes neither.
|
||||
*/
|
||||
if (process.env.TELEMETRY_COLLECTOR === '1') {
|
||||
app.post('/api/telemetry/report', express.json({ limit: '2kb' }), (req, res) => {
|
||||
const { instance_id: id, version, screen_count: screens } = req.body || {};
|
||||
// Validate rather than trust: this endpoint is open, so a malformed or hostile body must
|
||||
// land as a 400, never as a row that poisons the count it exists to produce.
|
||||
if (typeof id !== 'string' || !/^[0-9a-f-]{36}$/i.test(id)) return res.status(400).json({ error: 'bad instance_id' });
|
||||
if (version != null && (typeof version !== 'string' || version.length > 40)) return res.status(400).json({ error: 'bad version' });
|
||||
if (!Number.isInteger(screens) || screens < 0 || screens > 100000) return res.status(400).json({ error: 'bad screen_count' });
|
||||
db.prepare(`INSERT INTO telemetry_reports (instance_id, version, screen_count, first_seen, last_seen)
|
||||
VALUES (?, ?, ?, strftime('%s','now'), strftime('%s','now'))
|
||||
ON CONFLICT(instance_id) DO UPDATE SET
|
||||
version = excluded.version, screen_count = excluded.screen_count, last_seen = excluded.last_seen`)
|
||||
.run(id, version || null, screens);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
console.log('[telemetry] collector enabled at POST /api/telemetry/report');
|
||||
/* `require('./db/database').db`, not the module-scope `db` — that binding is declared far
|
||||
below this line, so naming it here throws "Cannot access 'db' before initialization" at
|
||||
load and the process never starts. The inline handler this replaced only touched `db`
|
||||
inside a request callback, which runs long after the binding exists; passing it to a
|
||||
factory made the reference eager. Every neighbouring call site in this region resolves
|
||||
the same lazy way. */
|
||||
app.use('/api', require('./routes/telemetry-collector')(require('./db/database').db));
|
||||
console.log('[telemetry] collector enabled at POST /api/telemetry/report (+ GET /api/public/stats)');
|
||||
}
|
||||
|
||||
// #146 BILLING: Usage Report on its OWN route (NOT part of /api/status — billing is revenue
|
||||
|
|
|
|||
|
|
@ -59,6 +59,24 @@ test('show_onscreen_keyboard flag is carried into the page config', async () =>
|
|||
assert.ok(html.includes('"show_onscreen_keyboard":false'), 'keyboard flag inlined (page hides the keyboard when false)');
|
||||
});
|
||||
|
||||
test('the built-in keyboard suppresses the platform one', async () => {
|
||||
// The page autofocuses a real <input>, which on Android raises the system IME over the
|
||||
// bottom of the screen - covering the keyboard this widget draws itself. A panel showed
|
||||
// Gboard, complete with a mic key, and never showed its own keyboard.
|
||||
seed('search_kb_on', 'directory-search', { source_widget_id: 'board1', show_onscreen_keyboard: true });
|
||||
const { html } = await fetchRender('search_kb_on');
|
||||
assert.ok(/setAttribute\(\s*'inputmode'\s*,\s*'none'\s*\)/.test(html),
|
||||
'page tells the platform not to raise its own keyboard');
|
||||
|
||||
// Only when we are drawing one. With the built-in keyboard off there is nothing to cover,
|
||||
// and the platform keyboard is the only way left to type.
|
||||
seed('search_kb_off2', 'directory-search', { source_widget_id: 'board1', show_onscreen_keyboard: false });
|
||||
const off = await fetchRender('search_kb_off2');
|
||||
assert.ok(off.html.includes('"show_onscreen_keyboard":false'), 'flag inlined as false');
|
||||
assert.ok(!/inputmode="none"/.test(off.html),
|
||||
'the input is not statically marked inputmode=none - suppression is gated on the flag at runtime');
|
||||
});
|
||||
|
||||
test('missing source -> friendly fallback page, not a 500', async () => {
|
||||
seed('search_missing', 'directory-search', { source_widget_id: 'does-not-exist' });
|
||||
const { status, html } = await fetchRender('search_missing');
|
||||
|
|
|
|||
94
server/test/email-nodemailer-live.test.js
Normal file
94
server/test/email-nodemailer-live.test.js
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
// The rest of the email tests mock nodemailer through require.cache, which proves our code
|
||||
// calls sendMail correctly but says nothing about whether nodemailer still ACCEPTS what we
|
||||
// hand it. That gap is exactly where a dependency bump breaks sending: the suite stays green
|
||||
// while mail stops leaving the building.
|
||||
//
|
||||
// This drives the real library against a throwaway SMTP server on a loopback port, using the
|
||||
// option and message shapes services/email.js actually builds. No network, no credentials.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const net = require('node:net');
|
||||
const nodemailer = require('nodemailer');
|
||||
const { buildSmtpMessage } = require('../services/email');
|
||||
|
||||
// A minimal SMTP server that answers enough of the protocol to accept one message and
|
||||
// records the conversation, so assertions can be made about what went over the wire.
|
||||
function startFakeSmtp() {
|
||||
const seen = { commands: [], data: '' };
|
||||
return new Promise((resolve) => {
|
||||
const srv = net.createServer((sock) => {
|
||||
let inData = false;
|
||||
sock.write('220 fake.local ESMTP\r\n');
|
||||
sock.on('data', (buf) => {
|
||||
const chunk = buf.toString();
|
||||
if (inData) {
|
||||
seen.data += chunk;
|
||||
if (/\r\n\.\r\n/.test(seen.data)) { inData = false; sock.write('250 OK queued\r\n'); }
|
||||
return;
|
||||
}
|
||||
for (const line of chunk.split('\r\n').filter(Boolean)) {
|
||||
seen.commands.push(line);
|
||||
const cmd = line.split(' ')[0].toUpperCase();
|
||||
if (cmd === 'EHLO' || cmd === 'HELO') sock.write('250-fake.local\r\n250 SIZE 10485760\r\n');
|
||||
else if (cmd === 'MAIL' || cmd === 'RCPT') sock.write('250 OK\r\n');
|
||||
else if (cmd === 'DATA') { inData = true; sock.write('354 send it\r\n'); }
|
||||
else if (cmd === 'QUIT') { sock.write('221 bye\r\n'); sock.end(); }
|
||||
else sock.write('250 OK\r\n');
|
||||
}
|
||||
});
|
||||
sock.on('error', () => {}); // a client hanging up mid-conversation is not a test failure
|
||||
});
|
||||
srv.listen(0, '127.0.0.1', () => resolve({ srv, port: srv.address().port, seen }));
|
||||
});
|
||||
}
|
||||
|
||||
test('the installed nodemailer accepts the options and messages we build', async () => {
|
||||
const { srv, port, seen } = await startFakeSmtp();
|
||||
try {
|
||||
// The shape getSmtpTransporter() constructs.
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: '127.0.0.1', port, secure: false, tls: { rejectUnauthorized: false },
|
||||
});
|
||||
|
||||
// The object `from` form, used when a display name overrides the configured one.
|
||||
await transporter.sendMail({
|
||||
from: { name: 'ScreenTinker', address: 'noreply@example.com' },
|
||||
to: 'user@x.com',
|
||||
subject: '[ScreenTinker] Hello',
|
||||
html: '<p>hi there</p>',
|
||||
text: 'hi there',
|
||||
});
|
||||
|
||||
assert.ok(seen.commands.some(c => /^EHLO/i.test(c)), 'greets the server');
|
||||
assert.ok(seen.commands.some(c => /^MAIL FROM:<noreply@example\.com>/i.test(c)),
|
||||
'envelope sender is the configured address, not the display name');
|
||||
assert.ok(seen.commands.some(c => /^RCPT TO:<user@x\.com>/i.test(c)), 'envelope recipient');
|
||||
assert.match(seen.data, /Subject: \[ScreenTinker\] Hello/, 'subject and its prefix survive encoding');
|
||||
assert.match(seen.data, /From: ScreenTinker <noreply@example\.com>/, 'display-name form still renders');
|
||||
assert.match(seen.data, /Content-Type: multipart\/alternative/, 'text and html sent as alternatives');
|
||||
|
||||
// The string `from` form, used when there is no override.
|
||||
await transporter.sendMail({
|
||||
from: 'ScreenTinker <noreply@example.com>', to: 'user@x.com', subject: 'Welcome', html: '<p>x</p>',
|
||||
});
|
||||
transporter.close();
|
||||
} finally {
|
||||
srv.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('buildSmtpMessage output is something the installed nodemailer can send', async () => {
|
||||
const { srv, port, seen } = await startFakeSmtp();
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: '127.0.0.1', port, secure: false, tls: { rejectUnauthorized: false },
|
||||
});
|
||||
// Built by our own code rather than hand-written here, so the two cannot drift apart.
|
||||
await transporter.sendMail(buildSmtpMessage('to@x.com', 'Subj', 'plain', '<p>rich</p>', 'Sender Name'));
|
||||
assert.ok(seen.commands.some(c => /^RCPT TO:<to@x\.com>/i.test(c)), 'recipient reached the wire');
|
||||
assert.match(seen.data, /Subject: Subj/, 'subject reached the wire');
|
||||
transporter.close();
|
||||
} finally {
|
||||
srv.close();
|
||||
}
|
||||
});
|
||||
94
server/test/telemetry-collector.test.js
Normal file
94
server/test/telemetry-collector.test.js
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
'use strict';
|
||||
|
||||
// The collector and the public aggregate it feeds. Both were previously inline in server.js
|
||||
// with no test at all — the endpoint that decides what a public marketing page claims, and the
|
||||
// unauthenticated one anyone on the internet can POST to.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const express = require('express');
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
const db = new Database(':memory:');
|
||||
db.exec(`CREATE TABLE telemetry_reports (
|
||||
instance_id TEXT PRIMARY KEY,
|
||||
version TEXT,
|
||||
screen_count INTEGER NOT NULL,
|
||||
first_seen INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL
|
||||
);`);
|
||||
|
||||
const app = express();
|
||||
app.use('/api', require('../routes/telemetry-collector')(db));
|
||||
const server = app.listen(0);
|
||||
let base;
|
||||
|
||||
test.before(async () => {
|
||||
await new Promise(r => (server.listening ? r() : server.once('listening', r)));
|
||||
base = `http://127.0.0.1:${server.address().port}`;
|
||||
});
|
||||
test.after(() => server.close());
|
||||
|
||||
const uuid = (n) => `0000000${n}-0000-4000-8000-00000000000${n}`.slice(0, 36).padEnd(36, '0');
|
||||
const report = (body) => fetch(`${base}/api/telemetry/report`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
test('a well-formed report is accepted and stored', async () => {
|
||||
const res = await report({ instance_id: uuid(1), version: '1.9.34', screen_count: 12 });
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(await res.json(), { ok: true });
|
||||
const row = db.prepare('SELECT * FROM telemetry_reports WHERE instance_id = ?').get(uuid(1));
|
||||
assert.equal(row.screen_count, 12);
|
||||
assert.equal(row.version, '1.9.34');
|
||||
});
|
||||
|
||||
test('reporting again updates the row rather than adding one', async () => {
|
||||
// An install reports daily. If this ever inserted instead of updating, one install would
|
||||
// occupy 365 rows a year and the public figure would count it 365 times.
|
||||
await report({ instance_id: uuid(1), version: '1.9.35', screen_count: 20 });
|
||||
const n = db.prepare('SELECT COUNT(*) AS c FROM telemetry_reports WHERE instance_id = ?').get(uuid(1)).c;
|
||||
assert.equal(n, 1, 'still a single row for this install');
|
||||
const row = db.prepare('SELECT * FROM telemetry_reports WHERE instance_id = ?').get(uuid(1));
|
||||
assert.equal(row.screen_count, 20, 'count is the latest reported');
|
||||
assert.equal(row.version, '1.9.35');
|
||||
});
|
||||
|
||||
test('hostile or malformed bodies are refused, not stored', async () => {
|
||||
const before = db.prepare('SELECT COUNT(*) AS c FROM telemetry_reports').get().c;
|
||||
const bad = [
|
||||
{ instance_id: 'not-a-uuid', screen_count: 1 },
|
||||
{ instance_id: uuid(2) }, // no count
|
||||
{ instance_id: uuid(2), screen_count: -1 },
|
||||
{ instance_id: uuid(2), screen_count: 1e9 }, // absurd, would skew the total
|
||||
{ instance_id: uuid(2), screen_count: 1.5 }, // not an integer
|
||||
{ instance_id: uuid(2), screen_count: 1, version: 'v'.repeat(41) },
|
||||
{},
|
||||
];
|
||||
for (const body of bad) {
|
||||
const res = await report(body);
|
||||
assert.equal(res.status, 400, `expected 400 for ${JSON.stringify(body)}`);
|
||||
}
|
||||
assert.equal(db.prepare('SELECT COUNT(*) AS c FROM telemetry_reports').get().c, before,
|
||||
'nothing rejected reached the table');
|
||||
});
|
||||
|
||||
test('the public aggregate sums screens across installs and names no one', async () => {
|
||||
db.prepare(`INSERT INTO telemetry_reports (instance_id, version, screen_count, first_seen, last_seen)
|
||||
VALUES (?,?,?,?,?)`).run(uuid(3), '1.9.34', 480, 1, 1);
|
||||
const res = await fetch(`${base}/api/public/stats`);
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.equal(body.screens, 500, '20 + 480');
|
||||
assert.equal(body.installs, 2);
|
||||
// The whole payload — no instance ids, no versions, nothing per-install.
|
||||
assert.deepEqual(Object.keys(body).sort(), ['installs', 'screens']);
|
||||
assert.match(res.headers.get('cache-control') || '', /max-age=300/);
|
||||
});
|
||||
|
||||
test('the aggregate is cached, so a scraper cannot turn page views into queries', async () => {
|
||||
db.prepare(`INSERT INTO telemetry_reports (instance_id, version, screen_count, first_seen, last_seen)
|
||||
VALUES (?,?,?,?,?)`).run(uuid(4), '1.9.34', 999, 1, 1);
|
||||
const body = await (await fetch(`${base}/api/public/stats`)).json();
|
||||
assert.equal(body.screens, 500, 'still the cached figure, not 1499');
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<widget xmlns="http://www.w3.org/ns/widgets" xmlns:tizen="http://tizen.org/ns/widgets"
|
||||
id="http://screentinker.com/player" version="1.9.34" viewmodes="maximized">
|
||||
id="http://screentinker.com/player" version="1.9.36" viewmodes="maximized">
|
||||
<tizen:application id="ScrnTinkr1.ScreenTinker" package="ScrnTinkr1" required_version="2.4"/>
|
||||
<tizen:profile name="tv"/>
|
||||
<name>ScreenTinker</name>
|
||||
|
|
|
|||
Loading…
Reference in a new issue