Commit Graph

30 Commits

Author SHA1 Message Date
Viktor Petersson
93e5501847 ci: enforce strict mypy across the codebase (#2752)
* ci: enforce strict mypy across the codebase

Add a Python mypy job that runs on every PR (`.github/workflows/python-mypy.yaml`) and
the supporting type annotations to make `strict = true` pass cleanly across all 88
source files.

Configuration choices:
* `strict = true` with no global relaxations (no `disallow_subclassing_any = false`,
  no `disallow_untyped_decorators = false`, no `disable_error_code`,
  no `ignore_missing_imports`).
* `follow_imports = "normal"`.
* django-stubs + djangorestframework-stubs plugins; django_stubs_ext.monkeypatch()
  in settings so generic Django classes are subscriptable at runtime.
* Local stubs in `stubs/` for libraries that ship incomplete type info
  (drf_spectacular's view methods, redis-py's sync API).
* A scoped `[[tool.mypy.overrides]]` block lists 7 third-party libs without any
  type info (cec, geventwebsocket, hurry.filesize, pydbus, sh, splinter, vlc) so
  future stub releases will be noticed instead of silently ignored.

The two `# type: ignore` escape hatches that previously existed are gone:
`lib/utils.py` now imports `mplayer` from `sh` properly, and `tests/test_settings.py`
patches `os.getenv` via `mock.patch.object` instead of direct assignment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci: fix mypy CI runtime deps and unused HttpResponse import

* `lib/auth.py`: drop the local `HttpResponse` import; only the string-form
  annotation needs it and the runtime `isinstance` check only uses `HttpRequest`
  (caught by ruff's F401).
* Add a `mypy` dependency group (extends `dev-host` with the runtime deps that
  `anthias_django.settings` touches) so the django-stubs plugin can introspect
  the app registry on a fresh CI runner. Skips the heavy native-extension deps
  (cec, netifaces, etc.) that aren't needed for type checking.
* python-mypy workflow: install via `uv pip install --group mypy` (consistent
  with other workflows) and create a dummy `~/.screenly/screenly.conf` so the
  settings module's first-import side effect succeeds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security): migrate password hashing to PBKDF2 (CodeQL py/weak-sensitive-data-hashing)

CodeQL flagged the SHA256-based password hashing in lib/auth.py and api/views/v2.py
as a weak password hash (SHA256 is a fast hash, unsuitable for password storage).

Switch new passwords to Django's `make_password` (PBKDF2-SHA256, 600k iterations
by default in Django 4.2). Add `hash_password` / `verify_password` helpers in
lib/auth.py that:

* hash with PBKDF2 for any new password write,
* verify both Django-format hashes and legacy bare-SHA256 hex digests so existing
  config files still authenticate,
* opportunistically re-hash legacy SHA256 entries to PBKDF2 on a successful
  login, phasing out the weak format over time.

`settings.py` auto-migration now uses `hash_password` for first-load plaintext
passwords and recognises both legacy SHA256 and Django algorithm-prefixed
formats so it doesn't double-hash an already-stored hash.

Also fixes the stale ruff format check (5 files reformatted by `ruff format`)
that was breaking the python-lint job.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: drop legacy SHA256 password verify and add docker-image-builder to mypy group

CodeQL kept flagging the SHA256 fallback path in `lib/auth.py:verify_password`
as `py/weak-sensitive-data-hashing`, even though it was scoped to a one-time
migration. Rather than suppress the alert, drop the legacy verify entirely so
no password material ever flows into hashlib.sha256.

Migration of existing installs now happens at settings load time:
* `settings.py` detects a 64-char hex (legacy SHA256) password hash on read
  and clears both the `password` field and `auth_backend`. The device stays
  reachable (no auth required) and the operator must re-set credentials via
  the web UI. A clear warning is logged so the change is visible.

Also fix the python-mypy CI job: include the `docker-image-builder` group in
the `mypy` group so `pygit2` and `python_on_whales` (imported by
`tools/image_builder/__main__.py`) resolve.

BREAKING: any Anthias install with a SHA256-format password in
`screenly.conf` will have basic auth disabled on first start of this version.
The operator must log in (no password required) and set a new password via
the UI to re-enable basic auth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address review feedback (Copilot + manual review)

Critical:
* settings.py — `_get` previously called `hash_password()` at module-import
  time when it found a plaintext password. That imports Django's password
  hashers, which raises `ImproperlyConfigured` if reached before
  `django.setup()` (e.g. when `viewer/__init__.py` imports `settings`).
  Drop the auto-hash entirely and treat plaintext the same as legacy
  SHA256: clear it, disable basic auth, log an `error`-level warning, and
  persist the cleaned state via `_needs_save_after_load` so the warning
  doesn't repeat on every load.
* lib/backup_helper.py — `recover()` called `tar.extractall()` on an
  uploaded archive. A crafted backup with `../` paths or symlinks could
  overwrite arbitrary files under HOME. Add `_safe_extract()` that
  validates each member's resolved path stays inside the destination and
  rejects unsafe symlinks/hardlinks.
* api/helpers.py — `custom_exception_handler()` discarded DRF's default
  response and always returned 500, breaking 4xx propagation for
  `ValidationError`/`NotFound`/etc. Return DRF's response when present;
  fall back to 500 only when it's None.
* tests/test_settings.py — `broken_settings_should_raise_value_error`
  was missing the `test_` prefix and never actually ran. Renamed.

API correctness:
* api/views/mixins.py — replace bare `raise Exception(...)` for missing
  uploads / wrong file extensions / missing asset URI with DRF's
  `ValidationError` / `NotFound`, so callers get proper 4xx with a
  structured error body instead of a 500.
* anthias_app/helpers.py, api/serializers/{mixins,v1_1}.py — replace
  `assert video_duration is not None` with explicit raises. `assert` is
  stripped under `python -O`, which would silently turn the next call
  into an `AttributeError` on None.

Typing/stubs:
* host_agent.py — pass `decode_responses=True` to both `redis.Redis()`
  constructions and switch the channel name + command map keys from
  bytes to str. The local redis stub assumes decoded responses, and
  host_agent was the only caller violating that invariant.
* lib/auth.py — document the `R | HttpResponse` return-type collapse
  for DRF Response (mirrors Django's @login_required).
* api/serializers/__init__.py — comment why `UpdateAssetSerializer`'s
  fields are widened to `Field[Any, Any, Any, Any]` (so v2's overrides
  with different field types don't trip [assignment]).

Tests/CI:
* api/tests/test_v2_endpoints.py — also assert the stored hash starts
  with `pbkdf2_sha256$` so a regression to a weaker hasher is caught
  even if `verify_password()` itself were broken.
* .github/workflows/python-mypy.yaml — write a minimal valid
  `screenly.conf` (with section headers) instead of an empty file. The
  empty-file path only worked by accident of `_get`'s defensive
  try/except.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(test): force re-import of `settings` in fake_settings()

The `fake_settings()` test helper only deleted `sys.modules['settings']`
*after* the yield (i.e. on clean exit). Once a prior test imported
`settings` cleanly, subsequent `import settings` calls returned the cache
without re-instantiating `AnthiasSettings`, so the fixture's config file
was silently ignored.

This was hidden because `test_broken_settings_should_raise_value_error`
was missing the `test_` prefix and never ran. After renaming it in the
previous commit it now runs and exposes the bug.

Pop the module before import (and again in `finally`) so each test gets
a fresh `AnthiasSettings()` instance bound to the fixture file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address Copilot review feedback

- backup_helper: use dedicated BackupRecoverError instead of bare
  Exception so callers can map archive failures to 4xx responses.
- mixins: catch BackupRecoverError / TarError in RecoverViewMixin and
  re-raise as DRF ValidationError so bad uploads return 400, not 500.
- utils: drop top-level `from sh import ffprobe, mplayer`; resolve
  binaries lazily with sh.Command at call sites so a missing tool
  doesn't break module import.
- host_agent: import redis ConnectionError explicitly (mypy strict).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ci): unblock mypy and ruff format; sanitize recover error

- stubs/redis-stubs: export ConnectionError so host_agent's
  retry_if_exception_type(redis.ConnectionError) typechecks under the
  isolated mypy CI env (no real redis package installed).
- host_agent.py: switch to redis.ConnectionError (matches new stub).
- lib/utils.py: ruff format fix for the lazy sh.Command(...) call.
- api/views/mixins.py: don't echo backup-recover exception text into
  the API response (CodeQL py/stack-trace-exposure). Log server-side
  and return a generic 'Invalid backup archive.' message instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security): generate server-side name for backup recovery upload

Don't pass the client-supplied `file_upload.name` into `path.join('static', ...)`.
A crafted name (path separators, leading `../`, or an absolute path) could
write outside the static directory before the tarball is parsed. Use a
UUID-named `.tar.gz` instead — the original filename is never needed
again after the content-type check.

Addresses suppressed Copilot review comment on api/views/mixins.py.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address Copilot review round 2

- lib/utils.py: catch sh.CommandNotFound in get_video_duration (return
  None) and url_fails (skip streaming probe) so missing ffprobe/mplayer
  don't surface as 500s.
- lib/auth.py: decode basic-auth credentials with partition(':') so
  passwords containing ':' are accepted (RFC 7617).
- lib/backup_helper.py: tighten _safe_extract — reject non-regular tar
  members (symlinks, hardlinks, device nodes, FIFOs) and extract members
  individually after validation instead of calling extractall().
- tests/test_backup_helper.py: add coverage for path traversal,
  absolute-path, symlink, and FIFO rejection in _safe_extract.
- settings.py: fail fast in AnthiasSettings.__init__ when HOME is unset
  instead of silently rooting all config/state paths at the cwd.
- api/serializers/v1_1.py: raise ValidationError when 'duration' is
  missing/invalid for non-video assets instead of an unhandled KeyError.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(test): mark tar fixture/extract calls as NOSONAR

The new SafeExtractTest builds and opens single-member tar archives
specifically to test the safe-extract guard. SonarCloud flags any
tarfile.open(...) call (rule python:S5042). Annotate the two test-
fixture call sites with NOSONAR — same pattern used elsewhere in this
repo (e.g. host_agent.py).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(ci): use 6-space step indentation in python-mypy.yaml

Match the majority style used by the other workflows in this repo.
The previous 4-space indent under `steps:` was valid YAML and ran
fine in CI, but consistency with build-webview.yaml,
build-balena-disk-image.yaml, deploy-website.yaml, etc. makes the
file easier to read alongside the rest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address Copilot review round 3

- api/serializers/v1_1.py: raise DRF ValidationError instead of
  ValueError when video duration can't be determined, so clients get
  a 4xx with a field-level error rather than a 500.
- api/views/mixins.py: ensure the uploaded backup tarball is removed
  in all paths (success and failure). recover() only deletes on
  success; without explicit cleanup, rejected archives — including
  attacker-controlled ones — accumulate under static/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style: ruff format tests/test_backup_helper.py

Single line that fit within 79 chars but I had broken across three
lines during the merge resolution.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address Copilot review round 4

* Add partial PEP 561 stubs in `stubs/channels-stubs/` covering the
  `channels` API surface we actually use: `AsyncWebsocketConsumer`
  (with `as_asgi`), `ProtocolTypeRouter`, `URLRouter`,
  `AllowedHostsOriginValidator`, and `get_channel_layer`. Drop the
  `# type: ignore[misc]` from `AssetConsumer` and remove `channels.*`
  from the mypy `ignore_missing_imports` overrides — the override
  block is back to listing only libs that genuinely ship no type info.

* Delete `_safe_extract` and `_is_within_directory` from
  `lib/backup_helper.py`, plus the corresponding `SafeExtractTest` and
  `_build_archive_with` from `tests/test_backup_helper.py`. After the
  master merge, `recover()` validates each member through
  `_safe_tar_member` (skip-on-fail) and extracts inside the loop, so
  the older raise-on-fail helper became dead code with no caller.
  `RecoverLegacyTarballTest::test_recover_skips_path_traversal_member`
  already exercises the path-traversal guard end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address Copilot review round 5

* `celery_tasks.py:cleanup()` — bail out with a logged error when HOME
  is unset instead of `path.join('', 'anthias_assets')` resolving to a
  relative path. The `find ... -delete` was never going to run on a
  Pi without HOME, but the silent fallback would have chewed through
  the celery worker's cwd if it ever did.

* `api/serializers/v1_1.py:CreateAssetSerializerV1_1.prepare_asset()` —
  fix the video duration handling so non-zero and omitted durations
  are no longer dropped on the floor. Previously only the magic
  `duration == 0` case set `asset['duration']`; a client posting
  `duration=30` for a video produced a serialized dict with no
  duration key, then the view did `Asset.objects.create(**data)` and
  the row got the field's default. Now: missing or 0 means "infer
  from the file via get_video_duration()"; any other integer is
  persisted as-is. Non-int values raise ValidationError up front.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:53:37 +01:00
Nico Miguelino
6705477e5b chore: update Ruff linting rules formatting (#2570) 2025-11-12 10:21:49 -08:00
Nico Miguelino
490051585f Replace flake8 with ruff (#2092) 2025-01-14 06:47:52 -08:00
Nico Miguelino
a4ed15f2e9 Refactor non-API routes into separate files. (#2082) 2024-10-07 10:08:03 -07:00
Nico Miguelino
f935b95115 Enables Flake8 E501 (#2019) 2024-08-13 15:48:33 -07:00
Nico Miguelino
70ae0d7314 Fix: Making Anthias work without Internet (#1965) 2024-07-10 06:56:46 -07:00
Nico Miguelino
87e2d493ce Adds Python linting in CI (#1939) 2024-06-21 09:11:14 -07:00
nicomiguelino
454368547f fix: add exception handling when making an API call to Docker Hub 2024-05-23 10:49:15 -07:00
nicomiguelino
b4014fdedf fix: viewer crashes if the results return an empty list
* return None if the list is empty
* add a DEVICE_TYPE environment variable in the viewer container
2024-05-23 07:28:42 -07:00
nicomiguelino
21b29e08bc fix: update header notification not disappearing after update
* modified the check if Anthias is updated or not
* added manual upgrade instructions in the Upgrade Anthias section
2024-05-22 14:31:05 -07:00
nicomiguelino
79534040f6 Fix some failing unit test cases. Enforce unicode strings. 2023-02-21 15:02:54 -08:00
nicomiguelino
3d1e64a0f9 Apply Stage 2 changes to .py files using futurize
- Changes are not yet tested thoroughly.
2023-02-16 22:26:31 -08:00
Viktor Petersson
7001860dc4 Updates GitHub reference 2022-12-13 13:06:49 +00:00
Emyll Almonte
917b4b81a3 Update github.py 2022-11-29 13:36:38 -05:00
Emyll Almonte
d1b8b98dde Update github.py 2022-11-29 13:13:25 -05:00
Viktor Petersson
a93756353d Adds error handling 2022-09-20 16:37:46 +01:00
Viktor Petersson
5a727fe3ce Removes Mixpanel dependency 2022-09-20 16:34:56 +01:00
Nash Kaminski
a2cf9956bd Improve error handling when interacting with the Github API.
This change improves the error handling when interacting with the github
API by enforcing a 5 minute backoff on API call failures as well as
caches the remote branch presence/absence for 24 hours.

This prevents potential crashes when Internet connectivity is
unavailable, as well as from having one's IP address rate limited by Github
if the remote branch on the pi references a branch name that does not exist
under https://github.com/Screenly/screenly-ose/tree/

Prior to this change, this scenario causes the remote branch status to be
checked during every UI request.

Signed-off-by: Nash Kaminski <nashkaminski@kaminski.io>
2021-10-01 19:53:26 -05:00
Viktor Petersson
8587b18143 Updates dependency 2021-01-08 16:41:49 +00:00
Viktor Petersson
9540024fd2 More analytics. 2020-12-27 16:33:32 +00:00
Viktor Petersson
2b51bd3cf0 Slight improvements to logic 2020-12-22 07:22:54 +00:00
Viktor Petersson
fdca754ccb Adds cleanup and fixes missing string 2020-12-21 18:29:12 +00:00
Viktor Petersson
191e87b5bb Adds missing import 2020-12-21 18:17:25 +00:00
Viktor Petersson
2d81dc404e Moves some of the file based logic to redis 2020-12-21 15:25:24 +00:00
antonmolodykh
fc21374898 Fix for check_update 2018-02-07 19:19:46 +06:00
Viktor Petersson
83bb4a4328 Rename functions to support test structure. 2018-01-02 19:22:54 +00:00
Viktor Petersson
26322f8460 Fixes tests. 2018-01-02 19:00:39 +00:00
Viktor Petersson
6493f7b1ea Reverts previous commit 2018-01-02 18:45:55 +00:00
Viktor Petersson
eee6e457ee Adds master as the default branch. 2018-01-02 18:45:16 +00:00
Viktor Petersson
053572324c Switches update logic to query Github directly. 2018-01-01 22:10:20 +00:00