mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-15 14:53:18 -06:00
Compare commits
5 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f9459139f | ||
|
|
94a81b6896 | ||
|
|
86db5929c1 | ||
|
|
3ec06b663c | ||
|
|
8cb67122ad |
54
.github/workflows/ci.yml
vendored
54
.github/workflows/ci.yml
vendored
|
|
@ -86,6 +86,39 @@ jobs:
|
|||
working-directory: android
|
||||
run: ./gradlew :app:testDebugUnitTest --no-daemon
|
||||
|
||||
# Every artifact that can enter the APK must have a licence on file. This runs here
|
||||
# rather than in its own job because the Gradle cache and Android SDK are already warm.
|
||||
- name: Licence gate (APK runtime classpath)
|
||||
run: node scripts/android-license-check.js
|
||||
|
||||
licenses:
|
||||
name: Licence gate + SBOM (production deps)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: npm
|
||||
cache-dependency-path: server/package-lock.json
|
||||
|
||||
# --omit=dev on purpose, and it is the whole point of the job. A developer checkout
|
||||
# carries sharp, whose @img/sharp-wasm32 declares LGPL-3.0-or-later; it is a test
|
||||
# fixture generator that never reaches a server. Auditing anything other than a
|
||||
# production install would report a licence we do not actually ship.
|
||||
- name: Install production dependencies only
|
||||
working-directory: server
|
||||
run: npm ci --omit=dev
|
||||
|
||||
- name: Licence gate
|
||||
run: node scripts/license-check.js --sbom sbom/screentinker-server.cdx.json
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: sbom
|
||||
path: sbom/
|
||||
if-no-files-found: error
|
||||
|
||||
smoke:
|
||||
name: Boot smoke + version check
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -109,6 +142,12 @@ jobs:
|
|||
working-directory: server
|
||||
env:
|
||||
SELF_HOSTED: 'true'
|
||||
# Boot WITH the collector on. This block is config-gated and only the
|
||||
# statistics-collecting deployment sets the flag, so it had never executed in CI,
|
||||
# on alpha, or in any test - and a load-time crash inside it took production down
|
||||
# while every check was green. Code only one deployment runs is exactly the code
|
||||
# CI has to execute.
|
||||
TELEMETRY_COLLECTOR: '1'
|
||||
run: |
|
||||
node server.js > "$RUNNER_TEMP/server.log" 2>&1 &
|
||||
echo $! > "$RUNNER_TEMP/server.pid"
|
||||
|
|
@ -131,6 +170,21 @@ jobs:
|
|||
test "$REPORTED" = "$EXPECTED"
|
||||
echo "OK: status ok, version $REPORTED matches VERSION"
|
||||
|
||||
# Booting is not enough on its own - the collector could be mounted and broken. Prove
|
||||
# the routes it adds actually answer, so a fault inside that block fails here rather
|
||||
# than on the single deployment that turns it on.
|
||||
- name: Assert the collector routes answer when enabled
|
||||
run: |
|
||||
STATS="$(curl -sf http://localhost:3001/api/public/stats)"
|
||||
echo "stats: $STATS"
|
||||
test "$(echo "$STATS" | jq -r 'has("screens") and has("installs")')" = "true"
|
||||
REPORT="$(curl -s -o /dev/null -w '%{http_code}' -X POST \
|
||||
-H 'Content-Type: application/json' -d '{"bad":1}' \
|
||||
http://localhost:3001/api/telemetry/report)"
|
||||
echo "malformed report -> HTTP $REPORT"
|
||||
test "$REPORT" = "400"
|
||||
echo "OK: collector mounted and answering"
|
||||
|
||||
- name: Stop server
|
||||
if: always()
|
||||
run: kill "$(cat "$RUNNER_TEMP/server.pid")" 2>/dev/null || true
|
||||
|
|
|
|||
11
.github/workflows/release.yml
vendored
11
.github/workflows/release.yml
vendored
|
|
@ -85,6 +85,15 @@ jobs:
|
|||
./scripts/build-autorun-zip.sh -o autorun.zip
|
||||
ls -la autorun.zip
|
||||
|
||||
# A published SBOM is what turns "we track licences" into something a customer or an
|
||||
# underwriter can check for themselves. Built from a PRODUCTION install — a dev tree
|
||||
# would list packages (sharp and its LGPL-bearing wasm variant) that never ship.
|
||||
- name: Generate SBOM (production dependencies)
|
||||
run: |
|
||||
( cd server && npm ci --omit=dev )
|
||||
node scripts/license-check.js --sbom "screentinker-sbom-${{ steps.ver.outputs.version }}.cdx.json"
|
||||
ls -la screentinker-sbom-*.cdx.json
|
||||
|
||||
- name: Build source tarball (bundles the .wgt; the signed apk is added by scripts/finalize-release.sh)
|
||||
run: |
|
||||
OUT="screentinker-${{ steps.ver.outputs.version }}.tar.gz"
|
||||
|
|
@ -154,6 +163,7 @@ jobs:
|
|||
echo "- Docker image: \`ghcr.io/screentinker/screentinker:${{ steps.ver.outputs.version }}\` (also \`:latest\`)."
|
||||
fi
|
||||
echo "- \`ScreenTinker.apk\` - signed Android player (attached during release finalization)."
|
||||
echo "- \`screentinker-sbom-${{ steps.ver.outputs.version }}.cdx.json\` - CycloneDX 1.5 software bill of materials for the server's production dependencies, with the licence of every component."
|
||||
} > RELEASE_NOTES.md
|
||||
cat RELEASE_NOTES.md
|
||||
|
||||
|
|
@ -171,6 +181,7 @@ jobs:
|
|||
--notes-file RELEASE_NOTES.md \
|
||||
"${TARBALL}" \
|
||||
autorun.zip \
|
||||
"screentinker-sbom-${{ steps.ver.outputs.version }}.cdx.json" \
|
||||
tizen/ScreenTinker.wgt
|
||||
|
||||
docker:
|
||||
|
|
|
|||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -59,3 +59,7 @@ audit/
|
|||
# Local SQLite artifacts (any extension the tooling might produce)
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Generated by scripts/license-check.js --sbom (CI publishes it as a release asset)
|
||||
sbom/
|
||||
*.cdx.json
|
||||
|
|
|
|||
34
CHANGELOG.md
34
CHANGELOG.md
|
|
@ -1,5 +1,39 @@
|
|||
# Changelog
|
||||
|
||||
## 1.9.36
|
||||
|
||||
A single fix. **1.9.36 replaces 1.9.35** — see below for whether that affects you.
|
||||
|
||||
### Fixed — 1.9.35 would not start on a server collecting install statistics
|
||||
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
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.
|
||||
|
||||
### Changed — the startup check now covers configuration only one deployment uses
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### 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
|
||||
|
|
|
|||
|
|
@ -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? ?: "122").toInt()
|
||||
versionName = System.getenv("VERSION_NAME") ?: findProperty("VERSION_NAME") as String? ?: "1.9.35"
|
||||
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")
|
||||
|
|
|
|||
45
android/licenses.json
Normal file
45
android/licenses.json
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"_comment": [
|
||||
"Licence policy for everything on the Android release runtime classpath — i.e. everything that",
|
||||
"can end up inside the APK customers install.",
|
||||
"",
|
||||
"scripts/android-license-check.js resolves the real classpath and checks it against this file.",
|
||||
"An artifact that appears in neither 'artifacts' nor 'groups' FAILS: a new transitive dependency",
|
||||
"must be looked at by a person before it ships, which is exactly how org.json:json:20090211 got",
|
||||
"into the APK unnoticed in the first place.",
|
||||
"",
|
||||
"Record what you verified in 'evidence' — the point is to be able to answer 'how do you know?'"
|
||||
],
|
||||
|
||||
"groups": {
|
||||
"androidx": { "license": "Apache-2.0", "evidence": "AndroidX / Jetpack, Apache-2.0 across the board" },
|
||||
"com.google.android.material": { "license": "Apache-2.0", "evidence": "Material Components for Android" },
|
||||
"com.google.code.gson": { "license": "Apache-2.0", "evidence": "google/gson LICENSE" },
|
||||
"com.google.crypto.tink": { "license": "Apache-2.0", "evidence": "google/tink LICENSE" },
|
||||
"com.google.errorprone": { "license": "Apache-2.0", "evidence": "google/error-prone LICENSE" },
|
||||
"com.google.guava": { "license": "Apache-2.0", "evidence": "google/guava LICENSE" },
|
||||
"com.google.j2objc": { "license": "Apache-2.0", "evidence": "google/j2objc LICENSE" },
|
||||
"com.squareup.okhttp3": { "license": "Apache-2.0", "evidence": "square/okhttp LICENSE" },
|
||||
"com.squareup.okio": { "license": "Apache-2.0", "evidence": "square/okio LICENSE" },
|
||||
"org.checkerframework": { "license": "MIT", "evidence": "checker-framework, MIT for the qualifiers" },
|
||||
"org.jetbrains": { "license": "Apache-2.0", "evidence": "JetBrains annotations" },
|
||||
"org.jetbrains.kotlin": { "license": "Apache-2.0", "evidence": "Kotlin stdlib" },
|
||||
"org.jetbrains.kotlinx": { "license": "Apache-2.0", "evidence": "kotlinx coroutines" },
|
||||
"io.socket": { "license": "MIT", "evidence": "socket.io-client-java LICENSE (MIT)" }
|
||||
},
|
||||
|
||||
"artifacts": {},
|
||||
|
||||
"denied": {
|
||||
"org.json:json": {
|
||||
"why": "JSON Licence — the 'shall be used for Good, not Evil' clause. Not OSI-approved, non-free per Debian and Fedora, Apache Category X. Arrives transitively via socket.io-client and was previously packaged into the APK in full (19 classes). Excluded in app/build.gradle.kts; Android provides org.json in the platform from API 1 and minSdk is 24, so nothing is lost."
|
||||
}
|
||||
},
|
||||
|
||||
"denied_licenses": [
|
||||
{ "match": "AGPL", "why": "network copyleft" },
|
||||
{ "match": "GPL", "why": "strong copyleft in a commercially distributed binary" },
|
||||
{ "match": "SSPL", "why": "not OSI-approved, service-scope obligations" },
|
||||
{ "match": "JSON", "why": "field-of-use restriction" }
|
||||
]
|
||||
}
|
||||
103
docs/licensing.md
Normal file
103
docs/licensing.md
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
# Licensing
|
||||
|
||||
ScreenTinker is MIT. This page records how we know what our dependencies are licensed under,
|
||||
so the answer to "do you track licences?" is something you can check rather than something you
|
||||
have to take on trust.
|
||||
|
||||
## The short answer
|
||||
|
||||
**No GPL or AGPL anywhere in the product.** Neither the server nor the Android player links,
|
||||
bundles, or ships anything under strong or network copyleft.
|
||||
|
||||
## Where the answer comes from
|
||||
|
||||
Two gates run in CI on every push, and both fail closed — a dependency whose licence nobody has
|
||||
recorded fails the build rather than shipping unnoticed.
|
||||
|
||||
| Gate | Covers | Script |
|
||||
|---|---|---|
|
||||
| Licence gate + SBOM (production deps) | the server's npm tree | `scripts/license-check.js` |
|
||||
| Licence gate (APK runtime classpath) | everything that can enter the APK | `scripts/android-license-check.js` |
|
||||
|
||||
Run either locally:
|
||||
|
||||
```sh
|
||||
cd server && npm ci --omit=dev && cd ..
|
||||
node scripts/license-check.js # server
|
||||
node scripts/android-license-check.js # APK
|
||||
node scripts/license-check.js --sbom sbom/x.json # also write an SBOM
|
||||
```
|
||||
|
||||
Neither script has dependencies of its own. A gate that needs its own supply chain audited is
|
||||
worth less than one that doesn't.
|
||||
|
||||
## ⚠️ Audit the production install, not the checkout
|
||||
|
||||
**A licence scanner pointed at a developer checkout will report LGPL, and it will be wrong about
|
||||
what we ship.**
|
||||
|
||||
`sharp` is a `devDependency` — a fixture generator for the image tests — and one of its platform
|
||||
binaries, `@img/sharp-wasm32`, declares `Apache-2.0 AND LGPL-3.0-or-later AND MIT`. It is never
|
||||
installed on a server: production installs with `npm ci --omit=dev`, which both the CI gate and
|
||||
`scripts/upgrade.sh` use.
|
||||
|
||||
If someone challenges the answer with a scan of the repo, this is the discrepancy they have found.
|
||||
|
||||
`sharp` is kept deliberately: it is the *independent* implementation used to generate fixtures for
|
||||
the pure-JavaScript image path that replaced it. Generating those fixtures with the library under
|
||||
test would mean a decode bug could produce a fixture that hides the same bug.
|
||||
|
||||
## Policy
|
||||
|
||||
**Allowed** — MIT, MIT-0, ISC, 0BSD, BSD-2-Clause, BSD-3-Clause, Apache-2.0, BlueOak-1.0.0,
|
||||
Unlicense, CC0-1.0, Python-2.0, WTFPL, Zlib, CC-BY-4.0.
|
||||
|
||||
**Denied** — AGPL, GPL, SSPL, Commons Clause, BUSL, and the JSON Licence.
|
||||
|
||||
**Reported but not failed** — LGPL, MPL, EPL, CDDL, OSL, EUPL. Weak copyleft is file- or
|
||||
library-scoped and usually fine when merely linked, but it is a judgement, and the judgement should
|
||||
be made by someone who knows they are making it.
|
||||
|
||||
**Unrecognised — fails.** A package with no licence we can identify is not a package we ship. Where
|
||||
a dependency ships a real licence *file* but declares no `license` field, it is recorded as an
|
||||
exception in the script with the evidence that was read off disk (currently `exif-parser` and
|
||||
`thirty-two`, both MIT).
|
||||
|
||||
### Why the JSON Licence is denied
|
||||
|
||||
`org.json:json:20090211` arrived transitively through `socket.io-client` and was **packaged into the
|
||||
APK in full** — 19 classes, including ones nothing referenced. Its licence carries the clause *"The
|
||||
Software shall be used for Good, not Evil"*: not OSI-approved, treated as non-free by Debian and
|
||||
Fedora, and Category X at Apache. Not copyleft, but not a term to accept in a binary distributed
|
||||
commercially.
|
||||
|
||||
It is now excluded in `android/app/build.gradle.kts`. Nothing is lost — Android has provided
|
||||
`org.json` in the platform since API 1 and `minSdk` is 24 — and `android/licenses.json` denies it by
|
||||
name so it cannot return quietly.
|
||||
|
||||
## SBOM
|
||||
|
||||
Every release publishes `screentinker-sbom-<version>.cdx.json`: **CycloneDX 1.5**, listing every
|
||||
production dependency with its version, package URL, and licence. Generated from a production
|
||||
install, so it describes what actually runs.
|
||||
|
||||
CI also uploads one as a build artifact on every run.
|
||||
|
||||
## Vendored code
|
||||
|
||||
Anything committed under `frontend/vendor/` **ships in the release tarball** and must carry its
|
||||
licence notice as a separate file — minifiers strip headers, which is exactly when the notice has to
|
||||
be kept alongside. See `frontend/vendor/README.md`.
|
||||
|
||||
## The GLSL transitions
|
||||
|
||||
The 14 shaders in `shared/Transitions/` are original work. Each carries its author and licence in
|
||||
the file header, and none derives from Shadertoy, gl-transitions, glslsandbox or similar. "GL
|
||||
Transitions v1" in those headers refers to the *interface convention* — the function signature the
|
||||
renderer calls — not to borrowed code.
|
||||
|
||||
## Limits
|
||||
|
||||
These gates identify licences from declared metadata and recorded evidence. They are not a
|
||||
clean-room provenance review, and they do not detect code copied into the repository without
|
||||
attribution.
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
openapi: 3.1.0
|
||||
info:
|
||||
title: ScreenTinker Public API
|
||||
version: 1.9.35
|
||||
version: 1.9.36
|
||||
description: |
|
||||
Public, token-scoped REST API for ScreenTinker digital signage.
|
||||
|
||||
|
|
|
|||
7
frontend/vendor/README.md
vendored
7
frontend/vendor/README.md
vendored
|
|
@ -4,9 +4,16 @@ Third-party libraries committed directly to the repo (not fetched from a CDN or
|
|||
from npm) so self-hosted / air-gapped instances work with no external dependency and no
|
||||
build step.
|
||||
|
||||
**Anything added here ships in the release tarball**, so it must carry its licence notice —
|
||||
a minified bundle usually has its headers stripped, which is exactly when the notice has to
|
||||
be kept as a separate file next to it. Record the licence below and add a `<name>.LICENSE`.
|
||||
|
||||
## redoc.standalone.js
|
||||
- **Library:** Redoc — renders the OpenAPI reference served at `/docs`.
|
||||
- **Version:** 2.3.9
|
||||
- **Licence:** MIT — Copyright (c) 2015-present, Rebilly, Inc. Full text in
|
||||
[`redoc.LICENSE`](redoc.LICENSE). The bundle itself carries no header (stripped by the
|
||||
upstream minifier), which is why the notice is kept separately.
|
||||
- **Source:** https://cdn.redoc.ly/redoc/v2.3.9/bundles/redoc.standalone.js
|
||||
- **Why committed:** the API reference must render on offline instances — no CDN, no build step.
|
||||
- **Regenerate / update:**
|
||||
|
|
|
|||
31
frontend/vendor/redoc.LICENSE
vendored
Normal file
31
frontend/vendor/redoc.LICENSE
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
Redoc — https://github.com/Redocly/redoc
|
||||
Version vendored here: 2.3.9 (see redoc.standalone.js)
|
||||
|
||||
The bundle in this directory is minified and its license headers were stripped upstream, so
|
||||
the notice is kept alongside it instead. MIT requires this notice to accompany the software
|
||||
wherever it is distributed, and redoc.standalone.js is included in the ScreenTinker release
|
||||
tarball.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015-present, Rebilly, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
112
scripts/android-license-check.js
Normal file
112
scripts/android-license-check.js
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* Licence gate for the APK.
|
||||
*
|
||||
* node scripts/android-license-check.js [--sbom <path>]
|
||||
*
|
||||
* Resolves the real `releaseRuntimeClasspath` — every artifact that can end up inside the APK a
|
||||
* customer installs, transitive ones included — and checks each against android/licenses.json.
|
||||
*
|
||||
* Fails on an artifact nobody has recorded a licence for. That is the case worth catching:
|
||||
* org.json:json:20090211 reached customers because it arrived as a transitive dependency of
|
||||
* socket.io-client and nothing ever asked what licence it carried.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const ANDROID = path.join(ROOT, 'android');
|
||||
const POLICY = JSON.parse(fs.readFileSync(path.join(ANDROID, 'licenses.json'), 'utf8'));
|
||||
const SBOM_OUT = process.argv.includes('--sbom') ? process.argv[process.argv.indexOf('--sbom') + 1] : null;
|
||||
|
||||
function resolveClasspath() {
|
||||
const out = execFileSync('./gradlew', ['-q', 'app:dependencies', '--configuration', 'releaseRuntimeClasspath'],
|
||||
{ cwd: ANDROID, maxBuffer: 64 * 1024 * 1024, encoding: 'utf8' });
|
||||
const found = new Map();
|
||||
for (const raw of out.split('\n')) {
|
||||
// Gradle prints "group:name:requested -> resolved" when a version is upgraded; the resolved
|
||||
// one is what ships, so prefer the right-hand side.
|
||||
const m = raw.match(/([a-zA-Z0-9._-]+):([a-zA-Z0-9._-]+):([0-9][a-zA-Z0-9._-]*)(?:\s*->\s*([0-9][a-zA-Z0-9._-]*))?/);
|
||||
if (!m) continue;
|
||||
const [, group, name, requested, upgraded] = m;
|
||||
found.set(`${group}:${name}`, { group, name, version: upgraded || requested });
|
||||
}
|
||||
return [...found.values()].sort((a, b) => `${a.group}:${a.name}`.localeCompare(`${b.group}:${b.name}`));
|
||||
}
|
||||
|
||||
function licenceFor(a) {
|
||||
const coord = `${a.group}:${a.name}`;
|
||||
if (POLICY.denied[coord]) return { verdict: 'DENY', why: POLICY.denied[coord].why };
|
||||
if (POLICY.artifacts[coord]) return { verdict: 'ALLOW', ...POLICY.artifacts[coord] };
|
||||
// Longest matching group prefix wins, so a specific rule beats a broad one.
|
||||
const groups = Object.keys(POLICY.groups)
|
||||
.filter(g => a.group === g || a.group.startsWith(g + '.'))
|
||||
.sort((x, y) => y.length - x.length);
|
||||
if (groups.length) return { verdict: 'ALLOW', ...POLICY.groups[groups[0]] };
|
||||
return { verdict: 'UNKNOWN' };
|
||||
}
|
||||
|
||||
const artifacts = resolveClasspath();
|
||||
if (!artifacts.length) {
|
||||
console.error('Resolved no artifacts — the gradle task did not run properly. Refusing to pass.');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const results = artifacts.map(a => ({ ...a, ...licenceFor(a) }));
|
||||
const denied = results.filter(r => r.verdict === 'DENY');
|
||||
const unknown = results.filter(r => r.verdict === 'UNKNOWN');
|
||||
|
||||
for (const r of results.filter(r => r.verdict === 'ALLOW')) {
|
||||
for (const d of POLICY.denied_licenses) {
|
||||
if (new RegExp(d.match, 'i').test(r.license)) { denied.push({ ...r, why: `${r.license}: ${d.why}` }); }
|
||||
}
|
||||
}
|
||||
|
||||
const counts = results.reduce((m, r) => (m[r.license || '(unrecorded)'] = (m[r.license || '(unrecorded)'] || 0) + 1, m), {});
|
||||
console.log(`\nScope: ${results.length} artifacts on releaseRuntimeClasspath (everything that can enter the APK)\n`);
|
||||
Object.entries(counts).sort((a, b) => b[1] - a[1]).forEach(([l, n]) => console.log(` ${String(n).padStart(4)} ${l}`));
|
||||
|
||||
if (SBOM_OUT) {
|
||||
const sbom = {
|
||||
bomFormat: 'CycloneDX',
|
||||
specVersion: '1.5',
|
||||
version: 1,
|
||||
metadata: {
|
||||
component: {
|
||||
type: 'application',
|
||||
name: 'screentinker-android-player',
|
||||
version: fs.readFileSync(path.join(ROOT, 'VERSION'), 'utf8').trim(),
|
||||
licenses: [{ license: { id: 'MIT' } }],
|
||||
},
|
||||
},
|
||||
components: results.map(r => ({
|
||||
type: 'library',
|
||||
name: `${r.group}:${r.name}`,
|
||||
version: r.version,
|
||||
purl: `pkg:maven/${r.group}/${r.name}@${r.version}`,
|
||||
licenses: r.license ? [{ license: { id: r.license } }] : [],
|
||||
})),
|
||||
};
|
||||
fs.mkdirSync(path.dirname(SBOM_OUT), { recursive: true });
|
||||
fs.writeFileSync(SBOM_OUT, JSON.stringify(sbom, null, 2));
|
||||
console.log(`\nSBOM: ${SBOM_OUT} (${sbom.components.length} components, CycloneDX 1.5)`);
|
||||
}
|
||||
|
||||
let failed = false;
|
||||
if (denied.length) {
|
||||
failed = true;
|
||||
console.log('\nDENIED');
|
||||
denied.forEach(r => console.log(` ${r.group}:${r.name}:${r.version}\n ${r.why}`));
|
||||
}
|
||||
if (unknown.length) {
|
||||
failed = true;
|
||||
console.log('\nUNRECORDED — a new dependency reached the APK with no licence on file.');
|
||||
console.log('Look it up, then add it to android/licenses.json with evidence, or exclude it.');
|
||||
unknown.forEach(r => console.log(` ${r.group}:${r.name}:${r.version}`));
|
||||
}
|
||||
console.log(failed ? '\nFAIL: licence policy violated.\n' : '\nOK: every artifact in the APK has a recorded, permitted licence.\n');
|
||||
process.exit(failed ? 1 : 0);
|
||||
184
scripts/license-check.js
Normal file
184
scripts/license-check.js
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* Licence gate for the dependencies that actually SHIP.
|
||||
*
|
||||
* node scripts/license-check.js [--sbom <path>] [--include-dev]
|
||||
*
|
||||
* Run from a PRODUCTION install (`npm ci --omit=dev`). That is the whole point: a developer
|
||||
* checkout carries `sharp`, whose `@img/sharp-wasm32` declares LGPL-3.0-or-later. It is a test
|
||||
* fixture generator, it is devDependencies-only, and it never reaches a server — but a scanner
|
||||
* pointed at a dev tree reports LGPL and contradicts the answer we give customers. Auditing the
|
||||
* installed production tree is what makes the answer defensible.
|
||||
*
|
||||
* Exits non-zero on anything denied or unresolved, so CI fails before a licence can arrive
|
||||
* unnoticed through a transitive bump.
|
||||
*
|
||||
* No dependencies, deliberately — a gate that needs its own supply chain audited is worth less.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const INCLUDE_DEV = args.includes('--include-dev');
|
||||
const SBOM_OUT = args.includes('--sbom') ? args[args.indexOf('--sbom') + 1] : null;
|
||||
const SERVER_DIR = path.join(__dirname, '..', 'server');
|
||||
|
||||
/* ── policy ───────────────────────────────────────────────────────────────────
|
||||
* ALLOW: permissive, no distribution obligation beyond keeping the notice.
|
||||
* DENY: strong/network copyleft, plus licences we will not ship for other reasons.
|
||||
* Anything matching neither is REVIEW — it fails, and a human decides. Failing closed
|
||||
* matters more than being clever: the risk is a licence arriving that nobody looked at.
|
||||
*/
|
||||
const ALLOW = [
|
||||
/^MIT$/i, /^MIT-0$/i, /^ISC$/i, /^0BSD$/i, /^BSD-2-Clause$/i, /^BSD-3-Clause$/i,
|
||||
/^Apache-2\.0$/i, /^BlueOak-1\.0\.0$/i, /^Unlicense$/i, /^CC0-1\.0$/i, /^Python-2\.0$/i,
|
||||
/^WTFPL$/i, /^Zlib$/i, /^CC-BY-4\.0$/i,
|
||||
];
|
||||
|
||||
const DENY = [
|
||||
{ re: /\bAGPL/i, why: 'network copyleft — obligations trigger on serving, not distributing' },
|
||||
{ re: /\bGPL-[123]|\bGPLv[123]|(^|[^L])\bGPL\b/i, why: 'strong copyleft — links into a product we distribute commercially' },
|
||||
{ re: /\bSSPL/i, why: 'server-side public licence — not OSI-approved, service-scope obligations' },
|
||||
{ re: /\bCommons-Clause/i, why: 'commercial-use restriction' },
|
||||
{ re: /\bBUSL|Business Source/i, why: 'source-available, not open source' },
|
||||
{ re: /Good, not Evil|^JSON$/i, why: 'JSON Licence — field-of-use clause, Apache Category X, non-free per Debian/Fedora' },
|
||||
];
|
||||
|
||||
// Weak copyleft: file- or library-scoped, generally fine when merely linked, but never silently.
|
||||
const REVIEW = [/\bLGPL/i, /\bMPL/i, /\bEPL/i, /\bCDDL/i, /\bOSL/i, /\bEUPL/i, /\bCPL/i];
|
||||
|
||||
/*
|
||||
* Packages that ship a real licence FILE but declare no `license` field in package.json.
|
||||
* Each entry records what was read off disk, so this is a documented finding rather than a
|
||||
* blanket exemption. Re-verify if the version changes.
|
||||
*/
|
||||
const EXCEPTIONS = {
|
||||
'exif-parser': { license: 'MIT', evidence: 'LICENSE.md — "The MIT License"' },
|
||||
'thirty-two': { license: 'MIT', evidence: 'LICENSE.txt — MIT, Copyright (c) 2011 Chris Umbel' },
|
||||
'screentinker': { license: 'MIT', evidence: 'repository root LICENSE' },
|
||||
};
|
||||
|
||||
function classify(id) {
|
||||
if (!id) return { verdict: 'UNKNOWN' };
|
||||
for (const d of DENY) if (d.re.test(id)) return { verdict: 'DENY', why: d.why };
|
||||
// A GPL-with-exception (Classpath, linking) is not the thing we are guarding against.
|
||||
if (/WITH .*exception/i.test(id)) return { verdict: 'REVIEW', why: 'copyleft with a linking exception' };
|
||||
for (const r of REVIEW) if (r.test(id)) return { verdict: 'REVIEW', why: 'weak copyleft' };
|
||||
// Composite expressions: every term must be allowed.
|
||||
const terms = id.split(/\s+(?:OR|AND)\s+|[()]/).map(s => s.trim()).filter(Boolean);
|
||||
if (terms.length && terms.every(t => ALLOW.some(a => a.test(t)))) return { verdict: 'ALLOW' };
|
||||
return { verdict: 'UNKNOWN' };
|
||||
}
|
||||
|
||||
function readLicense(dir) {
|
||||
let pkg;
|
||||
try { pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); } catch { return null; }
|
||||
let lic = pkg.license;
|
||||
if (lic && typeof lic === 'object') lic = lic.type;
|
||||
if (!lic && Array.isArray(pkg.licenses)) lic = pkg.licenses.map(l => l.type || l).join(' OR ');
|
||||
return { name: pkg.name, version: pkg.version, license: lic || null };
|
||||
}
|
||||
|
||||
/*
|
||||
* `npm ls` exits non-zero for any tree problem — an extraneous package, a peer-dep complaint —
|
||||
* while still printing the full listing. Treating that as fatal would turn a routine tree quirk
|
||||
* into an unexplained CI failure, and worse, a licence check that never actually ran. Read the
|
||||
* output either way; a genuinely empty result is the only thing worth aborting on.
|
||||
*/
|
||||
function listInstalled() {
|
||||
const argv = ['ls', ...(INCLUDE_DEV ? [] : ['--omit=dev']), '--all', '--parseable'];
|
||||
const opts = { cwd: SERVER_DIR, maxBuffer: 64 * 1024 * 1024, encoding: 'utf8' };
|
||||
try {
|
||||
return execFileSync('npm', argv, opts);
|
||||
} catch (e) {
|
||||
if (e.stdout && e.stdout.trim()) return e.stdout;
|
||||
console.error('npm ls produced no output:\n' + (e.stderr || e.message));
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
const dirs = listInstalled().split('\n').filter(Boolean);
|
||||
|
||||
const pkgs = [];
|
||||
const seen = new Set();
|
||||
for (const d of dirs) {
|
||||
const info = readLicense(d);
|
||||
if (!info || !info.name) continue;
|
||||
const key = `${info.name}@${info.version}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
let license = info.license;
|
||||
let note = null;
|
||||
if (!license && EXCEPTIONS[info.name]) {
|
||||
license = EXCEPTIONS[info.name].license;
|
||||
note = `no license field; ${EXCEPTIONS[info.name].evidence}`;
|
||||
}
|
||||
pkgs.push({ ...info, license, note, ...classify(license) });
|
||||
}
|
||||
|
||||
const denied = pkgs.filter(p => p.verdict === 'DENY');
|
||||
const review = pkgs.filter(p => p.verdict === 'REVIEW');
|
||||
const unknown = pkgs.filter(p => p.verdict === 'UNKNOWN');
|
||||
|
||||
const counts = pkgs.reduce((m, p) => (m[p.license || '(none)'] = (m[p.license || '(none)'] || 0) + 1, m), {});
|
||||
console.log(`\nScope: ${pkgs.length} packages (${INCLUDE_DEV ? 'INCLUDING dev' : 'production only, --omit=dev'})\n`);
|
||||
Object.entries(counts).sort((a, b) => b[1] - a[1]).forEach(([l, n]) => console.log(` ${String(n).padStart(4)} ${l}`));
|
||||
|
||||
if (SBOM_OUT) {
|
||||
// CycloneDX 1.5, hand-built. A standard format customers and underwriters recognise, without
|
||||
// taking a dependency on a generator to produce it.
|
||||
const sbom = {
|
||||
bomFormat: 'CycloneDX',
|
||||
specVersion: '1.5',
|
||||
version: 1,
|
||||
metadata: {
|
||||
component: {
|
||||
type: 'application',
|
||||
name: 'screentinker',
|
||||
version: fs.readFileSync(path.join(__dirname, '..', 'VERSION'), 'utf8').trim(),
|
||||
licenses: [{ license: { id: 'MIT' } }],
|
||||
},
|
||||
properties: [{ name: 'screentinker:scope', value: INCLUDE_DEV ? 'all' : 'production' }],
|
||||
},
|
||||
components: pkgs
|
||||
.filter(p => p.name !== 'screentinker')
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map(p => ({
|
||||
type: 'library',
|
||||
name: p.name,
|
||||
version: p.version,
|
||||
purl: `pkg:npm/${p.name.replace('@', '%40')}@${p.version}`,
|
||||
licenses: p.license ? [{ license: /[()]| OR | AND /.test(p.license) ? { name: p.license } : { id: p.license } }] : [],
|
||||
})),
|
||||
};
|
||||
fs.mkdirSync(path.dirname(SBOM_OUT), { recursive: true });
|
||||
fs.writeFileSync(SBOM_OUT, JSON.stringify(sbom, null, 2));
|
||||
console.log(`\nSBOM: ${SBOM_OUT} (${sbom.components.length} components, CycloneDX 1.5)`);
|
||||
}
|
||||
|
||||
let failed = false;
|
||||
if (denied.length) {
|
||||
failed = true;
|
||||
console.log('\nDENIED');
|
||||
denied.forEach(p => console.log(` ${p.name}@${p.version} -> ${p.license}\n ${p.why}`));
|
||||
}
|
||||
if (unknown.length) {
|
||||
failed = true;
|
||||
console.log('\nUNRESOLVED — no recognised licence. Read the package, then add it to EXCEPTIONS');
|
||||
console.log('with the evidence, or remove the dependency.');
|
||||
unknown.forEach(p => console.log(` ${p.name}@${p.version} -> ${p.license || '(no license field)'}`));
|
||||
}
|
||||
if (review.length) {
|
||||
// Not fatal, but never silent — weak copyleft is a judgement call, and the judgement should be
|
||||
// made by a person who knows it is being made.
|
||||
console.log('\nREVIEW (not failing)');
|
||||
review.forEach(p => console.log(` ${p.name}@${p.version} -> ${p.license} ${p.why}`));
|
||||
}
|
||||
|
||||
console.log(failed ? '\nFAIL: licence policy violated.\n' : '\nOK: no denied or unresolved licences.\n');
|
||||
process.exit(failed ? 1 : 0);
|
||||
5
server/package-lock.json
generated
5
server/package-lock.json
generated
|
|
@ -1,12 +1,13 @@
|
|||
{
|
||||
"name": "screentinker",
|
||||
"version": "1.9.35",
|
||||
"version": "1.9.36",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "screentinker",
|
||||
"version": "1.9.35",
|
||||
"version": "1.9.36",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@azure/msal-node": "^5.2.1",
|
||||
"@jsquash/avif": "^1.3.0",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"name": "screentinker",
|
||||
"version": "1.9.35",
|
||||
"version": "1.9.36",
|
||||
"license": "MIT",
|
||||
"description": "ScreenTinker - Digital Signage Management Server",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -983,7 +983,13 @@ app.use('/api/status', require('./routes/status'));
|
|||
* TELEMETRY_COLLECTOR=1, so a normal self-hosted install exposes neither.
|
||||
*/
|
||||
if (process.env.TELEMETRY_COLLECTOR === '1') {
|
||||
app.use('/api', require('./routes/telemetry-collector')(db));
|
||||
/* `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)');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.35" 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