mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 14:23:14 -06:00
Compare commits
59 commits
v1.9.34-al
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94a81b6896 | ||
|
|
86db5929c1 | ||
|
|
3ec06b663c | ||
|
|
8cb67122ad | ||
|
|
b13f11af13 | ||
|
|
dd7295792e | ||
|
|
114dc453bb | ||
|
|
955a691bcd | ||
|
|
702e107972 | ||
|
|
04a2ad99d1 | ||
|
|
243fc6688c | ||
|
|
741bc7b6a3 | ||
|
|
60dacad303 | ||
|
|
463e21f4d9 | ||
|
|
cb4b491840 | ||
|
|
23e80f2d27 | ||
|
|
d6c1a36d8c | ||
|
|
ab9ce40997 | ||
|
|
76f1fb7e7a | ||
|
|
66df64a798 | ||
|
|
66b5a9cc9d | ||
|
|
26c059c1b8 | ||
|
|
414c1e9ab5 | ||
|
|
8b0601b7bc | ||
|
|
feda25943c | ||
|
|
0b10776701 | ||
|
|
521025a073 | ||
|
|
58fdf8122c | ||
|
|
13534d9b61 | ||
|
|
b906bfa65f | ||
|
|
e9bd8ac8af | ||
|
|
8b162ecce2 | ||
|
|
7934156a2e | ||
|
|
3234c923a3 | ||
|
|
1fc50ec263 | ||
|
|
cb9f6a84df | ||
|
|
128a5be1b1 | ||
|
|
c436a44c89 | ||
|
|
13c9c67335 | ||
|
|
aa09631d69 | ||
|
|
2ed87f5cfb | ||
|
|
717d192d5f | ||
|
|
d4d95b6b92 | ||
|
|
72fd2314b5 | ||
|
|
f796876d91 | ||
|
|
b28924012d | ||
|
|
4bc0d433b0 | ||
|
|
350ca58f22 | ||
|
|
dd18a795ee | ||
|
|
b0de7ee208 | ||
|
|
3617a1a116 | ||
|
|
ffceaf2c1f | ||
|
|
a5cde06ed7 | ||
|
|
bc95f58d66 | ||
|
|
184ff71dee | ||
|
|
6cd697c3fc | ||
|
|
1dc63ce84c | ||
|
|
bd0b39168f | ||
|
|
e5e5b75b85 |
21
.github/workflows/ci.yml
vendored
21
.github/workflows/ci.yml
vendored
|
|
@ -109,6 +109,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 +137,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
|
||||
|
|
|
|||
37
.github/workflows/release.yml
vendored
37
.github/workflows/release.yml
vendored
|
|
@ -100,15 +100,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"
|
||||
|
|
|
|||
437
CHANGELOG.md
437
CHANGELOG.md
|
|
@ -1,115 +1,392 @@
|
|||
# Changelog
|
||||
|
||||
## 1.9.34-alpha2
|
||||
## 1.9.36
|
||||
|
||||
Everything in `1.9.34-alpha1`, plus one fix without which the headline feature could not be used with
|
||||
Microsoft at all.
|
||||
A single fix. **1.9.36 replaces 1.9.35** — see below for whether that affects you.
|
||||
|
||||
### 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.
|
||||
### Fixed — 1.9.35 would not start on a server collecting install statistics
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
**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.
|
||||
|
||||
Other identity providers that verify addresses without saying so in the token can opt in with
|
||||
`OIDC_<SLUG>_ASSUME_EMAIL_VERIFIED=true`.
|
||||
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.
|
||||
|
||||
### 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.
|
||||
### Changed — the startup check now covers configuration only one deployment uses
|
||||
|
||||
## 1.9.34-alpha1
|
||||
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.
|
||||
|
||||
**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 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.
|
||||
|
||||
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.
|
||||
### Upgrading
|
||||
|
||||
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.
|
||||
|
||||
## 1.9.35
|
||||
|
||||
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.
|
||||
|
||||
No migrations and no configuration changes. See the upgrade note at the end of this entry.
|
||||
|
||||
### Fixed — a player could get stuck on an update it was never able to install
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
### Fixed — directory search showed the system keyboard on top of its own
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### Changed — the dependency advisories that could reach a running server are cleared
|
||||
|
||||
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 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.
|
||||
|
||||
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.
|
||||
|
||||
### Added — an install that collects statistics can show the total on its landing page
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### Changed — release notes are the written ones
|
||||
|
||||
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 from 1.9.34 reinstalls dependencies
|
||||
|
||||
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.
|
||||
|
||||
Docker deployments need no action; dependencies are installed inside the image.
|
||||
|
||||
## 1.9.34
|
||||
|
||||
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. 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?*
|
||||
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.
|
||||
|
||||
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 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.
|
||||
|
||||
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 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.
|
||||
|
||||
### 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
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
With no SSO environment variables set, the product behaves exactly as it did before. That was
|
||||
verified in a browser, not merely reasoned about.
|
||||
⚠️ **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, 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 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.
|
||||
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 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.
|
||||
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` 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.
|
||||
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 actually reaches HTML
|
||||
### 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 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.
|
||||
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.
|
||||
|
||||
### Known limitations in this alpha
|
||||
Deliberately not resolved yet, and worth knowing before testing against them:
|
||||
### Added — an operations runbook
|
||||
[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.
|
||||
|
||||
- Enabling SSO-only **clears the passwords** of members at verified domains. That is irreversible
|
||||
### Changed — `better-sqlite3` pinned to 12.9.0
|
||||
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. A caret range would resolve to one of those and silently turn
|
||||
installation into a source build. Nothing in the query API changed.
|
||||
|
||||
### ⚠️ 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.
|
||||
|
||||
- **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.
|
||||
|
||||
Docker deployments need no action either way; dependencies are installed inside the image.
|
||||
|
||||
### Known limitations
|
||||
Deliberately unresolved, and worth knowing:
|
||||
|
||||
- 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
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@
|
|||
# No TLS in the image: it listens on plain HTTP :3001. Front it with a
|
||||
# TLS-terminating reverse proxy / Cloudflare in production.
|
||||
|
||||
# --- builder: install production deps (native: better-sqlite3, sharp) ---
|
||||
# --- builder: install production deps (better-sqlite3 is the only native one left; image
|
||||
# decoding is pure JS + WASM since sharp was dropped, and sharp is now a devDependency that
|
||||
# --omit=dev leaves out entirely) ---
|
||||
FROM node:20-slim AS builder
|
||||
WORKDIR /app/server
|
||||
# build toolchain in case a native prebuild is missing for the target arch
|
||||
|
|
|
|||
15
README.md
15
README.md
|
|
@ -320,6 +320,10 @@ a hidden plan was invisible to the operator as well as the customer.
|
|||
|
||||
#### Single sign-on (OpenID Connect)
|
||||
|
||||
> **Setting it up?** [**docs/sso-setup.md**](docs/sso-setup.md) is the step-by-step guide — Google and
|
||||
> Microsoft console walkthroughs, per-organization SSO, account linking, and a table of every error
|
||||
> code with its actual cause. The rest of this section is the reference.
|
||||
|
||||
Any OIDC provider works — Google, Microsoft/Entra, Okta, Auth0, Keycloak, Authentik, Zitadel — through
|
||||
one flow: **Authorization Code with PKCE, run server-side**. The browser never talks to the provider
|
||||
directly, so there is no SDK to load and no third-party script origin to allow in the CSP.
|
||||
|
|
@ -425,6 +429,13 @@ https://yourdomain.com/api/auth/oidc/<generated-slug>/callback
|
|||
The slug is generated rather than chosen so two customers cannot collide on — or guess — each
|
||||
other's. A domain may be claimed by only one organization; a second claim is refused.
|
||||
|
||||
A customer bringing **Microsoft/Entra** registers a single-tenant application in their own directory
|
||||
and uses `https://login.microsoftonline.com/<their-tenant-guid>/v2.0` as the issuer. Because Entra
|
||||
does not send `email_verified`, an organization's provider is trusted to assert addresses **once it
|
||||
has verified a domain** — the DNS proof is what stands in for the claim, and the provider is confined
|
||||
to those domains regardless. A provider that has verified nothing assumes nothing, and an explicit
|
||||
`email_verified: false` is refused whoever sends it.
|
||||
|
||||
⚠️ **A provider may only authenticate emails inside the domains it has VERIFIED.** An organization
|
||||
supplies its own issuer and client ID, so it controls that identity provider completely and could
|
||||
otherwise assert any address at all — including another company's, or an administrator's. Confining
|
||||
|
|
@ -619,6 +630,10 @@ Use this in local dev when running against a fresh production database clone to
|
|||
- **Sequential send pattern** through the offline-alert backlog — avoids Graph's per-app concurrent-send throttling (HTTP 429 `ApplicationThrottled`)
|
||||
- **Per-user opt-out** via the `email_alerts` toggle in Settings → Account; respects user preference before any Graph call
|
||||
|
||||
> **Running one day to day?** [**docs/operations.md**](docs/operations.md) is the runbook —
|
||||
> deploy and rollback for both shapes, how to verify a deploy actually took, the served-APK rules,
|
||||
> and the traps that have cost real time.
|
||||
|
||||
### Production Deployment
|
||||
|
||||
For production, put the app behind a reverse proxy (nginx, Caddy, etc.) with SSL:
|
||||
|
|
|
|||
|
|
@ -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? ?: "108").toInt()
|
||||
versionName = System.getenv("VERSION_NAME") ?: findProperty("VERSION_NAME") as String? ?: "1.9.34-alpha2"
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -149,6 +149,27 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
// Fullscreen immersive
|
||||
@Suppress("DEPRECATION")
|
||||
/*
|
||||
* Ask for a full-bleed window through BOTH APIs.
|
||||
*
|
||||
* systemUiVisibility has been deprecated since API 30 and some OEM builds honour it only
|
||||
* partially: `dumpsys window` on one RK356x box reported `init=1920x1080 app=1920x1024`,
|
||||
* i.e. the firmware kept reserving 56px for a navigation bar that was set to hide, so the
|
||||
* app was never given those pixels to paint. WindowCompat is the supported route on those
|
||||
* builds. Both are set because neither is reliable alone across signage hardware: the
|
||||
* legacy flags still carry older devices, the compat API carries newer and OEM ones.
|
||||
*
|
||||
* Verify per device rather than assume — `adb shell dumpsys window displays` should show
|
||||
* app= equal to init=. If it still does not, the reservation is a firmware behaviour no
|
||||
* app-side call can override and it has to be turned off on the device.
|
||||
*/
|
||||
androidx.core.view.WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
androidx.core.view.WindowInsetsControllerCompat(window, window.decorView).apply {
|
||||
hide(androidx.core.view.WindowInsetsCompat.Type.systemBars())
|
||||
systemBarsBehavior =
|
||||
androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
window.decorView.systemUiVisibility = (
|
||||
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
|
||||
View.SYSTEM_UI_FLAG_FULLSCREEN or
|
||||
|
|
@ -370,12 +391,57 @@ class MainActivity : AppCompatActivity() {
|
|||
// (playerView/imageView/youtubeWebView) and multi-zone (ZoneManager renders into
|
||||
// the same rootView). Values mirror the dashboard: landscape / portrait /
|
||||
// landscape-flipped / portrait-flipped.
|
||||
private fun applyOrientation(orientation: String) {
|
||||
if (orientation == currentOrientation) return
|
||||
currentOrientation = orientation
|
||||
/**
|
||||
* The size of the WINDOW we are allowed to paint, measured NOW.
|
||||
*
|
||||
* ⚠️ Deliberately the window and not the display. On a box whose firmware keeps reserving space
|
||||
* for a system bar, `dumpsys window` reports e.g. `init=1920x1080 app=1920x1024`: the panel is
|
||||
* 1080 tall but the window is 56px shorter, and those 56px are simply not ours to draw in.
|
||||
* Sizing the stage to the DISPLAY there would not fill the gap — it would push the bottom of
|
||||
* every asset outside the window and silently crop it, which is worse than a border.
|
||||
*
|
||||
* The original defect was not which size was read but WHEN: `resources.displayMetrics` was read
|
||||
* once, while a bar was still on screen, and written into rootView's layoutParams for good.
|
||||
* Immersive mode is a request — bars hide and the window grows a few frames later — so a
|
||||
* playlist arriving first froze the stage at bar-sized dimensions, leaving dead space exactly
|
||||
* the size of a bar that had since vanished. It looked random because it was a race, and
|
||||
* rendering the cached playlist immediately at boot made losing it the common case.
|
||||
*
|
||||
* So: read it late, read it again whenever the window changes, and never cache it across a
|
||||
* window resize. See reapplyOrientation().
|
||||
*/
|
||||
private fun windowSize(): Pair<Float, Float> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
val b = windowManager.currentWindowMetrics.bounds
|
||||
return b.width().toFloat() to b.height().toFloat()
|
||||
}
|
||||
val m = resources.displayMetrics
|
||||
val w = m.widthPixels.toFloat()
|
||||
val h = m.heightPixels.toFloat()
|
||||
return m.widthPixels.toFloat() to m.heightPixels.toFloat()
|
||||
}
|
||||
|
||||
/** Stage size last applied, so a stage sized during a transient window state can heal. */
|
||||
private var appliedStageW = 0f
|
||||
private var appliedStageH = 0f
|
||||
|
||||
/**
|
||||
* Re-run the current orientation against the panel size as it is NOW.
|
||||
*
|
||||
* Called when the window settles (focus regained, bars finally hidden). Without this, a stage
|
||||
* measured too early is permanent: applyOrientation() returns immediately when the orientation
|
||||
* string has not changed, and it never changes on a display that has always been landscape.
|
||||
*/
|
||||
private fun reapplyOrientation() {
|
||||
applyOrientation(currentOrientation ?: "landscape")
|
||||
}
|
||||
|
||||
private fun applyOrientation(orientation: String) {
|
||||
val (w, h) = windowSize()
|
||||
// The guard compares the measured SIZE as well as the orientation. Comparing the string
|
||||
// alone is what made a bad measurement unrecoverable.
|
||||
if (orientation == currentOrientation && w == appliedStageW && h == appliedStageH) return
|
||||
currentOrientation = orientation
|
||||
appliedStageW = w
|
||||
appliedStageH = h
|
||||
val (rot, swap) = when (orientation) {
|
||||
"portrait" -> 90f to true
|
||||
"portrait-flipped" -> 270f to true
|
||||
|
|
@ -875,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) {
|
||||
|
|
@ -1511,6 +1585,16 @@ class MainActivity : AppCompatActivity() {
|
|||
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
||||
super.onWindowFocusChanged(hasFocus)
|
||||
if (hasFocus) {
|
||||
/*
|
||||
* Re-measure the stage once the window has settled.
|
||||
*
|
||||
* Hiding the bars is asynchronous, so the window is often still bar-sized when the
|
||||
* first playlist arrives and sizes the stage. Without this the mistake is permanent:
|
||||
* applyOrientation() used to return immediately whenever the orientation string was
|
||||
* unchanged, and it never changes on a display that has always been landscape. Posting
|
||||
* it runs after this layout pass, when the window is whatever it is finally going to be.
|
||||
*/
|
||||
rootView.post { reapplyOrientation() }
|
||||
@Suppress("DEPRECATION")
|
||||
window.decorView.systemUiVisibility = (
|
||||
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
|
||||
|
|
|
|||
|
|
@ -47,6 +47,18 @@ class UpdateChecker(private val context: Context) {
|
|||
// class is the imperative shell that persists state and does the download/install.
|
||||
var otaLogReporter: ((level: String, message: String) -> Unit)? = null
|
||||
|
||||
/*
|
||||
* Why the last download/verify attempt failed, in specific terms.
|
||||
*
|
||||
* The caller could only ever say "failed to download or failed signature verification", which
|
||||
* covers SEVEN distinct branches — three of them download failures where verification never
|
||||
* runs at all. Every specific reason went to logcat, which an unprivileged app UID cannot read
|
||||
* on Android 9, so in the field the message was unactionable: it named a symptom shared by
|
||||
* unrelated causes and pointed at the wrong half of the code as often as the right one.
|
||||
* Diagnosing one occurrence took an evening. This makes the next one a sentence.
|
||||
*/
|
||||
private var lastFailure: String? = null
|
||||
|
||||
private fun report(level: String, message: String) {
|
||||
when (level) { "error" -> Log.e(TAG, message); "warn" -> Log.w(TAG, message); else -> Log.i(TAG, message) }
|
||||
try { otaLogReporter?.invoke(level, message) } catch (_: Throwable) {}
|
||||
|
|
@ -272,7 +284,7 @@ class UpdateChecker(private val context: Context) {
|
|||
// Unforced this is deliberately quiet (transient network blips are not news). Forced,
|
||||
// somebody is waiting on an answer, and "the APK would not download or did not match
|
||||
// our signing key" is the single most useful thing we can tell them.
|
||||
if (forced) report("error", "Force update: $latestVersion failed to download or failed signature verification — not installed")
|
||||
if (forced) report("error", "Force update: $latestVersion not installed — ${lastFailure ?: "reason unavailable"}")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -311,15 +323,99 @@ class UpdateChecker(private val context: Context) {
|
|||
// Returns TRUE only when a verified APK is in hand and an install has been launched (the
|
||||
// caller may then count an attempt); FALSE on any download/verify failure — the caller must
|
||||
// NOT count those, so a transient network problem can't burn a healthy device's budget. #139
|
||||
/*
|
||||
* Where a downloaded APK is staged.
|
||||
*
|
||||
* getExternalFilesDir() returns NULL whenever external storage is not mounted/available — and
|
||||
* on a signage panel that is not exotic: no emulated volume, a vendor ROM that never mounts one,
|
||||
* an SD card ejected, storage still unmounted early in boot.
|
||||
*
|
||||
* The bug this replaces: `File(context.getExternalFilesDir(...), name)`. Java's File(File,String)
|
||||
* treats a NULL parent as "no parent" and silently produces a RELATIVE path, so the download
|
||||
* targeted `ScreenTinker-x.y.z.apk` in the process working directory — `/` — which is not
|
||||
* writable. The write threw, the generic catch swallowed it, and the caller reported only
|
||||
* "failed to download or failed signature verification". Nothing was ever written, so there was
|
||||
* no partial file to find and nothing in the message pointed at storage. It fails on EVERY
|
||||
* attempt, forever, on an affected panel — and identically for the pushed-APK path, which had
|
||||
* the same line.
|
||||
*
|
||||
* Internal storage always exists, so fall back to it. It costs nothing when external is present.
|
||||
* NOTE: the intent-based install fallback resolves this file through FileProvider, so
|
||||
* res/xml/file_paths.xml must expose this directory too — see the <files-path> entry there.
|
||||
*/
|
||||
/*
|
||||
* Where to stage a downloaded APK — the FIRST location that actually accepts bytes.
|
||||
*
|
||||
* Internal app storage is tried first and is effectively guaranteed: /data/data/<pkg>/files is
|
||||
* this app's own private directory, always mounted, always writable. If it is not, the app is
|
||||
* not running. External storage is only a convenience (it survives uninstall and is visible for
|
||||
* a manual install), and it is the one that fails — it can be absent, unmounted, present but
|
||||
* unwritable, or report canWrite() = true and then refuse the write anyway.
|
||||
*
|
||||
* ⚠️ Each candidate is PROVEN with a real write, not asked. The previous version asked
|
||||
* canWrite(), believed the answer, and then died at outputStream() — before a single byte — so
|
||||
* the update failed instantly and reported it as a download problem. Every fallback in the world
|
||||
* is useless if the first choice is trusted rather than tested.
|
||||
*
|
||||
* Returns the directory, or null with every reason it could not find one, so the operator gets
|
||||
* the full picture instead of the first excuse.
|
||||
*/
|
||||
private fun apkStagingDir(needBytes: Long): Pair<File?, String> {
|
||||
val candidates = LinkedHashMap<String, File>()
|
||||
candidates["internal"] = File(context.filesDir, "Download")
|
||||
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)?.let { candidates["external"] = it }
|
||||
candidates["cache"] = File(context.cacheDir, "Download")
|
||||
candidates["files"] = context.filesDir // last resort: no subdirectory to create
|
||||
|
||||
val reasons = StringBuilder()
|
||||
for ((name, dir) in candidates) {
|
||||
val problem = apkDirProblem(dir, needBytes)
|
||||
if (problem == null) {
|
||||
if (name != "internal") Log.w(TAG, "Staging APK in $name (${dir.absolutePath})")
|
||||
return dir to name
|
||||
}
|
||||
if (reasons.isNotEmpty()) reasons.append("; ")
|
||||
reasons.append("$name ${problem}")
|
||||
}
|
||||
return null to reasons.toString()
|
||||
}
|
||||
|
||||
private fun apkDirProblem(dir: File, needBytes: Long): String? {
|
||||
if (!dir.exists() && !dir.mkdirs()) return "cannot create ${dir.absolutePath}"
|
||||
if (!dir.isDirectory) return "${dir.absolutePath} is not a directory"
|
||||
if (!dir.canWrite()) return "no write permission on ${dir.absolutePath}"
|
||||
val free = try { dir.usableSpace } catch (_: Throwable) { -1L }
|
||||
// Headroom, not an exact fit: the installer stages its own copy of the APK as well, so a
|
||||
// volume with barely the download's worth free still fails at install time.
|
||||
if (needBytes > 0 && free in 0 until (needBytes * 2)) {
|
||||
return "only ${free / 1024 / 1024}MB free on ${dir.absolutePath}, need ~${needBytes * 2 / 1024 / 1024}MB"
|
||||
}
|
||||
// Prove it rather than infer it: canWrite() can be true on a volume that refuses the write.
|
||||
return try {
|
||||
val probe = File(dir, ".st-write-probe")
|
||||
probe.writeBytes(byteArrayOf(1))
|
||||
probe.delete()
|
||||
null
|
||||
} catch (e: Throwable) {
|
||||
"write test failed in ${dir.absolutePath}: ${e.javaClass.simpleName} ${e.message}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadAndInstall(url: String, version: String): Boolean {
|
||||
try {
|
||||
val apkFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
|
||||
"ScreenTinker-$version.apk")
|
||||
// Find somewhere that will actually take the file, before asking the network for it.
|
||||
val (dir, whereOrWhy) = apkStagingDir(9L * 1024 * 1024)
|
||||
if (dir == null) {
|
||||
lastFailure = "nowhere to stage the update — $whereOrWhy"
|
||||
Log.e(TAG, "APK staging unavailable: $whereOrWhy")
|
||||
return false
|
||||
}
|
||||
val apkFile = File(dir, "ScreenTinker-$version.apk")
|
||||
|
||||
// #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
|
||||
|
|
@ -332,6 +428,7 @@ class UpdateChecker(private val context: Context) {
|
|||
val response = client.newCall(request).execute()
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
lastFailure = "server returned HTTP ${response.code} for the APK"
|
||||
Log.e(TAG, "Download failed: ${response.code}")
|
||||
return false
|
||||
}
|
||||
|
|
@ -351,7 +448,21 @@ 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.
|
||||
lastFailure = "${lastFailure ?: "signature verification failed"} (downloaded ${apkFile.length()} bytes)"
|
||||
Log.e(TAG, "Refusing update: APK signature/package verification failed (tampered or MITM'd APK)")
|
||||
apkFile.delete()
|
||||
return false
|
||||
|
|
@ -364,6 +475,7 @@ class UpdateChecker(private val context: Context) {
|
|||
}
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
lastFailure = "download/install threw ${e.javaClass.simpleName}: ${e.message}"
|
||||
Log.e(TAG, "Download/install error: ${e.message}")
|
||||
return false
|
||||
}
|
||||
|
|
@ -378,7 +490,9 @@ class UpdateChecker(private val context: Context) {
|
|||
try {
|
||||
val base = url.substringAfterLast('/').substringBefore('?').ifBlank { "app.apk" }
|
||||
val fileName = "pushed-" + (if (base.endsWith(".apk")) base else "$base.apk")
|
||||
val apkFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), fileName)
|
||||
val (dir, whyNot) = apkStagingDir(9L * 1024 * 1024)
|
||||
if (dir == null) { Log.e(TAG, "installFromUrl: nowhere to stage — $whyNot"); return@Thread }
|
||||
val apkFile = File(dir, fileName)
|
||||
if (apkFile.exists()) apkFile.delete()
|
||||
val response = client.newCall(Request.Builder().url(url).build()).execute()
|
||||
if (!response.isSuccessful) { Log.e(TAG, "installFromUrl: download failed ${response.code}"); return@Thread }
|
||||
|
|
@ -482,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
|
||||
|
|
@ -498,10 +658,12 @@ class UpdateChecker(private val context: Context) {
|
|||
PackageManager.GET_SIGNING_CERTIFICATES else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES
|
||||
val downloaded = pm.getPackageArchiveInfo(apkFile.absolutePath, archiveFlags)
|
||||
if (downloaded == null) {
|
||||
lastFailure = "the downloaded file could not be parsed as an APK (truncated or not an APK)"
|
||||
Log.e(TAG, "Could not parse downloaded APK")
|
||||
return false
|
||||
}
|
||||
if (downloaded.packageName != context.packageName) {
|
||||
lastFailure = "APK is package ${downloaded.packageName}, expected ${context.packageName}"
|
||||
Log.e(TAG, "APK package mismatch: ${downloaded.packageName} != ${context.packageName}")
|
||||
return false
|
||||
}
|
||||
|
|
@ -511,18 +673,37 @@ class UpdateChecker(private val context: Context) {
|
|||
val installedFlags = if (installedUsesSigningInfo)
|
||||
PackageManager.GET_SIGNING_CERTIFICATES else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES
|
||||
val installed = pm.getPackageInfo(context.packageName, installedFlags)
|
||||
val downloadedSigs = signingCertHashes(downloaded, archiveUsesSigningInfo)
|
||||
var downloadedSigs = signingCertHashes(downloaded, archiveUsesSigningInfo)
|
||||
// #139 follow-up: on API 28/29 the archive read goes through the legacy GET_SIGNATURES
|
||||
// path, and if PackageManager hands back nothing we previously refused a perfectly good
|
||||
// APK with no way to tell that apart from a real mismatch. Read the v1 signature
|
||||
// ourselves before giving up — JarFile is random-access, which is how the JAR signature
|
||||
// is meant to be read, and it verifies the same bytes PackageManager would have.
|
||||
// This does NOT weaken the check: the cert extracted here is still compared against the
|
||||
// installed app's below, and an unsigned or differently-signed APK still fails.
|
||||
if (downloadedSigs.isEmpty()) {
|
||||
val viaJar = archiveCertsViaJar(apkFile)
|
||||
if (viaJar.isNotEmpty()) {
|
||||
Log.w(TAG, "Archive certs unreadable via PackageManager on API ${Build.VERSION.SDK_INT}; used JarFile (${viaJar.size})")
|
||||
downloadedSigs = viaJar
|
||||
}
|
||||
}
|
||||
val installedSigs = signingCertHashes(installed, installedUsesSigningInfo)
|
||||
if (downloadedSigs.isEmpty() || installedSigs.isEmpty()) {
|
||||
lastFailure = "could not read signing certificates (archive=${downloadedSigs.size}, installed=${installedSigs.size}) on API ${Build.VERSION.SDK_INT}"
|
||||
Log.e(TAG, "Missing signing certificates (downloaded=${downloadedSigs.size}, installed=${installedSigs.size})")
|
||||
return false
|
||||
}
|
||||
// Require a non-empty overlap of signer certs (handles multi-signer / cert-rotation
|
||||
// the same way the API>=30 path does: compare the full current signer sets).
|
||||
val match = downloadedSigs.any { it in installedSigs }
|
||||
if (!match) Log.e(TAG, "APK signing certificate does not match installed app")
|
||||
if (!match) {
|
||||
lastFailure = "APK is signed by a different key than the installed app"
|
||||
Log.e(TAG, "APK signing certificate does not match installed app")
|
||||
}
|
||||
match
|
||||
} catch (e: Exception) {
|
||||
lastFailure = "signature check threw ${e.javaClass.simpleName}: ${e.message}"
|
||||
Log.e(TAG, "Signature verification error: ${e.message}", e)
|
||||
false
|
||||
}
|
||||
|
|
@ -533,6 +714,31 @@ class UpdateChecker(private val context: Context) {
|
|||
// multi-signer + rotation aware), GET_SIGNATURES -> legacy .signatures (the only field
|
||||
// populated for ARCHIVE reads on API 28/29). Both yield the same cert for a normally-signed
|
||||
// APK; the caller compares as sets so an overlapping signer still verifies.
|
||||
/*
|
||||
* Read the APK's v1 (JAR) signer certificates directly, as a fallback for the API 28/29 archive
|
||||
* read. Opening JarFile with verify=true and reading an entry to completion is what populates
|
||||
* JarEntry.certificates — the certificate is only known once the bytes it covers have been
|
||||
* checked, so the read is the verification, not a step before it.
|
||||
*
|
||||
* Returns an empty set on any problem, which leaves the caller refusing the install: this is a
|
||||
* fallback for "PackageManager told us nothing", never a way to skip the comparison.
|
||||
*/
|
||||
private fun archiveCertsViaJar(apkFile: File): Set<String> {
|
||||
return try {
|
||||
java.util.jar.JarFile(apkFile, true).use { jar ->
|
||||
val entry = jar.getJarEntry("AndroidManifest.xml") ?: return emptySet()
|
||||
jar.getInputStream(entry).use { input ->
|
||||
val buf = ByteArray(8192)
|
||||
while (input.read(buf) != -1) { /* must read fully before certificates populate */ }
|
||||
}
|
||||
entry.certificates?.mapNotNull { sha256(it.encoded) }?.toSet() ?: emptySet()
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "JarFile cert read failed: ${e.message}")
|
||||
emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
private fun signingCertHashes(info: PackageInfo, useSigningInfo: Boolean): Set<String> {
|
||||
val sigs: Array<Signature>? = if (useSigningInfo) {
|
||||
info.signingInfo?.apkContentsSigners
|
||||
|
|
|
|||
|
|
@ -2,4 +2,11 @@
|
|||
<paths>
|
||||
<external-files-path name="downloads" path="Download/" />
|
||||
<external-files-path name="apk" path="." />
|
||||
<!-- UpdateChecker.apkDir() stages APKs in internal storage when external storage is not
|
||||
available (getExternalFilesDir returns null). The silent PackageInstaller path streams the
|
||||
file itself and needs nothing here, but the intent-based install FALLBACK resolves it
|
||||
through FileProvider — without this entry that fallback throws
|
||||
IllegalArgumentException ("Failed to find configured root"), turning an already-degraded
|
||||
panel into one that cannot install at all. -->
|
||||
<files-path name="internal_downloads" path="Download/" />
|
||||
</paths>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
356
docs/operations.md
Normal file
356
docs/operations.md
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
# Operations runbook
|
||||
|
||||
Running an instance day to day: deploying, verifying, rolling back, and the traps that have actually
|
||||
cost people time.
|
||||
|
||||
The README covers the happy paths — [installing](../README.md#production-deployment),
|
||||
[updating](../README.md#updating), [backups](../README.md#backups) and
|
||||
[admin recovery](../README.md#admin-recovery). This is the part you want at 2am, or when a deploy
|
||||
did not behave.
|
||||
|
||||
---
|
||||
|
||||
## Contents
|
||||
|
||||
- [Two deployment shapes](#two-deployment-shapes)
|
||||
- [Before you deploy](#before-you-deploy)
|
||||
- [Deploying: native (git + systemd)](#deploying-native-git--systemd)
|
||||
- [Deploying: Docker](#deploying-docker)
|
||||
- [The served APK](#the-served-apk)
|
||||
- [Verifying a deploy](#verifying-a-deploy)
|
||||
- [Rolling back](#rolling-back)
|
||||
- [Releases and version numbers](#releases-and-version-numbers)
|
||||
- [Upgrading Node.js](#upgrading-nodejs)
|
||||
- [Traps worth knowing before they bite](#traps-worth-knowing-before-they-bite)
|
||||
|
||||
---
|
||||
|
||||
## Two deployment shapes
|
||||
|
||||
An instance is either **native** (a git checkout on a release tag, run by systemd) or **Docker** (a
|
||||
published image, run by compose). They are not interchangeable, and the commands differ at every
|
||||
step.
|
||||
|
||||
> ⚠️ **Know which one you are on before you type anything.** The most expensive mistakes in this
|
||||
> runbook come from applying one shape's procedure to the other — `git checkout` on a Docker host
|
||||
> changes nothing the container is running, and bumping an image tag on a native host does nothing
|
||||
> at all. If you run more than one instance, keep a note of which is which somewhere you will read.
|
||||
|
||||
---
|
||||
|
||||
## Before you deploy
|
||||
|
||||
Every time, in this order:
|
||||
|
||||
1. **Snapshot the database.**
|
||||
```bash
|
||||
sqlite3 <db> ".backup /path/to/pre-<version>-$(date +%Y%m%d-%H%M%S).db"
|
||||
sqlite3 /path/to/pre-<version>-*.db "PRAGMA integrity_check;" # want: ok
|
||||
```
|
||||
2. **Record the row counts** you intend to still have afterwards:
|
||||
```sql
|
||||
SELECT (SELECT COUNT(*) FROM devices), (SELECT COUNT(*) FROM users),
|
||||
(SELECT COUNT(*) FROM content), (SELECT COUNT(*) FROM playlists);
|
||||
```
|
||||
3. **Check whether dependencies changed.** If `server/package.json` differs by more than the version
|
||||
field between the running release and the target, you need an install step. If it differs only in
|
||||
`"version"`, skip it — that is the cheapest and safest kind of deploy.
|
||||
```bash
|
||||
git diff <current-tag> <target-tag> -- server/package.json
|
||||
```
|
||||
4. **Check whether migrations will run.** They apply automatically at boot. Additive columns and new
|
||||
tables are safe, and a code-only rollback simply leaves them unused.
|
||||
```bash
|
||||
git diff <current-tag> <target-tag> -- server/db/database.js | grep -E '^\+.*(ALTER|CREATE) TABLE|CREATE INDEX'
|
||||
```
|
||||
5. **Back up the compose file / the served APK** if you are about to change either.
|
||||
|
||||
---
|
||||
|
||||
## Deploying: native (git + systemd)
|
||||
|
||||
`scripts/upgrade.sh` does the whole sequence — snapshot, checkout, `npm ci --omit=dev`, restart, and
|
||||
report the running version. It defaults to the newest **stable** tag, deliberately skipping
|
||||
`-rc`/`-beta`/`-alpha` prereleases:
|
||||
|
||||
```bash
|
||||
cd /opt/screentinker
|
||||
scripts/upgrade.sh # latest stable release
|
||||
scripts/upgrade.sh v1.2.3 # or pin one
|
||||
```
|
||||
|
||||
If you are doing it by hand, the order matters:
|
||||
|
||||
```bash
|
||||
sudo -u <service-user> git fetch --tags origin
|
||||
sudo -u <service-user> git checkout -f v1.2.3
|
||||
# only if dependencies actually changed:
|
||||
cd server && sudo -u <service-user> npm ci --omit=dev
|
||||
sudo systemctl restart <service>
|
||||
```
|
||||
|
||||
**Ownership first.** Every file must belong to the service user *before* the checkout. A checkout
|
||||
that fails partway through leaves the worst possible state: `VERSION` updated while the code is
|
||||
still the old release, so the service reports a version it is not running and no migrations ran.
|
||||
|
||||
```bash
|
||||
sudo chown -R <service-user>:<service-user> /opt/screentinker
|
||||
```
|
||||
|
||||
**A service user with no home directory breaks npm.** It writes logs and a cache to `$HOME`, which
|
||||
does not exist, and installs nothing while looking like it worked:
|
||||
|
||||
```bash
|
||||
cd server && sudo -u <service-user> env HOME=/opt/screentinker \
|
||||
npm_config_cache=/opt/screentinker/.npm-cache npm ci --omit=dev
|
||||
```
|
||||
|
||||
**Prove the checkout is complete** — a version string alone will not tell you:
|
||||
|
||||
```bash
|
||||
git status --porcelain --untracked-files=no # want: empty
|
||||
git diff <tag> -- server frontend # want: empty
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deploying: Docker
|
||||
|
||||
```bash
|
||||
# in the compose directory
|
||||
cp -a docker-compose.yml docker-compose.yml.bak-pre-<version>
|
||||
sed -i 's|screentinker:<old>|screentinker:<new>|' docker-compose.yml
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
Migrations run at boot exactly as they do natively. State lives in the named volume (`st-data` in
|
||||
the example compose), so recreating the container does not touch the database.
|
||||
|
||||
Anything bind-mounted into the container — the served APK, a `.wgt`, custom assets — must be updated
|
||||
on the **host**, and see the inode warning below.
|
||||
|
||||
---
|
||||
|
||||
## The served APK
|
||||
|
||||
The file the OTA endpoint hands to Android displays. Two rules, both learned the hard way.
|
||||
|
||||
**1. Replace it in place. Never `mv` or `cp` over it.**
|
||||
|
||||
It is a bind-mounted *file*, so the container holds the inode. Replacing the file gives the host a
|
||||
new inode and the container keeps serving the old bytes forever, with nothing in any log to say so.
|
||||
|
||||
```bash
|
||||
cat /tmp/new.apk > /opt/screentinker/ScreenTinker.apk # correct — same inode
|
||||
# NOT: mv, cp, install, or anything that unlinks and recreates
|
||||
stat -c %i /opt/screentinker/ScreenTinker.apk # confirm it did not change
|
||||
```
|
||||
|
||||
**2. The advertised size must match the served bytes, or displays loop.**
|
||||
|
||||
`/api/update/check` reports `apk_size` from a cache refreshed every `OTA_APK_REFRESH_MS`
|
||||
(default 60s), and the server re-stats at boot. If the advertised size and the real file disagree,
|
||||
a display downloads, rejects, and retries — forever. After swapping, restart the service and confirm:
|
||||
|
||||
```bash
|
||||
curl -s 'http://127.0.0.1:3001/api/update/check?version=<an-older-version>'
|
||||
stat -c %s /opt/screentinker/ScreenTinker.apk # must equal the reported apk_size
|
||||
```
|
||||
|
||||
> ⚠️ The query parameter is **`version`**, not `current_version`. The wrong name yields
|
||||
> `reason: no-version, update_available: false`, which looks exactly like a broken OTA but is not.
|
||||
> `/api/version` is a different endpoint and its `update_available` is not the OTA verdict.
|
||||
|
||||
**Verify the signature after any APK swap**, and use `jarsigner`:
|
||||
|
||||
```bash
|
||||
jarsigner -verify ScreenTinker.apk # want: "jar verified."
|
||||
unzip -l ScreenTinker.apk | grep META-INF # want: a .SF and a .RSA
|
||||
```
|
||||
|
||||
`apksigner verify -v` misreports `v1 scheme: false` on some build-tools versions even when the JAR
|
||||
signature is present and valid. MDM-managed signage needs v1, so trust `jarsigner`.
|
||||
|
||||
---
|
||||
|
||||
## Verifying a deploy
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:3001/api/version # version + build hash
|
||||
curl -s http://127.0.0.1:3001/api/status # health, loop lag, connected displays
|
||||
```
|
||||
|
||||
Then, and this is the part people skip:
|
||||
|
||||
- **Row counts match** what you recorded beforehand.
|
||||
- **The log is clean.** Migrations reported, no errors:
|
||||
```bash
|
||||
docker logs <container> 2>&1 | grep -iE 'migrat|error|exception' # or journalctl -u <service>
|
||||
```
|
||||
- **Check through your reverse proxy / CDN too**, not only on loopback. Cached or misrouted assets
|
||||
only show up from outside.
|
||||
|
||||
> ⚠️ **A version string is not proof the new code is running, and neither is the build hash.** The
|
||||
> hash covers the frontend, so a server-only change deploys with an *unchanged* hash — which looks
|
||||
> exactly like a stale image. When it matters, check for the code itself:
|
||||
> ```bash
|
||||
> docker exec <container> grep -c '<a symbol only the new version has>' /app/server/<file>
|
||||
> ```
|
||||
|
||||
**A frontend change needs a hard refresh** (Ctrl+Shift+R) before you judge it. Assets revalidate,
|
||||
but a browser sitting on the old bundle will show you the old behaviour and you will debug a fixed
|
||||
bug.
|
||||
|
||||
---
|
||||
|
||||
## Rolling back
|
||||
|
||||
Because backups are taken per deploy, rollback is mechanical:
|
||||
|
||||
**Native**
|
||||
```bash
|
||||
sudo -u <service-user> git checkout -f <previous-tag>
|
||||
cd server && npm ci --omit=dev # only if dependencies changed
|
||||
cat /path/to/ScreenTinker.apk.bak > /opt/screentinker/ScreenTinker.apk
|
||||
sudo systemctl restart <service>
|
||||
```
|
||||
|
||||
**Docker**
|
||||
```bash
|
||||
cp -a docker-compose.yml.bak-<version> docker-compose.yml
|
||||
cat /path/to/ScreenTinker.apk.bak > /opt/screentinker/ScreenTinker.apk
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**The database usually does not need restoring.** Migrations are additive, so older code simply
|
||||
ignores the new columns. Restore the snapshot only if a migration was destructive — and if one ever
|
||||
is, that is the moment to stop and read it rather than reflexively rolling forward.
|
||||
|
||||
---
|
||||
|
||||
## Releases and version numbers
|
||||
|
||||
Cutting a release is documented in [RELEASING.md](../RELEASING.md). The operational consequences:
|
||||
|
||||
**A prerelease sorts BELOW its own release.** `1.2.3-alpha1` is semver-older than `1.2.3`. That has
|
||||
two effects worth internalising:
|
||||
|
||||
- A display that takes a prerelease is not "ahead"; a later stable of the same version supersedes it,
|
||||
which is what you want.
|
||||
- The Android update check offers a prerelease to any older client on the **stable** channel. Putting
|
||||
a prerelease on an instance means every Android display below it takes it at its next check. Do
|
||||
that deliberately, on an instance whose displays you are willing to move.
|
||||
|
||||
**`:latest` is not moved for a prerelease.** The release workflow skips it for any tag containing a
|
||||
`-`, so nobody tracking `:latest` pulls untested code on their next restart.
|
||||
|
||||
**Android `versionCode` must never go backwards.** Android refuses a downgrade, so a build with a
|
||||
lower code cannot install over a higher one — the usual cause is a side-loaded test build whose code
|
||||
was bumped past the release line. Keep the release line ahead of anything you side-load, or you will
|
||||
be reinstalling by hand (which wipes app data and drops pairing).
|
||||
|
||||
**A re-cut tag is only safe if it published nothing.** If a tag has already produced a GitHub Release
|
||||
or an image, delete-and-repush is not a fix; cut the next version instead.
|
||||
|
||||
---
|
||||
|
||||
## Upgrading Node.js
|
||||
|
||||
Upgrading the runtime is not like deploying a release: nothing in the app's own upgrade path is
|
||||
involved, so the usual `scripts/upgrade.sh` never runs and nothing reinstalls dependencies. Read
|
||||
this before changing the Node major.
|
||||
|
||||
**Do it as two separate deploys, never one.** Move the app to a release whose dependencies support
|
||||
both the old and new Node major first, confirm it on the runtime you already have, and only then
|
||||
change Node. Each half is then independently reversible. Doing both at once means a failure gives
|
||||
you nothing to bisect and no single step to undo.
|
||||
|
||||
**Check the version floor.** `npm start` uses `node --env-file-if-exists=.env`. That flag reached
|
||||
the Node 22 line only in **22.9.0** — it works on Node 20 because it was separately backported
|
||||
there. On Node 22.0–22.8 the server refuses to start with `node: bad option`. Target 22.9.0 or
|
||||
newer.
|
||||
|
||||
**One native module has to survive the move.** `better-sqlite3` is compiled against a single Node
|
||||
ABI, so changing Node invalidates it. Two things make this survivable:
|
||||
|
||||
- `lib/preflight-deps.js` runs before anything else at boot, detects the mismatch by *opening a
|
||||
database* (a bare `require` succeeds even on a wrong ABI, so it is not a valid check), and repairs
|
||||
it with `npm rebuild better-sqlite3`.
|
||||
- The pinned version ships **prebuilt binaries for both the current and the next Node major**, so
|
||||
that repair downloads a binary instead of compiling one.
|
||||
|
||||
⚠️ **That second point is why the version is pinned exactly rather than with a caret**, and why
|
||||
widening it is risky in a way `package.json` does not show. A version with no prebuild for your Node
|
||||
falls back to a from-source `node-gyp` build — and because preflight rebuilds *synchronously before
|
||||
the server listens*, a compile that outlives `TimeoutStartSec` turns `Restart=always` into a boot
|
||||
loop that never finishes. Before changing that pin, check the project's release assets and confirm a
|
||||
prebuild exists for every Node ABI you intend to run. A build toolchain (`python3`, `make`, `g++`)
|
||||
should still be present as a fallback.
|
||||
|
||||
**Native (git + systemd)**
|
||||
|
||||
1. Back up first — a Node upgrade cannot corrupt the database, but you want the rollback anyway.
|
||||
2. Change the Node major. If Node came from a distribution repository pinned to a major, the repo
|
||||
definition itself must be repointed — upgrading the package alone can never cross majors, and
|
||||
this pin lives in system configuration rather than in this repository.
|
||||
3. `node --version` to confirm.
|
||||
4. Rebuild the native module explicitly (`npm rebuild better-sqlite3` as the service user, in
|
||||
`server/`), or let preflight do it on the next restart. Doing it by hand keeps the logs readable.
|
||||
5. Restart, then verify as in [Verifying a deploy](#verifying-a-deploy). In the logs, confirm
|
||||
preflight reports a successful rebuild rather than exiting.
|
||||
|
||||
**Docker** — nothing to rebuild. Change the base image, build, and deploy the new tag: dependencies
|
||||
are installed inside the image against its own Node, so the ABI can never be stale. Rollback is
|
||||
repinning the previous tag.
|
||||
|
||||
**Afterwards, move CI too.** CI pins its own Node version, and it will happily keep validating a
|
||||
version nobody runs — which is worse than no signal, because it looks like coverage. The Docker base
|
||||
image is a separate pin from the CI one; both need changing or what CI tests and what ships diverge.
|
||||
|
||||
---
|
||||
|
||||
## Traps worth knowing before they bite
|
||||
|
||||
**Native modules are built for one Node ABI.** `better-sqlite3` is compiled against the Node that
|
||||
installed it. Run the app — or its tests — under a different major version and it fails with
|
||||
`NODE_MODULE_VERSION` mismatch, which presents as hundreds of unrelated test failures rather than
|
||||
one clear error. Use the same Node the service runs. See [Upgrading Node.js](#upgrading-nodejs)
|
||||
before changing it deliberately.
|
||||
|
||||
**SQLite foreign keys are off unless enabled per connection.** A declared `ON DELETE CASCADE` does
|
||||
not fire on its own, so deleting a parent row can leave orphaned children. Check with
|
||||
`PRAGMA foreign_key_check;` after any bulk delete.
|
||||
|
||||
**Deploying reloads every connected web player.** The frontend self-reloads when the build hash
|
||||
changes. Browsers cope. Some embedded webview players do not, and may need a restart afterwards —
|
||||
worth knowing before you deploy during business hours.
|
||||
|
||||
**An SSO-linked administrator has no password.** If you link the platform administrator account to
|
||||
an identity provider and that provider later fails, the login page cannot help you. Recovery is
|
||||
`node scripts/reset-admin.js` on the server. See [sso-setup.md](sso-setup.md).
|
||||
|
||||
**Backups are only real once restored.** A snapshot that has never been restored is a hypothesis.
|
||||
Periodically restore the newest one into a throwaway instance and confirm it boots and serves
|
||||
`/api/status`.
|
||||
|
||||
**`curl … | sudo bash` answers the installer's questions for you.** The pipe *is* stdin, and bash
|
||||
has consumed it before any prompt runs — so every question gets an instant end-of-input and the
|
||||
script takes the default. On the Pi installer that meant the mode menu appeared to skip itself and
|
||||
Player-Only was unreachable through the documented command. Fixed there (prompts read the terminal
|
||||
now), but the trap is general: any piped installer that asks you something is not really asking.
|
||||
Download the script and run it, or pass the answers as flags.
|
||||
|
||||
**A Raspberry Pi 5 on Bookworm runs Wayland, and X11 tools fail silently there.** `xset`,
|
||||
`unclutter` and `xrandr` return an error and do nothing — so screen blanking is never suppressed and
|
||||
the cursor is never hidden, while every command in your setup notes appears to have worked. If you
|
||||
have hand-rolled kiosk tweaks on a Pi, check which session is actually running (`echo
|
||||
$XDG_SESSION_TYPE`) before trusting them. The bundled launcher detects this and uses `wlopm` plus
|
||||
`--ozone-platform=wayland` on Wayland.
|
||||
|
||||
**Overlay FS protects the SD card and discards everything written to it.** Reasonable on a
|
||||
**player-only** Pi, where the loss is a content cache that simply re-downloads after each boot. Not
|
||||
usable for an **all-in-one** install as-is: the server writes continuously — the SQLite database,
|
||||
WAL, uploads and thumbnails — and a read-only root throws all of it away at reboot, so the instance
|
||||
silently reverts to its state at the moment you enabled overlay. If you want both, put `DATA_DIR` on
|
||||
a writable partition that overlay does not cover, and confirm a screen you add survives a power cut
|
||||
before relying on it.
|
||||
263
docs/sso-setup.md
Normal file
263
docs/sso-setup.md
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
# Single sign-on — setup guide
|
||||
|
||||
How to turn on SSO, for the two people who need it: the **operator** running the server, and an
|
||||
**organization admin** bringing their company's own identity provider.
|
||||
|
||||
Everything below is OpenID Connect. One flow — Authorization Code with PKCE, completed server-side —
|
||||
so the browser never talks to the provider directly and there is no SDK to load.
|
||||
|
||||
---
|
||||
|
||||
## Contents
|
||||
|
||||
- [Which kind of SSO do you want?](#which-kind-of-sso-do-you-want)
|
||||
- [Operator: Google](#operator-google)
|
||||
- [Operator: Microsoft / Entra ID](#operator-microsoft--entra-id)
|
||||
- [Operator: any other provider](#operator-any-other-provider)
|
||||
- [Organization admin: bring your own provider](#organization-admin-bring-your-own-provider)
|
||||
- [Requiring SSO for your organization](#requiring-sso-for-your-organization)
|
||||
- [Linking an existing account](#linking-an-existing-account)
|
||||
- [What users see at sign-in](#what-users-see-at-sign-in)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Which kind of SSO do you want?
|
||||
|
||||
There are two, and they are configured in completely different places.
|
||||
|
||||
| | Instance-wide | Per-organization |
|
||||
|---|---|---|
|
||||
| Configured by | the **operator**, in environment variables | an **org owner/admin**, in Settings → Single sign-on |
|
||||
| Restart needed | yes | no |
|
||||
| Who sees the button | everyone, on the login page | only people at that organization's **verified** domains |
|
||||
| Typical use | "Sign in with Google" for anyone | a customer wiring up their own Entra/Okta tenant |
|
||||
|
||||
An organization's provider **overrides** the instance's for its own verified domains, and never
|
||||
appears publicly — the login page reveals it only after someone enters an address at one of those
|
||||
domains, so a guessed domain cannot confirm who your customers are.
|
||||
|
||||
---
|
||||
|
||||
## Operator: Google
|
||||
|
||||
Google is the simplest: one fixed issuer, and it reports whether an address is verified.
|
||||
|
||||
1. **console.cloud.google.com** → create a project (a dedicated one — if you reuse an auto-created
|
||||
AI Studio project and later tidy those up, you take sign-in down with it).
|
||||
2. **Google Auth Platform** (formerly "OAuth consent screen"):
|
||||
- **App name** — users see this on the consent screen
|
||||
- **Audience**: External
|
||||
- Support and contact email
|
||||
- Scopes: nothing to add. `openid`, `email` and `profile` are implicit and non-sensitive, so
|
||||
**no Google verification review is required**.
|
||||
3. **Clients → Create client → Web application**
|
||||
- **Authorized redirect URI**, exactly:
|
||||
```
|
||||
https://your-domain.example/api/auth/oidc/google/callback
|
||||
```
|
||||
- Leave *Authorized JavaScript origins* empty — the exchange is server-side.
|
||||
4. **Audience → Test users**: while publishing status is *Testing*, only listed accounts can sign
|
||||
in. Add yourself, or **Publish app** (safe here, given the scopes).
|
||||
5. Set the environment:
|
||||
```bash
|
||||
GOOGLE_CLIENT_ID=…apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=… # a Web application client needs one
|
||||
```
|
||||
|
||||
> Google matches redirect URIs byte for byte. No trailing slash, `https` not `http`.
|
||||
|
||||
---
|
||||
|
||||
## Operator: Microsoft / Entra ID
|
||||
|
||||
Microsoft needs one decision up front: **whose accounts are signing in?** That decides both the app
|
||||
registration and, crucially, the tenant ID you configure.
|
||||
|
||||
### The rule that catches everyone
|
||||
|
||||
`MICROSOFT_TENANT_ID` is **not** "where the app is registered". It is the directory that
|
||||
**authenticates the user**, because it is what the ID token's `iss` will say. Those are different
|
||||
things whenever the two differ — most obviously for personal accounts.
|
||||
|
||||
| Who signs in | Supported account types | `MICROSOFT_TENANT_ID` |
|
||||
|---|---|---|
|
||||
| Personal Microsoft accounts (outlook.com, hotmail, …) | **Personal Microsoft account users** | `9188040d-6c67-4c5b-b112-36a304b66dad` (Microsoft's consumer directory) |
|
||||
| Your own staff | **Single tenant** | your **Directory (tenant) ID** |
|
||||
|
||||
⚠️ **`common`, `organizations` and `consumers` are refused, deliberately.** Two reasons that point
|
||||
the same way. They cannot work: Microsoft's multi-tenant metadata advertises the issuer as the
|
||||
literal template `https://login.microsoftonline.com/{tenantid}/v2.0`, so `iss` can never match. And
|
||||
the obvious workaround is dangerous — accepting that template means accepting tokens from *every*
|
||||
Azure tenant, which is [nOAuth](https://www.descope.com/blog/post/noauth): any tenant admin can set
|
||||
an arbitrary, unverified `email` on one of their own users and be issued a session as that address.
|
||||
Safe multi-tenant support needs per-tenant pinning (allowlist `tid`, key accounts on `oid`+`tid`
|
||||
rather than email) and is not implemented. Setting one of these disables Microsoft sign-in with a
|
||||
warning at boot rather than failing quietly.
|
||||
|
||||
### Steps
|
||||
|
||||
1. **portal.azure.com** → Entra ID → App registrations → **New registration**
|
||||
- **Name** — users see this on the consent screen
|
||||
- **Supported account types** — per the table above
|
||||
- **Redirect URI**: platform **Web**, value
|
||||
```
|
||||
https://your-domain.example/api/auth/oidc/microsoft/callback
|
||||
```
|
||||
⚠️ **Web, not SPA.** A SPA registration is rejected at the token endpoint, because this exchange
|
||||
runs server-side and sends no browser `Origin`.
|
||||
2. **Certificates & secrets → New client secret** → copy the **Value** (shown once, not the ID).
|
||||
A Web registration is a confidential client; the exchange fails without it.
|
||||
3. **Token configuration → Add optional claim → ID → `email`.** Without it the token can arrive with
|
||||
no address at all, which fails as `no_email`.
|
||||
4. Set the environment:
|
||||
```bash
|
||||
MICROSOFT_CLIENT_ID=…
|
||||
MICROSOFT_TENANT_ID=… # see the table — NOT necessarily the directory the app lives in
|
||||
MICROSOFT_CLIENT_SECRET=…
|
||||
```
|
||||
|
||||
> **Entra never sends `email_verified`.** ScreenTinker treats a tenant-pinned Microsoft provider as
|
||||
> vouching for the address rather than demanding a claim Microsoft does not emit — safe because the
|
||||
> operator chose that provider and it is pinned to one directory. An explicit `email_verified: false`
|
||||
> is still refused.
|
||||
|
||||
---
|
||||
|
||||
## Operator: any other provider
|
||||
|
||||
Okta, Auth0, Keycloak, Authentik, Zitadel — anything with a discovery document:
|
||||
|
||||
```bash
|
||||
OIDC_PROVIDERS=okta,authentik # comma-separated slugs
|
||||
OIDC_OKTA_ISSUER=https://example.okta.com # the base URL whose /.well-known/openid-configuration describes it
|
||||
OIDC_OKTA_CLIENT_ID=…
|
||||
OIDC_OKTA_CLIENT_SECRET=… # optional — PKCE means a public client works
|
||||
OIDC_OKTA_NAME=Okta # optional button label
|
||||
OIDC_OKTA_SCOPES=openid email profile # optional
|
||||
OIDC_OKTA_ASSUME_EMAIL_VERIFIED=true # only if it verifies addresses but omits the claim
|
||||
```
|
||||
|
||||
Redirect URI is `https://your-domain.example/api/auth/oidc/<slug>/callback`.
|
||||
|
||||
Set **`APP_URL`** so the redirect URI is pinned to one origin. It must match your provider's
|
||||
registration exactly, and deriving it from the request `Host` would both break behind a second
|
||||
hostname and take its value from the caller.
|
||||
|
||||
---
|
||||
|
||||
## Organization admin: bring your own provider
|
||||
|
||||
No environment variables, no restart, no operator involvement.
|
||||
|
||||
1. **Settings → Single sign-on → Add provider**
|
||||
- **Issuer** — for Entra, `https://login.microsoftonline.com/<your-tenant-guid>/v2.0`
|
||||
- **Client ID** and **Client secret** from your own app registration
|
||||
- **Email domains** you intend to claim
|
||||
2. Copy the **redirect URI** shown in Settings and register it with your provider. It carries a
|
||||
generated slug, so two customers can neither collide on nor guess each other's.
|
||||
3. **Verify each domain.** Publish the TXT record shown:
|
||||
```
|
||||
_screentinker-verify.<your-domain> TXT st-verify=<token>
|
||||
```
|
||||
Then press Verify. An unverified claim lapses after 8 hours and releases the domain.
|
||||
|
||||
Your provider may only assert addresses at domains you have **proved** you control. A domain can be
|
||||
claimed by one organization only; a second claim is refused.
|
||||
|
||||
> Proof by CNAME is not accepted — it would need a wildcard zone we do not operate, and would turn a
|
||||
> subdomain takeover into an apex takeover.
|
||||
|
||||
Once a domain is verified, your provider is trusted to assert addresses in it even if it omits
|
||||
`email_verified` (as Entra does) — the DNS proof stands in for the claim. A provider that has
|
||||
verified nothing assumes nothing.
|
||||
|
||||
---
|
||||
|
||||
## Requiring SSO for your organization
|
||||
|
||||
**Settings → Single sign-on → Require single sign-on.** Then, for anyone at your verified domains:
|
||||
|
||||
- passwords are refused
|
||||
- other providers are refused, **including the instance's own Google/Microsoft** — otherwise
|
||||
"requires SSO" would just be renaming the bypass
|
||||
|
||||
⚠️ **Enabling this clears the passwords** of members at your verified domains. That is not reversible
|
||||
without a reset.
|
||||
|
||||
Turning it **off** requires a platform administrator to approve the request, so one compromised org
|
||||
admin cannot quietly reopen password login. Plan for that turnaround before you enable it.
|
||||
|
||||
---
|
||||
|
||||
## Linking an existing account
|
||||
|
||||
Signing in with a provider never takes over an account that already has a password — otherwise
|
||||
anyone who could get a provider to assert your address would inherit your account. Link it
|
||||
deliberately instead:
|
||||
|
||||
**Settings → Sign-in method → Link `<provider>`**
|
||||
|
||||
- An account has **one** credential. Linking **deletes** the password; afterwards you sign in with
|
||||
the provider only.
|
||||
- **Unlink** asks for a new password and applies both changes together, so the account is never left
|
||||
without a way in.
|
||||
- The provider account must use the **same email address** as the ScreenTinker account.
|
||||
- Only the providers this server offers can be linked — an organization's own provider cannot attach
|
||||
itself to an account.
|
||||
|
||||
> ⚠️ If you link the **platform administrator** account, that provider becomes the only way in.
|
||||
> Should it break, recovery is `scripts/reset-admin.js` on the server, not the login page.
|
||||
|
||||
---
|
||||
|
||||
## What users see at sign-in
|
||||
|
||||
The login page asks for an email address first and shows the password box only after you continue.
|
||||
That is what lets it check whether the address belongs to an organization with its own provider
|
||||
*before* offering a credential — so someone whose company requires SSO is shown that, rather than a
|
||||
password box that was going to be refused. Correcting the address takes you back a step.
|
||||
|
||||
The instance's own providers are shown throughout.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Errors appear as a message on the login page (or Settings, when linking). The exact code is in the
|
||||
URL as `sso_error=…`, and the server logs a matching `[oidc]` line with the underlying reason.
|
||||
|
||||
| Code | What it means | Usual cause |
|
||||
|---|---|---|
|
||||
| `unknown_provider` | No such provider on this server | Slug typo; or `MICROSOFT_TENANT_ID` is multi-tenant, so Microsoft was disabled at boot — check the `[sso]` warning |
|
||||
| `provider_unavailable` | Discovery or the token exchange failed | Wrong issuer URL; no outbound network; **missing client secret** on a confidential client |
|
||||
| `provider_refused` | The provider itself said no | Consent declined; conditional-access policy; account not on the Google test-user list |
|
||||
| `expired` | The round trip took too long | Left the tab open; started over in another tab |
|
||||
| `bad_state` / `no_code` | The response did not match the request | Started in one browser and returned in another; a redirect URI that does not match the registration |
|
||||
| `verification_failed` | The ID token did not verify | **Wrong tenant** — the log prints the `iss` actually seen; clock skew; wrong client ID |
|
||||
| `no_email` | The token carried no address | Entra: add the **`email`** optional claim under Token configuration |
|
||||
| `email_unverified` | The provider would not vouch for the address | The provider sent `email_verified: false`; or it omits the claim and is not eligible to assume (an org provider with no verified domain) |
|
||||
| `account_exists_local` | That address already has a password | Sign in with the password, then **Settings → Sign-in method → Link** |
|
||||
| `account_exists_other_provider` | The account belongs to a different provider | Unlink first, or sign in with the provider that owns it |
|
||||
| `subject_mismatch` | Same address, different provider subject | The address was reassigned. Deliberate: it stops a recycled mailbox inheriting an account |
|
||||
| `domain_not_allowed` | The provider asserted a domain it has not verified | Verify the domain, or check which address the provider is actually sending |
|
||||
| `sso_required` | The organization requires its own provider | Use the organization's button, not the password box or an instance provider |
|
||||
| `registration_disabled` | New accounts are turned off | The address has no account and self-registration is disabled |
|
||||
| `link_email_mismatch` | The provider account has a different address | Sign in to the provider with the same address as the account |
|
||||
| `link_already_used` | That provider identity is linked elsewhere | Unlink it from the other account first |
|
||||
|
||||
### Checks worth doing first
|
||||
|
||||
```bash
|
||||
# What the server thinks is configured (public endpoint)
|
||||
curl -s https://your-domain.example/api/auth/config
|
||||
|
||||
# Does the start URL carry the right issuer and redirect?
|
||||
curl -s -o /dev/null -D - https://your-domain.example/api/auth/oidc/google/start | grep -i location
|
||||
|
||||
# Boot warnings, and every login outcome
|
||||
docker logs <container> 2>&1 | grep -E '\[sso\]|\[oidc\]'
|
||||
```
|
||||
|
||||
`/api/auth/config` reporting `microsoftEnabled: false` while `MICROSOFT_CLIENT_ID` is set almost
|
||||
always means the tenant ID was rejected — look for the `[sso]` line at boot.
|
||||
112
docs/telemetry.md
Normal file
112
docs/telemetry.md
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
# Install statistics
|
||||
|
||||
ScreenTinker can optionally report how many screens an install runs. It is **off until you turn it
|
||||
on**, and this page documents the whole of it.
|
||||
|
||||
---
|
||||
|
||||
## What is sent
|
||||
|
||||
Three fields. This is the complete payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"instance_id": "9f2c1b6e-4a17-4c8e-9d3b-27a5e0f81c44",
|
||||
"version": "1.9.34",
|
||||
"screen_count": 42
|
||||
}
|
||||
```
|
||||
|
||||
| Field | What it is |
|
||||
|---|---|
|
||||
| `instance_id` | A random UUID generated by your server on first use and kept in its own database. It carries no information about you — its only job is to let two reports from the same server be recognised as the same server, so a count is a count rather than a sum of duplicates. |
|
||||
| `version` | The ScreenTinker version this server is running. |
|
||||
| `screen_count` | How many displays have been paired with this server. |
|
||||
|
||||
## What is not sent
|
||||
|
||||
No hostnames, IP addresses or domains. No organization, workspace or user names. No email
|
||||
addresses and no user count. No device names, locations or serial numbers. No content, filenames,
|
||||
playlists or schedules. No logs and no configuration.
|
||||
|
||||
The request is sent over HTTPS, and the receiving service does not record the source address.
|
||||
|
||||
## Verifying that
|
||||
|
||||
Rather than take the above on trust:
|
||||
|
||||
- **In the product** — Settings → Install statistics shows the exact payload your server would
|
||||
send, generated live from your own data, plus what it last actually sent and when.
|
||||
- **In the source** — the payload is built in one function, `payload()` in
|
||||
[`server/lib/telemetry.js`](../server/lib/telemetry.js). Every field that leaves your server
|
||||
is in that object literal. `server/test/telemetry.test.js` fails if a field is added.
|
||||
- **On the wire** — the destination is a single `POST`, overridable with `TELEMETRY_ENDPOINT`, so
|
||||
you can point it at your own collector and read exactly what arrives.
|
||||
|
||||
## Turning it on or off
|
||||
|
||||
You are asked once, on the dashboard, if you are a platform administrator. Both answers are
|
||||
remembered, so declining is permanent and you will not be asked again after an update.
|
||||
|
||||
To change your mind at any time: **Settings → Install statistics**.
|
||||
|
||||
Reports are sent 5 minutes after the server starts, then once a day while it keeps running.
|
||||
Nothing is queued or retried — if your server is offline or the request fails, that attempt is
|
||||
simply skipped.
|
||||
|
||||
## If your outbound traffic is filtered
|
||||
|
||||
Reports are an ordinary HTTPS `POST` from your server to:
|
||||
|
||||
```
|
||||
https://stats.screentinker.com/api/telemetry/report
|
||||
```
|
||||
|
||||
Many self-hosted servers sit on networks that block outbound connections by default. **If yours
|
||||
does, that address has to be allowed or the reports never arrive** — sharing will appear to be on
|
||||
while nothing reaches us.
|
||||
|
||||
You do not have to guess whether that is happening. Turning sharing on sends a report immediately,
|
||||
so a blocked connection is reported there and then, and **Settings → Install statistics** names the
|
||||
failure and the address to allow.
|
||||
|
||||
Nothing needs to be opened *inbound*. This is an outbound connection from your server only.
|
||||
|
||||
## Keeping your own copy
|
||||
|
||||
If you want these numbers for your own fleet, set `TELEMETRY_EXTRA_ENDPOINT` to your own collector.
|
||||
Your server then posts the same three fields there as well.
|
||||
|
||||
Two things to be clear about, because the naming is deliberate:
|
||||
|
||||
- **It is additional, not a redirect.** Setting it does not stop the shared report going to
|
||||
ScreenTinker — that is why it is called `EXTRA` rather than `ENDPOINT`. Settings lists every
|
||||
destination a report goes to, so what is configured is always visible.
|
||||
- **It is independent of the sharing switch.** Your collector receives reports whether sharing is
|
||||
on or off, because that is your server posting to your host. **If you want your own statistics
|
||||
and nothing sent to us, set it and leave sharing off** — that combination is supported on purpose.
|
||||
|
||||
Each destination is attempted separately, so one being unreachable never stops the other.
|
||||
|
||||
## Why we ask
|
||||
|
||||
ScreenTinker is self-hostable, so most installs are invisible to us by design, and that is how it
|
||||
should stay. The cost is that we genuinely cannot answer "how many screens run this?" — a question
|
||||
that matters for arguing the project is worth continuing to build, and for deciding which players
|
||||
deserve the next round of work.
|
||||
|
||||
Sharing is a small, specific way to help with that. Declining is a completely reasonable answer and
|
||||
changes nothing about how the product works.
|
||||
|
||||
> **A note on honesty.** Because sharing is opt-in, any total we publish is a **floor** — "at least
|
||||
> N screens" — never an estimate of the whole install base. Instances that opt in are not a random
|
||||
> sample of those that don't, so the number is not something to extrapolate from, and we won't.
|
||||
|
||||
## Running your own collector
|
||||
|
||||
Set `TELEMETRY_COLLECTOR=1` and this server accepts reports at `POST /api/telemetry/report`,
|
||||
storing them in a `telemetry_reports` table keyed by `instance_id`. The endpoint is inert unless
|
||||
that variable is set, so a normal install never exposes it.
|
||||
|
||||
Reports are upserted rather than appended — one row per install holding its latest report, not a
|
||||
growing event log.
|
||||
|
|
@ -210,6 +210,12 @@ export const api = {
|
|||
|
||||
// TOTP 2FA (#100) — opt-in per-user, local accounts only. See routes/auth.js.
|
||||
totpStatus: () => request('/auth/totp/status'),
|
||||
// Unlink an instance-wide SSO provider. The new password is required in the same call:
|
||||
// the account must never sit between credentials.
|
||||
ssoUnlink: (password) => request('/auth/oidc/unlink', { method: 'POST', body: JSON.stringify({ password }) }),
|
||||
// Returns { url } to navigate to. Fetched rather than navigated to, because the session is
|
||||
// a bearer token and a top-level navigation cannot carry one.
|
||||
ssoLinkStart: (slug) => request(`/auth/oidc/${encodeURIComponent(slug)}/link/start`),
|
||||
totpSetup: () => request('/auth/totp/setup', { method: 'POST' }),
|
||||
totpEnable: (code) => request('/auth/totp/enable', { method: 'POST', body: JSON.stringify({ code }) }),
|
||||
totpDisable: (code) => request('/auth/totp/disable', { method: 'POST', body: JSON.stringify({ code }) }),
|
||||
|
|
@ -260,6 +266,10 @@ export const api = {
|
|||
// #146: toggle the /api/status debug block exposure (platform-admin only).
|
||||
adminGetStatusDebug: () => request('/admin/status-debug'),
|
||||
adminSetStatusDebug: (enabled) => request('/admin/status-debug', { method: 'PUT', body: JSON.stringify({ enabled }) }),
|
||||
// Opt-in install statistics. GET returns { state, payload, last_report } — payload is the exact
|
||||
// body that would be sent, so the UI can show it rather than describe it.
|
||||
adminGetTelemetry: () => request('/admin/telemetry'),
|
||||
adminSetTelemetry: (enabled) => request('/admin/telemetry', { method: 'PUT', body: JSON.stringify({ enabled }) }),
|
||||
|
||||
// Per-user workspace membership management (platform Users page modal).
|
||||
adminGetUserWorkspaces: (id) => request(`/admin/users/${id}/workspaces`),
|
||||
|
|
|
|||
|
|
@ -116,6 +116,8 @@ export default {
|
|||
'common.unknown': 'Unknown',
|
||||
|
||||
// Auth (login view)
|
||||
'auth.next': 'Next',
|
||||
'auth.error_email_required': 'Enter your email address',
|
||||
'auth.sign_in': 'Sign In',
|
||||
'auth.sign_out': 'Sign out',
|
||||
'auth.create_account': 'Create Account',
|
||||
|
|
@ -681,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',
|
||||
|
|
@ -771,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',
|
||||
|
|
@ -836,6 +841,33 @@ export default {
|
|||
'settings.save_profile': 'Save Profile',
|
||||
'settings.email_alerts': 'Email me when devices go offline',
|
||||
'settings.change_password': 'Change Password',
|
||||
// Sign-in method (#258). The link warning is deliberately explicit about destruction of the
|
||||
// password — that is the part users miss, and it is not reversible without setting a new one.
|
||||
'settings.signin_method': 'Sign-in method',
|
||||
'settings.signin_password_now': 'This account signs in with a password. You can link it to a single sign-on provider instead.',
|
||||
'settings.signin_password_only': 'This account signs in with a password. No single sign-on providers are configured on this server.',
|
||||
'settings.signin_link': 'Link {provider}',
|
||||
'settings.signin_link_warning': 'You are linking this account to {provider}.\n\nYour local password will be DELETED. After this you sign in with {provider} only.\n\nTo go back to a password later, unlink {provider} and set a new one.',
|
||||
'settings.signin_linked': 'This account signs in with {provider}. It has no password.',
|
||||
'settings.signin_unlink': 'Unlink {provider}',
|
||||
'settings.signin_unlink_desc': 'Set a password to sign in with instead. It takes effect immediately and {provider} is unlinked in the same step.',
|
||||
'settings.signin_unlink_confirm': 'Set password and unlink',
|
||||
'settings.signin_unlinked_toast': 'Unlinked. You now sign in with your password.',
|
||||
'settings.passwords_dont_match': 'The two passwords do not match',
|
||||
'settings.signin_linked_toast': 'Linked. You now sign in with {provider}, and your password has been removed.',
|
||||
'settings.signin_err_link_email_mismatch': 'That provider account uses a different email address than this account. Sign in to the provider with the same address and try again.',
|
||||
'settings.signin_err_link_already_used': 'That provider account is already linked to a different ScreenTinker account.',
|
||||
'settings.signin_err_not_linkable': 'Only the providers configured on this server can be linked to an account.',
|
||||
'settings.signin_err_no_email': 'The provider did not supply an email address, so the account could not be linked.',
|
||||
'settings.signin_err_email_unverified': 'The provider would not confirm that email address is verified.',
|
||||
'settings.signin_err_verification_failed': 'The sign-in could not be verified. Nothing was changed.',
|
||||
'settings.signin_err_provider_unavailable': 'The provider could not be reached. Nothing was changed.',
|
||||
'settings.signin_err_provider_refused': 'The provider refused the request. Nothing was changed.',
|
||||
'settings.signin_err_unknown_provider': 'That provider is not configured on this server.',
|
||||
'settings.signin_err_expired': 'That took too long. Start the link again.',
|
||||
'settings.signin_err_bad_state': 'The response did not match the request. Start the link again.',
|
||||
'settings.signin_err_no_code': 'The provider returned no authorization code. Start the link again.',
|
||||
'settings.signin_err_server_error': 'Something went wrong. Nothing was changed.',
|
||||
'settings.password_min_8': 'Must be at least 8 characters.',
|
||||
'settings.current_password': 'Current Password',
|
||||
'settings.new_password': 'New Password',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { api } from '../api.js';
|
||||
import { on, off, requestScreenshot } from '../socket.js';
|
||||
import { showToast } from '../components/toast.js';
|
||||
import { esc, livenessBadge } from '../utils.js';
|
||||
import { esc, livenessBadge, isPlatformAdmin } from '../utils.js';
|
||||
import { t, tn } from '../i18n.js';
|
||||
import * as gettingStarted from '../components/getting-started.js';
|
||||
import { showDeviceOwnerQRModal } from '../components/device-owner-qr-modal.js';
|
||||
|
|
@ -290,6 +290,47 @@ function renderGroupSection(group, devices, playlists) {
|
|||
`;
|
||||
}
|
||||
|
||||
/*
|
||||
* Asks, once, whether this install will share its screen count. Only a platform admin sees it,
|
||||
* and only while the decision is genuinely unmade — BOTH answers persist, so it never returns
|
||||
* after an update. Re-prompting is how telemetry earns its reputation and gets patched out.
|
||||
*/
|
||||
async function renderStatsPrompt(container) {
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
if (!isPlatformAdmin(user)) return;
|
||||
|
||||
let info;
|
||||
try { info = await api.adminGetTelemetry(); } catch { return; }
|
||||
if (info.state !== 'unasked') return;
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.className = 'settings-section';
|
||||
el.style.cssText = 'margin-bottom:16px;display:flex;gap:16px;align-items:flex-start;flex-wrap:wrap';
|
||||
el.innerHTML = `
|
||||
<div style="flex:1;min-width:260px">
|
||||
<strong>Help show how widely ScreenTinker is deployed?</strong>
|
||||
<p style="color:var(--text-muted);font-size:13px;margin:6px 0 0">
|
||||
Because most installs are private, we can't tell how many screens are out there. Sharing
|
||||
sends a random ID, the version, and how many screens you run — nothing else, ever.
|
||||
You can change this any time in Settings.
|
||||
</p>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn btn-primary btn-sm" id="statsYes">Share</button>
|
||||
<button class="btn btn-secondary btn-sm" id="statsNo">No thanks</button>
|
||||
</div>
|
||||
`;
|
||||
container.prepend(el);
|
||||
|
||||
const answer = async (enabled) => {
|
||||
try { await api.adminSetTelemetry(enabled); } catch { /* leave it unasked; it can ask again later */ return; }
|
||||
el.remove();
|
||||
if (enabled) showToast('Thank you — sharing install statistics', 'success');
|
||||
};
|
||||
el.querySelector('#statsYes').addEventListener('click', () => answer(true));
|
||||
el.querySelector('#statsNo').addEventListener('click', () => answer(false));
|
||||
}
|
||||
|
||||
export function render(container) {
|
||||
container.innerHTML = `
|
||||
<div class="page-header">
|
||||
|
|
@ -432,6 +473,10 @@ export function render(container) {
|
|||
// Load everything
|
||||
loadDashboard();
|
||||
|
||||
// Ask once about sharing install statistics. Fire-and-forget: it prepends itself if and only
|
||||
// if the decision is still unmade, and a failure here must never affect the dashboard.
|
||||
renderStatsPrompt(container).catch(() => {});
|
||||
|
||||
// Real-time updates
|
||||
statusHandler = (data) => {
|
||||
const b = livenessBadge(data, { short: true }); // list = concise label; tooltip carries the full text
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -287,7 +287,15 @@ function setupHandlers(config, isSetup) {
|
|||
if (isSetup) {
|
||||
document.getElementById('loginBtn')?.addEventListener('click', () => doRegister(true));
|
||||
} else {
|
||||
document.getElementById('loginBtn')?.addEventListener('click', doLogin);
|
||||
/*
|
||||
* Identifier-first. The button is "Next" until an address has been submitted: we ask the server
|
||||
* what that address uses BEFORE offering a credential, so an SSO-only user is never shown a
|
||||
* password box that is going to be refused, and the org lookup has somewhere to happen.
|
||||
*/
|
||||
document.getElementById('loginBtn')?.addEventListener('click', () => {
|
||||
if (identified && !ssoOnlyDomain) return doLogin();
|
||||
identify();
|
||||
});
|
||||
document.getElementById('showRegisterBtn')?.addEventListener('click', () => {
|
||||
document.getElementById('localAuthForm').style.display = 'none';
|
||||
document.getElementById('registerForm').style.display = 'block';
|
||||
|
|
@ -304,6 +312,40 @@ function setupHandlers(config, isSetup) {
|
|||
if (e.key === 'Enter') isSetup ? doRegister(true) : doLogin();
|
||||
});
|
||||
|
||||
/*
|
||||
* Enter in the EMAIL field advances rather than submitting. During first-run setup both fields
|
||||
* are needed at once, so identifier-first is skipped entirely there.
|
||||
*/
|
||||
document.getElementById('loginEmail')?.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
if (isSetup) return doRegister(true);
|
||||
if (identified && !ssoOnlyDomain) return doLogin();
|
||||
identify();
|
||||
});
|
||||
|
||||
/*
|
||||
* Editing the address after identifying returns to the identifier step. Someone who mistypes
|
||||
* their domain must get a fresh answer rather than keep the previous domain's one.
|
||||
*/
|
||||
document.getElementById('loginEmail')?.addEventListener('input', () => {
|
||||
if (!identified) return;
|
||||
identified = false;
|
||||
applyFormState();
|
||||
});
|
||||
|
||||
/*
|
||||
* Ask what this address uses, then show the right thing. The lookup itself sets ssoOnlyDomain via
|
||||
* setPasswordVisible(), so this only has to decide that we now know who is signing in.
|
||||
*/
|
||||
async function identify() {
|
||||
const email = document.getElementById('loginEmail').value.trim();
|
||||
if (!email || !email.includes('@')) { showError(t('auth.error_email_required')); return; }
|
||||
try { await lookupOrgSso(email); } catch { /* lookup failures fall through to the password box */ }
|
||||
identified = true;
|
||||
applyFormState();
|
||||
if (!ssoOnlyDomain) document.getElementById('loginPassword')?.focus();
|
||||
}
|
||||
|
||||
async function doLogin() {
|
||||
const email = document.getElementById('loginEmail').value.trim();
|
||||
const password = document.getElementById('loginPassword').value;
|
||||
|
|
@ -509,7 +551,6 @@ function setupHandlers(config, isSetup) {
|
|||
* Debounced because this fires while someone types, and the endpoint is rate limited; asking on
|
||||
* every keystroke would spend a user's whole budget before they finished their own address.
|
||||
*/
|
||||
let ssoLookupTimer = null;
|
||||
let lastDomainAsked = '';
|
||||
const orgSlot = () => document.getElementById('orgSsoSlot');
|
||||
|
||||
|
|
@ -520,44 +561,68 @@ function setupHandlers(config, isSetup) {
|
|||
* on every negative answer matters as much as hiding it: someone who types an SSO-only address,
|
||||
* then corrects it to their own, must get the password box back.
|
||||
*/
|
||||
function setPasswordVisible(visible) {
|
||||
/*
|
||||
* Password visibility has TWO independent drivers, and conflating them is how this got confusing:
|
||||
*
|
||||
* identified — identifier-first. The password box does not exist until an address has been
|
||||
* submitted, because until then we do not know whether this account uses a
|
||||
* password at all. This is what lets the org lookup happen before we offer the
|
||||
* wrong thing.
|
||||
* ssoOnlyDomain — the address belongs to an organization that REQUIRES its own provider. Then a
|
||||
* password box is not merely going to fail, it is the wrong thing to show.
|
||||
*
|
||||
* The field appears only when identified AND not SSO-only. Kept as one function so the two can
|
||||
* never disagree about what is on screen.
|
||||
*/
|
||||
let identified = false;
|
||||
let ssoOnlyDomain = false;
|
||||
|
||||
function applyFormState() {
|
||||
const showPassword = identified && !ssoOnlyDomain;
|
||||
const show = showPassword ? '' : 'none';
|
||||
/*
|
||||
* ⚠️ Hide the password FIELD, never its .form-group — the organization SSO slot lives inside
|
||||
* that same group, so hiding the container took the single sign-on button down with it and left
|
||||
* a login page whose only action was "Create Account". Found by looking at a screenshot.
|
||||
* that same group, so hiding the container took the single sign-on button down with it.
|
||||
*/
|
||||
const show = visible ? '' : 'none';
|
||||
for (const id of ['loginPassword', 'loginPasswordLabel', 'loginBtn']) {
|
||||
for (const id of ['loginPassword', 'loginPasswordLabel']) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.style.display = show;
|
||||
}
|
||||
|
||||
/*
|
||||
* The instance's own providers go too. They are the operator's, not this organization's, and
|
||||
* they are not domain-confined — so offering "Continue with Google" to someone whose company
|
||||
* requires its own identity provider is offering them the bypass. The server refuses it either
|
||||
* way; this stops the page inviting it.
|
||||
* The primary button is "Next" until an address has been submitted, then "Sign in". One button
|
||||
* rather than two, so there is never a choice about which to press.
|
||||
*/
|
||||
const instance = document.getElementById('instanceProviders');
|
||||
if (instance) instance.style.display = show;
|
||||
const btn = document.getElementById('loginBtn');
|
||||
if (btn) btn.textContent = identified && !ssoOnlyDomain ? t('auth.sign_in') : t('auth.next');
|
||||
if (btn) btn.style.display = ssoOnlyDomain ? 'none' : '';
|
||||
|
||||
/*
|
||||
* "Create Account" goes too. Registration at an SSO-only domain is refused by the server, and
|
||||
* leaving the button was worse than useless: it was the ONLY action left on the card, so the
|
||||
* page invited the one thing that cannot work.
|
||||
* The instance's own providers stay visible at ALL times, by explicit decision: they are the
|
||||
* operator's, they are offered to everyone, and the server refuses them for an SSO-only
|
||||
* organization anyway. (Previously they were hidden for such domains so the page would not
|
||||
* invite the bypass; the cost was a login page that changed shape while you typed.)
|
||||
*/
|
||||
|
||||
/*
|
||||
* "Create Account" and "Forgot your password?" DO go for an SSO-only domain: registration there
|
||||
* is refused by the server, and a password reset produces one that can never be used.
|
||||
*/
|
||||
const reg = document.getElementById('showRegisterBtn');
|
||||
if (reg) reg.style.display = show;
|
||||
// The OR divider sits outside #instanceProviders, so hiding those alone left a dangling rule
|
||||
// with nothing beneath it.
|
||||
const divider = document.getElementById('ssoDivider');
|
||||
if (divider) divider.style.display = show;
|
||||
// "Forgot your password?" sits in its own <p>; hide the wrapper so no empty gap is left.
|
||||
if (reg) reg.style.display = ssoOnlyDomain ? 'none' : '';
|
||||
const forgot = document.getElementById('forgotLink');
|
||||
if (forgot) {
|
||||
const wrap = forgot.parentElement && forgot.parentElement.tagName === 'P' ? forgot.parentElement : forgot;
|
||||
wrap.style.display = show;
|
||||
wrap.style.display = ssoOnlyDomain ? 'none' : '';
|
||||
}
|
||||
}
|
||||
|
||||
// Kept for the org lookup below, which reasons about SSO-only rather than about identification.
|
||||
function setPasswordVisible(visible) {
|
||||
ssoOnlyDomain = !visible;
|
||||
applyFormState();
|
||||
}
|
||||
|
||||
async function lookupOrgSso(email) {
|
||||
const at = String(email || '').lastIndexOf('@');
|
||||
const domain = at === -1 ? '' : email.slice(at + 1).trim().toLowerCase();
|
||||
|
|
@ -650,11 +715,21 @@ function setupHandlers(config, isSetup) {
|
|||
}
|
||||
}
|
||||
|
||||
document.getElementById('loginEmail')?.addEventListener('input', (e) => {
|
||||
clearTimeout(ssoLookupTimer);
|
||||
const value = e.target.value;
|
||||
ssoLookupTimer = setTimeout(() => lookupOrgSso(value), 400);
|
||||
});
|
||||
/*
|
||||
* The lookup now runs on SUBMIT (identify()), not on every keystroke.
|
||||
*
|
||||
* Identifier-first made the debounced version both redundant and wrong: redundant because nothing
|
||||
* is shown until an address is submitted anyway, and wrong because it would answer for a
|
||||
* half-typed domain and change the form under someone mid-address. It also spent a rate-limit
|
||||
* budget of 10/min per IP on people who had not finished typing — an office behind one address
|
||||
* could exhaust it without a single sign-in attempt.
|
||||
*
|
||||
* ⚠️ Applied HERE, after the `let identified` / `let ssoOnlyDomain` declarations above. Called any
|
||||
* earlier it would throw on the temporal dead zone, which on this page means a login form that
|
||||
* never renders.
|
||||
*/
|
||||
if (isSetup) identified = true; // first-run setup needs both fields at once
|
||||
applyFormState();
|
||||
|
||||
/*
|
||||
* Completing an SSO login.
|
||||
|
|
|
|||
|
|
@ -62,6 +62,16 @@ export async function render(container) {
|
|||
<p style="color:var(--text-muted);font-size:12px;margin-top:16px">${t('settings.sso_note', { provider: esc(user.auth_provider || 'SSO') })}</p>
|
||||
`}
|
||||
|
||||
<!--
|
||||
Sign-in method (#258). An account has exactly ONE credential: a password, or one
|
||||
instance-wide provider. Linking deletes the password; unlinking requires a new one in the
|
||||
same step, so the account is never briefly left with no way in. Populated by loadSsoLink().
|
||||
-->
|
||||
<div id="ssoLinkBlock" style="border-top:1px solid var(--border);margin-top:20px;padding-top:16px">
|
||||
<h4 style="font-size:14px;margin-bottom:8px">${t('settings.signin_method')}</h4>
|
||||
<p style="color:var(--text-muted);font-size:12px">…</p>
|
||||
</div>
|
||||
|
||||
<!-- Two-factor authentication (#100). Populated by load2FA() from /auth/totp/status. -->
|
||||
<div id="twoFactorBlock" style="border-top:1px solid var(--border);margin-top:20px;padding-top:16px">
|
||||
<h4 style="font-size:14px;margin-bottom:8px">${t('settings.2fa_title')}</h4>
|
||||
|
|
@ -159,6 +169,13 @@ export async function render(container) {
|
|||
<div id="licenseSection"><p style="color:var(--text-muted);font-size:13px">${t('settings.license_mit')}</p></div>
|
||||
</div>
|
||||
|
||||
${isSuperAdmin ? `
|
||||
<div class="settings-section" id="telemetrySection">
|
||||
<h3>Install statistics</h3>
|
||||
<div id="telemetryBody"><p style="color:var(--text-muted);font-size:13px">Loading…</p></div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${isSuperAdmin ? `<p style="font-size:12px;color:var(--text-muted);margin-bottom:12px">${t('settings.platform_admin_link')} <a href="#/admin" style="color:var(--accent)">${t('nav.admin')}</a> ${t('settings.platform_admin_page_suffix')}</p>` : ''}
|
||||
|
||||
<div class="settings-section">
|
||||
|
|
@ -265,6 +282,7 @@ export async function render(container) {
|
|||
if (isAdmin) {
|
||||
loadUsers();
|
||||
loadWhiteLabel();
|
||||
loadTelemetry();
|
||||
|
||||
// Support token generator
|
||||
document.getElementById('generateSupportBtn')?.addEventListener('click', async () => {
|
||||
|
|
@ -526,6 +544,176 @@ export async function render(container) {
|
|||
// ==================== Two-factor authentication (#100) ====================
|
||||
// Drives the merged TOTP backend (/api/auth/totp/*). Re-renders #twoFactorBlock
|
||||
// for each state: SSO note / disabled+enroll / recovery-codes / enabled+manage.
|
||||
/*
|
||||
* Sign-in method: password OR one instance-wide provider, never both.
|
||||
*
|
||||
* The warning on the link button is the whole UX: the local password is DELETED, not kept as a
|
||||
* fallback, and someone who does not read that will think they gained a second way in. Unlink
|
||||
* asks for the new password up front for the same reason — the account must never sit between
|
||||
* credentials.
|
||||
*
|
||||
* Only instance-wide providers appear. An organization's provider is chosen by a customer and
|
||||
* must not be attachable to a platform account; the server refuses it too.
|
||||
*/
|
||||
/*
|
||||
* Install statistics. Shows the ACTUAL payload rather than a description of it — the whole
|
||||
* proposition is "you can check instead of trusting us", and the code is public, so a sentence
|
||||
* that didn't match the bytes would be found. Also shows what was last really sent.
|
||||
*/
|
||||
async function loadTelemetry() {
|
||||
const box = document.getElementById('telemetryBody');
|
||||
if (!box) return;
|
||||
let info;
|
||||
try { info = await api.adminGetTelemetry(); }
|
||||
catch { box.innerHTML = `<p style="color:var(--text-muted);font-size:13px">Unavailable.</p>`; return; }
|
||||
|
||||
const on = info.state === 'on';
|
||||
const sent = info.last_report
|
||||
? `Last sent ${new Date(info.last_report.at * 1000).toLocaleString()}.`
|
||||
: 'Nothing has been sent yet.';
|
||||
|
||||
// A blocked outbound connection is the normal failure on a self-hosted box, and it is
|
||||
// otherwise invisible — the operator just sees nothing arriving. Name the failure and the
|
||||
// host, so the fix is "allow this in the firewall" rather than "guess".
|
||||
const failed = on && info.last_error;
|
||||
const why = failed
|
||||
? ({ network: 'the connection was refused or the address did not resolve',
|
||||
timeout: 'the connection timed out' }[info.last_error.reason]
|
||||
|| `the server replied ${esc(info.last_error.reason)}`)
|
||||
: '';
|
||||
|
||||
box.innerHTML = `
|
||||
<p style="color:var(--text-muted);font-size:13px;margin-bottom:12px">
|
||||
ScreenTinker can't see how widely it's deployed, because most installs are private by
|
||||
design. Sharing lets us say how many screens are running — nothing more.
|
||||
</p>
|
||||
<label style="display:flex;align-items:center;gap:8px;margin-bottom:12px">
|
||||
<input type="checkbox" id="telemetryToggle" ${on ? 'checked' : ''}>
|
||||
Share install statistics
|
||||
</label>
|
||||
<p style="color:var(--text-muted);font-size:12px;margin-bottom:6px">
|
||||
Everything that would be sent, in full:
|
||||
</p>
|
||||
<pre style="background:var(--bg-input,rgba(0,0,0,.2));padding:10px;border-radius:var(--radius);font-size:12px;overflow-x:auto;margin-bottom:8px">${esc(JSON.stringify(info.payload, null, 2))}</pre>
|
||||
<p style="color:var(--text-muted);font-size:12px;margin-bottom:${info.extra_endpoint ? '4' : '8'}px">
|
||||
${on ? 'Sent once a day to' : 'When enabled, sent once a day to'}
|
||||
<code style="font-size:11px">${esc(info.endpoint || '')}</code>. If this server's outbound
|
||||
traffic is filtered, that address has to be allowed or the reports never arrive.
|
||||
</p>
|
||||
${info.extra_endpoint ? `
|
||||
<p style="color:var(--text-muted);font-size:12px;margin-bottom:8px">
|
||||
A second copy also goes to your own collector at
|
||||
<code style="font-size:11px">${esc(info.extra_endpoint)}</code>, configured on this server
|
||||
with <code style="font-size:11px">TELEMETRY_EXTRA_ENDPOINT</code>. That is in addition to
|
||||
the above, not instead of it — turn the switch off if you want your own statistics without
|
||||
sharing.
|
||||
</p>` : ''}
|
||||
${failed ? `
|
||||
<p style="font-size:12px;color:var(--danger);margin-bottom:8px">
|
||||
The last attempt (${esc(new Date(info.last_error.at * 1000).toLocaleString())}) did not get
|
||||
through — ${why}. Check that outbound HTTPS to that address is permitted.
|
||||
</p>` : ''}
|
||||
<p style="color:var(--text-muted);font-size:12px">
|
||||
No names, addresses, content, or user details. The ID is random and identifies the install
|
||||
only so repeat reports aren't counted twice. ${esc(sent)}
|
||||
</p>
|
||||
`;
|
||||
|
||||
document.getElementById('telemetryToggle')?.addEventListener('change', async (e) => {
|
||||
const enabled = e.target.checked;
|
||||
try {
|
||||
// Turning it on sends immediately, so a blocked firewall is reported here and now rather
|
||||
// than failing quietly tonight — say so plainly instead of a cheerful success toast.
|
||||
const r = await api.adminSetTelemetry(enabled);
|
||||
if (!enabled) showToast('Install statistics off', 'success');
|
||||
else if (r.first_report && r.first_report.sent) showToast('Shared — thank you', 'success');
|
||||
else showToast('Saved, but the first report did not get through — see below', 'error');
|
||||
loadTelemetry();
|
||||
} catch {
|
||||
e.target.checked = !enabled;
|
||||
showToast('Could not save that setting', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSsoLink() {
|
||||
const block = document.getElementById('ssoLinkBlock');
|
||||
if (!block) return;
|
||||
const head = `<h4 style="font-size:14px;margin-bottom:8px">${t('settings.signin_method')}</h4>`;
|
||||
const muted = 'color:var(--text-muted);font-size:12px';
|
||||
const paint = (inner) => { block.innerHTML = head + inner; };
|
||||
|
||||
let me;
|
||||
try { me = await api.getMe(); }
|
||||
catch (e) { paint(`<p style="${muted}">${esc(e.message)}</p>`); return; }
|
||||
|
||||
let providers = [];
|
||||
try {
|
||||
const res = await fetch('/api/auth/providers');
|
||||
if (res.ok) providers = (await res.json()).providers || [];
|
||||
} catch { /* offline: fall through to the no-providers copy */ }
|
||||
|
||||
if (me.auth_provider && me.auth_provider !== 'local') {
|
||||
const name = providers.find((p) => p.slug === me.auth_provider)?.name || me.auth_provider;
|
||||
paint(`
|
||||
<p style="${muted};margin-bottom:12px">${t('settings.signin_linked', { provider: esc(name) })}</p>
|
||||
<div id="unlinkForm" style="display:none;margin-bottom:12px">
|
||||
<p style="${muted};margin-bottom:8px">${t('settings.signin_unlink_desc')}</p>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px">
|
||||
<div class="form-group"><label>${t('settings.new_password')}</label><input type="password" id="unlinkPw" class="input" autocomplete="new-password"></div>
|
||||
<div class="form-group"><label>${t('settings.confirm_new_password')}</label><input type="password" id="unlinkPw2" class="input" autocomplete="new-password"></div>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" id="unlinkConfirmBtn">${t('settings.signin_unlink_confirm')}</button>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm" id="unlinkBtn">${t('settings.signin_unlink', { provider: esc(name) })}</button>
|
||||
`);
|
||||
document.getElementById('unlinkBtn').onclick = () => {
|
||||
document.getElementById('unlinkForm').style.display = '';
|
||||
document.getElementById('unlinkBtn').style.display = 'none';
|
||||
document.getElementById('unlinkPw').focus();
|
||||
};
|
||||
document.getElementById('unlinkConfirmBtn').onclick = async () => {
|
||||
const pw = document.getElementById('unlinkPw').value;
|
||||
const pw2 = document.getElementById('unlinkPw2').value;
|
||||
if (pw !== pw2) return showToast(t('settings.passwords_dont_match'), 'error');
|
||||
try {
|
||||
await api.ssoUnlink(pw);
|
||||
showToast(t('settings.signin_unlinked_toast'), 'success');
|
||||
loadSsoLink();
|
||||
} catch (e) { showToast(e.message, 'error'); }
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (!providers.length) {
|
||||
paint(`<p style="${muted}">${t('settings.signin_password_only')}</p>`);
|
||||
return;
|
||||
}
|
||||
paint(`
|
||||
<p style="${muted};margin-bottom:12px">${t('settings.signin_password_now')}</p>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
${providers.map((p) => `<button class="btn btn-secondary btn-sm" data-link-slug="${esc(p.slug)}">${t('settings.signin_link', { provider: esc(p.name) })}</button>`).join('')}
|
||||
</div>
|
||||
`);
|
||||
block.querySelectorAll('[data-link-slug]').forEach((btn) => {
|
||||
btn.onclick = async () => {
|
||||
const slug = btn.dataset.linkSlug;
|
||||
const name = providers.find((p) => p.slug === slug)?.name || slug;
|
||||
// Deliberately blunt: the password is destroyed, and that is the part people miss.
|
||||
if (!window.confirm(t('settings.signin_link_warning', { provider: name }))) return;
|
||||
/*
|
||||
* Fetch the authorize URL, then navigate to it. NOT location.href straight at the start
|
||||
* route: the session is a bearer token in localStorage, so a top-level navigation arrives
|
||||
* with no Authorization header and is refused as anonymous.
|
||||
*/
|
||||
try {
|
||||
const { url } = await api.ssoLinkStart(slug);
|
||||
window.location.href = url;
|
||||
} catch (e) { showToast(e.message, 'error'); }
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function load2FA() {
|
||||
const block = document.getElementById('twoFactorBlock');
|
||||
if (!block) return;
|
||||
|
|
@ -660,6 +848,32 @@ export async function render(container) {
|
|||
|
||||
loadTokens();
|
||||
load2FA();
|
||||
loadSsoLink();
|
||||
|
||||
/*
|
||||
* Report the outcome of a link round trip.
|
||||
*
|
||||
* The callback returns to #/settings rather than the login page — an authenticated user bounced
|
||||
* to a login screen to be told "that did not work" reads as having been signed out. Params are
|
||||
* stripped afterwards so a refresh or a copied URL does not replay the message.
|
||||
*/
|
||||
(function reportLinkOutcome() {
|
||||
const q = new URLSearchParams((location.hash.split('?')[1] || ''));
|
||||
const linked = q.get('sso_linked');
|
||||
const err = q.get('sso_error');
|
||||
if (!linked && !err) return;
|
||||
if (linked) {
|
||||
showToast(t('settings.signin_linked_toast', { provider: linked }), 'success');
|
||||
} else {
|
||||
const known = ['link_email_mismatch', 'link_already_used', 'not_linkable', 'no_email',
|
||||
'email_unverified', 'verification_failed', 'provider_unavailable', 'provider_refused',
|
||||
'unknown_provider', 'expired', 'bad_state', 'no_code', 'server_error'];
|
||||
const key = known.includes(err) ? `settings.signin_err_${err}` : 'auth.sso_failed';
|
||||
showToast(t(key), 'error');
|
||||
}
|
||||
history.replaceState(null, '', location.pathname + location.search + '#/settings');
|
||||
loadSsoLink();
|
||||
}());
|
||||
|
||||
// #73: agency scope reveals a playlist picker (the token's allowlist). Loaded lazily once.
|
||||
const tokScopeSel = document.getElementById('tokScope');
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -641,6 +641,19 @@ const migrations = [
|
|||
// it can do nothing and must be respected.
|
||||
'ALTER TABLE devices ADD COLUMN capabilities TEXT',
|
||||
|
||||
// Opt-in install statistics, COLLECTOR side only — inert unless TELEMETRY_COLLECTOR=1, which
|
||||
// is the hosted deployment. Keyed by instance_id and upserted rather than appended, so it is a
|
||||
// table of current state ("this install last reported N screens") rather than an event log that
|
||||
// grows without bound on a box nobody prunes. Answering "how many screens are deployed" needs
|
||||
// the latest row per install, never the history.
|
||||
`CREATE TABLE IF NOT EXISTS telemetry_reports (
|
||||
instance_id TEXT PRIMARY KEY,
|
||||
version TEXT,
|
||||
screen_count INTEGER NOT NULL DEFAULT 0,
|
||||
first_seen INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL
|
||||
)`,
|
||||
|
||||
];
|
||||
// Apply each ALTER idempotently. A "duplicate column name" / "already exists"
|
||||
// error means the column is already present (expected on a migrated DB) - benign.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
'use strict';
|
||||
|
||||
const { preCmp } = require('./version-precedence');
|
||||
|
||||
/*
|
||||
* Should a BrightSign player replace its own host package (autorun.zip)?
|
||||
*
|
||||
|
|
@ -54,7 +56,9 @@ function compareVersions(a, b) {
|
|||
if (A.pre === B.pre) return 0;
|
||||
if (A.pre === null) return 1; // 1.9.29 beats 1.9.29-rc1
|
||||
if (B.pre === null) return -1;
|
||||
return A.pre < B.pre ? -1 : 1; // rc1 < rc2, lexicographic is right for our naming
|
||||
// Natural compare, NOT lexicographic: rc10 must outrank rc9. This file carried the same
|
||||
// "lexicographic is right for our naming" assumption that broke the Android OTA path.
|
||||
return preCmp(A.pre, B.pre);
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ function safeFilename(name) {
|
|||
* orientation bug (#170) that the ingest path fixes with imageDisplayDims + .rotate(). A second
|
||||
* copy of this logic is a second place for it to rot; there is now one.
|
||||
*
|
||||
* Best-effort by contract: a missing ffprobe or a sharp failure yields nulls and a warning, never
|
||||
* Best-effort by contract: a missing ffprobe or a decode failure yields nulls and a warning, never
|
||||
* a throw — the file itself is already stored and is worth more than its metadata.
|
||||
*
|
||||
* @returns {{width:number|null, height:number|null, durationSec:number|null, thumbnailPath:string|null}}
|
||||
|
|
@ -39,25 +39,30 @@ function safeFilename(name) {
|
|||
async function deriveMediaMetadata(sourcePath, filepath, mime) {
|
||||
let width = null, height = null, durationSec = null, thumbnailPath = null;
|
||||
try {
|
||||
// SVG is deliberately NOT handed to sharp: rasterising it goes through librsvg, which
|
||||
// is where the outstanding libvips CVEs live, and an SVG is already its own thumbnail.
|
||||
// SVG is deliberately NOT rasterised: it is already its own thumbnail. (It also used to be
|
||||
// the one format kept away from sharp, because rasterising went through librsvg — where the
|
||||
// outstanding libvips CVEs live. Nothing rasterises it now either.)
|
||||
if (mime === 'image/svg+xml') {
|
||||
thumbnailPath = filepath;
|
||||
} else if (mime.startsWith('image/')) {
|
||||
const sharp = require('sharp');
|
||||
const metadata = await sharp(sourcePath).metadata();
|
||||
// #170: honor EXIF orientation so a portrait photo isn't stored as landscape.
|
||||
({ width, height } = imageDisplayDims(metadata));
|
||||
// Assign thumbnailPath only AFTER the write succeeds: a sharp failure used to
|
||||
// return the already-assigned name for a file that was never written, storing a
|
||||
// phantom thumbnail_path that the UI then requests forever as a broken image.
|
||||
const imageOps = require('./image-ops');
|
||||
const thumbName = `thumb_${filepath}`;
|
||||
await sharp(sourcePath)
|
||||
.rotate() // #170: auto-orient per EXIF (and strip the tag) so the thumbnail matches
|
||||
.resize(config.thumbnailWidth)
|
||||
.jpeg({ quality: 70 })
|
||||
.toFile(path.join(config.contentDir, thumbName));
|
||||
thumbnailPath = thumbName;
|
||||
// Measure and thumbnail from ONE decode. Asking separately costs two, and a decode is the
|
||||
// single most expensive thing on this path (~1s for a 12MP photo — unlike sharp, whose
|
||||
// .metadata() only read the header). #170: rotation is implicit, the decoder auto-orients,
|
||||
// so the recorded dimensions and the thumbnail agree without an explicit rotate.
|
||||
const metadata = await imageOps.measureAndThumbnail(
|
||||
sourcePath, path.join(config.contentDir, thumbName), config.thumbnailWidth, 70);
|
||||
// #170: honor EXIF orientation so a portrait photo isn't stored as landscape. The decoder
|
||||
// applies it and reports orientation 1, so this is a no-op pass-through today — kept so the
|
||||
// rule lives in one place regardless of which decoder is underneath.
|
||||
({ width, height } = imageDisplayDims(metadata));
|
||||
// Assign thumbnailPath only if the write actually succeeded: naming it unconditionally used
|
||||
// to store a phantom thumbnail_path for a file that was never created, which the UI then
|
||||
// requests forever as a broken image. The dimensions above survive that failure on purpose —
|
||||
// they are independently useful, and losing them would letterbox the asset wrongly.
|
||||
if (metadata.thumbnailWritten) thumbnailPath = thumbName;
|
||||
else console.warn(`Thumbnail write failed for ${filepath}: ${metadata.thumbnailError}`);
|
||||
} else if (mime.startsWith('video/')) {
|
||||
try {
|
||||
// execFile, NOT execFileSync. These two spawns each carry a 15s timeout, and run
|
||||
|
|
|
|||
142
server/lib/image-ops-core.js
Normal file
142
server/lib/image-ops-core.js
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
'use strict';
|
||||
|
||||
/*
|
||||
* Pure-JavaScript image operations — the two things the ingest path ever asked sharp for:
|
||||
* measure an image, and write a thumbnail.
|
||||
*
|
||||
* THIS FILE IS THE WORK, NOT THE ENTRY POINT. Callers use ./image-ops, which runs these on a
|
||||
* worker thread; everything here is CPU-bound pure JS that would otherwise stall the event loop
|
||||
* for ~1s per 12MP photo. Requiring this module directly is only correct inside the worker (or in
|
||||
* image-ops' inline fallback). See ./image-ops for why.
|
||||
*
|
||||
* WHY NOT SHARP: sharp is a native module wrapping libvips. That costs us a prebuilt binary per
|
||||
* platform/ABI, and when there isn't one (or Node moves ABI) the failure is
|
||||
* ERR_DLOPEN_FAILED/NODE_MODULE_VERSION at require time — the same class of breakage
|
||||
* lib/preflight-deps.js exists to explain for better-sqlite3. Nothing in here is native, so the
|
||||
* server runs anywhere Node runs, including the embedded targets that have no toolchain.
|
||||
*
|
||||
* FORMAT COVERAGE vs the sharp it replaces:
|
||||
* jpeg png gif tiff bmp Jimp, natively
|
||||
* webp avif @jsquash/* — WebAssembly, bundled, no network (see wasmDecode below)
|
||||
* svg never reaches here; callers thumbnail an SVG with itself
|
||||
* heic unsupported — and it already was. sharp lists `heif`, but its
|
||||
* prebuilt libvips has AV1 only and refuses HEVC ("Unsupported
|
||||
* compression"), so .heic uploads have never produced a thumbnail.
|
||||
*
|
||||
* ORIENTATION (#170): Jimp applies EXIF orientation when it decodes and rewrites the tag to 1,
|
||||
* so what comes back is already DISPLAY dimensions — the rotation sharp needed an explicit
|
||||
* .rotate() for. metadata() therefore reports orientation 1 and lets imageDisplayDims() run as a
|
||||
* no-op rather than swapping W/H a second time. Report the tag honestly and that helper stays
|
||||
* correct for any future decoder that does NOT auto-orient.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { sniffMime } = require('./upload-sniff');
|
||||
|
||||
// Jimp is ESM-first but ships a CJS entry; require() is fine and keeps this file loadable from
|
||||
// the CommonJS server. Deferred so a caller that never touches an image never pays for it.
|
||||
let _jimp = null;
|
||||
function jimp() {
|
||||
if (!_jimp) _jimp = require('jimp');
|
||||
return _jimp;
|
||||
}
|
||||
|
||||
/*
|
||||
* @jsquash's decoders are browser-first: they locate their .wasm with
|
||||
* `fetch(new URL('...wasm', import.meta.url))`. Under Node that URL is a file:// one and Node's
|
||||
* fetch does not implement file://, so the bundled binary never loads and the only symptom is a
|
||||
* bare "fetch failed". The binary IS on disk in the package — read and compile it ourselves, then
|
||||
* hand the Module to init(). No network, at install time or after.
|
||||
*/
|
||||
const WASM_CODECS = {
|
||||
'image/webp': { pkg: '@jsquash/webp', wasm: '@jsquash/webp/codec/dec/webp_dec.wasm' },
|
||||
'image/avif': { pkg: '@jsquash/avif', wasm: '@jsquash/avif/codec/dec/avif_dec.wasm' },
|
||||
};
|
||||
const decoderCache = new Map();
|
||||
|
||||
async function wasmDecode(mime, buf) {
|
||||
const spec = WASM_CODECS[mime];
|
||||
if (!spec) return null;
|
||||
if (!decoderCache.has(mime)) {
|
||||
decoderCache.set(mime, (async () => {
|
||||
const mod = await import(`${spec.pkg}/decode.js`);
|
||||
await mod.init(await WebAssembly.compile(fs.readFileSync(require.resolve(spec.wasm))));
|
||||
return mod.default;
|
||||
})());
|
||||
}
|
||||
const decode = await decoderCache.get(mime);
|
||||
return decode(buf); // -> ImageData-ish { data, width, height }
|
||||
}
|
||||
|
||||
/*
|
||||
* Decode to a Jimp image whatever the format. Reuses sniffMime rather than carrying a second copy
|
||||
* of the magic-byte table — routes/media.js already duplicating it once is noted there as a smell.
|
||||
* Throws on anything undecodable, which is the contract callers already handle (a failure yields
|
||||
* null metadata and no thumbnail, never a lost upload).
|
||||
*/
|
||||
async function readImage(src) {
|
||||
const buf = await fs.promises.readFile(src);
|
||||
const mime = sniffMime(buf);
|
||||
|
||||
if (WASM_CODECS[mime]) {
|
||||
const raw = await wasmDecode(mime, buf);
|
||||
if (!raw) throw new Error(`no decoder for ${mime}`);
|
||||
return jimp().Jimp.fromBitmap({ data: Buffer.from(raw.data), width: raw.width, height: raw.height });
|
||||
}
|
||||
return jimp().Jimp.read(buf);
|
||||
}
|
||||
|
||||
/*
|
||||
* Display dimensions, shaped like the sharp metadata the callers already destructure.
|
||||
* orientation is 1 because the decode above already applied it — see ORIENTATION note at the top.
|
||||
*/
|
||||
async function metadata(src) {
|
||||
const img = await readImage(src);
|
||||
return { width: img.bitmap.width, height: img.bitmap.height, orientation: 1 };
|
||||
}
|
||||
|
||||
/*
|
||||
* Resize-and-encode an ALREADY DECODED image. Never upscales: sharp's resize() would enlarge a
|
||||
* small source, but a thumbnail bigger than its original is pure waste and callers only shrink.
|
||||
* Mutates img, so measure before calling.
|
||||
*/
|
||||
async function encodeThumbnail(img, destPath, width, quality) {
|
||||
if (img.bitmap.width > width) img.resize({ w: width });
|
||||
await fs.promises.writeFile(destPath, await img.getBuffer('image/jpeg', { quality }));
|
||||
}
|
||||
|
||||
/*
|
||||
* Write a JPEG thumbnail `width` px wide, aspect preserved — sharp's
|
||||
* .rotate().resize(width).jpeg({quality}).toFile(). Rotation is implicit in the decode.
|
||||
*/
|
||||
async function writeThumbnail(src, destPath, width, quality = 70) {
|
||||
await encodeThumbnail(await readImage(src), destPath, width, quality);
|
||||
}
|
||||
|
||||
/*
|
||||
* Measure AND thumbnail from a SINGLE decode — what ingest actually wants.
|
||||
*
|
||||
* Calling metadata() then writeThumbnail() decodes the file twice. That was free under sharp,
|
||||
* whose .metadata() only parses the header, but here every decode is the full ~1s of a 12MP
|
||||
* photo, so the naive pairing doubled the most expensive thing the ingest path does.
|
||||
*
|
||||
* A thumbnail failure must NOT discard the dimensions: they are independently useful (the player
|
||||
* needs them to letterbox correctly) and that is how the two-call version behaved, since width and
|
||||
* height were already assigned before the thumbnail was written. So the write is reported, not
|
||||
* thrown — and the caller assigns a thumbnail_path only when thumbnailWritten is true, keeping the
|
||||
* phantom-path discipline that stops the UI requesting a file that was never created.
|
||||
* A DECODE failure still throws: there is nothing to report about an unreadable image.
|
||||
*/
|
||||
async function measureAndThumbnail(src, destPath, width, quality = 70) {
|
||||
const img = await readImage(src);
|
||||
const measured = { width: img.bitmap.width, height: img.bitmap.height, orientation: 1 };
|
||||
try {
|
||||
await encodeThumbnail(img, destPath, width, quality);
|
||||
return { ...measured, thumbnailWritten: true, thumbnailError: null };
|
||||
} catch (err) {
|
||||
return { ...measured, thumbnailWritten: false, thumbnailError: err && err.message ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { metadata, writeThumbnail, measureAndThumbnail, readImage };
|
||||
30
server/lib/image-ops-worker.js
Normal file
30
server/lib/image-ops-worker.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
'use strict';
|
||||
|
||||
/*
|
||||
* Worker-thread host for image-ops-core. One job per message, one reply per job, keyed by id.
|
||||
*
|
||||
* Deliberately thin: every decision (queueing, lifecycle, fallback) lives in ../lib/image-ops so
|
||||
* there is one place to reason about them. This end only does the work and reports what happened.
|
||||
*
|
||||
* Errors come back as a message rather than a thrown exception, so one undecodable upload does
|
||||
* not tear down the worker and take the queued jobs of unrelated callers with it.
|
||||
*/
|
||||
|
||||
const { parentPort } = require('worker_threads');
|
||||
const core = require('./image-ops-core');
|
||||
|
||||
const OPS = {
|
||||
metadata: (job) => core.metadata(job.src),
|
||||
writeThumbnail: (job) => core.writeThumbnail(job.src, job.dest, job.width, job.quality),
|
||||
measureAndThumbnail: (job) => core.measureAndThumbnail(job.src, job.dest, job.width, job.quality),
|
||||
};
|
||||
|
||||
parentPort.on('message', async (job) => {
|
||||
try {
|
||||
const op = OPS[job.op];
|
||||
if (!op) throw new Error(`unknown image op: ${job.op}`);
|
||||
parentPort.postMessage({ id: job.id, ok: true, result: await op(job) });
|
||||
} catch (err) {
|
||||
parentPort.postMessage({ id: job.id, ok: false, error: err && err.message ? err.message : String(err) });
|
||||
}
|
||||
});
|
||||
147
server/lib/image-ops.js
Normal file
147
server/lib/image-ops.js
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
'use strict';
|
||||
|
||||
/*
|
||||
* Image operations, off the main thread.
|
||||
*
|
||||
* WHY THIS EXISTS: image-ops-core is pure JavaScript, so unlike the native sharp it replaced —
|
||||
* which handed work to a libvips threadpool — its CPU cost lands on whatever thread calls it. A
|
||||
* 12MP photo measures at ~1.0s of solid, uninterruptible main-thread work. That is not a slow
|
||||
* upload, it is a stalled event loop: no heartbeats, no socket traffic, nothing. lib/thumbnail-
|
||||
* backfill.js walks an entire content library at boot, so in-process it reproduces #240 exactly
|
||||
* (blocked loop -> missed heartbeats -> panels marked offline -> reconnect churn), arriving from
|
||||
* our own maintenance. The same reasoning already moved this file's video branch from
|
||||
* execFileSync to execFile; this is that fix for the image branch.
|
||||
*
|
||||
* The work is therefore hosted on a worker thread and this module is the only entry point.
|
||||
*
|
||||
* ONE JOB AT A TIME, deliberately. Decoding holds a full RGBA bitmap — a 12MP photo is ~48MB — so
|
||||
* letting jobs overlap multiplies peak memory by the queue depth, which is exactly the wrong
|
||||
* failure on the small targets this whole change is meant to reach. Serialized, the ceiling is one
|
||||
* image regardless of how many uploads land at once. It also costs nothing in throughput: the work
|
||||
* is CPU-bound, and a single busy worker already saturates the core it runs on.
|
||||
*
|
||||
* The worker is unref'd while idle so it never holds the process open — scripts/backfill-rotation-
|
||||
* dims.js is a CLI that must exit, and `node --test` would otherwise hang forever — and ref'd only
|
||||
* while a job is in flight, so an in-progress thumbnail cannot be cut short by the process exiting.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
|
||||
const WORKER_PATH = path.join(__dirname, 'image-ops-worker.js');
|
||||
const IDLE_SHUTDOWN_MS = 60_000; // release the decoder heap (jimp + the WASM codecs) when quiet
|
||||
|
||||
let worker = null;
|
||||
let idleTimer = null;
|
||||
let inFlight = null; // { id, resolve, reject } — at most one, by design
|
||||
let inlineOnly = false; // set if a worker cannot be created at all; see runInline
|
||||
let nextId = 1;
|
||||
const queue = [];
|
||||
|
||||
function clearIdleTimer() {
|
||||
if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
|
||||
}
|
||||
|
||||
function scheduleIdleShutdown() {
|
||||
clearIdleTimer();
|
||||
if (!worker || inFlight || queue.length) return;
|
||||
idleTimer = setTimeout(() => {
|
||||
idleTimer = null;
|
||||
if (worker && !inFlight && !queue.length) { const w = worker; worker = null; w.terminate(); }
|
||||
}, IDLE_SHUTDOWN_MS);
|
||||
idleTimer.unref?.();
|
||||
}
|
||||
|
||||
// Reject everything outstanding. Called when the worker dies underneath us — a crash means OOM or
|
||||
// a bug, not a bad image (image-ops-worker catches decode failures and replies normally), so there
|
||||
// is nothing to usefully retry and callers already treat a rejection as "no metadata".
|
||||
function failAll(reason) {
|
||||
const dead = [inFlight, ...queue].filter(Boolean);
|
||||
inFlight = null;
|
||||
queue.length = 0;
|
||||
for (const job of dead) job.reject(new Error(reason));
|
||||
}
|
||||
|
||||
function ensureWorker() {
|
||||
if (worker) return worker;
|
||||
const { Worker } = require('worker_threads');
|
||||
worker = new Worker(WORKER_PATH);
|
||||
worker.unref();
|
||||
worker.on('message', (msg) => {
|
||||
const job = inFlight;
|
||||
if (!job || job.id !== msg.id) return; // a reply from a terminated generation; ignore
|
||||
inFlight = null;
|
||||
if (msg.ok) job.resolve(msg.result); else job.reject(new Error(msg.error));
|
||||
pump();
|
||||
});
|
||||
worker.on('error', (err) => { worker = null; failAll(`image worker failed: ${err.message}`); });
|
||||
worker.on('exit', (code) => {
|
||||
worker = null;
|
||||
if (inFlight || queue.length) failAll(`image worker exited (code ${code})`);
|
||||
});
|
||||
return worker;
|
||||
}
|
||||
|
||||
function pump() {
|
||||
if (inFlight) return;
|
||||
if (!queue.length) { worker?.unref(); scheduleIdleShutdown(); return; }
|
||||
clearIdleTimer();
|
||||
inFlight = queue.shift();
|
||||
const w = ensureWorker();
|
||||
w.ref(); // a job is running: hold the process open until it finishes
|
||||
w.postMessage(inFlight.job);
|
||||
}
|
||||
|
||||
// Last resort: if worker_threads cannot give us a thread at all, do the work in-process rather
|
||||
// than refuse to thumbnail. Stalls the loop — that is the bug this module exists to avoid — so it
|
||||
// is announced rather than silent.
|
||||
async function runInline(job) {
|
||||
const core = require('./image-ops-core');
|
||||
return job.op === 'metadata'
|
||||
? core.metadata(job.src)
|
||||
: core.writeThumbnail(job.src, job.dest, job.width, job.quality);
|
||||
}
|
||||
|
||||
function submit(job) {
|
||||
if (inlineOnly) return runInline(job);
|
||||
job.id = nextId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
ensureWorker();
|
||||
} catch (err) {
|
||||
inlineOnly = true;
|
||||
console.warn(`[image-ops] no worker thread (${err.message}) — decoding in-process, which blocks the event loop`);
|
||||
return resolve(runInline(job));
|
||||
}
|
||||
queue.push({ id: job.id, job, resolve, reject });
|
||||
pump();
|
||||
});
|
||||
}
|
||||
|
||||
/* Display dimensions, shaped like the sharp metadata callers destructure. See image-ops-core. */
|
||||
function metadata(src) {
|
||||
return submit({ op: 'metadata', src });
|
||||
}
|
||||
|
||||
/* Write a JPEG thumbnail `width` px wide, aspect preserved. Rotation is implicit in the decode. */
|
||||
function writeThumbnail(src, dest, width, quality = 70) {
|
||||
return submit({ op: 'writeThumbnail', src, dest, width, quality });
|
||||
}
|
||||
|
||||
/*
|
||||
* Both of the above from ONE decode -> { width, height, orientation, thumbnailWritten,
|
||||
* thumbnailError }. Prefer this wherever both are wanted: a decode here is the full ~1s of a 12MP
|
||||
* photo, not sharp's cheap header parse, so the pair costs double. See image-ops-core.
|
||||
*/
|
||||
function measureAndThumbnail(src, dest, width, quality = 70) {
|
||||
return submit({ op: 'measureAndThumbnail', src, dest, width, quality });
|
||||
}
|
||||
|
||||
/* Drop the worker now rather than waiting out the idle timer. For shutdown paths and tests. */
|
||||
async function shutdown() {
|
||||
clearIdleTimer();
|
||||
const w = worker;
|
||||
worker = null;
|
||||
if (w) await w.terminate();
|
||||
}
|
||||
|
||||
module.exports = { metadata, writeThumbnail, measureAndThumbnail, shutdown };
|
||||
|
|
@ -74,11 +74,18 @@ function fromEnv(env, slug) {
|
|||
* already exempts it from domain confinement — and the Microsoft entry is additionally pinned to one
|
||||
* tenant GUID, so only that directory can issue tokens for it.
|
||||
*
|
||||
* Two limits keep this from becoming the hole the strict check was closing:
|
||||
* An organization's own provider may assume too, but only once it has DNS-verified a domain — the
|
||||
* callback confines it to those domains, so it can only ever speak for names it proved it controls.
|
||||
* Requiring the claim from it as well meant a customer's Entra tenant went green on domain
|
||||
* verification and then failed the login anyway.
|
||||
*
|
||||
* Three limits keep this from becoming the hole the strict check was closing:
|
||||
* - an EXPLICIT `email_verified: false` is always refused. Assuming only ever covers an omitted
|
||||
* claim, never a provider actively saying the address is unverified;
|
||||
* - org-configured providers can never set it (rowToProvider pins it false, and nothing reads it
|
||||
* from the database), so the account-takeover path stays shut.
|
||||
* - for an org provider it is DERIVED from proof (`verified.length > 0`) and is never a column, so
|
||||
* a customer cannot switch it on for themselves;
|
||||
* - domain confinement is unchanged and still runs first, so an org provider that assumes still
|
||||
* cannot assert an address outside a domain it has proven.
|
||||
*/
|
||||
function emailIsVerified(claims, provider) {
|
||||
const asserted = (claims || {}).email_verified;
|
||||
|
|
@ -211,6 +218,9 @@ function db() {
|
|||
}
|
||||
|
||||
function rowToProvider(row, secretbox) {
|
||||
// Resolved once: it decides both which addresses this provider may assert and, below, whether it
|
||||
// has proven anything at all.
|
||||
const verified = verifiedDomainsFor(row.id);
|
||||
return {
|
||||
slug: row.slug,
|
||||
name: row.name,
|
||||
|
|
@ -226,14 +236,26 @@ function rowToProvider(row, secretbox) {
|
|||
: null,
|
||||
scopes: row.scopes || DEFAULT_SCOPES,
|
||||
/*
|
||||
* ⚠️ NEVER settable for an organization's provider, and deliberately not read from the row.
|
||||
* DERIVED from proof, never read from a column.
|
||||
*
|
||||
* A customer chooses this provider, so it speaks for the party it vouches for. Letting an org
|
||||
* assume verification would hand back exactly the takeover primitive the strict check exists to
|
||||
* stop. Domain confinement narrows WHICH addresses it may assert; this keeps the assertion
|
||||
* itself honest.
|
||||
* Entra ID v2 omits email_verified, so demanding it refused every customer who brought their own
|
||||
* Microsoft tenant — the domain went green and the login still failed. Requiring a claim
|
||||
* Microsoft does not send is not a security control, it is an outage.
|
||||
*
|
||||
* What makes it safe to stop requiring it is the proof that already gates this provider: the
|
||||
* callback confines it to DNS-verified domains, and an address is only reached here after
|
||||
* passing that. Whoever controls a domain's DNS controls its mail, which is the same trust that
|
||||
* makes a verification link meaningful in the first place.
|
||||
*
|
||||
* So the assumption is tied to having proven SOMETHING. A provider with no verified domain
|
||||
* assumes nothing — belt and braces, because emailAllowedForProvider already refuses it (an
|
||||
* empty allow-list matches no domain), and this way a future refactor that reorders those checks
|
||||
* cannot silently widen it.
|
||||
*
|
||||
* ⚠️ Still never a column. An org must not be able to switch this on for itself; it is a
|
||||
* consequence of DNS proof, not a setting.
|
||||
*/
|
||||
assumeEmailVerified: false,
|
||||
assumeEmailVerified: verified.length > 0,
|
||||
source: 'org',
|
||||
organizationId: row.organization_id,
|
||||
/*
|
||||
|
|
@ -244,7 +266,7 @@ function rowToProvider(row, secretbox) {
|
|||
* Reading the typed column here would reduce the whole verification feature to a decoration:
|
||||
* a tenant could type any company's domain and immediately assert addresses in it.
|
||||
*/
|
||||
emailDomains: verifiedDomainsFor(row.id).join(','),
|
||||
emailDomains: verified.join(','),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ const { rollingCounter, bump, read } = require('./rolling-counter');
|
|||
const rateBackoffCtr = rollingCounter();
|
||||
|
||||
// --- minimal semver-ish parse/compare (no dependency) ---
|
||||
const { preCmp } = require('./version-precedence');
|
||||
|
||||
function parseVer(v) {
|
||||
if (typeof v !== 'string') return null;
|
||||
const m = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(v.trim());
|
||||
|
|
@ -54,8 +56,10 @@ function cmpParsed(a, b) {
|
|||
if (a.pre === b.pre) return 0;
|
||||
if (a.pre === null) return 1; // release outranks a prerelease of the same core
|
||||
if (b.pre === null) return -1;
|
||||
// lexical prerelease compare — fine for beta1..beta9 (cores decide everything else).
|
||||
return a.pre < b.pre ? -1 : (a.pre > b.pre ? 1 : 0);
|
||||
// Natural prerelease compare: digit runs numerically, so alpha8 < alpha9 < alpha10 < alpha11.
|
||||
// A plain lexical compare (what this used to do) put every build from alpha10 onward BELOW
|
||||
// alpha8, so the check answered client-newer and the fleet could not be moved forward at all.
|
||||
return preCmp(a.pre, b.pre);
|
||||
}
|
||||
function cmp(a, b) { const pa = parseVer(a), pb = parseVer(b); return (!pa || !pb) ? null : cmpParsed(pa, pb); }
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -14,6 +14,18 @@
|
|||
* NODE UPGRADE better-sqlite3 is a native module compiled against one ABI. Upgrading Node makes
|
||||
* every boot fail with NODE_MODULE_VERSION mismatch, which reads like database
|
||||
* corruption and is not.
|
||||
*
|
||||
* ⚠️ better-sqlite3 is pinned to EXACTLY 12.9.0, not a caret range, and the reason
|
||||
* is invisible from package.json: 12.10.0 DROPPED the prebuilt binary for Node 20
|
||||
* (ABI 115) while still advertising `"node": "20.x || ..."` in engines. So a caret
|
||||
* resolves to 12.11.x, finds no prebuild on Node 20, and silently falls through to
|
||||
* `node-gyp rebuild` — a from-source compile during install, and during the repair
|
||||
* below. That matters here: this file rebuilds synchronously BEFORE the server
|
||||
* listens, and prod's systemd unit has TimeoutStartSec=90 with Restart=always, so a
|
||||
* slow or failing compile is a boot loop rather than a self-heal. 12.9.0 is the last
|
||||
* version shipping prebuilds for BOTH Node 20 (115) and Node 22 (127), which is what
|
||||
* lets the runtime move without the module having to compile at all.
|
||||
* Re-check the release assets before widening the pin.
|
||||
* HAND EDITS a `git checkout`, a partly-copied tree, an interrupted install.
|
||||
*
|
||||
* All three present as a server that will not start, with an error that names a file rather than
|
||||
|
|
|
|||
200
server/lib/telemetry.js
Normal file
200
server/lib/telemetry.js
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
'use strict';
|
||||
|
||||
/*
|
||||
* Opt-in install statistics.
|
||||
*
|
||||
* WHY THIS EXISTS: there is no way to answer "how many screens run ScreenTinker?" — the product is
|
||||
* self-hostable by design, so most installs are invisible to us on purpose. This asks, once, and
|
||||
* only reports if the operator says yes.
|
||||
*
|
||||
* WHAT IS SENT — the whole payload, three fields:
|
||||
*
|
||||
* { instance_id, version, screen_count }
|
||||
*
|
||||
* and nothing else. No hostnames, no addresses, no organization or user names, no device names,
|
||||
* no content or filenames, no user counts. The list is short on purpose: every field added costs
|
||||
* participation, and participation is the only thing that makes the resulting number worth
|
||||
* quoting. Anyone can verify it — the payload is built in `payload()` below, in one place, and
|
||||
* `getLastReport()` shows an operator the exact bytes last sent.
|
||||
*
|
||||
* `instance_id` is a random UUID generated on first use and kept in app_settings. It carries no
|
||||
* information about the install; its only job is to let two reports from the same server be
|
||||
* recognised as the same server, so a count is a count rather than a sum of duplicates. That does
|
||||
* make a report PSEUDONYMOUS rather than anonymous, and the wording shown to operators says so.
|
||||
*
|
||||
* ⚠️ Restoring a backup or cloning a VM carries the id with it, so two installs report as one.
|
||||
* Deliberate: under-counting is the honest failure here, and the alternative (re-identifying on
|
||||
* some hardware signal) means collecting exactly the kind of thing this file promises not to.
|
||||
*
|
||||
* ⚠️ Opt-in populations are self-selected, so the total is a FLOOR — "at least N screens" — never
|
||||
* a basis for extrapolating a fleet size.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const appSettings = require('./app-settings');
|
||||
|
||||
const KEY_ID = 'telemetry_instance_id';
|
||||
const KEY_ENABLED = 'telemetry_enabled'; // unset = never asked
|
||||
const KEY_LAST = 'telemetry_last_report'; // last SUCCESSFUL send
|
||||
const KEY_LAST_ERROR = 'telemetry_last_error';// last FAILED attempt — see getLastError
|
||||
|
||||
/*
|
||||
* Where reports go. TWO independent destinations, deliberately:
|
||||
*
|
||||
* SCREENTINKER_ENDPOINT hard-wired, and reached only when the operator has switched sharing on.
|
||||
* Not overridable — an "override" that silently redirected the shared
|
||||
* report would make the opt-in mean something different from what it says.
|
||||
*
|
||||
* TELEMETRY_EXTRA_ENDPOINT an operator's OWN collector, for their own fleet numbers. Additional,
|
||||
* never a replacement, and named so it cannot be mistaken for one. It is
|
||||
* sent independently of the sharing toggle: it is their server posting to
|
||||
* their host, so our opt-in has no business gating it. An operator who
|
||||
* wants internal statistics and nothing leaving for us sets this and
|
||||
* leaves sharing off — that combination is supported on purpose.
|
||||
*/
|
||||
const SCREENTINKER_ENDPOINT = 'https://stats.screentinker.com/api/telemetry/report';
|
||||
const REPORT_INTERVAL_MS = 24 * 60 * 60 * 1000; // daily; this is a count, not a metric
|
||||
const FIRST_REPORT_DELAY_MS = 5 * 60 * 1000; // let boot settle before any outbound call
|
||||
|
||||
let timer = null;
|
||||
|
||||
/* The instance's own id, minted on first read. Stable for the life of the install. */
|
||||
function instanceId() {
|
||||
let id = appSettings.get(KEY_ID, null);
|
||||
if (!id) {
|
||||
id = crypto.randomUUID();
|
||||
appSettings.set(KEY_ID, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/*
|
||||
* 'unasked' | 'on' | 'off'. The distinction matters: 'unasked' is what the prompt keys on, and a
|
||||
* declined install must be remembered as 'off' rather than falling back to 'unasked' and being
|
||||
* asked again on every update — re-prompting is how telemetry gets patched out by annoyed admins.
|
||||
*/
|
||||
function state() {
|
||||
const v = appSettings.get(KEY_ENABLED, undefined);
|
||||
if (v === undefined) return 'unasked';
|
||||
return (v === 'true' || v === '1') ? 'on' : 'off';
|
||||
}
|
||||
|
||||
function setEnabled(enabled) {
|
||||
appSettings.setBool(KEY_ENABLED, !!enabled);
|
||||
return state();
|
||||
}
|
||||
|
||||
/* Every field that leaves this install, built in one place so it can be audited at a glance. */
|
||||
function payload(db) {
|
||||
return {
|
||||
instance_id: instanceId(),
|
||||
version: require('../version'),
|
||||
screen_count: countScreens(db),
|
||||
};
|
||||
}
|
||||
|
||||
// Devices that have actually been paired — a provisioning row nobody ever connected is not a
|
||||
// screen, and counting it would overstate exactly the number this exists to state honestly.
|
||||
function countScreens(db) {
|
||||
try {
|
||||
return db.prepare('SELECT COUNT(*) AS c FROM devices WHERE device_token IS NOT NULL').get().c;
|
||||
} catch (_) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* The address an operator may need to allowlist for the shared report. Hard-wired. */
|
||||
function endpoint() { return SCREENTINKER_ENDPOINT; }
|
||||
|
||||
/* The operator's own collector, if they configured one. Null when they have not. */
|
||||
function extraEndpoint() { return process.env.TELEMETRY_EXTRA_ENDPOINT || null; }
|
||||
|
||||
/*
|
||||
* Everywhere this report is going, right now, and why — so the UI can list every destination
|
||||
* rather than implying there is only one. Sharing gates OUR endpoint alone.
|
||||
*/
|
||||
function destinations() {
|
||||
const out = [];
|
||||
if (state() === 'on') out.push({ url: endpoint(), kind: 'screentinker' });
|
||||
const extra = extraEndpoint();
|
||||
if (extra) out.push({ url: extra, kind: 'extra' });
|
||||
return out;
|
||||
}
|
||||
|
||||
/* What was last sent, and when. Surfaced in Settings so an operator can check rather than trust. */
|
||||
function getLastReport() {
|
||||
const raw = appSettings.get(KEY_LAST, null);
|
||||
if (!raw) return null;
|
||||
try { return JSON.parse(raw); } catch (_) { return null; }
|
||||
}
|
||||
|
||||
/*
|
||||
* The last FAILED attempt, kept separately from the last success.
|
||||
*
|
||||
* A self-hosted server frequently sits behind egress filtering, so "enabled but nothing arrives"
|
||||
* is the normal failure and it is otherwise completely silent — the operator sees "nothing has
|
||||
* been sent" and has no way to tell a blocked firewall from a broken feature. Recording the
|
||||
* failure lets the UI name the host that needs unblocking instead.
|
||||
*/
|
||||
function getLastError() {
|
||||
const raw = appSettings.get(KEY_LAST_ERROR, null);
|
||||
if (!raw) return null; // '' is how a success clears it
|
||||
try { return JSON.parse(raw); } catch (_) { return null; }
|
||||
}
|
||||
|
||||
/*
|
||||
* Send one report. Returns {sent:false, reason} rather than throwing — a stats call must never be
|
||||
* able to affect the running server, so every failure path here is quiet and local.
|
||||
*/
|
||||
async function postTo(url, body) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
return res.ok ? { sent: true } : { sent: false, reason: `http_${res.status}` };
|
||||
} catch (err) {
|
||||
// Offline, DNS failure, blocked egress — all normal for a self-hosted box, none of them news
|
||||
// in the log, but all worth surfacing in the UI so the operator can act on it.
|
||||
return { sent: false, reason: err && err.name === 'TimeoutError' ? 'timeout' : 'network' };
|
||||
}
|
||||
}
|
||||
|
||||
async function report(db, { urls = null } = {}) {
|
||||
// `urls` is a test seam. Normal callers get destinations() — sharing gates ours, an operator's
|
||||
// own collector is independent of it.
|
||||
const targets = urls || destinations();
|
||||
if (!targets.length) return { sent: false, reason: 'not_enabled', results: [] };
|
||||
|
||||
const now = () => Math.floor(Date.now() / 1000);
|
||||
const body = payload(db);
|
||||
|
||||
// Every destination is attempted, independently. One unreachable collector must not stop the
|
||||
// other from receiving — a blocked corporate firewall on their host should not cost us the
|
||||
// shared count, and our endpoint being down should not cost them their own fleet numbers.
|
||||
const results = [];
|
||||
for (const t of targets) results.push({ ...t, ...(await postTo(t.url, body)) });
|
||||
|
||||
const failed = results.filter(r => !r.sent);
|
||||
if (results.some(r => r.sent)) appSettings.set(KEY_LAST, JSON.stringify({ at: now(), body, results }));
|
||||
// Keep only a LIVE complaint: record what is still failing, and clear it once nothing is.
|
||||
appSettings.set(KEY_LAST_ERROR, failed.length
|
||||
? JSON.stringify({ at: now(), reason: failed[0].reason, url: failed[0].url, failed })
|
||||
: '');
|
||||
|
||||
return { sent: failed.length === 0, results, body, reason: failed[0]?.reason };
|
||||
}
|
||||
|
||||
function start(db) {
|
||||
if (timer) return;
|
||||
const tick = () => { report(db).catch(() => {}); };
|
||||
setTimeout(tick, FIRST_REPORT_DELAY_MS).unref?.();
|
||||
timer = setInterval(tick, REPORT_INTERVAL_MS);
|
||||
timer.unref?.(); // never hold the process open for a stats timer
|
||||
}
|
||||
|
||||
function stop() { if (timer) { clearInterval(timer); timer = null; } }
|
||||
|
||||
module.exports = { instanceId, state, setEnabled, payload, report, endpoint, extraEndpoint, destinations, getLastReport, getLastError, start, stop };
|
||||
62
server/lib/version-precedence.js
Normal file
62
server/lib/version-precedence.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
'use strict';
|
||||
|
||||
/*
|
||||
* Precedence for PRERELEASE identifiers — the `-alpha11` half of `1.9.34-alpha11`.
|
||||
*
|
||||
* ⚠️ WHY THIS EXISTS: a plain string compare is what semver specifies for a single alphanumeric
|
||||
* identifier, and it is wrong for how this project actually names builds. `"alpha11" < "alpha8"`
|
||||
* because `'1' < '8'`, so EVERY build from alpha10 onward sorted below alpha8 and alpha9. The OTA
|
||||
* check then answered `client-newer` and refused to offer the update at all — a fleet on alpha8
|
||||
* could not be moved forward, silently, with the server reporting the newer build as `latest` in
|
||||
* the same breath. Two comparators carried the same assumption, both with a comment saying lexical
|
||||
* was "fine for our naming"; it was fine only while the counter stayed below 10.
|
||||
*
|
||||
* The rule here is natural ordering: split each identifier into digit and non-digit runs and
|
||||
* compare digit runs NUMERICALLY. That gives what a human means by the name — alpha8 < alpha9 <
|
||||
* alpha10 < alpha11 — while leaving everything else alphabetical, so beta still outranks alpha and
|
||||
* rc still outranks beta.
|
||||
*
|
||||
* Dot-separated identifiers are compared one at a time per semver, and a shorter run of identifiers
|
||||
* loses when all preceding ones are equal (`alpha` < `alpha.1`), so a future move to the semver-
|
||||
* correct `-alpha.11` form keeps working without another change here.
|
||||
*
|
||||
* Deliberately NOT handled: whether a prerelease outranks a release. That is the caller's rule —
|
||||
* both callers already implement it, and each has its own exceptions (ota-breaker treats the legacy
|
||||
* `-patchN` scheme as released).
|
||||
*/
|
||||
|
||||
// Compare one identifier, digit runs numerically. "alpha10" -> ["alpha", "10"].
|
||||
function naturalCmp(x, y) {
|
||||
const rx = String(x).match(/\d+|\D+/g) || [];
|
||||
const ry = String(y).match(/\d+|\D+/g) || [];
|
||||
for (let i = 0; i < Math.max(rx.length, ry.length); i++) {
|
||||
const a = rx[i], b = ry[i];
|
||||
if (a === undefined) return -1; // "alpha" < "alpha1"
|
||||
if (b === undefined) return 1;
|
||||
const aNum = /^\d+$/.test(a), bNum = /^\d+$/.test(b);
|
||||
if (aNum && bNum) {
|
||||
// Numeric, so 10 beats 8 — the whole point of this file.
|
||||
if (Number(a) !== Number(b)) return Number(a) < Number(b) ? -1 : 1;
|
||||
} else if (a !== b) {
|
||||
// A digit run sorts below a word run, matching semver's numeric-identifiers-first rule.
|
||||
if (aNum !== bNum) return aNum ? -1 : 1;
|
||||
return a < b ? -1 : 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Full prerelease precedence: dot-separated identifiers, each compared naturally. */
|
||||
function preCmp(a, b) {
|
||||
if (a === b) return 0;
|
||||
const as = String(a).split('.'), bs = String(b).split('.');
|
||||
for (let i = 0; i < Math.max(as.length, bs.length); i++) {
|
||||
if (as[i] === undefined) return -1; // "alpha" < "alpha.1"
|
||||
if (bs[i] === undefined) return 1;
|
||||
const c = naturalCmp(as[i], bs[i]);
|
||||
if (c !== 0) return c;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
module.exports = { preCmp, naturalCmp };
|
||||
873
server/package-lock.json
generated
873
server/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"name": "screentinker",
|
||||
"version": "1.9.34-alpha2",
|
||||
"version": "1.9.36",
|
||||
"license": "MIT",
|
||||
"description": "ScreenTinker - Digital Signage Management Server",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
|
@ -11,19 +12,21 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@azure/msal-node": "^5.2.1",
|
||||
"@jsquash/avif": "^1.3.0",
|
||||
"@jsquash/webp": "^1.5.0",
|
||||
"archiver": "^7.0.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^9.4.3",
|
||||
"better-sqlite3": "12.9.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^8.3.1",
|
||||
"helmet": "^8.1.0",
|
||||
"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",
|
||||
"sharp": "^0.35.3",
|
||||
"socket.io": "^4.7.2",
|
||||
"stripe": "^20.4.1",
|
||||
"unzipper": "^0.12.3",
|
||||
|
|
@ -32,6 +35,7 @@
|
|||
"devDependencies": {
|
||||
"js-yaml": "^4.2.0",
|
||||
"puppeteer-core": "^24.43.1",
|
||||
"sharp": "^0.35.3",
|
||||
"socket.io-client": "^4.8.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -426,6 +426,50 @@ router.put('/status-debug', requirePlatformAdmin, (req, res) => {
|
|||
res.json({ enabled });
|
||||
});
|
||||
|
||||
// ===================== Opt-in install statistics =====================
|
||||
// Returns the decision state, the EXACT payload that would be sent, and what was last actually
|
||||
// sent. Handing over the real payload rather than a description is the point: an operator can
|
||||
// check instead of trusting a sentence, and the code is public so a mismatch would be visible.
|
||||
const telemetry = require('../lib/telemetry');
|
||||
|
||||
router.get('/telemetry', requirePlatformAdmin, (req, res) => {
|
||||
res.json({
|
||||
state: telemetry.state(), // 'unasked' | 'on' | 'off'
|
||||
payload: telemetry.payload(db), // what WOULD be sent, right now
|
||||
endpoint: telemetry.endpoint(), // ours — the host an operator may need to allowlist
|
||||
extra_endpoint: telemetry.extraEndpoint(),// their own collector, if configured
|
||||
destinations: telemetry.destinations(), // everywhere it actually goes, right now
|
||||
last_report: telemetry.getLastReport(), // what was actually sent, and when
|
||||
last_error: telemetry.getLastError(), // why the last attempt failed, if it did
|
||||
});
|
||||
});
|
||||
|
||||
router.put('/telemetry', requirePlatformAdmin, async (req, res) => {
|
||||
// Both answers are recorded. Declining must persist as 'off' rather than staying 'unasked',
|
||||
// or the prompt returns after every update — which is how telemetry earns its bad name.
|
||||
const enabled = !!req.body.enabled;
|
||||
const state = telemetry.setEnabled(enabled);
|
||||
logActivity(req.user.id, 'admin_set_telemetry', `enabled: ${enabled}`, null, getClientIp(req), null);
|
||||
|
||||
// Send once, now, rather than waiting for the next daily tick. Two reasons: the operator is
|
||||
// standing right here and "nothing has been sent" for the next 24h reads as broken, and an
|
||||
// egress-filtered network fails HERE where we can name the host to unblock — instead of
|
||||
// failing silently tonight where nobody is watching.
|
||||
let first = null;
|
||||
if (enabled) first = await telemetry.report(db);
|
||||
|
||||
res.json({
|
||||
state,
|
||||
payload: telemetry.payload(db),
|
||||
endpoint: telemetry.endpoint(),
|
||||
extra_endpoint: telemetry.extraEndpoint(),
|
||||
destinations: telemetry.destinations(),
|
||||
first_report: first && { sent: first.sent, reason: first.reason || null },
|
||||
last_report: telemetry.getLastReport(),
|
||||
last_error: telemetry.getLastError(),
|
||||
});
|
||||
});
|
||||
|
||||
// ===================== Version update indicator =====================
|
||||
// check-update = requireAdmin — a read-only GHCR poll, operational.
|
||||
// trigger-update = requirePlatformAdmin — it runs `docker compose up -d` on the
|
||||
|
|
|
|||
|
|
@ -1158,6 +1158,13 @@ function backToApp(res, params) {
|
|||
res.redirect(`/app#/login?${qs}`);
|
||||
}
|
||||
|
||||
// A link attempt starts from Settings while signed in, so it must end there — bouncing an
|
||||
// authenticated user to the login page to report the outcome reads as "you were signed out".
|
||||
function backToSettings(res, params) {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
res.redirect(`/app#/settings?${qs}`);
|
||||
}
|
||||
|
||||
// Which providers this instance offers. Public: it is what draws the login buttons.
|
||||
router.get('/providers', (req, res) => {
|
||||
res.json({ providers: oidcProviders.publicList() });
|
||||
|
|
@ -1233,10 +1240,15 @@ router.post('/sso/start', express.urlencoded({ extended: false }), (req, res) =>
|
|||
res.redirect(startUrl);
|
||||
});
|
||||
|
||||
router.get('/oidc/:slug/start', asyncRoute(async (req, res) => {
|
||||
const provider = oidcProviders.get(req.params.slug);
|
||||
if (!provider) return backToApp(res, { sso_error: 'unknown_provider' });
|
||||
|
||||
/**
|
||||
* Begin an OIDC round trip.
|
||||
*
|
||||
* `extra` is merged into the signed transaction, which is how LINK mode is carried: the tx is
|
||||
* server-signed and lives in an httpOnly cookie, so the browser can neither read nor forge which
|
||||
* account a link is for. Login and link therefore share one flow — the same PKCE, state, nonce and
|
||||
* verification — instead of a second copy that drifts.
|
||||
*/
|
||||
async function beginOidc(req, res, provider, extra = {}, onError = backToApp, asJson = false) {
|
||||
try {
|
||||
const doc = await oidc.discover(provider.issuer);
|
||||
const pkce = oidc.createPkce();
|
||||
|
|
@ -1244,7 +1256,7 @@ router.get('/oidc/:slug/start', asyncRoute(async (req, res) => {
|
|||
const state = oidc.randomToken();
|
||||
|
||||
const tx = jwt.sign(
|
||||
{ typ: 'oidc-tx', slug: provider.slug, nonce, verifier: pkce.verifier, state },
|
||||
{ typ: 'oidc-tx', slug: provider.slug, nonce, verifier: pkce.verifier, state, ...extra },
|
||||
config.jwtSecret,
|
||||
// HS256 explicitly, and a `typ` the session verifier does not accept: two token kinds signed
|
||||
// with one secret must never be interchangeable, even if today only `slug` happens to stop it.
|
||||
|
|
@ -1267,11 +1279,77 @@ router.get('/oidc/:slug/start', asyncRoute(async (req, res) => {
|
|||
url.searchParams.set('nonce', nonce);
|
||||
url.searchParams.set('code_challenge', pkce.challenge);
|
||||
url.searchParams.set('code_challenge_method', pkce.method);
|
||||
/*
|
||||
* A LINK start is fetched, not navigated to.
|
||||
*
|
||||
* The session lives in localStorage and travels as an Authorization header, so a top-level
|
||||
* `location.href` to an authenticated route arrives anonymous — which is exactly how this first
|
||||
* shipped, and it 401'd every time. The caller therefore fetches this with its token and gets
|
||||
* the authorize URL back to navigate to itself. The transaction cookie is still set by this
|
||||
* response, because a same-origin fetch stores Set-Cookie normally.
|
||||
*/
|
||||
if (asJson) return res.json({ url: url.toString() });
|
||||
res.redirect(url.toString());
|
||||
} catch (err) {
|
||||
console.error(`[oidc] ${req.params.slug} start failed:`, err.message);
|
||||
backToApp(res, { sso_error: 'provider_unavailable' });
|
||||
console.error(`[oidc] ${provider.slug} start failed:`, err.message);
|
||||
if (asJson) return res.status(502).json({ error: 'The provider could not be reached' });
|
||||
onError(res, { sso_error: 'provider_unavailable' });
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/oidc/:slug/start', asyncRoute(async (req, res) => {
|
||||
const provider = oidcProviders.get(req.params.slug);
|
||||
if (!provider) return backToApp(res, { sso_error: 'unknown_provider' });
|
||||
await beginOidc(req, res, provider);
|
||||
}));
|
||||
|
||||
/*
|
||||
* Link an EXISTING account to an instance-wide provider.
|
||||
*
|
||||
* Signing in with a provider never adopts an account that has a password — that would let anyone who
|
||||
* can make a provider assert an address inherit the account behind it. So the owner proves they are
|
||||
* the owner first, by being signed in, and starts the link themselves. The account is taken from the
|
||||
* SESSION, never from the email in the returned token.
|
||||
*
|
||||
* ⚠️ INSTANCE-WIDE PROVIDERS ONLY. An organization's provider is chosen by a customer; letting one
|
||||
* attach itself to a platform account would hand that customer whatever the account can do. Org
|
||||
* membership arrives through the normal org SSO path, which is domain-confined.
|
||||
*/
|
||||
/*
|
||||
* Unlink, and set a password in the SAME operation.
|
||||
*
|
||||
* Not two steps. An account whose only credential is a provider has nothing to fall back on the
|
||||
* moment that link is removed, so "unlink now, set a password next" leaves a window — and a failure
|
||||
* in between leaves an account nobody can sign into at all. The new password is therefore required
|
||||
* up front and written in one transaction with the unlink.
|
||||
*/
|
||||
router.post('/oidc/unlink', requireAuth, (req, res) => {
|
||||
const password = String((req.body || {}).password || '');
|
||||
const user = db.prepare('SELECT id, email, auth_provider, password_hash FROM users WHERE id = ?').get(req.user.id);
|
||||
if (!user) return res.status(404).json({ error: 'Account not found' });
|
||||
if (user.auth_provider === 'local') {
|
||||
return res.status(400).json({ error: 'This account already signs in with a password' });
|
||||
}
|
||||
if (password.length < passwordReset.MIN_PASSWORD_LENGTH) {
|
||||
return res.status(400).json({ error: `Password must be at least ${passwordReset.MIN_PASSWORD_LENGTH} characters` });
|
||||
}
|
||||
|
||||
const was = user.auth_provider;
|
||||
db.prepare("UPDATE users SET auth_provider = 'local', provider_id = NULL, password_hash = ? WHERE id = ?")
|
||||
.run(bcrypt.hashSync(password, 10), user.id);
|
||||
logActivity(user.id, 'auth:sso_unlinked', `was ${was}`, null, getClientIp(req));
|
||||
console.log(`[oidc] ${was} unlinked from ${user.email} (password set)`);
|
||||
res.json({ ok: true, auth_provider: 'local' });
|
||||
});
|
||||
|
||||
router.get('/oidc/:slug/link/start', requireAuth, asyncRoute(async (req, res) => {
|
||||
const provider = oidcProviders.get(req.params.slug);
|
||||
if (!provider) return res.status(404).json({ error: 'Unknown provider' });
|
||||
if (provider.organizationId) {
|
||||
return res.status(400).json({ error: 'Only this server\'s own providers can be linked' });
|
||||
}
|
||||
// JSON, not a redirect — see beginOidc. The browser cannot send a bearer token on a navigation.
|
||||
await beginOidc(req, res, provider, { link: req.user.id }, backToSettings, true);
|
||||
}));
|
||||
|
||||
router.get('/oidc/:slug/callback', asyncRoute(async (req, res) => {
|
||||
|
|
@ -1338,7 +1416,9 @@ router.get('/oidc/:slug/callback', asyncRoute(async (req, res) => {
|
|||
}
|
||||
|
||||
const email = String(claims.email || '').toLowerCase().trim();
|
||||
if (!email) return backToApp(res, { sso_error: 'no_email' });
|
||||
const linking = !!tx.link;
|
||||
const fail = linking ? backToSettings : backToApp;
|
||||
if (!email) return fail(res, { sso_error: 'no_email' });
|
||||
|
||||
/*
|
||||
* ⚠️ AN ORGANIZATION'S PROVIDER MAY ONLY SPEAK FOR ITS OWN DOMAINS.
|
||||
|
|
@ -1387,7 +1467,39 @@ router.get('/oidc/:slug/callback', asyncRoute(async (req, res) => {
|
|||
// explicit false is still refused, and an org-configured provider still cannot assume anything.
|
||||
// See oidcProviders.emailIsVerified() for why that division is the safe one.
|
||||
if (!oidcProviders.emailIsVerified(claims, provider)) {
|
||||
return backToApp(res, { sso_error: 'email_unverified' });
|
||||
return fail(res, { sso_error: 'email_unverified' });
|
||||
}
|
||||
|
||||
/*
|
||||
* LINK: attach this provider to the account that STARTED the link, and drop its password.
|
||||
*
|
||||
* The account comes from the signed transaction (i.e. from the session that began this), never
|
||||
* from the returned email — otherwise "linking" would be the very email-keyed takeover the login
|
||||
* path refuses. The email must still match the account's own, because login resolves an account by
|
||||
* the address the provider asserts: linking a different address would produce an account that
|
||||
* cannot be signed into, or would collide with someone else's.
|
||||
*
|
||||
* The password is DELETED rather than kept alongside. One credential at a time is the whole point
|
||||
* — a password left behind is a second way in that the user believes they replaced.
|
||||
*/
|
||||
if (linking) {
|
||||
const target = db.prepare('SELECT id, email, auth_provider FROM users WHERE id = ?').get(tx.link);
|
||||
if (!target) return backToSettings(res, { sso_error: 'server_error' });
|
||||
if (target.email.toLowerCase() !== email) {
|
||||
console.warn(`[oidc] link refused: ${provider.slug} asserted ${email} for account ${target.email}`);
|
||||
return backToSettings(res, { sso_error: 'link_email_mismatch' });
|
||||
}
|
||||
// Someone else already signed in with this provider identity. Two accounts must never share one
|
||||
// provider subject, or whoever signs in second silently takes the first one's place.
|
||||
const taken = db.prepare('SELECT id FROM users WHERE provider_id = ? AND auth_provider = ? AND id != ?')
|
||||
.get(String(claims.sub), provider.slug, target.id);
|
||||
if (taken) return backToSettings(res, { sso_error: 'link_already_used' });
|
||||
|
||||
db.prepare('UPDATE users SET auth_provider = ?, provider_id = ?, password_hash = NULL, avatar_url = COALESCE(?, avatar_url) WHERE id = ?')
|
||||
.run(provider.slug, String(claims.sub), claims.picture || null, target.id);
|
||||
logActivity(target.id, 'auth:sso_linked', `provider=${provider.slug}`, null, getClientIp(req));
|
||||
console.log(`[oidc] ${provider.slug} linked to ${target.email} (password cleared)`);
|
||||
return backToSettings(res, { sso_linked: provider.slug });
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
|
|||
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){
|
||||
|
|
|
|||
|
|
@ -31,13 +31,14 @@ function probeVideoDims(filePath) {
|
|||
}
|
||||
|
||||
async function probeImageDims(filePath) {
|
||||
const sharp = require('sharp');
|
||||
return imageDisplayDims(await sharp(filePath).metadata());
|
||||
const imageOps = require('../lib/image-ops');
|
||||
return imageDisplayDims(await imageOps.metadata(filePath));
|
||||
}
|
||||
|
||||
async function regenImageThumb(filePath, thumbName) {
|
||||
const sharp = require('sharp');
|
||||
await sharp(filePath).rotate().resize(config.thumbnailWidth).jpeg({ quality: 70 }).toFile(path.join(config.contentDir, thumbName));
|
||||
const imageOps = require('../lib/image-ops');
|
||||
// Rotation is implicit: the decoder auto-orients per EXIF, which is what .rotate() bought here.
|
||||
await imageOps.writeThumbnail(filePath, path.join(config.contentDir, thumbName), config.thumbnailWidth, 70);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
|
|
|
|||
|
|
@ -977,6 +977,22 @@ app.get('/api/version', (req, res) => {
|
|||
// Public status page
|
||||
app.use('/api/status', require('./routes/status'));
|
||||
|
||||
/*
|
||||
* 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') {
|
||||
/* `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
|
||||
// data and a heavier aggregate than the hot status path). bearerAuth is the dual front door:
|
||||
// a 'billing:read' API token (Bearer st_...) OR a JWT session both reach it; the route's
|
||||
|
|
@ -1214,6 +1230,7 @@ startContentExpiry(io);
|
|||
const { startAlertService } = require('./services/alerts');
|
||||
startAlertService(io);
|
||||
|
||||
|
||||
// Start activation-nudge sweep (T+3 onboarding nudge; gated on HOSTED_INSTANCE)
|
||||
const { startActivationNudge } = require('./services/activationNudge');
|
||||
startActivationNudge();
|
||||
|
|
@ -1248,6 +1265,13 @@ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|||
|
||||
// Handle provisioning via WebSocket notification
|
||||
const { db } = require('./db/database');
|
||||
|
||||
// Opt-in install statistics — REPORTER side. Sends nothing until an operator says yes; the timer
|
||||
// is unref'd so it can never hold the process open, and every failure path is silent and local.
|
||||
// Must sit AFTER the `db` binding above: `const` is hoisted but uninitialised, so calling this
|
||||
// earlier in the file throws "Cannot access 'db' before initialization" at load.
|
||||
require('./lib/telemetry').start(db);
|
||||
|
||||
const originalProvisionRoute = require('./routes/provisioning');
|
||||
|
||||
// #161: device-owner QR provisioning. Returns the AOSP provisioning payload (DPC component + APK
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
@ -24,7 +24,15 @@ globalThis.localStorage = {
|
|||
removeItem: (k) => store.delete(k),
|
||||
clear: () => store.clear(),
|
||||
};
|
||||
globalThis.navigator = globalThis.navigator || { language: 'en' };
|
||||
// Node 22 added a built-in `navigator` global, defined as a getter with NO setter — so the plain
|
||||
// assignment this used to do throws ("only a getter") under 'use strict' there, while being fine on
|
||||
// Node 20 where the global does not exist at all. It is configurable, so define it rather than
|
||||
// assign. Doing that unconditionally is also the more honest fixture: Node 22's own navigator
|
||||
// reports the HOST locale (en-US here, something else on another machine or in CI), and a test that
|
||||
// reads its language should not depend on where it runs.
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: { language: 'en' }, configurable: true, writable: true,
|
||||
});
|
||||
|
||||
const MOD = pathToFileURL(path.join(__dirname, '..', '..', 'frontend', 'js', 'components', 'getting-started.js')).href;
|
||||
let GS;
|
||||
|
|
|
|||
132
server/test/image-ops.test.js
Normal file
132
server/test/image-ops.test.js
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
'use strict';
|
||||
|
||||
// Image decoding is pure JavaScript now (no native sharp), so its CPU cost lands on whichever
|
||||
// thread runs it — ~1s of solid work for a 12MP photo. In-process that is a stalled event loop:
|
||||
// no heartbeats, no socket traffic, panels marked offline, reconnect churn — #240 arriving from
|
||||
// our own thumbnail backfill. lib/image-ops therefore hosts the work on a worker thread, and
|
||||
// these bites pin the properties that makes it safe, none of which a functional test would catch.
|
||||
|
||||
const { test, after, mock } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const sharp = require('sharp'); // devDependency: fixture generator only, never shipped
|
||||
const imageOps = require('../lib/image-ops');
|
||||
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'image-ops-'));
|
||||
after(async () => { await imageOps.shutdown(); fs.rmSync(tmp, { recursive: true, force: true }); });
|
||||
|
||||
// 12MP — a phone photo, and the size the thresholds below are calibrated against. Smaller is
|
||||
// tempting for test speed but defeats the point: at 4MP the inline path stalls only ~350ms, which
|
||||
// slips under any threshold loose enough not to be flaky, so the guard stops detecting the very
|
||||
// regression it exists for. Measured: inline ~1000ms stall / ~2 timers serviced, worker ~0ms / ~90.
|
||||
async function bigPhoto(name = 'big.jpg') {
|
||||
const p = path.join(tmp, name);
|
||||
if (!fs.existsSync(p)) {
|
||||
// Random pixels, not a flat fill: a solid colour compresses to almost nothing and decodes far
|
||||
// faster than any real photo, which would quietly defeat the timing assertion below.
|
||||
const px = Buffer.allocUnsafe(4000 * 3000 * 3);
|
||||
for (let i = 0; i < px.length; i++) px[i] = (i * 2654435761) & 0xff;
|
||||
fs.writeFileSync(p, await sharp(px, { raw: { width: 4000, height: 3000, channels: 3 } }).jpeg().toBuffer());
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
test('image work does not stall the event loop (#240)', async () => {
|
||||
const src = await bigPhoto();
|
||||
|
||||
let ticks = 0, worstGap = 0, last = Date.now();
|
||||
const timer = setInterval(() => { ticks++; worstGap = Math.max(worstGap, Date.now() - last - 10); last = Date.now(); }, 10);
|
||||
const started = Date.now();
|
||||
await imageOps.writeThumbnail(src, path.join(tmp, 'thumb.jpg'), 320, 70);
|
||||
const elapsed = Date.now() - started;
|
||||
clearInterval(timer);
|
||||
|
||||
// The point is not that it was fast — it is that the loop kept running while it was slow.
|
||||
// Thresholds sit in the gap between the two behaviours (worker ~90 ticks / ~0ms stall, inline
|
||||
// ~2 ticks / ~1000ms stall), far enough from both to bite without being flaky.
|
||||
assert.ok(ticks >= 20, `event loop serviced only ${ticks} timers in ${elapsed}ms — it is being blocked`);
|
||||
assert.ok(worstGap < 200, `event loop stalled ${worstGap}ms in one go — image work is on the main thread`);
|
||||
assert.ok(fs.existsSync(path.join(tmp, 'thumb.jpg')), 'thumbnail was still written');
|
||||
});
|
||||
|
||||
test('an undecodable image rejects without killing the worker', async () => {
|
||||
const bad = path.join(tmp, 'corrupt.jpg');
|
||||
fs.writeFileSync(bad, Buffer.from('not an image'));
|
||||
await assert.rejects(() => imageOps.metadata(bad), 'corrupt input must reject, so ingest records nulls');
|
||||
|
||||
// Crash isolation: one bad upload must not take out the queued work of unrelated callers.
|
||||
const ok = path.join(tmp, 'fine.png');
|
||||
fs.writeFileSync(ok, await sharp({ create: { width: 40, height: 25, channels: 3, background: '#123456' } }).png().toBuffer());
|
||||
assert.deepEqual(await imageOps.metadata(ok), { width: 40, height: 25, orientation: 1 });
|
||||
});
|
||||
|
||||
test('concurrent callers are serialized, and each still gets its own answer', async () => {
|
||||
// Serialization bounds peak memory to ONE decoded bitmap (a 12MP photo is ~48MB of RGBA);
|
||||
// overlapping jobs would multiply that by the queue depth on exactly the small targets this
|
||||
// change exists to reach. Correctness under concurrency is what is asserted here.
|
||||
const sizes = [[30, 10], [60, 20], [90, 30], [120, 40]];
|
||||
const files = await Promise.all(sizes.map(async ([w, h], i) => {
|
||||
const p = path.join(tmp, `c${i}.png`);
|
||||
fs.writeFileSync(p, await sharp({ create: { width: w, height: h, channels: 3, background: '#0a0' } }).png().toBuffer());
|
||||
return p;
|
||||
}));
|
||||
const got = await Promise.all(files.map(f => imageOps.metadata(f)));
|
||||
assert.deepEqual(got.map(m => [m.width, m.height]), sizes, 'replies must not be crossed between queued jobs');
|
||||
});
|
||||
|
||||
test('measureAndThumbnail decodes the file exactly once', async () => {
|
||||
// Counted, not timed: a wall-clock comparison against metadata()+writeThumbnail() would be
|
||||
// flaky under load, and this is an exact property. Asserted against image-ops-core directly
|
||||
// because the decode happens on the worker thread, out of reach of a spy set up here.
|
||||
// readImage() is the only reader in core, so readFile calls == decodes.
|
||||
const core = require('../lib/image-ops-core');
|
||||
const fsp = require('node:fs/promises');
|
||||
const src = path.join(tmp, 'once.png');
|
||||
fs.writeFileSync(src, await sharp({ create: { width: 200, height: 80, channels: 3, background: '#246' } }).png().toBuffer());
|
||||
|
||||
const spy = mock.method(fsp, 'readFile');
|
||||
// Count reads OF THIS FILE only. Node's ESM loader also reads through fs.promises.readFile, so
|
||||
// a raw call count picks up jimp's and the WASM codecs' lazy module loading on first use.
|
||||
const decodes = () => spy.mock.calls.filter(c => String(c.arguments[0]) === src).length;
|
||||
try {
|
||||
const r = await core.measureAndThumbnail(src, path.join(tmp, 'once-thumb.jpg'), 100, 70);
|
||||
assert.equal(decodes(), 1, 'combined op must decode once, not once per answer');
|
||||
assert.deepEqual([r.width, r.height], [200, 80]);
|
||||
assert.equal(r.thumbnailWritten, true);
|
||||
|
||||
// The pairing it replaces, for contrast — this is the cost being removed.
|
||||
spy.mock.resetCalls();
|
||||
await core.metadata(src);
|
||||
await core.writeThumbnail(src, path.join(tmp, 'twice-thumb.jpg'), 100, 70);
|
||||
assert.equal(decodes(), 2, 'the separate calls are what cost two decodes');
|
||||
} finally { spy.mock.restore(); }
|
||||
});
|
||||
|
||||
test('a thumbnail that cannot be written still yields dimensions', async () => {
|
||||
// Dimensions are independently useful — the player needs them to letterbox — and the two-call
|
||||
// version kept them, because width/height were assigned before the thumbnail was attempted.
|
||||
// Merging the calls must not quietly turn a thumbnail failure into a total metadata failure.
|
||||
const src = path.join(tmp, 'ok.png');
|
||||
fs.writeFileSync(src, await sharp({ create: { width: 150, height: 60, channels: 3, background: '#654' } }).png().toBuffer());
|
||||
|
||||
const undirectable = path.join(tmp, 'no-such-dir', 'thumb.jpg'); // parent does not exist
|
||||
const r = await imageOps.measureAndThumbnail(src, undirectable, 100, 70);
|
||||
assert.deepEqual([r.width, r.height], [150, 60], 'dimensions survive a thumbnail write failure');
|
||||
assert.equal(r.thumbnailWritten, false);
|
||||
assert.match(r.thumbnailError || '', /ENOENT|no such file/i);
|
||||
});
|
||||
|
||||
test('#170 EXIF orientation is applied by the decoder, so dimensions are as DISPLAYED', async () => {
|
||||
// orientation 6 = "rotate 90° CW to display": a 30x100 stored buffer DISPLAYS as 100x30.
|
||||
const p = path.join(tmp, 'rot6.jpg');
|
||||
fs.writeFileSync(p, await sharp({ create: { width: 30, height: 100, channels: 3, background: '#00ff00' } })
|
||||
.withMetadata({ orientation: 6 }).jpeg().toBuffer());
|
||||
|
||||
const meta = await imageOps.metadata(p);
|
||||
assert.equal(meta.width, 100, 'EXIF-rotated image measures as displayed, not as stored');
|
||||
assert.equal(meta.height, 30);
|
||||
// Reported as 1 because the rotation is already applied — imageDisplayDims() must NOT swap again.
|
||||
assert.equal(meta.orientation, 1, 'a tag of 6 here would double-rotate downstream');
|
||||
});
|
||||
63
server/test/login-identifier-first.test.js
Normal file
63
server/test/login-identifier-first.test.js
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
'use strict';
|
||||
|
||||
/*
|
||||
* Identifier-first login (#258).
|
||||
*
|
||||
* The password box does not exist until an address has been submitted. That is what lets the
|
||||
* organization lookup happen BEFORE a credential is offered, so someone whose company requires its
|
||||
* own identity provider is never shown a password box that is going to be refused.
|
||||
*
|
||||
* Verified in a real browser as well (password hidden -> submit -> visible + focused -> edit the
|
||||
* address -> hidden again); these assertions stop the wiring being removed silently.
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const LOGIN = fs.readFileSync(path.join(__dirname, '..', '..', 'frontend', 'js', 'views', 'login.js'), 'utf8');
|
||||
|
||||
test('password visibility depends on BOTH identification and SSO-only', () => {
|
||||
assert.match(LOGIN, /const showPassword = identified && !ssoOnlyDomain;/,
|
||||
'the two drivers must be combined in one place so they cannot disagree');
|
||||
});
|
||||
|
||||
test('the primary button advances before it signs in', () => {
|
||||
assert.match(LOGIN, /if \(identified && !ssoOnlyDomain\) return doLogin\(\);\s*\n\s*identify\(\);/,
|
||||
'the button must identify first and only sign in once an address is known');
|
||||
assert.match(LOGIN, /btn\.textContent = identified && !ssoOnlyDomain \? t\('auth\.sign_in'\) : t\('auth\.next'\)/);
|
||||
});
|
||||
|
||||
test('editing the address returns to the identifier step', () => {
|
||||
assert.match(LOGIN, /if \(!identified\) return;\s*\n\s*identified = false;/,
|
||||
'a corrected address must get a fresh answer, not the previous domain\'s');
|
||||
});
|
||||
|
||||
test('the per-keystroke lookup is gone', () => {
|
||||
assert.doesNotMatch(LOGIN, /ssoLookupTimer/,
|
||||
'the debounced lookup answered for half-typed domains and burned a 10/min budget');
|
||||
assert.match(LOGIN, /async function identify\(\)[\s\S]{0,400}await lookupOrgSso\(email\)/,
|
||||
'the lookup now runs on submit');
|
||||
});
|
||||
|
||||
test('instance-wide providers are never hidden', () => {
|
||||
// Deliberate: they are the operator's, offered to everyone, and the server refuses them for an
|
||||
// SSO-only organization anyway. Hiding them made the page change shape while typing.
|
||||
assert.doesNotMatch(LOGIN, /getElementById\('instanceProviders'\)[\s\S]{0,120}style\.display/,
|
||||
'nothing may hide #instanceProviders');
|
||||
});
|
||||
|
||||
test('first-run setup skips identifier-first', () => {
|
||||
assert.match(LOGIN, /if \(isSetup\) identified = true;/,
|
||||
'creating the first admin needs both fields at once');
|
||||
});
|
||||
|
||||
test('the initial state is applied after its declarations (temporal dead zone)', () => {
|
||||
const decl = LOGIN.indexOf('let identified = false;');
|
||||
const call = LOGIN.lastIndexOf('\n applyFormState();');
|
||||
assert.ok(decl !== -1 && call !== -1, 'both the declaration and the init call must exist');
|
||||
assert.ok(call > decl,
|
||||
'applyFormState() must be called AFTER the let declarations — earlier throws on the TDZ, which '
|
||||
+ 'on this page means a login form that never renders');
|
||||
});
|
||||
120
server/test/oidc-account-linking.test.js
Normal file
120
server/test/oidc-account-linking.test.js
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
'use strict';
|
||||
|
||||
/*
|
||||
* Linking an existing account to an instance-wide provider (#258).
|
||||
*
|
||||
* Signing in with a provider never adopts an account that already has a password — that is the
|
||||
* takeover the login path exists to refuse. The README promised an escape hatch ("the owner signs
|
||||
* in locally and links from Settings") that was never built, so an account created with a password
|
||||
* could never use SSO at all.
|
||||
*
|
||||
* The rules this pins down, all of which are load-bearing:
|
||||
* - the account being linked comes from the SIGNED TRANSACTION (i.e. the session that started the
|
||||
* link), never from the email in the returned token. Otherwise "linking" is the same email-keyed
|
||||
* takeover under a friendlier name;
|
||||
* - the provider's email must still equal the account's, because login resolves accounts by the
|
||||
* asserted address;
|
||||
* - one provider subject may not be linked to two accounts;
|
||||
* - linking DELETES the password: one credential at a time;
|
||||
* - unlinking SETS a password in the same statement, so the account is never between credentials;
|
||||
* - ORG providers are not linkable at all.
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const AUTH = fs.readFileSync(path.join(__dirname, '..', 'routes', 'auth.js'), 'utf8');
|
||||
|
||||
/** Body of a route handler, from its `router.<verb>('<route>'` to the next `router.`. */
|
||||
function handler(verb, route) {
|
||||
const start = AUTH.indexOf(`router.${verb}('${route}'`);
|
||||
assert.notEqual(start, -1, `route ${verb.toUpperCase()} ${route} not found`);
|
||||
const rest = AUTH.slice(start + 1);
|
||||
const end = rest.indexOf('\nrouter.');
|
||||
return end === -1 ? rest : rest.slice(0, end);
|
||||
}
|
||||
|
||||
test('link start requires authentication and refuses org providers', () => {
|
||||
const body = handler('get', '/oidc/:slug/link/start');
|
||||
assert.match(AUTH, /router\.get\('\/oidc\/:slug\/link\/start', requireAuth/,
|
||||
'the link must be startable only by someone already signed in — that is the proof of ownership');
|
||||
assert.match(body, /provider\.organizationId[\s\S]{0,160}status\(400\)/,
|
||||
"an organization's provider must never attach itself to a platform account");
|
||||
assert.match(body, /link: req\.user\.id/,
|
||||
'the account must come from the session, not from anything the browser can set');
|
||||
});
|
||||
|
||||
test('the linked account is taken from the transaction, never from the returned email', () => {
|
||||
const cb = handler('get', '/oidc/:slug/callback');
|
||||
assert.match(cb, /WHERE id = \?'\)\.get\(tx\.link\)/,
|
||||
'the target account is looked up by tx.link (the session that started it)');
|
||||
// The email is still checked, but as a constraint on the link — not as the way the account is found.
|
||||
assert.match(cb, /target\.email\.toLowerCase\(\) !== email/, 'email must match the account being linked');
|
||||
assert.match(cb, /link_email_mismatch/);
|
||||
});
|
||||
|
||||
test('one provider subject cannot be linked to two accounts', () => {
|
||||
const cb = handler('get', '/oidc/:slug/callback');
|
||||
assert.match(cb, /provider_id = \? AND auth_provider = \? AND id != \?/,
|
||||
'must check whether this provider identity already belongs to another account');
|
||||
assert.match(cb, /link_already_used/);
|
||||
});
|
||||
|
||||
test('linking deletes the password — one credential at a time', () => {
|
||||
const cb = handler('get', '/oidc/:slug/callback');
|
||||
assert.match(cb, /UPDATE users SET auth_provider = \?, provider_id = \?, password_hash = NULL/,
|
||||
'the password must be cleared in the same statement that attaches the provider');
|
||||
});
|
||||
|
||||
test('unlinking sets a password in the SAME statement', () => {
|
||||
const body = handler('post', '/oidc/unlink');
|
||||
assert.match(body, /UPDATE users SET auth_provider = 'local', provider_id = NULL, password_hash = \?/,
|
||||
'unlink and set-password must be one write — never unlink first and set a password after');
|
||||
assert.match(body, /password\.length < passwordReset\.MIN_PASSWORD_LENGTH/,
|
||||
'the replacement password must meet the same minimum as a reset');
|
||||
assert.match(body, /auth_provider === 'local'/, 'refuse unlinking an account that has no provider');
|
||||
});
|
||||
|
||||
test('both link and unlink are recorded in the activity log', () => {
|
||||
assert.match(handler('get', '/oidc/:slug/callback'), /logActivity\([^)]*'auth:sso_linked'/);
|
||||
assert.match(handler('post', '/oidc/unlink'), /logActivity\([^)]*'auth:sso_unlinked'/);
|
||||
});
|
||||
|
||||
test('link failures return to Settings, not the login page', () => {
|
||||
const cb = handler('get', '/oidc/:slug/callback');
|
||||
assert.match(cb, /const fail = linking \? backToSettings : backToApp/,
|
||||
'an authenticated user must not be bounced to a login screen to be told the link failed');
|
||||
assert.match(AUTH, /function backToSettings\(res, params\)[\s\S]{0,200}#\/settings/);
|
||||
});
|
||||
|
||||
test('link start answers with JSON, because a navigation cannot carry a bearer token', () => {
|
||||
/*
|
||||
* Shipped broken once: the Settings button did `location.href = .../link/start`, which is a
|
||||
* top-level navigation. The session lives in localStorage and travels as an Authorization header,
|
||||
* so the request arrived anonymous and requireAuth refused it — "Authentication required" on
|
||||
* every click. The client must FETCH this with its token and navigate to the returned URL.
|
||||
*/
|
||||
const body = handler('get', '/oidc/:slug/link/start');
|
||||
assert.match(body, /beginOidc\([^)]*backToSettings, true\)/,
|
||||
'link start must run in JSON mode');
|
||||
assert.match(AUTH, /if \(asJson\) return res\.json\(\{ url: url\.toString\(\) \}\);/,
|
||||
'JSON mode must return the authorize URL rather than a 302');
|
||||
|
||||
const settings = require('fs').readFileSync(
|
||||
require('path').join(__dirname, '..', '..', 'frontend', 'js', 'views', 'settings.js'), 'utf8');
|
||||
assert.match(settings, /await api\.ssoLinkStart\(slug\)/,
|
||||
'the client must fetch the start route so its Authorization header is sent');
|
||||
assert.doesNotMatch(settings, /location\.href = `\/api\/auth\/oidc/,
|
||||
'never navigate straight at the authenticated start route');
|
||||
});
|
||||
|
||||
test('login and link share one flow, so verification cannot drift between them', () => {
|
||||
// beginOidc is the single place PKCE/state/nonce are minted; both entry points call it.
|
||||
assert.match(AUTH, /async function beginOidc\(req, res, provider, extra = \{\}/);
|
||||
const login = handler('get', '/oidc/:slug/start');
|
||||
const link = handler('get', '/oidc/:slug/link/start');
|
||||
assert.match(login, /beginOidc\(req, res, provider\)/);
|
||||
assert.match(link, /beginOidc\(req, res, provider, \{ link: req\.user\.id \}/);
|
||||
});
|
||||
|
|
@ -9,10 +9,14 @@
|
|||
* never pushed a Microsoft-shaped token through the policy, so nothing failed.
|
||||
*
|
||||
* The rule these tests pin down:
|
||||
* - `email_verified: true` -> believed, always
|
||||
* - claim ABSENT + operator-chosen -> believed (Microsoft, or an opted-in generic provider)
|
||||
* - claim ABSENT + org-configured -> refused
|
||||
* - `email_verified: false` -> refused, whoever asked
|
||||
* - `email_verified: true` -> believed, always
|
||||
* - claim ABSENT + operator-chosen -> believed (Microsoft, or an opted-in provider)
|
||||
* - claim ABSENT + org with a VERIFIED domain -> believed (it proved DNS control)
|
||||
* - claim ABSENT + org with NO verified domain -> refused (it has proven nothing)
|
||||
* - `email_verified: false` -> refused, whoever asked
|
||||
*
|
||||
* The org case is a consequence of DNS proof, never a setting: an organization must not be able to
|
||||
* turn it on for itself, so it is derived and never read from a column.
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
|
|
@ -22,10 +26,18 @@ const { emailIsVerified, list } = require('../lib/oidc-providers');
|
|||
const MS_ENV = { MICROSOFT_CLIENT_ID: 'client-abc', MICROSOFT_TENANT_ID: 'ffffffff-1111-2222-3333-444444444444' };
|
||||
const microsoft = () => list(MS_ENV).find((p) => p.slug === 'microsoft');
|
||||
const google = () => list({ GOOGLE_CLIENT_ID: 'g-abc' }).find((p) => p.slug === 'google');
|
||||
const orgProvider = { slug: 'acme7f3', source: 'org', organizationId: 'org-1', assumeEmailVerified: false };
|
||||
// An org provider as rowToProvider builds it: `assumeEmailVerified` follows from whether any domain
|
||||
// has actually been DNS-verified.
|
||||
const orgProvider = (verifiedDomains = []) => ({
|
||||
slug: 'acme7f3', source: 'org', organizationId: 'org-1',
|
||||
emailDomains: verifiedDomains.join(','),
|
||||
assumeEmailVerified: verifiedDomains.length > 0,
|
||||
});
|
||||
const orgUnproven = orgProvider(); // configured, nothing verified yet
|
||||
const orgProven = orgProvider(['bytetinker.net']); // TXT published, domain green
|
||||
|
||||
test('an explicit true is believed from any provider', () => {
|
||||
for (const p of [microsoft(), google(), orgProvider]) {
|
||||
for (const p of [microsoft(), google(), orgUnproven, orgProven]) {
|
||||
assert.equal(emailIsVerified({ email_verified: true }, p), true, `${p.slug} should accept an explicit true`);
|
||||
}
|
||||
});
|
||||
|
|
@ -42,14 +54,26 @@ test('Google stays strict — it does send the claim, so there is nothing to ass
|
|||
assert.equal(emailIsVerified({ email: 'someone@example.com' }, g), false);
|
||||
});
|
||||
|
||||
test('an ORG-configured provider may never assume, even if the object claims it can', () => {
|
||||
assert.equal(emailIsVerified({ email: 'a@b.c' }, orgProvider), false);
|
||||
// Belt and braces: a tampered/hand-built org object must not be able to opt itself in through
|
||||
// the database, which is why rowToProvider pins the field rather than reading a column.
|
||||
test('an org provider that has proven a domain may assume — the customer-Entra case', () => {
|
||||
// Entra sends no email_verified. Before this, the domain went green and the login still failed.
|
||||
assert.equal(emailIsVerified({ email: 'dan@bytetinker.net' }, orgProven), true);
|
||||
});
|
||||
|
||||
test('an org provider that has proven NOTHING assumes nothing', () => {
|
||||
assert.equal(emailIsVerified({ email: 'dan@bytetinker.net' }, orgUnproven), false);
|
||||
});
|
||||
|
||||
test('the org assumption is DERIVED from proof, never readable from the row', () => {
|
||||
const src = require('fs').readFileSync(require.resolve('../lib/oidc-providers'), 'utf8');
|
||||
assert.match(src, /assumeEmailVerified: false,\s*\n\s*source: 'org'/,
|
||||
'rowToProvider must hard-code assumeEmailVerified:false next to source:org');
|
||||
assert.match(src, /assumeEmailVerified: verified\.length > 0,\s*\n\s*source: 'org'/,
|
||||
'rowToProvider must derive it from the verified-domain list, next to source:org');
|
||||
// The whole point: an organization must not be able to switch this on for itself.
|
||||
assert.doesNotMatch(src, /assumeEmailVerified: *row\./, 'must never be read from the org row');
|
||||
// ...and there must be no column for it to be read FROM. Checked against the schema rather than
|
||||
// this file, where `OIDC_<SLUG>_ASSUME_EMAIL_VERIFIED` is a legitimate operator-set env var.
|
||||
const schema = require('fs').readFileSync(require.resolve('../db/database'), 'utf8');
|
||||
assert.doesNotMatch(schema, /assume_email_verified/i,
|
||||
'org_sso_providers must have no assume_email_verified column');
|
||||
});
|
||||
|
||||
test('an EXPLICIT false is refused even where absence would be assumed', () => {
|
||||
|
|
|
|||
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');
|
||||
});
|
||||
232
server/test/telemetry.test.js
Normal file
232
server/test/telemetry.test.js
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
'use strict';
|
||||
|
||||
// Opt-in install statistics. The promises this feature makes are all negative ones — it does not
|
||||
// send until asked, it does not send more than three fields, it does not ask twice — and a
|
||||
// negative promise is exactly the kind that rots silently. These bites pin each one.
|
||||
|
||||
const { test, after, mock } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'telemetry-'));
|
||||
process.env.DATA_DIR = tmp;
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
const { db } = require('../db/database');
|
||||
const appSettings = require('../lib/app-settings');
|
||||
const telemetry = require('../lib/telemetry');
|
||||
|
||||
after(() => { telemetry.stop(); fs.rmSync(tmp, { recursive: true, force: true }); });
|
||||
|
||||
function reset() {
|
||||
db.prepare('DELETE FROM app_settings').run();
|
||||
appSettings.__reload();
|
||||
}
|
||||
|
||||
test('an install that has not been asked reports nothing', async () => {
|
||||
reset();
|
||||
assert.equal(telemetry.state(), 'unasked');
|
||||
|
||||
const spy = mock.method(globalThis, 'fetch', async () => { throw new Error('must not be called'); });
|
||||
try {
|
||||
const r = await telemetry.report(db);
|
||||
assert.equal(r.sent, false); assert.equal(r.reason, 'not_enabled');
|
||||
assert.equal(spy.mock.callCount(), 0, 'no outbound request may be made before consent');
|
||||
} finally { spy.mock.restore(); }
|
||||
});
|
||||
|
||||
test('declining is remembered, so the prompt does not return after an update', () => {
|
||||
reset();
|
||||
telemetry.setEnabled(false);
|
||||
assert.equal(telemetry.state(), 'off', 'a decline must persist as off, never fall back to unasked');
|
||||
appSettings.__reload(); // survives a restart
|
||||
assert.equal(telemetry.state(), 'off');
|
||||
});
|
||||
|
||||
test('a declined install still reports nothing', async () => {
|
||||
reset();
|
||||
telemetry.setEnabled(false);
|
||||
const spy = mock.method(globalThis, 'fetch', async () => { throw new Error('must not be called'); });
|
||||
try {
|
||||
const d = await telemetry.report(db); assert.equal(d.sent, false); assert.equal(d.reason, 'not_enabled');
|
||||
assert.equal(spy.mock.callCount(), 0);
|
||||
} finally { spy.mock.restore(); }
|
||||
});
|
||||
|
||||
test('the payload is exactly three fields, and no more', async () => {
|
||||
reset();
|
||||
const body = telemetry.payload(db);
|
||||
assert.deepEqual(Object.keys(body).sort(), ['instance_id', 'screen_count', 'version'],
|
||||
'adding a field here is a privacy decision, not a refactor — it must fail this test first');
|
||||
assert.match(body.instance_id, /^[0-9a-f-]{36}$/i);
|
||||
assert.equal(typeof body.version, 'string');
|
||||
assert.equal(typeof body.screen_count, 'number');
|
||||
});
|
||||
|
||||
test('the instance id is stable across reads and restarts', () => {
|
||||
reset();
|
||||
const first = telemetry.instanceId();
|
||||
assert.equal(telemetry.instanceId(), first, 'must not mint a new id per call');
|
||||
appSettings.__reload();
|
||||
assert.equal(telemetry.instanceId(), first, 'must survive a restart, or every install counts twice');
|
||||
});
|
||||
|
||||
test('screen_count counts paired displays, not provisioning rows', () => {
|
||||
reset();
|
||||
db.prepare('DELETE FROM devices').run();
|
||||
const ins = db.prepare("INSERT INTO devices (id, name, pairing_code, device_token, status) VALUES (?, ?, ?, ?, 'offline')");
|
||||
ins.run('d1', 'One', '111111', 'tok1');
|
||||
ins.run('d2', 'Two', '222222', 'tok2');
|
||||
// Never paired: a provisioning row nobody connected is not a deployed screen.
|
||||
db.prepare("INSERT INTO devices (id, name, pairing_code, device_token, status) VALUES ('d3','Three','333333',NULL,'offline')").run();
|
||||
|
||||
assert.equal(telemetry.payload(db).screen_count, 2);
|
||||
db.prepare('DELETE FROM devices').run();
|
||||
});
|
||||
|
||||
test('when enabled it sends exactly the payload, and records what it sent', async () => {
|
||||
reset();
|
||||
telemetry.setEnabled(true);
|
||||
|
||||
let seen = null;
|
||||
const spy = mock.method(globalThis, 'fetch', async (url, opts) => {
|
||||
seen = { url, body: JSON.parse(opts.body), method: opts.method };
|
||||
return { ok: true, status: 200 };
|
||||
});
|
||||
try {
|
||||
const r = await telemetry.report(db, { urls: [{ url: 'https://example.test/report', kind: 'screentinker' }] });
|
||||
assert.equal(r.sent, true);
|
||||
assert.equal(seen.method, 'POST');
|
||||
assert.equal(seen.url, 'https://example.test/report');
|
||||
assert.deepEqual(Object.keys(seen.body).sort(), ['instance_id', 'screen_count', 'version'],
|
||||
'the bytes on the wire must match the audited payload, not a superset');
|
||||
|
||||
// An operator can check rather than trust: what was sent is retrievable verbatim.
|
||||
const last = telemetry.getLastReport();
|
||||
assert.deepEqual(last.body, seen.body);
|
||||
assert.equal(typeof last.at, 'number');
|
||||
} finally { spy.mock.restore(); }
|
||||
});
|
||||
|
||||
test('a blocked outbound connection is recorded, with the address that was blocked', async () => {
|
||||
// Egress filtering is the normal failure on a self-hosted box and is otherwise invisible: the
|
||||
// operator sees nothing arriving and cannot tell a firewall from a broken feature. The UI can
|
||||
// only name the host to allowlist if the failure is recorded here.
|
||||
reset();
|
||||
telemetry.setEnabled(true);
|
||||
const spy = mock.method(globalThis, 'fetch', async () => { throw new Error('ECONNREFUSED'); });
|
||||
try {
|
||||
await telemetry.report(db, { urls: [{ url: 'https://stats.example.test/report', kind: 'screentinker' }] });
|
||||
const err = telemetry.getLastError();
|
||||
assert.ok(err, 'a failed attempt must be recorded, or the operator has nothing to act on');
|
||||
assert.equal(err.reason, 'network');
|
||||
assert.equal(err.url, 'https://stats.example.test/report', 'must record the address actually tried');
|
||||
assert.equal(typeof err.at, 'number');
|
||||
} finally { spy.mock.restore(); }
|
||||
});
|
||||
|
||||
test('a later success clears the stale failure', async () => {
|
||||
reset();
|
||||
telemetry.setEnabled(true);
|
||||
const bad = mock.method(globalThis, 'fetch', async () => { throw new Error('ECONNREFUSED'); });
|
||||
try { await telemetry.report(db, { urls: [{ url: 'https://example.test/report', kind: 'screentinker' }] }); } finally { bad.mock.restore(); }
|
||||
assert.ok(telemetry.getLastError(), 'precondition: a failure was recorded');
|
||||
|
||||
const good = mock.method(globalThis, 'fetch', async () => ({ ok: true, status: 200 }));
|
||||
try {
|
||||
await telemetry.report(db, { urls: [{ url: 'https://example.test/report', kind: 'screentinker' }] });
|
||||
assert.equal(telemetry.getLastError(), null,
|
||||
'a stale firewall warning must not outlive the problem it describes');
|
||||
} finally { good.mock.restore(); }
|
||||
});
|
||||
|
||||
test('an operator collector is ADDITIONAL — it never replaces the shared report', async () => {
|
||||
// The whole point of naming it EXTRA rather than ENDPOINT: configuring your own collector must
|
||||
// not silently redirect the report the operator agreed to share. If this ever becomes a
|
||||
// redirect, the opt-in stops meaning what the UI says it means.
|
||||
reset();
|
||||
telemetry.setEnabled(true);
|
||||
const original = process.env.TELEMETRY_EXTRA_ENDPOINT;
|
||||
process.env.TELEMETRY_EXTRA_ENDPOINT = 'https://mine.example.test/collect';
|
||||
try {
|
||||
const dests = telemetry.destinations();
|
||||
assert.equal(dests.length, 2, 'sharing on + own collector = both, never one');
|
||||
assert.deepEqual(dests.map(d => d.kind).sort(), ['extra', 'screentinker']);
|
||||
|
||||
const hits = [];
|
||||
const spy = mock.method(globalThis, 'fetch', async (url) => { hits.push(url); return { ok: true, status: 200 }; });
|
||||
try {
|
||||
await telemetry.report(db);
|
||||
assert.equal(hits.length, 2, 'both destinations must receive the report');
|
||||
assert.ok(hits.includes('https://mine.example.test/collect'));
|
||||
assert.ok(hits.some(u => u.includes('screentinker.com')), 'the shared report must still be sent');
|
||||
} finally { spy.mock.restore(); }
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.TELEMETRY_EXTRA_ENDPOINT;
|
||||
else process.env.TELEMETRY_EXTRA_ENDPOINT = original;
|
||||
}
|
||||
});
|
||||
|
||||
test('an operator can keep their own statistics while sharing nothing with us', async () => {
|
||||
// Someone who wants internal fleet numbers but nothing leaving for us sets their own collector
|
||||
// and leaves sharing off. Supported on purpose: it is their server posting to their host.
|
||||
reset();
|
||||
telemetry.setEnabled(false);
|
||||
const original = process.env.TELEMETRY_EXTRA_ENDPOINT;
|
||||
process.env.TELEMETRY_EXTRA_ENDPOINT = 'https://mine.example.test/collect';
|
||||
try {
|
||||
const hits = [];
|
||||
const spy = mock.method(globalThis, 'fetch', async (url) => { hits.push(url); return { ok: true, status: 200 }; });
|
||||
try {
|
||||
await telemetry.report(db);
|
||||
assert.deepEqual(hits, ['https://mine.example.test/collect']);
|
||||
assert.ok(!hits.some(u => u.includes('screentinker.com')),
|
||||
'sharing is off — nothing may reach us, whatever else is configured');
|
||||
} finally { spy.mock.restore(); }
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.TELEMETRY_EXTRA_ENDPOINT;
|
||||
else process.env.TELEMETRY_EXTRA_ENDPOINT = original;
|
||||
}
|
||||
});
|
||||
|
||||
test('one unreachable destination does not stop the other', async () => {
|
||||
reset();
|
||||
telemetry.setEnabled(true);
|
||||
const spy = mock.method(globalThis, 'fetch', async (url) => {
|
||||
if (url.includes('broken')) throw new Error('ECONNREFUSED');
|
||||
return { ok: true, status: 200 };
|
||||
});
|
||||
try {
|
||||
const r = await telemetry.report(db, { urls: [
|
||||
{ url: 'https://broken.example.test/a', kind: 'extra' },
|
||||
{ url: 'https://working.example.test/b', kind: 'screentinker' },
|
||||
] });
|
||||
assert.equal(r.results.filter(x => x.sent).length, 1, 'the reachable one still receives it');
|
||||
assert.equal(r.results.filter(x => !x.sent).length, 1);
|
||||
assert.equal(telemetry.getLastError().url, 'https://broken.example.test/a',
|
||||
'the failure names the destination that actually failed');
|
||||
} finally { spy.mock.restore(); }
|
||||
});
|
||||
|
||||
test('a failed send is quiet and local — never throws, never records a phantom report', async () => {
|
||||
reset();
|
||||
telemetry.setEnabled(true);
|
||||
const spy = mock.method(globalThis, 'fetch', async () => { throw new Error('ECONNREFUSED'); });
|
||||
try {
|
||||
const r = await telemetry.report(db, { urls: [{ url: 'https://example.test/report', kind: 'screentinker' }] });
|
||||
assert.equal(r.sent, false);
|
||||
assert.equal(r.reason, 'network');
|
||||
assert.equal(telemetry.getLastReport(), null, 'a failed send must not look like a successful one');
|
||||
} finally { spy.mock.restore(); }
|
||||
|
||||
// An HTTP error is likewise not a success.
|
||||
const spy2 = mock.method(globalThis, 'fetch', async () => ({ ok: false, status: 503 }));
|
||||
try {
|
||||
const r = await telemetry.report(db, { urls: [{ url: 'https://example.test/report', kind: 'screentinker' }] });
|
||||
assert.equal(r.sent, false);
|
||||
assert.equal(r.reason, 'http_503');
|
||||
assert.equal(telemetry.getLastReport(), null);
|
||||
} finally { spy2.mock.restore(); }
|
||||
});
|
||||
68
server/test/version-precedence.test.js
Normal file
68
server/test/version-precedence.test.js
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
'use strict';
|
||||
|
||||
/*
|
||||
* Prerelease ordering.
|
||||
*
|
||||
* The bug this pins: a plain string compare put every build from alpha10 onward BELOW alpha8,
|
||||
* because '1' < '8'. The OTA check then answered `client-newer` and refused to offer the update,
|
||||
* so a fleet on alpha8 could not be moved forward — silently, while the server reported the newer
|
||||
* build as `latest` in the same response. Two comparators carried the assumption, each with a
|
||||
* comment saying lexical was fine "for our naming". It was, until the counter passed 9.
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { preCmp } = require('../lib/version-precedence');
|
||||
const { cmp } = require('../lib/ota-breaker');
|
||||
const bsUpdate = require('../lib/brightsign-update');
|
||||
|
||||
const sign = (n) => (n === 0 ? 0 : n < 0 ? -1 : 1);
|
||||
|
||||
test('double-digit prereleases outrank single-digit ones', () => {
|
||||
// The exact case that stranded the fleet.
|
||||
assert.equal(sign(preCmp('alpha11', 'alpha8')), 1, 'alpha11 must be newer than alpha8');
|
||||
assert.equal(sign(preCmp('alpha10', 'alpha9')), 1);
|
||||
assert.equal(sign(preCmp('beta12', 'beta2')), 1);
|
||||
assert.equal(sign(preCmp('rc10', 'rc9')), 1);
|
||||
// And the reverse still holds, so nothing was merely inverted.
|
||||
assert.equal(sign(preCmp('alpha2', 'alpha10')), -1);
|
||||
});
|
||||
|
||||
test('ordinary alphabetical precedence is unchanged', () => {
|
||||
assert.equal(sign(preCmp('beta1', 'alpha11')), 1, 'beta outranks alpha regardless of number');
|
||||
assert.equal(sign(preCmp('rc1', 'beta9')), 1, 'rc outranks beta');
|
||||
assert.equal(sign(preCmp('alpha8', 'alpha8')), 0);
|
||||
});
|
||||
|
||||
test('semver dot form works too, so the naming can move without another fix', () => {
|
||||
assert.equal(sign(preCmp('alpha.11', 'alpha.8')), 1);
|
||||
assert.equal(sign(preCmp('alpha', 'alpha.1')), -1, 'fewer identifiers = lower precedence');
|
||||
assert.equal(sign(preCmp('alpha.1', 'beta.1')), -1);
|
||||
});
|
||||
|
||||
test('OTA: a device on alpha8 is offered alpha11', () => {
|
||||
// Through the real comparator the update check uses, not just the helper.
|
||||
assert.equal(cmp('1.9.34-alpha11', '1.9.34-alpha8'), 1);
|
||||
assert.equal(cmp('1.9.34-alpha10', '1.9.34-alpha6'), 1);
|
||||
// A release still outranks any prerelease of the same core.
|
||||
assert.equal(cmp('1.9.34', '1.9.34-alpha11'), 1);
|
||||
// And a newer core still wins outright, whatever the prerelease says.
|
||||
assert.equal(cmp('1.9.35-alpha1', '1.9.34-alpha11'), 1);
|
||||
});
|
||||
|
||||
test('OTA decide(): alpha8 -> alpha11 is an offer, not client-newer', () => {
|
||||
// The end-to-end symptom: the endpoint reported the newer build as `latest` and refused it
|
||||
// in the same breath.
|
||||
const { decide } = require('../lib/ota-breaker');
|
||||
const d = decide('1.9.34-alpha8', '1.9.34-alpha11', 'test-device-precedence');
|
||||
assert.equal(d.update_available, true, `expected an offer, got ${d.reason}`);
|
||||
assert.notEqual(d.reason, 'client-newer');
|
||||
});
|
||||
|
||||
test('BrightSign host packages order the same way', () => {
|
||||
// Same assumption lived here, with the same comment. A BrightSign package update that goes
|
||||
// wrong replaces the script that boots the player, so wrong-way ordering matters more here.
|
||||
assert.equal(sign(bsUpdate.compareVersions('1.9.34-rc10', '1.9.34-rc9')), 1);
|
||||
assert.equal(sign(bsUpdate.compareVersions('1.9.34', '1.9.34-rc10')), 1);
|
||||
assert.equal(sign(bsUpdate.compareVersions('1.9.34-alpha2', '1.9.34-alpha10')), -1);
|
||||
});
|
||||
|
|
@ -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