1
0
Fork
You've already forked endurain
0

fix(strava): import activities without streams (e.g. manual entries) #2

Closed
emon wants to merge 55 commits from fix/strava-import-no-streams into master
pull from: fix/strava-import-no-streams
merge into: emon:master
emon:master
emon:fix/health-sleep-decimal-columns
emon:fix/idp-sync-mfa-enabled-readonly
emon:fix/restore-admin-user-idp-endpoints
emon:fix/sleep-tooltip-local-timezone
emon:fix/fit-numeric-manufacturer-import
emon:fix/activity-graph-locale-refresh
emon:fix/desktop-activity-graph-stats
emon:fix/csp-forward-auth-connect-src
emon:docs/nginx-example-fix
emon:feat/training-context-consolidated
emon:feat/training-context-readiness-decimal-fix
emon:feat/training-context-readiness-advice-ui
emon:feat/training-context-readiness-v2
emon:feat/training-context-readiness-ui
emon:feat/garmin-hrv-numeric
emon:feat/training-context-readiness
emon:feat/training-context-all-sports-load
emon:feat/training-context-sleep-signal
emon:feat/training-context-hr-coverage-flag
emon:feat/training-context-load-v2-ui
emon:feat/training-context-load-v2
emon:feat/training-context-history-ui
emon:feat/training-context-history
emon:feat/training-context-ui
emon:feat/training-context-fallback-load
emon:feat/training-context-activity-signal
emon:docs/training-context
emon:feat/training-context-skeleton
emon:renovate/non-major-dependency-updates
emon:chore/backend_unit_tests_coverage
emon:fix/641
emon:chore/backend_unit_tests_fixing
emon:ci/backend_tests
emon:chore/frontend_lint_and_formatting
emon:chore/backend_lint_and_formatting
emon:fix/issue624
emon:ci/separate-lint-workflows
emon:ci/add-format-and-lint-workflow
emon:refactor/pr-12-issue-625
emon:0.18.X
emon:l10n/weblate
emon:fix_redirect_custom_uri_scheme
emon:LBRNZ/onelapfit-integration
emon:julienheinen/Create-course
emon:xgaia/feat/gear-widget
emon:schreibubi/AscentDescent
emon:maxdd/master
emon:MateoGreil/feature/heatmap
emon:marcdjulien/add-intraday-health-metrics
emon:marshalleq/master
emon:smorar/feature/segments
emon:smorar/bug-326
Owner
Copy link

What does this PR do?

Fixes a crash when importing Strava activities that have no streams.

Activities added manually (e.g. entered in Apple Health and synced to Strava) carry no stream data, so Strava returns 404 Not Found on the streams endpoint. fetch_and_process_activity_streams caught that as a generic error and raised 424 Failed Dependency, which failed the whole import.

The handler now treats ObjectNotFound as "no streams" and returns empty stream data, so the activity imports without map/heart rate/power/etc., which is the expected behaviour. Non-404 stream errors still fail the import as before.

Closes #705

Type of change

  • Bug fix

Checklist

  • This PR is focused on a single concern
  • The PR is under ~300 lines changed
  • Tests have been added or updated where applicable
  • Commit messages follow Conventional Commits format

Notes for the reviewer

Repro from the issue: a manual Apple Health activity (no GPS/HR/power) 404s on GET /activities/{id}/streams, logged as Error fetching Strava activity streams ...: Not Found. Downstream already handles empty streams: stream entries with is_set=False are skipped on save, is_lat_lon_set=False leaves the timezone unset, and fetch_and_process_activity_laps already returns None on its own 404. Added tests/strava/test_activity_utils.py covering the no-streams path and that other errors still raise.

## What does this PR do? Fixes a crash when importing Strava activities that have no streams. Activities added manually (e.g. entered in Apple Health and synced to Strava) carry no stream data, so Strava returns `404 Not Found` on the streams endpoint. `fetch_and_process_activity_streams` caught that as a generic error and raised `424 Failed Dependency`, which failed the whole import. The handler now treats `ObjectNotFound` as "no streams" and returns empty stream data, so the activity imports without map/heart rate/power/etc., which is the expected behaviour. Non-404 stream errors still fail the import as before. ## Related issue Closes #705 ## Type of change - [x] Bug fix ## Checklist - [x] This PR is focused on a single concern - [x] The PR is under ~300 lines changed - [x] Tests have been added or updated where applicable - [x] Commit messages follow Conventional Commits format ## Notes for the reviewer Repro from the issue: a manual Apple Health activity (no GPS/HR/power) 404s on `GET /activities/{id}/streams`, logged as `Error fetching Strava activity streams ...: Not Found`. Downstream already handles empty streams: stream entries with `is_set=False` are skipped on save, `is_lat_lon_set=False` leaves the timezone unset, and `fetch_and_process_activity_laps` already returns `None` on its own 404. Added `tests/strava/test_activity_utils.py` covering the no-streams path and that other errors still raise.
## What does this PR do?
Adds a dedicated Forgejo workflow to run backend unit tests on pull requests targeting master.
The workflow:
- Triggers on pull_request to master
- Runs on codeberg-small
- Sets up Python 3.13
- Installs uv
- Installs backend dependencies with uv sync --frozen
- Runs pytest with coverage using: `uv run pytest app tests --cov=app --cov-fail-under=45 -v`
This keeps testing separate from linting and helps catch regressions before merge.
## Related issue
Closes #647
## Type of change
Check all that apply
- [ ] Bug fix
- [x] New feature (exception during freeze; link prior discussion/acknowledgment)
- [ ] Documentation
- [x] Refactor / code quality
- [ ] Translation
- [x] Other (describe below)
Other: CI workflow improvement (backend automated test gate for PRs)
## Checklist
- [x] This PR is focused on a single concern
- [x] An issue was opened and acknowledged before writing this code (if not a small fix)
- [x] If this PR introduces a new feature during freeze, it was discussed and explicitly acknowledged first
- [x] The PR is under ~300 lines changed, excluding lock files, migrations, and generated code
- [x] Tests have been added or updated where applicable
- [x] Documentation has been updated where applicable
- [x] Commit messages follow Conventional Commits format
## Notes for the reviewer
- Coverage threshold is intentionally set to `45` in CI to match current practical coverage and allow incremental improvement.
- This does not change backend test configuration in `pyproject.toml`, it only adds a PR gate workflow.
Co-authored-by: hugobatista <hugobatista@noreply.codeberg.org>
Reviewed-on: endurain-project/endurain#653 
## What does this PR do?
Adds a user-facing configuration error screen when the frontend cannot load public server settings because `ENDURAIN_HOST` is missing, invalid, or points to a different origin than the current page.
Instead of failing silently and leaving the user on a blank page, the app now detects the mismatch during startup, stores a configuration error state, and renders a clear message telling the user to verify `ENDURAIN_HOST` and restart the container.
## Feature freeze note
This PR is a bug fix and improves first-run/debugging behavior without adding a new feature surface.
## Related issue
Closes #616
## Type of change
Check all that apply
- [x] Bug fix
- [ ] New feature (exception during freeze; link prior discussion/acknowledgment)
- [ ] Documentation
- [ ] Refactor / code quality
- [ ] Translation
- [ ] Other (describe below)
## Checklist
- [x] This PR is focused on a single concern
- [x] An issue was opened and acknowledged before writing this code (if not a small fix)
- [ ] If this PR introduces a new feature during freeze, it was discussed and explicitly acknowledged first
- [x] The PR is under ~300 lines changed, excluding lock files, migrations, and generated code
- [ ] Tests have been added or updated where applicable
- [ ] Documentation has been updated where applicable
- [ ] Commit messages follow Conventional Commits format
## Notes for the reviewer
 ![Screenshot 2026年06月05日 at 12.10.00](/attachments/a278b9ca-ba1f-4ffc-ae23-0a29d1d76eed)
Reviewed-on: endurain-project/endurain#663 
## What does this PR do?
Fixes gear matching failure during Strava bulk import when CSV fields
(brand, model, nickname) contain trailing or leading whitespace, and
prevents TypeError crashes when brand/model are None.
Three changes:
1. `create_gear_dictionary_for_bulk_import()` — smoosh key now uses
 `" ".join()` with `.strip()` and `or ""` guards instead of `+`
 concatenation (fixes whitespace mismatch + None crash)
2. `transform_csv_bike_gear_to_schema_gear()` — strip Brand/Model/nickname
3. `transform_csv_shoe_gear_to_schema_gear()` — strip Brand/Model/nickname
## Feature freeze note
This is a bug fix with matching tests — fits the freeze criteria.
## Related issue
Closes #657
## Type of change
- [x] Bug fix
## Checklist
- [x] This PR is focused on a single concern
- [x] An issue was opened and acknowledged before writing this code (if not a small fix)
- [x] If this PR introduces a new feature during freeze, it was discussed and explicitly acknowledged first
- [x] The PR is under ~300 lines changed, excluding lock files, migrations, and generated code
- [x] Tests have been added or updated where applicable
- [ ] Documentation has been updated where applicable
- [x] Commit messages follow Conventional Commits format
## Notes for the reviewer
The lookup side at `bulk_import_utils.py:295` already does
`activity_gear.replace("+", " ").strip()` — the fix makes the
key construction side equally robust. 14 new unit tests cover
whitespace, None fields, and edge cases.
Reviewed-on: endurain-project/endurain#668 
## What does this PR do?
This PR adds support for hostnames in TRUSTED_PROXIES by:
- Adding strict hostname syntax validation for TRUSTED_PROXIES entries so malformed values are rejected at startup
- Keeping request-time proxy trust checks read-only to avoid synchronous DNS work on the API request path
- Scheduling trusted proxy hostname refresh as a recurring background job
- Updating and expanding tests to cover hostname validation and refresh behavior
- Keeping documentation aligned with the final behavior
## Related issue
Closes #655
## Type of change
Check all that apply
- [x] Bug fix
- [ ] New feature (exception during freeze; link prior discussion/acknowledgment)
- [x] Documentation
- [x] Refactor / code quality
- [ ] Translation
- [ ] Other (describe below)
## Checklist
- [x] This PR is focused on a single concern
- [x] An issue was opened and acknowledged before writing this code (if not a small fix)
- [x] If this PR introduces a new feature during freeze, it was discussed and explicitly acknowledged first
- [x] The PR is under ~300 lines changed, excluding lock files, migrations, and generated code
- [x] Tests have been added or updated where applicable
- [x] Documentation has been updated where applicable
- [x] Commit messages follow Conventional Commits format
## Notes for the reviewer
Main security focus:
- Reject invalid hostname-style TRUSTED_PROXIES values early
- Prevent request-path blocking due to DNS resolution
Refresh behavior:
- Hostname resolutions are refreshed by the scheduler at a short interval
- Trust checks consume the cached results only
Tests now cover:
- Valid and invalid hostname cases
- Cache refresh behavior and replacement of stale IPs
Co-authored-by: hugobatista <hugobatista@noreply.codeberg.org>
Reviewed-on: endurain-project/endurain#667 
## What does this PR do?
Harden Endurain's software supply chain against dependency tampering and supply-chain attacks. Applies defense-in-depth across Python (uv), Node.js (npm), Docker, Forgejo Actions, and automated dependency management (Renovate).
## Feature freeze note
Security/refactor PR — falls within allowed categories during freeze.
## Related issue
N/A
## Type of change
- [ ] Refactor / code quality
- [x] Other (describe below): Dependency supply-chain hardening
## Checklist
- [x] This PR is focused on a single concern
- [ ] An issue was opened and acknowledged before writing this code (if not a small fix)
- [ ] If this PR introduces a new feature during freeze, it was discussed and explicitly acknowledged first
- [x] The PR is under ~300 lines changed, excluding lock files, migrations, and generated code
- [ ] Tests have been added or updated where applicable
- [ ] Documentation has been updated where applicable
- [x] Commit messages follow Conventional Commits format
## Notes for the reviewer
### Action SHA pinning
All Forgejo action references (`actions/checkout`, `actions/setup-python`, `astral-sh/setup-uv`, etc.) are pinned to exact commit SHAs instead of mutable version tags. A tag like `v6` can be force-pushed to point to a different commit; a SHA is immutable. Pinning ensures every CI run executes the exact action version that was reviewed. Renovate (`pinDigests: true`) keeps the SHA updated alongside the tag.
### Docker image pinning
Base images (`python:3.13-alpine`, `node:24-slim`, `astral-sh/uv`) are pinned to `tag@sha256:digest`. Same principle as action pinning — tags are mutable, a digest is guaranteed content-addressed. Prevents a compromised registry from swapping a tagged image.
### pip hash verification
The Dockerfile exports production dependencies from `uv.lock` to `requirements.txt` and installs with `pip install --require-hashes`. This ensures every installed wheel matches the hash recorded in `uv.lock` at lock-time. A compromised PyPI cannot inject malicious code during `pip install` without also altering the lockfile.
### uv version pinning + age-based resolution
- `required-version = "0.11.18"`: Different uv versions may resolve the same `pyproject.toml` constraints differently. Pinning uv prevents lockfile drift across team members and CI.
- `exclude-newer = "30 days"`: uv refuses to resolve packages published less than 30 days ago. This provides a safety window for malicious packages to be detected and retracted before entering Endurain's dependency tree.
### npm hardening
- `.npmrc` with `ignore-scripts=true`: npm packages can execute arbitrary code via `preinstall`/`postinstall` scripts. Blocking all scripts eliminates this attack vector. The frontend lint/format/type-check pipeline needs only static source analysis, not native builds.
- `audit-level=high`: `npm audit` exits non-zero only on high/critical vulnerabilities, avoiding noise from low-severity advisories.
- Dockerfile `npm ci --prefer-offline --ignore-scripts`: extends the same protection to the Docker build stage, where npm installs frontend dependencies for the `vite build`.
### Renovate supply-chain defaults
- `minimumReleaseAge: "30 days"`: extends the safety window to automated updates. A newly published (possibly malicious) version has at least 30 days to be detected before Renovate proposes it.
- `dependencyDashboard: true`: creates a visibility surface for all pending, blocked, and ignored updates. Critical for tracking why a security patch hasn't landed.
- `osvVulnerabilityAlerts: true`: actively monitors the OSV.dev database for known vulnerabilities across all dependency types (npm, PyPI, Docker, actions). Vulnerability PRs receive `prPriority: 10` and explicit security labels so they stand out from routine updates.
- `pinDigests: true`: enables digest pinning for actions and Docker images, allowing Renovate to auto-add and update SHAs.
### git-pages/action custom manager
The `forgejo-tags` datasource reduces semver version comments to their major component (`v2.1.0` → `v2`) and queries the tags API for that value. The `git-pages/action` repository has a `v2` branch but no `v2` tag, causing a 404. A `customManager` bypasses the native manager by extracting the full semver (`v2.1.0`) and feeding it directly to the `forgejo-tags` datasource, which successfully resolves the tag. Renovate continues to manage this dependency.
### Explicit package index
`[[tool.uv.index]]` declares `https://pypi.org/simple` as the sole package index. Previously this was implicit. Making it explicit documents the trust boundary and prevents accidental cross-resolution if a second index is added later.
### Renovate workflow enhancements
- All action URLs migrated to `data.forgejo.org` mirror, enabling Renovate's `github-actions` manager to detect and update them.
- `$FORGEJO_ENV` pattern: env vars are written to the ephemeral env file instead of the step `env:` block, so `forgejo-runner exec --env` overrides pass through to the Renovate Docker container. Enables local dry-run testing.
Reviewed-on: endurain-project/endurain#670 
## What does this PR do?
Follow-up to #670 — migrates the docs deployment workflow to uv and removes redundant `setup-python` steps, completing the supply-chain hardening across all workflows.
## Feature freeze note
Security/refactor PR — falls within allowed categories during freeze.
## Related issue
N/A — follow-up to #670
## Type of change
- [ ] Refactor / code quality
- [x] Other (describe below): Dependency supply-chain hardening (follow-up)
## Checklist
- [x] This PR is focused on a single concern
- [ ] An issue was opened and acknowledged before writing this code (if not a small fix)
- [ ] If this PR introduces a new feature during freeze, it was discussed and explicitly acknowledged first
- [x] The PR is under ~300 lines changed, excluding lock files, migrations, and generated code
- [ ] Tests have been added or updated where applicable
- [ ] Documentation has been updated where applicable
- [x] Commit messages follow Conventional Commits format
## Notes for the reviewer
### Migrate docs.yml to uv
Replaces `actions/setup-python` + `pip install` with `setup-uv` + `uv sync --only-group docs`. Dependencies are now managed through uv.lock with hash verification, consistent with the backend test and lint workflows. The `docs` dependency group was added to `backend/pyproject.toml` with mkdocs-material and mkdocstrings.
A dry-run input was added to the workflow using the `$FORGEJO_ENV` pattern, allowing local testing via `forgejo-runner exec --env DOCS_DRY_RUN=true` without deploying.
### Remove setup-python from test-backend.yml
On Forgejo runners (`codeberg-small`), `actions/setup-python` has no performance advantage — there are no pre-cached Python images. `uv run --no-sync pytest` auto-installs Python from `requires-python` in `pyproject.toml`. This removes one external action dependency with zero downside.
### Pin versions in setup-dev-env docs
Updates the inline compose example in the dev environment documentation to use `postgres:18` (instead of `postgres:latest`) and `adminer:5.3.0` (instead of `adminer` / latest). Adds a note recommending digest pinning in production.
Reviewed-on: endurain-project/endurain#671 
## What does this PR do?
Exempts `codeberg.org/endurain-project/endurain` from Renovate's digest pinning so the project's own Docker image continues to use `:latest` in docker-compose examples instead of being pinned to a SHA256 digest.
## Related issue
Closes #681
## Type of change
- [x] Bug fix
## Checklist
- [x] This PR is focused on a single concern
- [x] An issue was opened and acknowledged before writing this code (if not a small fix)
- [x] The PR is under ~300 lines changed, excluding lock files, migrations, and generated code
- [x] Commit messages follow Conventional Commits format
Reviewed-on: endurain-project/endurain#687 
## What does this PR do?
Routes `astral-sh/setup-uv` and `actions/checkout` lookups through `git-refs` datasource (using `git ls-remote`) instead of the `forgejo-tags` datasource, which doesn't work for mirrored actions on data.forgejo.org. Also suppresses a spurious Python version lookup from `requires-python` in pyproject.toml.
### Changes to `renovate.json`
1. **Custom manager for `astral-sh/setup-uv`** — regex-based, uses `git-refs` pointing to `https://github.com/astral-sh/setup-uv.git`
2. **Custom manager for `actions/checkout`** — same pattern, points to `https://github.com/actions/checkout.git`
3. **Broadened `managerFilePatterns`** — all three custom managers now match any `.forgejo/workflows/*.yml` file
4. **Disabled duplicate `github-actions` manager entries** — extends existing package rule to also disable `astral-sh/setup-uv` and `actions/checkout`
5. **Suppressed Python version lookup** — disables `pep621` manager's dependency on `python` to avoid a noisy but harmless lookup failure
### Why `git-refs` instead of `github-tags`?
`github-tags` requires a `GITHUB_COM_TOKEN` and hits GitHub API rate limits. `git-refs` runs `git ls-remote` directly against the public git URL — no token needed, no rate limits.
## Feature freeze note
Bug fix / dependency configuration improvement — falls within allowed categories during freeze.
## Related issue
Closes #682
## Type of change
- [x] Bug fix
- [ ] New feature (exception during freeze; link prior discussion/acknowledgment)
- [ ] Documentation
- [x] Refactor / code quality
- [ ] Translation
- [ ] Other (describe below)
## Checklist
- [x] This PR is focused on a single concern
- [x] An issue was opened and acknowledged before writing this code
- [ ] If this PR introduces a new feature during freeze, it was discussed and explicitly acknowledged first
- [x] The PR is under ~300 lines changed
- [ ] Tests have been added or updated where applicable
- [ ] Documentation has been updated where applicable
- [x] Commit messages follow Conventional Commits format
## Notes for the reviewer
Confirmed working via local forgejo-runner exec dry run. The `git-refs` datasource successfully resolved `v8.2.0` for setup-uv and `v6.0.3` for checkout. No more "Package lookup failures" warnings in the dependency dashboard.
Reviewed-on: endurain-project/endurain#688 
## What does this PR do?
Adds a `packageRule` to enforce `minimumReleaseAge: "30 days"` for `digest` and `pinDigest` update types, which were not covered by the global `minimumReleaseAge` setting.
Renovate's global `minimumReleaseAge` applies to `minor`, `patch`, `major` updates but does **not** cascade to `digest` or `pinDigest` update types. When `pinDigests: true` is enabled, this caused Docker digest pin PRs to be created immediately regardless of image age.
## Feature freeze note
Bug fix — falls within allowed categories during freeze.
## Related issue
Closes #683
## Type of change
- [x] Bug fix
- [ ] New feature (exception during freeze; link prior discussion/acknowledgment)
- [ ] Documentation
- [ ] Refactor / code quality
- [ ] Translation
- [ ] Other (describe below)
## Checklist
- [x] This PR is focused on a single concern
- [x] An issue was opened and acknowledged before writing this code (if not a small fix)
- [x] If this PR introduces a new feature during freeze, it was discussed and explicitly acknowledged first
- [x] The PR is under ~300 lines changed, excluding lock files, migrations, and generated code
- [ ] Tests have been added or updated where applicable
- [ ] Documentation has been updated where applicable
- [x] Commit messages follow Conventional Commits format
## Notes for the reviewer
The fix adds a `packageRule` in `renovate.json` targeting `matchUpdateTypes: ["digest", "pinDigest"]` with `minimumReleaseAge: "30 days"`. This has been verified via dry-run against the `fix/issue683` branch — the `ghcr.io/renovatebot/renovate` digest update now correctly shows as pending with `minimumReleaseAgeBehaviour=timestamp-required`.
Reviewed-on: endurain-project/endurain#689 
## What does this PR do?
Update logging handler setup to support multiple handlers and to also write to log file on all deployment types.
## Related issue
Closes #620
## Type of change
Check all that apply
- [x] Bug fix
- [ ] New feature (exception during freeze; link prior discussion/acknowledgment)
- [ ] Documentation
- [ ] Refactor / code quality
- [ ] Translation
- [ ] Other (describe below)
## Checklist
- [x] This PR is focused on a single concern
- [x] An issue was opened and acknowledged before writing this code (if not a small fix)
- [ ] If this PR introduces a new feature during freeze, it was discussed and explicitly acknowledged first
- [x] The PR is under ~300 lines changed, excluding lock files, migrations, and generated code
- [x] Tests have been added or updated where applicable
- [ ] Documentation has been updated where applicable
- [x] Commit messages follow Conventional Commits format
Reviewed-on: endurain-project/endurain#692 
## What does this PR do?
Fixes the profile export/import pipeline which could **silently drop all user data** when transient errors occurred during export, resulting in a "successful" import with zero activities, gear, notifications, etc.
**Export side** — broad `except Exception` handlers caught every error, logged at `warning`, and called `write_json_to_zip(..., [])` which was a **no-op** because `write_json_to_zip` had an `if data:` guard that skipped empty lists. The ZIP would be missing data files entirely, yet the endpoint returned HTTP 201.
**Import side** — missing data files were silently treated as `[]`, each `collect_and_import_*` method logged "No X data to import" at `info` level and returned early. The endpoint returned HTTP 201 with all-zero counts.
### Changes
| # | Area | Fix |
|---|------|------|
| 1 | `profile/utils.py:write_json_to_zip` | Remove `if data:` guard so empty `[]` is always written to ZIP |
| 2 | `export_service.py` — all broad `except Exception` | Re-raise as `DataCollectionError` — export fails visibly with HTTP 422 instead of silently producing empty ZIP |
| 3 | `export_service.py:_get_activities_batch` | Re-raise instead of returning `[]`; while-loop wraps call in try/except so one failed batch logs and continues to next page |
| 4 | `import_service.py:import_from_zip_data` | Raise `FileFormatError` when all imported counts are zero |
| 5 | `import_service.py:_load_single_json` | Validate parsed JSON is a `list`; log `warning` and return `[]` for `None`/`{}`/other types |
| 6 | `import_service.py:_load_single_json` | Log at `warning` when expected files are missing from ZIP |
| 7 | `import_service.py:_load_components_for_batch` | Only catch `JSONDecodeError`, `FileFormatError`, `FileSizeError`, `OSError` — critical errors (`MemoryAllocationError`, etc.) propagate |
| 8 | `import_service.py:collect_and_import_user_default_gear` | Create a new DB record when none exists (formerly silently dropped the imported data) |
| 9 | `import_service.py:collect_and_import_user_data` | Always import sub-setting data even when `user_data` is empty |
## Related issue
Closes #656
## Type of change
- [x] Bug fix
- [ ] New feature (exception during freeze; link prior discussion/acknowledgment)
- [ ] Documentation
- [ ] Refactor / code quality
- [ ] Translation
- [ ] Other (describe below)
## Checklist
- [x] This PR is focused on a single concern
- [x] An issue was opened and acknowledged before writing this code
- [ ] If this PR introduces a new feature during freeze, it was discussed and explicitly acknowledged first
- [x] The PR is under ~300 lines changed, excluding lock files, migrations, and generated code
- [x] Tests have been added or updated where applicable
- [ ] Documentation has been updated where applicable
- [x] Commit messages follow Conventional Commits format
## Notes for the reviewer
The diff is +356/−295 across 6 files (all in `backend/`). The increase is mostly new test coverage for the fixes. Key things to review:
- `utils.py:410` — the `if data:` removal is the linchpin; empty arrays are now serialized as `[]` in the ZIP
- `export_service.py` — every `except Exception` handler now re-raises `DataCollectionError`. The outer `SQLAlchemyError` / `DataCollectionError` / `MemoryAllocationError` handlers in each method still catch their specific types as before
- `import_service.py:296` — the zero-count guard is intentionally strict: *all* counts must be zero. If even one gear or notification was imported, it passes
- `test_export_service.py` — several tests that tested the old "exception falls back to empty" behavior were rewritten to assert the new re-raise behavior
Co-authored-by: João Vitória Silva <joaovitoriasilva@noreply.codeberg.org>
Reviewed-on: endurain-project/endurain#669 
## What does this PR do?
Enables strict mypy typechecking across the backend by adding type stubs, configuring mypy discovery, and fixing ~200 type errors. This is the first wave of work toward resolving #678. Also narrows ruff lint ignores from broad global suppressions to scoped per-file entries (tracked in #677 and #680).
The changes fall into four categories:
### 1. Config & infrastructure (7 commits)
- Added `mypy_path = ["app"]` and `[[tool.mypy.overrides]]` for 8 third-party packages without stubs to `pyproject.toml`
- Added 6 `types-*` stubs to the typecheck dependency group (requests, bleach, psutil, qrcode, aiofiles, Authlib)
- Added ruff `src = ["app", "tests"]` to fix module discovery
- Moved `S105`/`S106` global ignores to per-file entries for 7 specific files (false positives on field names, constants, migrations) — tracked in #680
- Added per-file ruff ignore for `app/profile/**/*.py` → `A005` (stdlib shadow, tracked in #677)
### 2. Sequence→list conversions (~115 occurrences, ~40 files)
Wrapping SQLAlchemy `.scalars().all()` results in `list(...)` to align with return type annotations expecting `list[Model]` instead of `Sequence[Model]`. Runtime behavior is unchanged. Affects CRUD modules across activities, auth, gears, health, notifications, users, and followers.
### 3. Type annotations added (~90 occurrences, ~55 files)
- `result: Any = db.execute(stmt)` — allows `.rowcount` access without mypy errors (~15 files)
- Return type annotations on router handlers (`-> SignUpResponse`, `-> list[...] | None`)
- Dictionary type annotations for config-like objects (`IDP_TEMPLATES`, `TILE_MAPS_TEMPLATES`, import mappings)
- Variable annotations for parsed/fetched data (`parsed_activities: list[Any]`, `iso_dow: Any`)
### 4. Null safety / behavioral fixes (~20 occurrences, ~8 files)
- Guarded `None` access on `activity.start_time`, `activity.user_id`, `user_privacy_settings` before attribute access
- Replaced `.first()` → `.scalar_one_or_none()` for optional-result queries
- Added null fallbacks: `sort_by or ""`, `float(result or 0)`, `scalar_one() or 0`
- Fixed variable shadowing bug in GPX parsing (`power` vs `raw_power`)
- Added explicit `str()` conversion for IP addresses in DNS resolution
- Added `isinstance(exc, Exception)` guard in error handler
### 5. Style-only changes (~270+ files)
- Ruff import sorting (I001) reordered third-party/stdlib imports above local application imports across the entire backend
- Minor formatting pass in one commit
- Variable renames for clarity (`stmt` → `media_stmt`/`sets_stmt`/`steps_stmt`)
### 6. Frontend (3 files)
- `package.json`/`package-lock.json` updates, asset cleanup (login PNG)
## Related issue
- References #677 (profile module rename, tracked via A005 ignore)
- References #678 (278 remaining type errors after this PR)
- References #680 (remaining B008 global ignore)
## Type of change
Check all that apply
- [ ] Bug fix
- [ ] New feature (exception during freeze; link prior discussion/acknowledgment)
- [ ] Documentation
- [x] Refactor / code quality
- [ ] Translation
- [ ] Other (describe below)
## Checklist
- [x] This PR is focused on a single concern (type safety improvements)
- [x] An issue was opened and acknowledged before writing this code
- [x] If this PR introduces a new feature during freeze, it was discussed and explicitly acknowledged first
- [x] The PR is under ~300 lines changed (exception: pre-approved under Project Sinew)
- [x] Tests have been added or updated where applicable
- [ ] Documentation has been updated where applicable
- [x] Commit messages follow Conventional Commits format
## Notes for the reviewer
This is the first PR in a series addressing #678. It sets up mypy infrastructure and fixes the most mechanical errors (sequence→list, missing annotations). Remaining work (~278 errors) includes model/schema return type mismatches in routers, Optional narrowing in strava/utils, SQLAlchemy rowcount migration, and call-arg mismatches — tracked in #678.
Co-authored-by: João Vitória Silva <joaovitoriasilva@noreply.codeberg.org>
Reviewed-on: endurain-project/endurain#679 
## What does this PR do?
Completes the auth/users module boundary refactor started in issue #625. The earlier PRs (1–12) established the `IdentityService` boundary, moved sessions, API keys, and MFA to `auth/`, and enforced the import contract baseline. This PR finishes the remaining ownership moves, hardens the boundary with fully-enforcing import-linter contracts, and resolves auth-adjacent issues discovered along the way.
### Module moves:
- password_reset_tokens → auth/password_reset_tokens
- sign_up_tokens → auth/sign_up_tokens
- profile/mfa_store.py → auth/mfa/setup_store.py
- profile/schema.py (MFA schema) → auth/mfa/schema.py
- auth/mfa_backup_codes/ → auth/mfa/backup_codes/
- auth/idp_link_tokens/ → auth/identity_providers/link_tokens/
- auth/identity_links/ → auth/identity_providers/links/
- profile/ → users/users_profile/
- auth/security.py → auth/internal_dependencies.py
### New auth-owned services (auth/services/):
- account_security_service - password change and session management workflows
- mfa_workflow - MFA setup, enable, disable, backup-code management
- identity_link_service - IdP link listing, token generation, removal, browser-redirect claiming
- step_up_service - step-up credential verification with progressive lockout (5/5 min, 10/30 min, 15/2 hr)
### Credential table split:
- New users_local_credentials table owned by auth/credentials/; stores password hashes separately from the user profile row
- Backfill migration copies existing hashes and drops users.password
- Users.has_local_password property replaces empty-string sentinels; SSO-only accounts have no credential row
### Hardening and cleanup:
- auth/token_hashing.py - centralized SHA-256 and HMAC-SHA256 helpers for opaque auth tokens
- auth/password_policy.py - centralized password policy and hashing helper
- DB uniqueness constraints on identity links (uq_identity_links_user_idp, uq_identity_links_idp_subject)
- Redis-capable GarminMFACodeStore for cross-request Garmin MFA code handoff
- Fixed MFA backup-code verification path in the login flow
- SSO-only account support in sign-up and account-security flows
- **OAuth token-exchange client_type enforcement** - `oauth_state.client_type` (server-recorded at flow initiation) now takes authority over the `X-Client-Type` header on the exchange request; a mismatched header returns HTTP 400. Prevents an attacker from flipping the response shape (cookie-set `web` vs body-only `mobile`) after the PKCE flow was initiated.
- **IdP email-link security gate** - `_find_or_create_user` now only auto-links an OIDC identity to an existing local account when the IdP asserts `email_verified: true`. Missing or false values fail closed (HTTP 403). Prevents account-takeover via unverified email matching at permissive IdPs.
- **API-key router boundary fix** - replaced the `PasswordHasher` dependency in the API-key creation route with `IdentityService`, completing the boundary contract in that router.
- **Centralized API-key scopes constant** - supported scopes are now defined in one place (`auth/api_keys/utils.py`) and referenced by both the router and schema validator.
- **Removed duplicate IdP router registration** from `core/routes.py`.
- **Test conftest cleanup** - dependency overrides aligned to the new `IdentityService`-based auth boundary.
- **Rate limiting on IdP link/unlink** - the link and unlink endpoints now carry the `SENSITIVE` rate-limit decorator alongside step-up verification.
- **Session revocation on password change** - `change_own_password` gains an optional `revoke_other_sessions` flag; when set, all sessions except the caller's current session are deleted, letting a user evict a suspected attacker without self-logout.
- **Backup-code timing fix** - the verification loop no longer short-circuits on the first match; all stored hashes are verified unconditionally so Argon2 latency is independent of the matching code's position (constant-time-equivalent across the candidate set).
- **Explicit refresh-token session ownership check** - the refresh endpoint now explicitly asserts `session.user_id == token_user_id` before hash verification, making the `sub`/`sid` binding invariant fail fast rather than relying on implicit hash failure.
### Boundary enforcement:
- Two import-linter contracts in .importlinter - non-auth modules may not import auth.internal_dependencies, auth.password_hasher, auth.token_manager, low-level auth CRUD/persistence modules, auth.security_stores, or auth.services directly
- Backend CI workflow gets a lint-imports step that runs on every push
### Tests and docs:
- All moved/new modules have corresponding test updates; new test files for account_security_service, identity_link_service, step_up_service, mfa_service, credentials, password_policy, GarminMFACodeStore, users_profile, and users.User model
- auth-boundary.md - new boundary guide covering the Principal model, IdentityService protocol, ownership map, service modules, import-linter contracts, and known structural debt
- authentication.md updated to reflect the completed boundary state
## Related issue
Closes #625
Closes #677
## Type of change
Check all that apply
- [ ] Bug fix
- [ ] New feature (exception during freeze; link prior discussion/acknowledgment)
- [ ] Documentation
- [x] Refactor / code quality
- [ ] Translation
- [ ] Other (describe below)
## Checklist
- [ ] This PR is focused on a single concern
- [x] An issue was opened and acknowledged before writing this code (if not a small fix)
- [ ] If this PR introduces a new feature during freeze, it was discussed and explicitly acknowledged first
- [ ] The PR is under ~300 lines changed, excluding lock files, migrations, and generated code
- [x] Tests have been added or updated where applicable
- [x] Documentation has been updated where applicable
- [x] Commit messages follow Conventional Commits format
## Notes for the reviewer
This PR is large by line count (~8.4k insertions, ~5.9k deletions across 180 files) but the vast majority is mechanical renames and import path updates following the module moves. The structural decisions are concentrated in a small number of files:
- auth/identity_service.py - IdentityService protocol and DefaultIdentityService implementation
- auth/services/ - the four new facade modules
- auth/credentials/ - new users_local_credentials ORM and CRUD
- auth/token_hashing.py, auth/password_policy.py - shared helpers extracted from scattered locations
- .importlinter - the two enforced boundary contracts
- alembic/versions/v0_19_0/2_identity_links_uniqueness_constraints.py and 3_add_local_credentials_table.py - the two new migrations
The users_local_credentials migration is behavior-preserving: existing hashes are copied verbatim and the downgrade restores the original users.password column. SSO-only accounts (currently a random-hash sentinel) get no credential row, making "has a local password" a simple row-existence check going forward.
Known structural debt intentionally deferred (documented in auth-boundary.md): ORM relationships from users.User to auth tables remain for cascade deletes; a full credential-model split is a higher-risk schema project out of scope here. The SSO-only step-up gap (no IdP re-auth for SSO-only accounts) is also documented and tracked.
Co-authored-by: hugobatista <hugobatista@noreply.hugobatista.com>
Reviewed-on: endurain-project/endurain#685 
HR zone percentages were previously computed on every activity stream read inside `transform_activity_streams_hr()`: a user DB lookup, optional age-derived max-HR calculation, NumPy array construction over all HR waypoints, and five zone-boundary comparisons, all for data that is completely immutable after ingestion.
This refactor moves that work to the write path so reads serve a stored value:
- **New `zone_percentages` JSON column** on `activities_streams` (nullable, generic, keyed by metric, e.g. `{"hr": {"zone_1": {...}, ..., "zone_5": {...}}}`). The metric-keyed shape means power/pace zones can be added later with no further migration.
- **Write path** (`create_activity_streams`): resolves user + activity once per activity ID and computes `zone_percentages` only for HR streams before the bulk insert.
- **Read path:** `transform_activity_streams_hr` was removed entirely. `transform_activity_streams` is now a pure `model_validate()` call, no user lookup, no NumPy, no branching. Rows not yet backfilled will have `zone_percentages: null` until migration 7 runs at startup.
- **Alembic migration** adds the column; a registered **data migration** (ID 7, processed at startup via the existing custom framework) backfills existing HR streams in batches of 500.
- **API field** renamed from `hr_zone_percentages` to `zone_percentages` (the stored metric-keyed shape). No `hr_zone_percentages` references remain in `backend/app` or `frontend/app`.
- **Frontend** updated: `ActivityZonePercentages` type and both HR-zone components now read `zone_percentages?.hr`.
Pure compute helpers (`resolve_max_heart_rate`, `compute_hr_zone_breakdown`, `build_zone_percentages`) were extracted to make the math independently testable.
## Related issue
Closes #666
## Type of change
- [ ] Bug fix
- [ ] New feature (exception during freeze; link prior discussion/acknowledgment)
- [ ] Documentation
- [x] Refactor / code quality
- [ ] Translation
- [ ] Other (describe below)
## Checklist
- [x] This PR is focused on a single concern
- [x] An issue was opened and acknowledged before writing this code (if not a small fix)
- [ ] If this PR introduces a new feature during freeze, it was discussed and explicitly acknowledged first
- [x] The PR is under ~300 lines changed, excluding lock files, migrations, and generated code
- [x] Tests have been added or updated where applicable
- [ ] Documentation has been updated where applicable
- [x] Commit messages follow Conventional Commits format
Reviewed-on: endurain-project/endurain#693 
## What does this PR do?
Refactors the `activity_streams` module and the `core/decorators.py` `handle_db_errors` decorator as part of a broader CRUD code-quality pass.
**`core/decorators.py`**
- Extended `handle_db_errors` to support both synchronous and asynchronous (coroutine) functions via `inspect.iscoroutinefunction` and `@overload` signatures.
- Extracted helper functions `_find_db_session`, `_rollback_session`, and `_handle_db_error` to eliminate duplicated error-handling logic between the sync and async paths.
- Tightened log output: only the exception *class name* is logged (not the full SQLAlchemy error string), avoiding accidental leakage of SQL statements or parameter values that may contain PII (OWASP A09).
**`activities/activity_streams`**
- Replaced inline `try/except SQLAlchemyError` blocks across all CRUD functions with the `@handle_db_errors` decorator, removing ~80 lines of boilerplate.
- Narrowed return types from `list[ActivityStreams] | None` to `list[ActivityStreamsRead]`, returning `[]` instead of `None` for empty results.
- Simplified `transform_activity_streams` call-site — the helper now accepts a list directly.
- Renamed ambiguous parameters (`token_user_id` → `_user_id`, `activities` → `_activities`) to clarify intent.
- Updated `__init__.py` exports to expose `ActivityStreamsBase`, `ActivityStreamsCreate`, and `ActivityStreamsRead`.
**Tests & migrations**
- Updated test suite for the refactored CRUD and utility functions.
- Extended `migration_7` tests to cover the updated migration logic.
## Related issue
Closes #666
## Type of change
- [x] Refactor / code quality
## Checklist
- [x] This PR is focused on a single concern
- [x] The PR is under ~300 lines changed, excluding lock files, migrations, and generated code
- [x] Tests have been added or updated where applicable
- [ ] Documentation has been updated where applicable
- [x] Commit messages follow Conventional Commits format
## Notes for the reviewer
The decorator change is backward-compatible — all existing sync usages continue to work unchanged. The async overload is additive and verified against the new async CRUD functions in this PR. The log-scrubbing change (class name only) is a deliberate security improvement and slightly reduces debuggability of raw DB errors; the full exception object is still passed to the logger as `exc=` for traceback capture.
Co-authored-by: hugobatista <hugobatista@noreply.hugobatista.com>
Co-authored-by: hugobatista <hugobatista@noreply.codeberg.org>
Reviewed-on: endurain-project/endurain#700 
Fixes #675.
The Nginx Proxy Manager example in the getting-started guide pasted the NPM UI section labels (`Let's Encrypt SSL`, `Asset Caching`, `Block Exploits`, `HSTS...`, `Force SSL`, `Proxy!`, `Custom`) as bare lines. These are invalid Nginx directives and make the server fail to start with an `unknown directive` error. This PR prefixes them with `#` so the block is valid, and fixes the malformed `server_proxy[.]conf` include (the `[.]` is a copy artifact).
It also adds a minimal reverse-proxy example to the bare-metal guide, which previously had none. Endurain serves the frontend and the API from a single origin (uvicorn on `:8080`), so a single `location /` is enough — a separate `location /backend { root ... }` block is unnecessary and is the likely cause of the missing-thumbnail reports in #672 (left as a hypothesis for you to confirm).
Small, docs-only change; happy to adjust wording or split it.
EDIT:
Closes #675
Closes #672
Reviewed-on: endurain-project/endurain#708 
Fixes #552.
Endurain's Content-Security-Policy hardcodes `connect-src 'self' https://cdn.jsdelivr.net`. When deployed behind a forward-auth reverse proxy (e.g. Pangolin) that intercepts the app's API calls and redirects them to its own domain for session validation, the browser blocks the redirect because the proxy's origin isn't in `connect-src`, and the app fails to load.
This adds an optional `CSP_ADDITIONAL_CONNECT_SRC` env var (comma-separated CSP source expressions) that is appended to the `connect-src` directive at runtime. It defaults to empty, so the policy is unchanged for everyone else. Entries containing whitespace or `;` are rejected to prevent header injection.
The setting follows the existing `TRUSTED_PROXIES` / `SSRF_ALLOWED_HOSTS` list-env-var pattern, and is documented in `.env.example` and the environment-variables guide. Added unit tests for the validator (parsing + injection rejection) and the resulting CSP header (default + with extra origins).
I went with an env var since the proxy domain is deployment infrastructure rather than an admin-facing setting, which also keeps it migration-free. If you'd prefer it as an admin/server setting instead, that's an easy change.
EDIT:
Closes #552
Reviewed-on: endurain-project/endurain#710 
## What does this PR do?
This PR refactors the users module to improve code consistency, type safety, and maintainability across all user-related CRUD operations. The changes consolidate user retrieval logic, standardize return types to use Pydantic schemas, and enhance naming conventions throughout the module.
## Related issue
#678
## Type of change
- [ ] Bug fix
- [ ] New feature (exception during freeze; link prior discussion/acknowledgment)
- [ ] Documentation
- [x] Refactor / code quality
- [ ] Translation
- [ ] Other (describe below)
## Checklist
- [x] This PR is focused on a single concern
- [x] An issue was opened and acknowledged before writing this code (if not a small fix)
- [x] If this PR introduces a new feature during freeze, it was discussed and explicitly acknowledged first
- [x] The PR is under ~300 lines changed, excluding lock files, migrations, and generated code
- [x] Tests have been added or updated where applicable
- [ ] Documentation has been updated where applicable
- [x] Commit messages follow Conventional Commits format
## Notes for the reviewer
A separate follow-up PR is planned to address event-loop blocking in `users_profile/import_service.py`. The current `import_from_zip_data` method is declared `async` but performs purely synchronous work (ZIP decompression, `json.loads` on large payloads, batched SQLAlchemy writes) inline on the event loop, stalling all other requests during a large import. The fix, offloading the sync work via `run_in_threadpool` (or moving to a proper background task) and bridging WebSocket progress updates thread-safely, is intentionally kept out of this refactor PR to preserve focused, reviewable scopes. Tracked in issue #707.
Co-authored-by: hugobatista <hugobatista@noreply.codeberg.org>
Reviewed-on: endurain-project/endurain#704
Reviewed-by: hugobatista <hugobatista@noreply.codeberg.org>
## What does this PR do?
Fixes timezone corruption of activity timestamps during import. Timestamps from GPX, TCX, FIT and Strava sources are now normalized to UTC before storage, so the stored instant is correct regardless of the server's `TZ` setting. Activity-local display time is derived from the activity's IANA timezone (resolved from GPS), not the server timezone.
**Root causes fixed:**
- Import parsers called `strftime("%Y-%m-%dT%H:%M:%S")` on timezone-aware datetimes, silently dropping the offset (e.g. `08:19:19-07:00` was stored as the naive wall-clock `08:19:19` instead of the correct `15:19:19` UTC).
- Naive timestamps were written into `TIMESTAMP WITH TIME ZONE` columns, so PostgreSQL re-interpreted them in the server session timezone (driven by the container `TZ`), shifting the stored instant when `TZ != UTC`.
- `find_timezone_name()` (FIT no-GPS fallback for pool/treadmill activities) had a dead conditional that checked the input date's offset instead of the candidate timezone's, so it never matched and silently returned `None`.
**Key changes:**
- Added `to_utc_aware()` and `format_utc()` to `core/timezone.py` as the single source of truth for UTC normalization, with unit tests covering naive, aware, offset-bearing, and ISO-string inputs.
- Updated GPX and TCX parsers to convert timestamps to UTC before formatting (waypoint streams, activity start/end, and lap start times).
- Normalized activity `start_time`, `end_time`, and `created_at` to UTC-aware at the schema→model boundary (`transform_schema_activity_to_model_activity`), covering all import paths (file upload, Strava, Garmin).
- Pinned the database session timezone to UTC (`-c timezone=utc`) as a defense-in-depth backstop for any naive value that slips through.
- Fixed the `find_timezone_name()` dead conditional.
- Added GPX/TCX regression tests for offset-bearing timestamps.
**Behavior matrix (GPS activities, June/DST offsets):**
| # | Server `TZ` | Activity location/start | Stored (UTC) | `activity.timezone` (GPS) | Displayed | Correct? |
|---|---|---|---|---|---|---|
| 1 | Lisbon | Lisbon 09:00 | 08:00 | Europe/Lisbon | **09:00** |  |
| 2 | Lisbon | Vancouver 09:00 | 16:00 | America/Vancouver | **09:00** |  |
| 3 | Berlin | Lisbon 09:00 | 08:00 | Europe/Lisbon | **09:00** |  |
| 4 | Berlin | Berlin 09:00 | 07:00 | Europe/Berlin | **09:00** |  |
## Related issue
Closes #703
Closes #588
## Type of change
- [x] Bug fix
- [ ] New feature (exception during freeze; link prior discussion/acknowledgment)
- [ ] Documentation
- [ ] Refactor / code quality
- [ ] Translation
- [ ] Other (describe below)
## Checklist
- [x] This PR is focused on a single concern
- [x] An issue was opened and acknowledged before writing this code (if not a small fix)
- [x] The PR is under ~300 lines changed, excluding lock files, migrations, and generated code
- [x] Tests have been added or updated where applicable
- [ ] Documentation has been updated where applicable
- [x] Commit messages follow Conventional Commits format
## Notes for the reviewer
This PR does not fix already imported activities. Fix could be load activity file if present in the disk and fix the data, if file not present, default to server TZ.
This PR fixes the immediate timezone corruption (UTC normalization at import + DB session pinned to UTC), but it is a **targeted fix, not the final architecture**.
Audited the full datetime flow end-to-end; the following are **known limitations/follow-up work**, intentionally *out of scope* here to keep the PR focused and small.
Recommend tracking each as a separate ticket:
1. **Inconsistent column types.** `activities.start_time/end_time/created_at` and `activity_laps.start_time` are `TIMESTAMP WITH TIME ZONE`, but `activity_sets.start_time` is a bare `TIMESTAMP WITHOUT TIME ZONE` - the same latent bug class fixed here, in another table. Needs a migration to `timestamptz`.
2. **Inconsistent source semantics.** Strava activity start uses `start_date` (UTC) while Strava laps use `start_date_local` (local). `to_utc_aware()` assumes naive == UTC, so a local lap time can be mis-stored. Sources that know their offset should convert to UTC *at the source*, not at a downstream boundary that has already lost the offset.
3. **Stringly-typed time / early formatting.** Parsers convert `datetime → str` immediately and the pipeline re-parses with `strptime`. Time should stay timezone-aware (`datetime`) until the API edge; this early stringification is what made the original `Z`/offset bugs possible.
4. **Server `TZ` leaks into data semantics.** `serialize_activity` emits a server-`settings.TZ`-based `start_time` plus a duplicate `start_time_tz_applied`. In a multi-user/SaaS/multi-region context there is no meaningful single "server timezone" — display zone is a property of the **activity** (where it happened) or the **user** (preference), never the server.
5. **Presentation done server-side.** The API ships pre-formatted, offset-less wall-clock strings that the frontend renders verbatim. Preferred direction: return one canonical instant (ISO-8601 **with offset**) + the IANA `timezone` and let the client format — which would let us delete the `*_tz_applied` duplication.
6. **Summary bucketing in UTC.** Day/week/month aggregations use `func.date(start_time)` in the DB (UTC), which is wrong for users near midnight in non-UTC zones. A per-user display timezone would fix bucketing.
7. **Stream waypoints store absolute timestamp strings** in JSON. Storing relative seconds-from-`start_time` would be smaller and timezone-agnostic.
**The core data model (UTC instant + per-activity IANA timezone) is sound** and is preserved here, the follow-ups are about typed-datetime plumbing and removing the server-TZ presentation layer, not a redesign. Existing rows imported before this fix remain shifted and require a re-parse-from-source backfill (separate task).
Co-authored-by: hugobatista <hugobatista@noreply.codeberg.org>
Reviewed-on: endurain-project/endurain#706 
## What does this PR do?
Refactors the `server_settings` module to return Pydantic schemas from CRUD operations instead of raw ORM models, aligning it with the project's schema-driven patterns. Alongside that, moves thumbnail regeneration out of the startup critical path by introducing a one-shot background job in the scheduler, preventing it from blocking application startup.
**Changes:**
- `server_settings/crud.py`: CRUD functions now return Pydantic schema instances instead of SQLAlchemy model objects.
- `server_settings/crud.py`: Edit logic now regenerates activity thumbnails logic on a scheduler one-shot job to not block API thread.
- `server_settings/utils.py`: Updated helper functions to work with schema types.
- `server_settings/router.py` & `public_router.py`: Updated to consume the new schema-returning CRUD layer.
- `core/scheduler.py`: Added a one-shot job (`generate_missing_thumbnails`) that runs after startup to regenerate missing activity thumbnails without blocking the main startup sequence.
- `main.py`: Wired the new scheduler job into the application lifespan.
- `tests/server_settings/test_crud.py` & `test_router.py`: Updated to mock and assert against Pydantic schema instances.
## Related issue
#678
## Type of change
- [ ] Bug fix
- [x] Refactor / code quality
- [ ] New feature (exception during freeze; link prior discussion/acknowledgment)
- [ ] Documentation
- [ ] Translation
- [ ] Other (describe below)
## Checklist
- [x] This PR is focused on a single concern
- [x] An issue was opened and acknowledged before writing this code (if not a small fix)
- [ ] If this PR introduces a new feature during freeze, it was discussed and explicitly acknowledged first
- [x] The PR is under ~300 lines changed, excluding lock files, migrations, and generated code
- [x] Tests have been added or updated where applicable
- [ ] Documentation has been updated where applicable
- [x] Commit messages follow Conventional Commits format
## Notes for the reviewer
- The thumbnail regeneration job is one-shot (runs once after startup and on server settings edit logic) and uses the existing thumbnail utility; no new external dependencies introduced.
- The schema migration is non-breaking at the API level — response shapes are unchanged since routers were already serializing via Pydantic.
Co-authored-by: hugobatista <hugobatista@noreply.codeberg.org>
Reviewed-on: endurain-project/endurain#709 
## What does this PR do?
Refactors the `notifications` module as part of the broader issue #678 module standardisation effort + type check.
## Related issue
#678
## Type of change
- [ ] Bug fix
- [ ] New feature (exception during freeze; link prior discussion/acknowledgment)
- [ ] Documentation
- [x] Refactor / code quality
- [ ] Translation
- [ ] Other (describe below)
## Checklist
- [x] This PR is focused on a single concern
- [x] An issue was opened and acknowledged before writing this code (if not a small fix)
- [ ] If this PR introduces a new feature during freeze, it was discussed and explicitly acknowledged first
- [ ] The PR is under ~300 lines changed, excluding lock files, migrations, and generated code
- [x] Tests have been added or updated where applicable
- [ ] Documentation has been updated where applicable
- [x] Commit messages follow Conventional Commits format
Reviewed-on: endurain-project/endurain#712
Reviewed-by: hugobatista <hugobatista@noreply.codeberg.org>
## What does this PR do?
Restores the admin per-user identity-provider endpoints that the frontend still calls but that were removed during the auth/users boundary refactor (#685):
- `GET /users/{user_id}/identity-providers`
- `DELETE /users/{user_id}/identity-providers/{idp_id}`
These existed from v0.16.0 through v0.18.3 (`users/user_identity_providers/router.py`) and were dropped in v0.19.0-beta1. The frontend (`userIdentityProvidersService.js`, used by `UsersListComponent.vue`) was not updated, so the admin "identity providers" panel in Settings, Users fails. Because the backend serves the SPA as a catch-all, the missing routes returned `index.html` (text/html, 200) instead of JSON, and the client choked parsing it (a cryptic `SyntaxError` on WebKit), showing nothing.
The endpoints are wired to the surviving identity service: `get_user_identity_provider_links(user_id)` already takes a `user_id`, and a new `admin_delete_identity_provider_link` unlinks without requiring caller step-up. Both are gated by the `users:read` / `users:write` scopes used by the other admin user routes.
## Feature freeze note
Bug fix (regression), allowed during the freeze.
## Related issue
Closes #725
## Type of change
- [x] Bug fix
- [ ] New feature (exception during freeze; link prior discussion/acknowledgment)
- [ ] Documentation
- [ ] Refactor / code quality
- [ ] Translation
- [ ] Other (describe below)
## Checklist
- [x] This PR is focused on a single concern
- [x] An issue was opened and acknowledged before writing this code (if not a small fix)
- [ ] If this PR introduces a new feature during freeze, it was discussed and explicitly acknowledged first
- [x] The PR is under ~300 lines changed, excluding lock files, migrations, and generated code
- [ ] Tests have been added or updated where applicable
- [ ] Documentation has been updated where applicable
- [x] Commit messages follow Conventional Commits format
## Notes for the reviewer
The admin delete intentionally does not require step-up (it is an administrative action gated by `users:write`), matching the original pre-refactor endpoint; the self-service unlink under `/profile/idp` keeps its step-up and anti-lockout checks.
Verified on a test instance: both routes now return `401` JSON (route present, auth required) instead of `200 text/html` (the SPA fallback), while an unrelated bogus path still returns the SPA HTML.
Separately, it may be worth making the API catch-all return a JSON 404 for unmatched `/api/*` paths so future client/route mismatches surface clearly instead of as a JSON parse error, but that is out of scope here.
Reviewed-on: endurain-project/endurain#728 
## What does this PR do?
Fixes a 500 on SSO/IdP login when the IdP user-info sync has a field to update.
`edit_user` mass-assigns every field of the incoming `UsersRead` via `setattr(db_users, key, value)`. The MFA refactor (`5018c3d0`, "migrate MFA columns to users_mfa table") turned `mfa_enabled` into a read-only computed property on the `Users` model, so assigning it raises `AttributeError: property 'mfa_enabled' of 'Users' object has no setter`. On an IdP login, `_update_user_from_idp` passes a fully populated `UsersRead` to `edit_user`, so `model_dump(exclude_unset=True)` no longer excludes `mfa_enabled`, and any change to `name` or `email` crashes the OAuth callback.
The fix skips attributes that are read-only properties (a `property` with no setter) in the mass-assignment loop.
## Feature freeze note
Bug fix, allowed during the freeze.
## Related issue
Closes #726
## Type of change
- [x] Bug fix
- [ ] New feature (exception during freeze; link prior discussion/acknowledgment)
- [ ] Documentation
- [ ] Refactor / code quality
- [ ] Translation
- [ ] Other (describe below)
## Checklist
- [x] This PR is focused on a single concern
- [x] An issue was opened and acknowledged before writing this code (if not a small fix)
- [ ] If this PR introduces a new feature during freeze, it was discussed and explicitly acknowledged first
- [x] The PR is under ~300 lines changed, excluding lock files, migrations, and generated code
- [ ] Tests have been added or updated where applicable
- [ ] Documentation has been updated where applicable
- [x] Commit messages follow Conventional Commits format
## Notes for the reviewer
Not provider specific: an IdP that returns the same name/email as stored never enters the update path and appears to work, which is why some providers log in fine and others fail for the same user.
The admin "edit user" endpoint does not hit this, because it builds a sparse `UsersRead` from the request body, so `exclude_unset=True` drops `mfa_enabled`. Only the IdP sync round-trips a fully populated read model.
The guard is generic (any read-only property), so future computed fields on the read schema are covered too. MFA state is managed through its own endpoints, never through `edit_user`.
Verified locally: the repro `UsersRead.model_copy(update={"name": ...})` then `edit_user(...)` raises `AttributeError` on master and succeeds with this patch, with `mfa_enabled` left untouched.
Reviewed-on: endurain-project/endurain#727 
## What does this PR do?
On release, the CI now pushes a floating `vX.Y` manifest tag alongside the full semver tag (e.g. `v0.19` alongside `v0.19.0`). This lets users pin to a minor version train (e.g. `:v0.18`) and get patch updates without auto-upgrading to the next minor version.
Pre-release versions (`-beta1`, `-RC1`, `-Alpha1`) are excluded — they only get their full tag and `:latest`.
No new images are built — `docker buildx imagetools create` creates manifest-only aliases pointing at the same per-arch images already pushed.
## Feature freeze note
This is a small CI enhancement with no user-facing UI changes, no backend changes, and no new dependencies. It improves the self-hosted deployment experience by giving users more control over version pinning.
## Related issue
Closes #723
## Type of change
- [ ] Bug fix
- [x] New feature (exception during freeze; link prior discussion/acknowledgment)
- [ ] Documentation
- [ ] Refactor / code quality
- [ ] Translation
- [ ] Other (describe below)
## Checklist
- [x] This PR is focused on a single concern
- [x] An issue was opened and acknowledged before writing this code (if not a small fix)
- [ ] If this PR introduces a new feature during freeze, it was discussed and explicitly acknowledged first
- [x] The PR is under ~300 lines changed, excluding lock files, migrations, and generated code
- [ ] Tests have been added or updated where applicable
- [x] Documentation has been updated where applicable
- [x] Commit messages follow Conventional Commits format
## Notes for the reviewer
Two files changed: `.forgejo/workflows/docker-publish.yml` (new step in `publish-manifests` job) and `docs/getting-started/advanced-started.md` (documented the new tag type). Tag extraction logic was tested manually — all edge cases verified.
Reviewed-on: endurain-project/endurain#724 
## What does this PR do?
Fixes a 500 error when importing a `.fit` file whose device reports a **numeric** manufacturer id instead of a name.
Some watches (reported with an Amazfit/Zepp device) write an unmapped numeric value in the FIT `file_id` `manufacturer` field. That value was passed straight into `Activity.tracker_manufacturer`, which is typed `str | None`, so pydantic raised a `ValidationError` and the upload failed with:
```
500 - Can't parse FIT file sessions
```
The adjacent `tracker_model` is already wrapped in `str(...)`; this applies the same coercion to `manufacturer`, while preserving `None` when the field is absent (so it doesn't store the literal string `"None"`).
The rest of the FIT parses fine — with this one-line fix the activity imports completely, heart-rate stream included.
## Related issue
None — small, self-contained bug fix. (This is **not** the indoor-TCX issue #567; that's a different device, format, and root cause.)
## Type of change
- [x] Bug fix
## Checklist
- [x] This PR is focused on a single concern
- [ ] An issue was opened and acknowledged before writing this code (if not a small fix)
- [ ] If this PR introduces a new feature during freeze, it was discussed and explicitly acknowledged first
- [x] The PR is under ~300 lines changed, excluding lock files, migrations, and generated code
- [x] Tests have been added or updated where applicable
- [ ] Documentation has been updated where applicable
- [x] Commit messages follow Conventional Commits format
## Notes for the reviewer
- Repro: a FIT with `file_id.manufacturer = 339` (numeric, unmapped by fitdecode) hits the `str | None` schema and 500s. Coercing to `"339"` lets the import succeed.
- Added `tests/activities/activity_file_import/test_utils_fit.py` with two cases: numeric manufacturer → stringified, missing manufacturer → stays `None`.
Reviewed-on: endurain-project/endurain#717 
## What does this PR do?
I am working on packaging up Endurain for NixOS. I have been making progress, however one thing I noticed is the fact that the User Avatars were not loading. After some digging, I discovered that the frontend was looking for the `user_images` directory based on a `slice(4)` of the `DATA_DIR` value (effectively).
The `slice(4)` only allowed for the `data` directory to be at the 4th
level, which works for the docker `/app/backend/data`, but doesn't work
for anything like `/var/lib/endurain/data`. Changes the path
 construction to search for `/data` instead and uses the thing after
 that. This allows for bare metal installation with a custom data dir
## Related issue
I was going to make an issue, however figured this was a small enough fix it didn't need it.
## Type of change
Check all that apply
- [x] Bug fix
- [ ] New feature (exception during freeze; link prior discussion/acknowledgment)
- [ ] Documentation
- [ ] Refactor / code quality
- [ ] Translation
- [ ] Other (describe below)
## Checklist
- [x] This PR is focused on a single concern
- [ ] An issue was opened and acknowledged before writing this code (if not a small fix)
- [ ] If this PR introduces a new feature during freeze, it was discussed and explicitly acknowledged first
- [x] The PR is under ~300 lines changed, excluding lock files, migrations, and generated code
- [ ] Tests have been added or updated where applicable
- [ ] Documentation has been updated where applicable
- [ ] Commit messages follow Conventional Commits format
## Notes for the reviewer
If desired, I can provide more info on my NixOS implementation.
Reviewed-on: endurain-project/endurain#702 
## What does this PR do?
 On the desktop activity view, the selected graph (pace, speed, HR, power, cadence, elevation) showed the chart but not the summary stat rows (avg / max / etc.) that the mobile layout already renders. This adds those summary stats to the desktop layout so both layouts show the same numbers. Note the layout issue only appears if the screen is wide enough to toogle the rendering of one graph only.
 ### Before
![Capture d’écran du 2026年06月17日 19-42-11](/attachments/6807fcdc-d34a-4f1d-a27a-bed713dc9b87)
 ### After
![Capture d’écran du 2026年06月17日 19-42-02](/attachments/73801ef3-0297-48ea-aabf-87c1c6d17600)
 ## Feature freeze note
 This is a bug fix (desktop/mobile parity), which is a preferred PR type during the freeze. Issue #538 was opened and acknowledged beforehand.
 ## Related issue
 Closes #538
 ## Type of change
 - [x] Bug fix
 - [ ] New feature (exception during freeze; link prior discussion/acknowledgment)
 - [ ] Documentation
 - [ ] Refactor / code quality
 - [ ] Translation
 - [ ] Other (describe below)
 ## Checklist
 - [x] This PR is focused on a single concern
 - [x] An issue was opened and acknowledged before writing this code (if not a small fix)
 - [ ] If this PR introduces a new feature during freeze, it was discussed and explicitly acknowledged first
 - [x] The PR is under ~300 lines changed, excluding lock files, migrations, and generated code
 - [ ] Tests have been added or updated where applicable
 - [x] Documentation has been updated where applicable
 - [x] Commit messages follow Conventional Commits format
 ## Notes for the reviewer
 - The summary block reuses the existing `activityBellowMPillsComponent.*` i18n keys and the same formatters as the mobile layout — no new strings.
 - Mapping used: elapsed = `total_elapsed_time`, moving = `total_timer_time`. (The mobile speed section has these two swapped; I left that alone to keep this PR scoped — flagging it as a possible follow-up.)
 - Possible follow-up, your call: add a "Best Pace" / max row to the pace section (missing in both layouts).
 - Verified live on a dev instance loaded with real activity data.
Reviewed-on: endurain-project/endurain#711 
## What does this PR do?
On the desktop (large-screen) activity view, the graph-selector menu (Heart Rate, Heart Rate zones, Power, Cadence, Elevation, Pace, Temperature) did not update its labels when the UI language was switched; they stayed in the language active at page load until a reload. The menu items were built once in `onMounted`, resolving each label with `t(...)` at that moment and storing the resolved string, so the array was never rebuilt on locale change.
This stores the i18n key per item and translates it reactively with `$t` in the template, matching how the rest of the component already renders text. The detection logic, present-flags, and default selection are unchanged.
## Feature freeze note
This is a bug fix, which is a preferred PR type during the freeze. Issue #713 was opened beforehand.
## Related issue
Closes #713
## Type of change
- [x] Bug fix
- [ ] New feature (exception during freeze; link prior discussion/acknowledgment)
- [ ] Documentation
- [ ] Refactor / code quality
- [ ] Translation
- [ ] Other (describe below)
## Checklist
- [x] This PR is focused on a single concern
- [x] An issue was opened and acknowledged before writing this code (if not a small fix)
- [ ] If this PR introduces a new feature during freeze, it was discussed and explicitly acknowledged first
- [x] The PR is under ~300 lines changed, excluding lock files, migrations, and generated code
- [ ] Tests have been added or updated where applicable
- [ ] Documentation has been updated where applicable
- [x] Commit messages follow Conventional Commits format
## Notes for the reviewer
- Scope is one file, 10 lines: each menu item now carries `labelKey` (the i18n key) instead of a pre-resolved `label`, and the template renders `{{ \$t(item.labelKey) }}`.
- Verified live on a dev instance: switching language now updates the menu labels immediately, consistent with the chart and the rest of the view.
- Noticed separately (not part of this PR): the selected language does not survive a page refresh on the activity view — appears to be a localStorage-vs-user-profile interaction on session restore. Happy to open a separate issue for it.
Reviewed-on: endurain-project/endurain#714 
## What does this PR do?
Adds a repair migration that converts the `health_sleep` columns `avg_heart_rate`, `avg_spo2`, `avg_respiration`, `lowest_respiration`, `highest_respiration`, `avg_stress_level` and `avg_sleep_stress` from `numeric` back to `Integer`, but only when they are still `numeric`.
These columns were created as `DECIMAL(10, 2)` by the original v0.16.0 migration. Commit `09538580` corrected them to `Integer` for new installs before the stable v0.16.0 release, but databases that already ran the migration during a v0.16.0 pre-release keep the numeric columns: Alembic does not re-run an applied revision, and no later migration repairs the types. Because the read schema declares these fields as `StrictInt`, any `Decimal` value coming from Garmin (respiration, stress, etc.) breaks serialization and `GET /health/sleep` returns 500, so the sleep tab shows no data.
The migration is a no-op on installs that already have `Integer`, and the downgrade restores `numeric(10, 2)`.
## Feature freeze note
This is a bug fix, a preferred PR type during the freeze.
## Related issue
Closes #718. Previously reported and closed without a code fix in #506 and #488.
## Type of change
- [x] Bug fix
## Checklist
- [x] This PR is focused on a single concern
- [x] An issue was opened before writing this code (#718, acknowledgment pending)
- [x] The PR is under ~300 lines changed, excluding migrations
- [ ] Tests added (no migration test harness in the repo; verified manually, see notes)
- [x] Commit messages follow Conventional Commits format
## Notes for the reviewer
Verified manually against a scratch database and against a full app startup:
- `alembic upgrade head` converts the affected columns to integer with rounding (for example 15.50 to 16) and stamps the revision; `avg_skin_temp_deviation` and already-integer columns are left untouched.
- Running the upgrade twice is a no-op (guarded on `data_type = 'numeric'`).
- `alembic downgrade -1` restores `numeric(10, 2)`.
- On a database reproduced in the broken state (numeric columns, sleep endpoint returning 500), an app restart applied the migration and the sleep data became visible again, with no manual DB edit.
Alternative considered: relaxing the `StrictInt` fields to coerce `Decimal`. Rejected, since a genuinely fractional value would still fail and the intended column type is integer (per `09538580`); repairing the data at rest is the durable fix. The affected columns are exactly those changed from `DECIMAL` to `Integer` in `09538580`.
Reviewed-on: endurain-project/endurain#719 
## What does this PR do?
Fixes the sleep stages timeline tooltip on the health page showing stage start/end times in UTC instead of the viewer's local time zone, so the tooltip labels no longer match the chart x-axis.
Adds a `formatTimeLocal()` helper in `dateTimeUtils.js` that converts the timestamp to the browser local zone (`DateTime.fromISO(...).toLocal()`) before formatting, and uses it for the `startTimeStr` / `endTimeStr` labels in `HealthSleepTimelineChartComponent.vue`. The existing `formatTime()` is left untouched so other callers keep their current behaviour.
## Related issue
Closes #504
## Type of change
- [x] Bug fix
## Checklist
- [x] This PR is focused on a single concern
- [x] An issue was opened and acknowledged before writing this code (if not a small fix)
- [ ] If this PR introduces a new feature during freeze, it was discussed and explicitly acknowledged first
- [x] The PR is under ~300 lines changed, excluding lock files, migrations, and generated code
- [ ] Tests have been added or updated where applicable
- [ ] Documentation has been updated where applicable
- [x] Commit messages follow Conventional Commits format
## Notes for the reviewer
Frontend-only, 2 files, +12/-3. Verified on a local stack with sleep data: the tooltip times now line up with the timeline x-axis instead of being offset by the UTC delta. The chart axis already renders in local time, so this just makes the tooltip consistent with it.
Reviewed-on: endurain-project/endurain#733 
Activities added manually (e.g. via Apple Health) have no streams, so
Strava returns 404 on the streams endpoint. fetch_and_process_activity_streams
turned that into a 424 and failed the whole import. Treat ObjectNotFound as
"no streams" and import the activity without map/heart rate/power/etc.
Closes #705 
emon closed this pull request 2026年06月24日 16:56:18 +02:00

Pull request closed

Please reopen this pull request to perform a merge.
Sign in to join this conversation.
No reviewers
Labels
Clear labels
No items
No labels
Milestone
Clear milestone
No items
No milestone
Projects
Clear projects
No items
No project
Assignees
Clear assignees
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
emon/endurain!2
Reference in a new issue
emon/endurain
No description provided.
Delete branch "fix/strava-import-no-streams"

Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?