* refactor(server): collapse nginx + websocket containers into uvicorn
Replace the nginx + gunicorn + gevent-websocket trio with a single
uvicorn ASGI server inside `anthias-server`:
* HTTP, /static/, /anthias_assets/, /static_with_mime/, and /hotspot
are now served from Django (WhiteNoise + small file-serving views in
`anthias_app/views_files.py` that re-implement nginx's IP allowlists).
* WebSockets move from a separate gevent process talking ZMQ to Django
Channels with a Redis-backed channel layer, fanned out by celery via
`channel_layer.group_send`.
* TLS termination is handled by uvicorn directly when SSL_CERTFILE /
SSL_KEYFILE are set; `bin/enable_ssl.sh` now writes a compose
override (no longer ansible) and a companion `bin/disable_ssl.sh`
removes it. Cert + key live under `~/.anthias/ssl/`.
* `bin/upgrade_containers.sh` removes the legacy `anthias-nginx` and
`anthias-websocket` containers on upgrade so they don't linger.
* Drop `gunicorn`, `gevent`, `gevent-websocket`, and the `websocket`
uv group from `pyproject.toml`; add `channels`, `channels-redis`,
`daphne`, `uvicorn[standard]`, and `whitenoise`.
Notes on hardening: `--forwarded-allow-ips` defaults to off so the IP
allowlist can't be bypassed via a spoofed `X-Forwarded-For`; operators
behind a reverse proxy can opt in via the `FORWARDED_ALLOW_IPS` env
var. Backup uploads previously sized by nginx's `client_max_body_size
4G` are preserved by setting `DATA_UPLOAD_MAX_MEMORY_SIZE = None`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: address review feedback on uvicorn migration
* Drop USE_X_FORWARDED_HOST (inconsistent with the deliberate
--forwarded-allow-ips hardening; without a proxy, X-Forwarded-Host is
client-controlled).
* Remove daphne — uvicorn runs production and the test environment now
uses it too (bin/prepare_test_environment.sh).
* Replace _safe_join's parents-membership check with Path.is_relative_to.
* Drop AllowedHostsOriginValidator wrapper (no-op under ALLOWED_HOSTS=['*'])
and document where to put it back if hosts are ever locked down.
* Rename DOCKER_CIDR → DOCKER_BRIDGE_CIDR with a comment that this is
defense-in-depth, not a real perimeter (LAN clients via the published
port also appear in 172.16/12).
* Add anthias_app/tests.py covering the IP allowlists, mime override,
hotspot gating, and traversal/symlink rejection in _safe_join (17 tests).
* Note the single-worker ZmqPublisher bind constraint in start_server.sh
so a future scale-up doesn't EADDRINUSE on tcp://0.0.0.0:10001.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(security): clear SonarCloud hotspots on uvicorn migration
* Restrict views_files.anthias_assets / static_with_mime / hotspot to
GET via @require_GET (Sonar S3752, x3): they are read-only file
servers and should reject other methods at the view boundary.
* Mark RFC1918 / Docker-bridge CIDR literals as NOSONAR S1313 (x4):
they are intentional, well-known private network ranges.
* Mark `http://*` in CSRF_TRUSTED_ORIGINS as NOSONAR S5332 with a
comment explaining devices ship over HTTP and operators opt into TLS
via bin/enable_ssl.sh.
Existing 17 view tests continue to pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: clear remaining static-analysis findings
* ruff format -- the previous tests.py reformatted itself; CI's
`ruff format --check` now passes.
* CodeQL py/path-injection on _safe_join: rewrite using
os.path.realpath + os.path.commonpath, which CodeQL recognises as a
sanitiser for path-injection sinks. Behaviour is identical to the
Path.is_relative_to version (both reject `..` and symlink escapes;
the 17 tests in anthias_app/tests.py still pass).
* SonarCloud NOSONAR markers: switch to the codebase's bare `# NOSONAR`
form (matches host_agent.py and tests/test_backup_helper.py); the
earlier `# NOSONAR <rule>` form was not being honoured.
* Centralise the test-fixture IPs in module-level constants so S1313
is suppressed in one place rather than at every callsite.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(security): inline path-injection check in views
CodeQL only treats os.path.commonpath as a sanitiser when the check
sits in the same function as the file-system sink — calling
_safe_join() from a separate function still leaves the open()/isfile()
sinks tainted (4 alerts on PR #2757).
Repeat the realpath + commonpath check inline in anthias_assets and
static_with_mime so CodeQL can prove the post-check path stays under
the configured root. _safe_join is kept for the SafeJoinTest unit
tests and as a documented helper.
Existing 17 tests in anthias_app/tests.py continue to pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(security): use realpath+startswith path sanitiser for CodeQL
CodeQL's path-injection model recognises the canonical
`realpath(...).startswith(base + sep)` pattern but apparently not
`os.path.commonpath(...) == root` in this codepath. Switch the inline
check in anthias_assets and static_with_mime to startswith so the
analyser can prove the post-check path stays under the configured
root.
Behaviour is identical: traversal and symlink-escape still 404
(verified by SafeJoinTest + view tests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: address Copilot review feedback
* lib/utils.py imported channels/asgiref at module level. The viewer
container imports lib.utils via viewer/__init__.py but its uv
dependency group does not ship channels, so the viewer would
ImportError on startup. Move the channels imports into
YoutubeDownloadThread.run() (server/celery-only path) so lib.utils
remains importable from the viewer.
* Drop the unused _safe_join() helper and its three SafeJoinTest
cases — the views inline a realpath+startswith sanitiser (CodeQL
needs the check in the same function as the sink), and the helper
was only being exercised in isolation. Add an equivalent
symlink-escape test against anthias_assets so the actual code path
used by the views is covered.
* Refresh the anthias_django/settings.py docstring + Django doc URLs
from /3.2/ → /4.2/ to match the pinned Django version.
15 view tests pass (was 17 — lost 3 SafeJoinTest + gained 1 symlink
test against the real view).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: refresh architecture diagram for uvicorn migration
Drop the anthias-nginx and anthias-websocket nodes (and their edges)
from docs/d2/anthias-diagram-overview.d2 — the user now talks
directly to anthias-server (uvicorn handling HTTP + /ws), Celery
fans out asset-update events through the Redis-backed Channels
layer, and the viewer fetches media from anthias-server over HTTP.
Regenerate the SVG with d2 v0.7.1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: address Copilot SSL + CSRF / WS-origin feedback
* Dual uvicorn listeners when SSL is enabled (Copilot #1, #2). HTTP on
$HTTP_PORT (default 8080) for inter-container traffic — viewer +
webview hit anthias-server over plain HTTP on the Docker network and
cannot validate uvicorn's self-signed cert. HTTPS on $HTTPS_PORT
(default 8443) for external clients. bin/enable_ssl.sh now appends
443:8443 to the compose ports list (instead of using `!override` to
swap 80:8080 for 443:8080), so port 80 stays available for backward
compatibility and the Docker-network HTTP port keeps working.
* Drop CSRF_TRUSTED_ORIGINS = ['http://*', 'https://*'] (Copilot #3).
Verified via Django shell: those leading wildcards are ignored by
Django 4.2 (only subdomain wildcards like https://*.example.com are
honoured), so the setting was a no-op. Same-origin POSTs still pass
through Django's built-in Origin/Host check.
* Re-add channels.security.websocket.AllowedHostsOriginValidator to
the WebSocket router (Copilot #5). Currently a no-op under
ALLOWED_HOSTS=['*'], but tightening ALLOWED_HOSTS later will now
also tighten /ws.
Smoke test (dev + SSL override):
- HTTP http://localhost:8000/ -> 200
- HTTPS https://localhost:8443/ -> 200
- HTTP http://localhost:8443/ -> 000 (TLS-only, expected)
- internal http://localhost:8080/ -> 200
- 15 view tests still pass.
Note: Copilot #4 (Docker-bridge CIDR is bypassable via the published
port) is documented in views_files.py as defense-in-depth and matches
the original nginx posture; switching to app-layer auth is out of
scope for this PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(ssl): switch from in-uvicorn TLS to a Caddy sidecar
The previous SSL implementation gave anthias-server two uvicorn
listeners (HTTP + HTTPS) so the viewer/webview could keep talking
plain HTTP over the Docker network while external clients got TLS.
That dual-listener dance is non-zero overhead and complicates signal
handling. Switch to the standard reverse-proxy pattern instead.
When SSL is enabled by bin/enable_ssl.sh:
* anthias-server stays a single uvicorn listener on plain HTTP 8080
(no SSL_CERTFILE/SSL_KEYFILE knobs, no dual-port logic).
* A Caddy sidecar (caddy:2-alpine, only present when the override is
installed) terminates TLS on host port 443, redirects 80→443, and
reverse-proxies to anthias-server:8080 — so X-Forwarded-Proto /
X-Forwarded-For are forwarded as-is by Caddy.
* The override removes anthias-server's external port mapping
(`ports: !override []`), so all external traffic must enter through
Caddy and the IP allowlists in views_files.py see the original LAN
client IP rather than the docker-bridge gateway. Inter-container
traffic is unchanged.
* `FORWARDED_ALLOW_IPS=*` is set on anthias-server in the override —
safe because anthias-server is no longer reachable from outside the
Docker network — and `SECURE_PROXY_SSL_HEADER` is added in Django
settings so request.is_secure() returns True for HTTPS callers.
* When SSL is *not* enabled there is zero new container, zero new
config — the base compose file is untouched and Caddy isn't pulled
or run.
bin/disable_ssl.sh now also removes the anthias-caddy container
before deleting the override, so HTTPS-only state is fully reversed.
Smoke-tested with a temporary Caddy override:
- HTTPS via Caddy: 200
- HTTP via Caddy: 301 → https://...
- Direct anthias-server: refused (port mapping dropped by override)
- WebSocket upgrade: 101 Switching Protocols
- request.is_secure() with X-Forwarded-Proto=https: True
- 15 anthias_app view tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(views_files): document IP-allowlist threat model
Spell out exactly when the docker-bridge CIDR check is and isn't a
real perimeter:
* No-SSL default: anthias-server is published as 80:8080, so requests
arrive with REMOTE_ADDR set to the docker bridge gateway (172.x) and
LAN clients aren't actually excluded. Trying to plug the gap with
auth would be security theatre — credentials would travel in
plaintext over the LAN anyway.
* SSL via the Caddy sidecar: Caddy terminates TLS, rewrites
X-Forwarded-For, uvicorn honours it (FORWARDED_ALLOW_IPS=*), and the
check sees the real client IP — so the bypass is closed for any
deployment that actually cares about confidentiality.
This is documentation only; no behavioural change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(ssl): add --domain (auto Let's Encrypt) + drop openssl shim
bin/enable_ssl.sh now has three modes instead of two:
* Default (no args) — Caddy issues per-SNI certs lazily from its
built-in local CA via `tls internal { on_demand }`. Drops the
openssl self-signed-cert generation step entirely; Caddy persists
the CA in the anthias-caddy-data volume and rotates leaf certs
itself. Browsers still warn (CA is local) but no openssl/cert
hygiene is needed on the host.
* `--domain example.com [--email you@example.com] [--staging]` —
Caddy auto-issues + renews from Let's Encrypt. Caddy auto-creates
the HTTP→HTTPS redirect for hostname sites. Use `--staging` to point
at the ACME staging endpoint while testing, so the production rate
limits aren't burned.
* `--cert /path/to/cert.pem --key /path/to/key.pem [--domain ...]` —
unchanged: bring your own cert, Caddy serves it as-is with
`auto_https off`.
Verified:
- All three Caddyfiles pass `caddy validate`.
- Default mode end-to-end: HTTPS=200 with cert from "Caddy Local
Authority - ECC Intermediate", per-SNI SANs (DNS:localhost,
IP Address:192.168.99.99 etc.), HTTP→HTTPS=301, /ws upgrade=101,
anthias-server's external port mapping is dropped so direct access
is refused.
Docs (CLAUDE.md, docs/README.md, docs/developer-documentation.md)
updated to describe the Caddy sidecar instead of in-uvicorn TLS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: address self-review findings on PR #2757
* Gate SECURE_PROXY_SSL_HEADER on FORWARDED_ALLOW_IPS
(anthias_django/settings.py): without the gate, a client on a
plain-HTTP deploy could send `X-Forwarded-Proto: https` and flip
`request.is_secure()`. Django reads the header from META directly,
independent of uvicorn's --proxy-headers flag, so the previous
unconditional setting was actually exploitable in non-SSL mode
(secure-cookied sessions would drop on the next plain-HTTP request,
redirects would point at https:// URLs that don't exist).
Verified live: non-SSL → SECURE_PROXY_SSL_HEADER is None and
is_secure() with spoofed XFP=https returns False; SSL via Caddy
override → header is set and is_secure() returns True.
* Replace the isfile() pre-check + open() in anthias_assets and
static_with_mime with a try/except FileNotFoundError around open()
(anthias_app/views_files.py). Eliminates a (tiny but real) TOCTOU
window between the stat and the open. IsADirectoryError handled
too, since `realpath('/dir/')` resolves to the directory and open()
would otherwise 500.
* Comment FORWARDED_ALLOW_IPS=* assumption in bin/enable_ssl.sh: the
wildcard is only safe because the override drops anthias-server's
external port mapping, so any future edit that re-adds a host:port
publication has to either tighten the wildcard to Caddy's IP/CIDR
or unset it.
* Replace ANSI-C escape sequences in the Caddyfile generator with
plain multi-line strings. `read -r -d ''` was the first attempt
but it strips trailing newlines, which collapsed `auto_https off`
onto the same line as `}` in cert mode. Multi-line literals with
echo "$VAR" are unambiguous and Caddy validates all three modes
cleanly again.
* Add a docker-volume cleanup hint to bin/disable_ssl.sh: Caddy's
local CA persists in anthias_anthias-caddy-data so an enable →
disable → enable cycle reuses the same CA (intentional — browsers
that trusted it stay trusted), and operators who want a fresh CA
now have the exact `docker volume rm` command in the script's
output.
15 view tests still pass; default + SSL Caddyfiles still validate;
default + SSL endpoints still return 200 / 301 / 101 in smoke tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: address Copilot's host/MIME hardening feedback
Two security tightenings on top of the prior SECURE_PROXY_SSL_HEADER
gate (which Copilot flagged on a stale snapshot — that one's already
fixed in 07b784b9):
* `ALLOWED_HOSTS` is now driven by the `ALLOWED_HOSTS` env var, with
`*` kept as the default so flexible LAN-by-IP / mDNS access still
works out of the box. Operators on hardened LANs can opt into a
strict allowlist (`ALLOWED_HOSTS=192.168.1.50,anthias.local,...`)
to defend against DNS-rebinding without us guessing the right set
of hostnames at install time. Verified the env override parses to
`['192.168.1.50', 'anthias.local', 'localhost']`.
* `static_with_mime` now allowlists the `?mime=` query param against
a small set of download-only types
(`application/{gzip,octet-stream,x-gzip,x-tar,x-tgz,zip}`) instead
of accepting whatever the caller sends. Closes the XSS footgun
where `?mime=text/html` would have served a stored file as HTML.
The frontend's only legitimate caller (the backup download) sends
`application/x-tgz`, which is in the allowlist; anything else
falls back to mimetypes.guess_type. Added
`test_mime_override_rejects_html` to lock that behaviour in.
16 view tests pass; ruff clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor: rename legacy 'screenly' dirs to 'anthias' with auto-migration
For legacy reasons the host directories storing the cloned repo, user
assets, and config + DB still carried the old 'screenly' name. Rename
all three to their 'anthias' equivalents, plus the in-container paths,
the screenly.db / screenly.conf filenames, /tmp/screenly.watchdog,
/etc/sudoers.d/screenly_overrides, the ansible role, and the nginx URL
location. Existing installations are migrated automatically:
~/screenly/ -> ~/anthias/
~/screenly_assets/ -> ~/anthias_assets/
~/.screenly/ -> ~/.anthias/
screenly.db -> anthias.db
screenly.conf -> anthias.conf (paths rewritten in the body)
/etc/sudoers.d/screenly_overrides -> /etc/sudoers.d/anthias_overrides
Migration is driven by two new helpers:
- bin/migrate_legacy_paths.sh: idempotent host-side rename. Self-relocates
if invoked from inside the dir being renamed. Rewrites both relative
and absolute path values inside screenly.conf. Leaves dir-level
back-compat symlinks at the old paths and file-level symlinks
(screenly.db, screenly.conf) inside the migrated config dir so
user automation / one-version downgrade still find familiar names.
- bin/migrate_in_container_paths.sh: defensive /data/.screenly and
/data/screenly_assets symlinks invoked from the container start
scripts, in case an older docker-compose.yml is still mounting the
legacy paths during a partial upgrade.
Wired into bin/install.sh (renames ~/screenly before clone_repo, then
runs the in-repo helper after) and bin/upgrade_containers.sh (runs the
helper near the top before regenerating docker-compose.yml).
Out of scope (intentional): the screenly/anthias-* Docker Hub namespace,
the Screenly/Anthias GitHub repo URLs, the screenly_ose Balena fleet,
api.screenlyapp.com / apt.screenlyapp.com legacy URLs, and brand URLs
in docs.
Tests: added tests/test_migrate_legacy_paths.py (4 cases: full migration,
absolute-path conf rewrite, idempotent rerun, fresh-install no-op) and
tests/test_backup_helper.py::RecoverLegacyTarballTest (recover() still
accepts pre-rename .tar.gz backups). Ruff clean. All 6 new tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* style: apply ruff format to new test files
CI's `ruff format --check` flagged tests/test_backup_helper.py and
tests/test_migrate_legacy_paths.py. Reformatted; behaviour unchanged,
6/6 migration-related tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: suppress SonarCloud S5042 on write-mode tarfile.open in fixtures
The two new fixture-building calls in tests/test_backup_helper.py use
`tarfile.open(..., 'w:gz')` (write mode), which Sonar's python:S5042
rule flags as "expanding this archive file" without distinguishing
read from write. arcnames are hardcoded test inputs with no
path-traversal surface, so the warning is a false positive here.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: address Copilot review feedback
- lib/backup_helper.py: harden recover() against tar path traversal
(Zip Slip / CVE-2007-4559). New _safe_tar_member() rejects absolute
paths, '..' components, non-regular-non-directory members
(symlinks/hardlinks/devices), members outside the allowed top-level
dirs, and any post-normalisation path that escapes $HOME. Iterates
members manually instead of bulk extractall(), and passes
filter='data' on Python with PEP-706 extraction filters
(3.11.4+/3.12+) for belt-and-suspenders defence.
- tests/test_backup_helper.py: BackupHelperTest now patches HOME to a
per-test tmpdir so `tearDown` no longer rmtree's a real ~/anthias
checkout when run on a developer workstation. Also added
test_recover_skips_path_traversal_member, which proves a hostile
tarball entry like `../evil.txt` is logged-and-skipped, not written
outside $HOME.
- docs/raspberry-pi5-ssd-install-instructions.md: capitalise "This"
after the period.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: add missing leading slash to repo dir heading
The heading for the cloned repo dir was rendered as
`home/${USER}/anthias/`, while every other heading in the section uses
absolute paths like `/home/${USER}/.anthias/`. Same fix applied to the
legacy-path mention in the note below it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(deps): manage Python deps via uv dependency-groups
Replaces the six service-scoped requirements*.txt files with
PEP 735 dependency-groups in pyproject.toml and rebuilds every
Docker image as a two-stage build: a uv-builder stage (using the
official ghcr.io/astral-sh/uv image, with a pip fallback for
armv6) produces /venv via `uv sync --group <svc>`, which the
runtime stage copies in. uv.lock becomes authoritative for all
services. requirements/requirements.host.txt is kept as a
committed, auto-generated artifact (`uv export --group host`) so
bin/install.sh and the Ansible role keep working; a python-lint
CI step enforces it stays in sync.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(deps): bump Django, cryptography, pyOpenSSL, and 5 others
- Django 4.2.29 → 4.2.30 (latest 4.2 LTS)
- cryptography 3.3.2 → 46.0.7 (capped by pyOpenSSL 26's `cryptography<47`;
cryptography 47 is incompatible with the latest pyOpenSSL)
- pyOpenSSL 19.1.0 → 26.0.0 (required by newer cryptography ABI —
pyOpenSSL 19 crashed at import against cryptography ≥ ~3.4)
- requests 2.32.5 → 2.33.1 (aligned across every group, including
docker-image-builder and local)
- pyasn1 0.6.2 → 0.6.3
- redis 7.1.0 → 7.4.0
- Cython 3.2.3 → 3.2.4
- sh 1.8 → 2.2.2 (major bump; usages in celery_tasks.py, bin/wait.py,
lib/utils.py stick to the stable `sh.<cmd>` + `sh.ErrorReturnCode_N`
API — verified still works)
- python-vlc 3.0.20123 → 3.0.21203
`mako` and `flatted` were requested but skipped: `mako` was already
removed from the project (9535745e), and `flatted` is an npm dep in
`package-lock.json`, not a Python dep.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(deps): bump wheel from 0.38.1 to 0.46.2
Closes Dependabot PR #2651.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(deps): manage Python deps via uv dependency-groups
Replaces the six service-scoped requirements*.txt files with
PEP 735 dependency-groups in pyproject.toml and rebuilds every
Docker image as a two-stage build: a uv-builder stage (using the
official ghcr.io/astral-sh/uv image, with a pip fallback for
armv6) produces /venv via `uv sync --group <svc>`, which the
runtime stage copies in. uv.lock becomes authoritative for all
services. requirements/requirements.host.txt is kept as a
committed, auto-generated artifact (`uv export --group host`) so
bin/install.sh and the Ansible role keep working; a python-lint
CI step enforces it stays in sync.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(deps): bump Django, cryptography, pyOpenSSL, and 5 others
- Django 4.2.29 → 4.2.30 (latest 4.2 LTS)
- cryptography 3.3.2 → 46.0.7 (capped by pyOpenSSL 26's `cryptography<47`;
cryptography 47 is incompatible with the latest pyOpenSSL)
- pyOpenSSL 19.1.0 → 26.0.0 (required by newer cryptography ABI —
pyOpenSSL 19 crashed at import against cryptography ≥ ~3.4)
- requests 2.32.5 → 2.33.1 (aligned across every group, including
docker-image-builder and local)
- pyasn1 0.6.2 → 0.6.3
- redis 7.1.0 → 7.4.0
- Cython 3.2.3 → 3.2.4
- sh 1.8 → 2.2.2 (major bump; usages in celery_tasks.py, bin/wait.py,
lib/utils.py stick to the stable `sh.<cmd>` + `sh.ErrorReturnCode_N`
API — verified still works)
- python-vlc 3.0.20123 → 3.0.21203
`mako` and `flatted` were requested but skipped: `mako` was already
removed from the project (9535745e), and `flatted` is an npm dep in
`package-lock.json`, not a Python dep.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(deps): bump wheel from 0.38.1 to 0.46.2
Closes Dependabot PR #2651.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: adapt sh 2.x API changes in wait.py and viewer
Two real breakages uncovered by auditing every `sh.*` call site
against the sh 1.x → 2.x API:
- bin/wait.py: `sh.grep(sh.route(), 'default')` no longer pipes
in sh 2.x — the inner command stringifies to its stdout and
becomes a literal argument to grep, producing
`grep '<route_output>' default` and an ErrorReturnCode_2. Use
the idiomatic `sh.grep('default', _in=sh.route())` instead.
- viewer/__init__.py: `browser.process.alive` is gone in sh 2.x
(`OProc` no longer exposes it). Use `browser.process.is_alive()[0]`,
which returns the `(alive_bool, exit_code)` tuple.
Plus two review nits:
- Add trailing newline to docs/migrating-assets-to-screenly.md
- Use `diff -u` in the requirements.host.txt CI drift check so
failures print a readable unified diff.
Verified against sh==2.2.2 inside the rebuilt server image:
- `sh.grep('default', _in=sh.echo('…'))` pipes correctly
- `cmd.process.is_alive()` → `(True, None)` while running,
`(False, 0)` after wait()
- `cmd.process.stdout.decode('utf-8')` still works on `_bg=True`
processes
83/83 unit tests + 12/12 integration tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(docker): serialize apt cache access with sharing=locked
The multi-stage uv-builder + runtime layout means two RUN steps can
race on BuildKit's shared `/var/cache/apt` cache mount. apt requires
an exclusive lock on /var/cache/apt/archives, so a concurrent
apt-get in the sibling stage causes the build to fail with
`E: Could not get lock /var/cache/apt/archives/lock`.
BuildKit's default cache mount sharing mode is `shared` (unrestricted
concurrent access). Switching to `sharing=locked` makes BuildKit
serialize access across stages, matching apt's locking model.
Discovered while cross-compiling `pi4-64` under QEMU, where the
slower emulated apt-get in stage 1 overlapped with the host-speed
apt-get in stage 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci: fix ansible-lint and sbom workflows
**ansible-lint** (broken since 2026-04-08, #2732):
- `ansible-community/ansible-lint-action@main` repo is gone (404),
so every run failed with "Unable to resolve action".
- Rewrite the workflow to use setup-uv + `uv run ansible-lint` from
a new `ansible-lint==26.4.0` entry in the `dev-host` dependency
group — matches the uv-based pattern already used by
`python-lint.yaml`.
- Add `.ansible-lint` config with a skip list covering 19
pre-existing violations in `ansible/` roles
(`var-naming[no-role-prefix]`, `risky-shell-pipe`, `no-free-form`)
so the workflow can go green today; follow-up PRs should drive
the skip list down.
- Extend the path triggers to fire on config, workflow, and lock
changes — not just `ansible/**`.
**sbom** (broken since 2026-04-02):
- The `sbomify/github-action` renamed `SBOM_FILE` to `LOCK_FILE` for
lockfile inputs. Every run has been failing with "`uv.lock` is a
lock file, not an SBOM. Please use LOCK_FILE instead of SBOM_FILE."
- Rename both `SBOM_FILE` envs (`package-lock.json` and `uv.lock`)
to `LOCK_FILE`.
Verified locally: `uv run ansible-lint ansible/` passes (0
failures, 0 warnings).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(build): replace webpack, npm, and jest with bun
Collapses the JS toolchain to a single tool. Bun handles installs
(replacing npm), bundling via `bun build` + `sass` CLI (replacing
webpack + ts-loader + babel + mini-css-extract-plugin), and testing
via `bun test` (replacing jest + ts-jest + jest-fixed-jsdom). Dev/test
Dockerfiles pull the bun binary from the official `oven/bun` image via
`COPY --from=`; production uses `oven/bun` as a builder stage.
Removes 18 devDependencies and 5 config files; adds only `bunfig.toml`
and `@happy-dom/global-registrator`.
Drive-by fix: `FormData` was imported as a value from `@/types` in
two files but is a type-only interface shadowing the browser global.
Webpack+ts-loader silently erased it; Bun's bundler surfaced the bug.
Converted to `import type`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(docker): symlink bunx to bun in dev and test images
`bunx` is a symlink to `bun` in the official `oven/bun` image, so the
single-file `COPY --from=oven/bun:...-slim /usr/local/bin/bun` missed it.
Result: `bun run dev:css` / `bun run build:css` failed with
`bunx: command not found` inside dev and test containers.
Recreate the symlink after the copy. Production is unaffected because
its builder stage uses `FROM oven/bun` (bunx already present).
Caught by full end-to-end build verification.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci: SHA-pin all external GitHub Actions
Addresses SonarCloud rule githubactions:S7637 ("Use full commit SHA
hash for this dependency") and brings the repo in line with the
hardened CI guidance from OpenSSF, CISA, and GitHub itself: tag refs
like @v7 or @master are mutable and can be retargeted by the action
owner or via compromise. Pinning to a full commit SHA removes that
supply-chain risk.
Every `uses:` reference to an external action across all 13 workflow
files is now pinned by SHA, with the original tag preserved as an
inline comment so the intent remains readable:
uses: actions/checkout@de0fac2e45 # v6
Dependabot's github-actions ecosystem (already configured in
.github/dependabot.yml) recognises this `<SHA> # <tag>` format and
will update both the SHA and the comment together on future version
bumps, so we don't lose automated update coverage.
Scope: 21 distinct external actions × 73 total use sites across
ansible-lint, build-balena-disk-image, build-webview, codeql-analysis,
deploy-website, docker-build, generate-openapi-schema, javascript-lint,
lint-workflows, python-lint, sbom, and test-runner. Local workflow
references (./.github/workflows/...) left untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs,chore: address review feedback on bun migration
- Update CLAUDE.md and docs/developer-documentation.md to replace
npm/webpack/jest references with bun equivalents. The old webpack
ProvidePlugin bullet was superseded by tsconfig's react-jsx runtime;
restate that.
- Add comments in setupTests.ts explaining (1) why Bun's native fetch
is stashed and restored around happy-dom's GlobalRegistrator (so MSW
can intercept) and (2) why testing-library is imported dynamically
after registration (so `screen` binds to a live document.body).
- Narrow the production builder SCSS COPY back to `*.scss` and drop
the unused `bunfig.toml` copy (it's only consumed by `bun test`).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(dev): fail-fast when a watcher crashes in `bun run dev`
`wait` without arguments returns the last-exiting job's status, so a
crashing JS or CSS watcher could leave the script reporting success.
Track each watcher's PID, use `wait -n` to exit on the first failure,
and kill the survivor via a trap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* upgrade containers from using Buster to Bookworm
* replace OMX with VLC
* update the Qt version, webview hash, and the webview download URL
* enable FKMS for Raspberry Pi 1, 2, 3 and 4 devices
* cleanup upgrade (via web UI) code
* rename `screenly.scss` and `screenly.css` files to `anthias.scss`
and `anthias.css`, respectively
* install Node.js dependencies for transpiling SASS files
* add NPM scripts for compiling SASS and CoffeeScript in development mode