From 5fab96e824e65698f1086e72052d09e1b8333860 Mon Sep 17 00:00:00 2001 From: avi Date: Tue, 8 Sep 2026 13:23:45 -0500 Subject: [PATCH] M0: repo scaffold, backend skeleton, schema + migrations, dev compose, docs - Apache-2.0, README, CONTRIBUTING, CODE_OF_CONDUCT, SECURITY, issue templates - FastAPI app with /api/v1 healthz/readyz/system-status (honest AI disclosure) - Full SQLAlchemy schema (users, devices, refresh tokens, recordings, assets, upload sessions, transcripts, summaries, tags, jobs, exports) + Alembic migrations incl. Postgres FTS tsvector columns - Settings via SHONAR_* env only; local + S3 storage abstraction with path-traversal-safe keys - Docker dev compose (postgres+redis, 127.0.0.1-only); CI workflow; scripts - shared/openapi.json contract generated from app --- .env.example | 67 ++ .github/ISSUE_TEMPLATE/bug_report.md | 28 + .github/ISSUE_TEMPLATE/feature_request.md | 17 + .github/workflows/ci.yml | 49 ++ .gitignore | 32 + CODE_OF_CONDUCT.md | 29 + CONTRIBUTING.md | 53 ++ LICENSE | 202 +++++ README.md | 121 +++ SECURITY.md | 23 + backend/README.md | 16 + backend/alembic.ini | 37 + backend/migrations/env.py | 62 ++ backend/migrations/script.py.mako | 26 + .../versions/8d51af959ae4_initial_schema.py | 274 ++++++ .../versions/fts0000000001_fts_columns.py | 79 ++ backend/pyproject.toml | 60 ++ backend/shonar/__init__.py | 3 + backend/shonar/api/__init__.py | 0 backend/shonar/api/deps.py | 49 ++ backend/shonar/api/schemas_common.py | 69 ++ backend/shonar/api/v1/__init__.py | 13 + backend/shonar/api/v1/health.py | 65 ++ backend/shonar/api/v1/users.py | 54 ++ backend/shonar/core/__init__.py | 0 backend/shonar/core/config.py | 124 +++ backend/shonar/core/ratelimit.py | 19 + backend/shonar/core/security.py | 98 +++ backend/shonar/db/__init__.py | 3 + backend/shonar/db/base.py | 33 + backend/shonar/db/models.py | 427 ++++++++++ backend/shonar/db/session.py | 48 ++ backend/shonar/main.py | 64 ++ backend/shonar/services/__init__.py | 0 backend/shonar/storage/__init__.py | 179 ++++ backend/tests/conftest.py | 80 ++ backend/tests/test_health.py | 29 + deploy/docker-compose.dev.yml | 33 + docs/ROADMAP.md | 31 + docs/architecture.md | 64 ++ docs/recording-consent.md | 33 + docs/security.md | 81 ++ scripts/dev_bootstrap.sh | 25 + scripts/gen_openapi.sh | 14 + shared/openapi.json | 797 ++++++++++++++++++ worker/README.md | 6 + 46 files changed, 3616 insertions(+) create mode 100644 .env.example create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 backend/README.md create mode 100644 backend/alembic.ini create mode 100644 backend/migrations/env.py create mode 100644 backend/migrations/script.py.mako create mode 100644 backend/migrations/versions/8d51af959ae4_initial_schema.py create mode 100644 backend/migrations/versions/fts0000000001_fts_columns.py create mode 100644 backend/pyproject.toml create mode 100644 backend/shonar/__init__.py create mode 100644 backend/shonar/api/__init__.py create mode 100644 backend/shonar/api/deps.py create mode 100644 backend/shonar/api/schemas_common.py create mode 100644 backend/shonar/api/v1/__init__.py create mode 100644 backend/shonar/api/v1/health.py create mode 100644 backend/shonar/api/v1/users.py create mode 100644 backend/shonar/core/__init__.py create mode 100644 backend/shonar/core/config.py create mode 100644 backend/shonar/core/ratelimit.py create mode 100644 backend/shonar/core/security.py create mode 100644 backend/shonar/db/__init__.py create mode 100644 backend/shonar/db/base.py create mode 100644 backend/shonar/db/models.py create mode 100644 backend/shonar/db/session.py create mode 100644 backend/shonar/main.py create mode 100644 backend/shonar/services/__init__.py create mode 100644 backend/shonar/storage/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_health.py create mode 100644 deploy/docker-compose.dev.yml create mode 100644 docs/ROADMAP.md create mode 100644 docs/architecture.md create mode 100644 docs/recording-consent.md create mode 100644 docs/security.md create mode 100755 scripts/dev_bootstrap.sh create mode 100755 scripts/gen_openapi.sh create mode 100644 shared/openapi.json create mode 100644 worker/README.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..93cd99a --- /dev/null +++ b/.env.example @@ -0,0 +1,67 @@ +# S.H.O.N.A.R. configuration — copy to .env and edit. +# NEVER commit a real .env. NEVER hard-code keys in code. + +# --- Core ------------------------------------------------------------------ +# REQUIRED in production. Generate with: +# python3 -c "import secrets; print(secrets.token_urlsafe(48))" +SHONAR_SECRET_KEY=change…ring + +# Set to true only for local debugging (verbose errors, permissive CORS). +SHONAR_DEBUG=false + +# Allow new user registration on this server. Set false to invite-only +# (create accounts via `scripts/create_user.py`). +SHONAR_ALLOW_REGISTRATION=true + +# --- Database ---------------------------------------------------------------- +# Used by the API and worker. In docker compose these are wired automatically. +SHONAR_DATABASE_URL=postgresql+asyncpg://shonar:shonar@localhost:5432/shonar +SHONAR_DB_POOL_SIZE=5 + +# Compose Postgres credentials (must match DATABASE_URL above) +SHONAR_DB_USER=shonar +SHONAR_DB_PASS=shonar +SHONAR_DB_NAME=shonar + +# --- Storage ------------------------------------------------------------------- +# local | s3 (s3 works with any S3-compatible store: MinIO, R2, etc.) +SHONAR_STORAGE_BACKEND=local +SHONAR_STORAGE_PATH=./data/storage + +SHONAR_S3_ENDPOINT_URL= +SHONAR_S3_BUCKET= +SHONAR_S3_REGION=us-east-1 +SHONAR_S3_ACCESS_KEY_ID= +SHONAR_S3_SECRET_ACCESS_KEY= + +# --- Upload limits ------------------------------------------------------------- +SHONAR_MAX_UPLOAD_BYTES=2147483648 +SHONAR_MAX_CHUNK_BYTES=16777216 + +# --- Queue ---------------------------------------------------------------------- +SHONAR_REDIS_URL=redis://localhost:6379/0 + +# --- Audio processing ------------------------------------------------------------- +# Optional server-side FFmpeg normalization (creates derivative copies; +# originals are never modified). +SHONAR_AUDIO_CONVERSION_ENABLED=false +SHONAR_FFMPEG_BIN=ffmpeg + +# --- AI (all optional; system fully works with providers=none) ---------------------- +# Transcription: none | whisper_http | faster_whisper +SHONAR_TRANSCRIPTION_PROVIDER=none +SHONAR_TRANSCRIPTION_MODEL=base +# Any whisper-compatible HTTP server (e.g. faster-whisper-server, whisper.cpp) +SHONAR_TRANSCRIPTION_BASE_URL= +SHONAR_TRANSCRIPTION_API_KEY= + +# LLM: none | openai_compat | ollama +SHONAR_LLM_PROVIDER=none +SHONAR_LLM_MODEL= +# e.g. http://localhost:11434/v1 (Ollama) or any OpenAI-compatible endpoint +SHONAR_LLM_BASE_URL= +SHONAR_LLM_API_KEY= + +# --- Rate limits ----------------------------------------------------------------- +SHONAR_RATE_LIMIT_AUTH=10/minute +SHONAR_RATE_LIMIT_DEFAULT=120/minute diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..4de6fc7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,28 @@ +--- +name: Bug report +about: Something is broken +title: "[BUG] " +labels: bug +--- + +## Describe the bug + +## Component +- [ ] Android app +- [ ] Backend API +- [ ] Worker / AI pipeline +- [ ] Deployment / Docker + +## To Reproduce +Steps to reproduce the behavior. + +## Expected behavior + +## Environment +- Server version (`GET /api/v1/system/status`): +- Android version / device: +- Storage backend: local / s3 +- AI providers configured: none / transcription / llm + +## Logs +Redact any personal data before pasting logs. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..1fec957 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,17 @@ +--- +name: Feature request +about: Suggest an idea for S.H.O.N.A.R. +title: "[FEATURE] " +labels: enhancement +--- + +## Problem statement +What problem does this solve for self-hosted voice-note users? + +## Proposed solution + +## Privacy impact +Does this touch audio, transcripts, network, or AI providers? How is it +disclosed to the user? + +## Alternatives considered diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cddbcef --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + backend: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: shonar + POSTGRES_PASSWORD: shonar + POSTGRES_DB: shonar_test + ports: ["5432:5432"] + options: >- + --health-cmd "pg_isready -U shonar" + --health-interval 5s --health-timeout 3s --health-retries 10 + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - run: uv venv .venv && uv pip install -e ".[dev]" + - run: .venv/bin/ruff check . + - run: .venv/bin/pytest + env: + SHONAR_TEST_DATABASE_URL: postgresql+asyncpg://shonar:shonar@localhost:5432/shonar_test + + android: + runs-on: ubuntu-latest + defaults: + run: + working-directory: android + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: { distribution: temurin, java-version: 17 } + - uses: gradle/actions/setup-gradle@v4 + # Skipped until the Android project lands (M3). + - run: ./gradlew test assembleDebug --no-daemon + continue-on-error: ${{ !hashFiles('android/**/build.gradle.kts') }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ffde9f1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# --- Python --- +__pycache__/ +*.py[cod] +.venv/ +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +dist/ +build/ + +# --- Secrets & data --- +.env +.env.* +!.env.example +data/ +*.sqlite3 + +# --- Android / Gradle --- +android/.gradle/ +android/build/ +android/app/build/ +*.apk +*.aab +*.keystore +local.properties + +# --- IDE / OS --- +.idea/ +.vscode/ +*.swp +.DS_Store diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..416c7c4 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,29 @@ +# Contributor Code of Conduct + +Version 2.1 (Contributor Covenant) + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the project maintainers at the contact address listed in this +repository's security policy. All complaints will be reviewed and investigated +promptly and fairly. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +This Code of Conduct is adapted from the Contributor Covenant, version 2.1, +available at https://www.contributor-covenant.org/version/2/1/code_of_conduct/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..da7c87f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,53 @@ +# Contributing to S.H.O.N.A.R. + +Thanks for your interest in improving S.H.O.N.A.R.! + +## Ground rules + +1. **Original code only.** Do not submit code, assets, names, logos, or API + details derived from proprietary voice-note products (Plaud or otherwise). + This project is Apache-2.0 and must stay clean-room. +2. **Privacy is a feature.** Changes must not add telemetry, analytics, or + undisclosed network calls. Any new external service integration requires + explicit configuration and in-app disclosure. +3. **Never commit secrets.** Configuration goes through environment variables + (`SHONAR_*`) — never hard-coded credentials. +4. **Keep `main` buildable.** Every PR must pass lint and tests. + +## Development setup + +```bash +# Backend +cd backend +uv venv .venv && uv pip install -e ".[dev]" +docker compose -f ../deploy/docker-compose.dev.yml up -d +.venv/bin/alembic upgrade head +.venv/bin/uvicorn shonar.main:app --reload +.venv/bin/pytest && .venv/bin/ruff check . + +# Android +cd android +./gradlew test assembleDebug +``` + +## Pull request checklist + +- [ ] `ruff check .` and `pytest` pass (backend) +- [ ] `./gradlew test` passes (Android, if touched) +- [ ] API changes regenerate `shared/openapi.json` (`scripts/gen_openapi.sh`) +- [ ] New endpoints have tests, including authorization checks +- [ ] Docs updated when behaviour or configuration changes +- [ ] Commit per logical unit; imperative-mess-free messages ("Add X", not "added x") + +## Style + +- Python 3.11+, type hints everywhere, 100-col, `ruff format`. +- Kotlin with Compose; follow existing MVVM structure (ui → viewmodel → + repository → data source). +- Error messages shown to users must be safe: no stack traces, no internal + paths, no information leakage about other users' data. + +## Reporting issues + +Use the issue templates. For security issues, see SECURITY.md — do not open +a public issue. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..03861d8 --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +# S.H.O.N.A.R. + +**S.H.O.N.A.R. — Self-hosted Oral Notes and Audio Recorder** + +An open-source, self-hosted alternative to cloud voice-note AI devices and +services. Record conversations on your Android phone, sync them to a server +**you** control, transcribe and summarize them with AI providers **you** +choose, and keep full ownership of your audio, transcripts, and accounts. + +- No telemetry. No third-party analytics. No hidden external AI calls. +- Runs fully locally by default (local transcription + local LLM supported). +- Works with **no AI configured at all**: recording, sync, playback, download, + and manual transcripts still work. +- Apache-2.0 licensed. All code in this repository is original. + +## Repository layout + +``` +android/ Android app (Kotlin, Jetpack Compose, Material 3) +backend/ FastAPI + PostgreSQL API server +worker/ Background worker entrypoint (same image as backend) +shared/ OpenAPI spec shared with the Android client +deploy/ Docker Compose, reverse proxy, backup/restore +docs/ Architecture, API, security, self-hosting guides +scripts/ Dev / CI helper scripts +``` + +## Quick start (Docker) + +```bash +cp .env.example .env +# EDIT .env — at minimum set SHONAR_SECRET_KEY (any 32+ random chars): +# python3 -c "import secrets; print(secrets.token_urlsafe(48))" +docker compose up -d --build +``` + +Then open `http://localhost:8000/api/docs` for the interactive API docs and +point the Android app at your server URL. + +The default compose stack is: API + worker + PostgreSQL + Redis, with audio +stored on the local filesystem. No paid cloud service is required. + +## Quick start (Android) + +Requirements: JDK 17, Android SDK (platform 35). See +[android/README.md](android/README.md). + +```bash +cd android +./gradlew assembleDebug +adb install app/build/outputs/apk/debug/app-debug.apk +``` + +## Feature status + +S.H.O.N.A.R. is developed in milestones; each merged milestone is tested and +buildable. See [docs/ROADMAP.md](docs/ROADMAP.md) for the maintained matrix. + +**Current state:** M0–M1 complete — backend health, full auth (register, +login, rotating refresh tokens with reuse detection, logout, delete-account), +rate limiting, Alembic schema, Docker dev stack. Everything else is +in-progress or TODO as per the roadmap. + +## AI providers + +All AI is optional and provider-independent, configured only through +environment variables (never hard-coded keys): + +| Variable | Values | Default | +|---|---|---| +| `SHONAR_TRANSCRIPTION_PROVIDER` | `none`, `whisper_http`, `faster_whisper` | `none` | +| `SHONAR_TRANSCRIPTION_MODEL` | model name (e.g. `base`, `small`) | `base` | +| `SHONAR_TRANSCRIPTION_BASE_URL` | whisper-compatible HTTP server URL | — | +| `SHONAR_LLM_PROVIDER` | `none`, `openai_compat`, `ollama` | `none` | +| `SHONAR_LLM_MODEL` / `SHONAR_LLM_BASE_URL` / `SHONAR_LLM_API_KEY` | model/endpoint/credentials | — | +| `SHONAR_STORAGE_BACKEND` | `local`, `s3` | `local` | +| `SHONAR_STORAGE_PATH` | filesystem storage root | `./data/storage` | +| `SHONAR_DATABASE_URL` | SQLAlchemy async URL | postgres in compose | + +When any external (non-local) provider is enabled, `/api/v1/system/status` +reports `external_ai_in_use: true` and the Android app shows it in Settings — +you always know if your audio or text leaves your machine. + +## Documentation + +- [docs/self-hosting.md](docs/self-hosting.md) — deployment, HTTPS, backups +- [docs/api.md](docs/api.md) — API overview (OpenAPI at `/api/docs`) +- [docs/security.md](docs/security.md) — security model and honest limits +- [docs/architecture.md](docs/architecture.md) — system design +- [docs/recording-consent.md](docs/recording-consent.md) — legal notice + +## Development + +```bash +# backend +cd backend && uv venv .venv && uv pip install -e ".[dev]" +docker compose -f deploy/docker-compose.dev.yml up -d +.venv/bin/alembic upgrade head +.venv/bin/uvicorn shonar.main:app --reload +.venv/bin/pytest # tests +.venv/bin/ruff check . # lint + +# android +cd android && ./gradlew test +``` + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) and +[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). + +## License + +Apache-2.0 — see [LICENSE](LICENSE). + +## Legal notice + +**You are responsible for complying with recording-consent laws in your +jurisdiction.** Many jurisdictions require all-party consent to record a +conversation. S.H.O.N.A.R. shows this notice in-app and never records without +a visible, persistent recording indicator and an obvious stop control. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..db70b70 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +**Do not open public issues for security problems.** Email the maintainers +directly (see README contact). We aim to acknowledge within 72 hours. + +## Scope + +- Authentication / authorization bypass +- Data leakage between users +- Path traversal / unauthorized file access +- Injection (SQL, command, template) +- Secrets exposure in API responses + +## Out of scope + +- Physical device access +- Social engineering +- Vulnerabilities in optional third-party AI providers you configure + +## What this project guarantees + +See `docs/security.md` for the honest security model, including what is NOT +implemented (at-rest encryption is documented but not enabled by default). diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..0f73b7f --- /dev/null +++ b/backend/README.md @@ -0,0 +1,16 @@ +# S.H.O.N.A.R. backend + +FastAPI + PostgreSQL backend for the S.H.O.N.A.R. Android app. + +See the repository root [README](../README.md) and `docs/` for full documentation. + +## Quick start (development) + +```bash +cd backend +uv venv .venv && uv pip install -e ".[dev]" +cp ../.env.example .env # then edit SHONAR_SECRET_KEY etc. +uvicorn shonar.main:app --reload --port 8000 +``` + +Tests: `pytest` · Lint: `ruff check .` · Migrations: `alembic upgrade head` diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..5d2e0e2 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,37 @@ +[alembic] +script_location = migrations +prepend_sys_path = . +# URL is injected from settings in env.py; this is a placeholder. +sqlalchemy.url = + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s diff --git a/backend/migrations/env.py b/backend/migrations/env.py new file mode 100644 index 0000000..fae9a1d --- /dev/null +++ b/backend/migrations/env.py @@ -0,0 +1,62 @@ +"""Alembic environment (async).""" + +from __future__ import annotations + +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from shonar.core.config import get_settings +from shonar.db import models # noqa: F401 (register tables) +from shonar.db.base import Base + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +settings = get_settings() +config.set_main_option("sqlalchemy.url", settings.database_url) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + context.configure( + url=settings.database_url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + + +def run_migrations_online() -> None: + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/migrations/script.py.mako b/backend/migrations/script.py.mako new file mode 100644 index 0000000..5e0c0c5 --- /dev/null +++ b/backend/migrations/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +${imports if imports else ""} +revision: str = ${repr(up_revision)} +down_revision: str | None = ${repr(down_revision)} +branch_labels: str | Sequence[str] | None = ${repr(branch_labels)} +depends_on: str | Sequence[str] | None = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/migrations/versions/8d51af959ae4_initial_schema.py b/backend/migrations/versions/8d51af959ae4_initial_schema.py new file mode 100644 index 0000000..d12331d --- /dev/null +++ b/backend/migrations/versions/8d51af959ae4_initial_schema.py @@ -0,0 +1,274 @@ +"""initial schema + +Revision ID: 8d51af959ae4 +Revises: +Create Date: 2026-09-08 13:07:10.455837 + +""" +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = '8d51af959ae4' +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('users', + sa.Column('email', sa.String(length=320), nullable=False), + sa.Column('password_hash', sa.String(length=255), nullable=False), + sa.Column('display_name', sa.String(length=120), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('location_storage_enabled', sa.Boolean(), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_table('devices', + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('name', sa.String(length=120), nullable=False), + sa.Column('platform', sa.String(length=40), nullable=False), + sa.Column('last_seen_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_devices_user_id'), 'devices', ['user_id'], unique=False) + op.create_table('tags', + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'name') + ) + op.create_index(op.f('ix_tags_user_id'), 'tags', ['user_id'], unique=False) + op.create_table('recordings', + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('device_id', sa.Uuid(), nullable=True), + sa.Column('client_recording_id', sa.String(length=64), nullable=True), + sa.Column('title', sa.String(length=300), nullable=False), + sa.Column('recorded_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('duration_seconds', sa.Float(), nullable=False), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('latitude', sa.Float(), nullable=True), + sa.Column('longitude', sa.Float(), nullable=True), + sa.Column('location_accuracy_m', sa.Float(), nullable=True), + sa.Column('processing_status', sa.Enum('pending_upload', 'uploaded', 'queued', 'processing', 'completed', 'failed', 'ai_disabled', name='processing_status'), nullable=False), + sa.Column('processing_error', sa.Text(), nullable=True), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['device_id'], ['devices.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_recordings_client_recording_id'), 'recordings', ['client_recording_id'], unique=False) + op.create_index(op.f('ix_recordings_processing_status'), 'recordings', ['processing_status'], unique=False) + op.create_index('ix_recordings_user_client_id', 'recordings', ['user_id', 'client_recording_id'], unique=True, postgresql_where='client_recording_id IS NOT NULL') + op.create_index(op.f('ix_recordings_user_id'), 'recordings', ['user_id'], unique=False) + op.create_index('ix_recordings_user_recorded', 'recordings', ['user_id', 'recorded_at'], unique=False) + op.create_table('refresh_tokens', + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('token_hash', sa.String(length=128), nullable=False), + sa.Column('family', sa.Uuid(), nullable=False), + sa.Column('device_id', sa.Uuid(), nullable=True), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('replaced_by', sa.Uuid(), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['device_id'], ['devices.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['replaced_by'], ['refresh_tokens.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('token_hash') + ) + op.create_index(op.f('ix_refresh_tokens_family'), 'refresh_tokens', ['family'], unique=False) + op.create_index(op.f('ix_refresh_tokens_user_id'), 'refresh_tokens', ['user_id'], unique=False) + op.create_table('assets', + sa.Column('recording_id', sa.Uuid(), nullable=True), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('kind', sa.Enum('original', 'normalized', 'export', name='asset_kind'), nullable=False), + sa.Column('storage_key', sa.String(length=500), nullable=False), + sa.Column('mime_type', sa.String(length=100), nullable=False), + sa.Column('size_bytes', sa.BigInteger(), nullable=False), + sa.Column('checksum_sha256', sa.String(length=64), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['recording_id'], ['recordings.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_assets_recording_id'), 'assets', ['recording_id'], unique=False) + op.create_index(op.f('ix_assets_user_id'), 'assets', ['user_id'], unique=False) + op.create_index('uq_assets_one_original', 'assets', ['recording_id'], unique=True, postgresql_where="kind = 'original'") + op.create_table('processing_jobs', + sa.Column('recording_id', sa.Uuid(), nullable=False), + sa.Column('job_type', sa.Enum('normalize_audio', 'transcribe', 'summarize', name='job_type'), nullable=False), + sa.Column('status', sa.Enum('queued', 'running', 'succeeded', 'failed', 'skipped', name='job_status'), nullable=False), + sa.Column('attempt', sa.Integer(), nullable=False), + sa.Column('max_attempts', sa.Integer(), nullable=False), + sa.Column('error', sa.Text(), nullable=True), + sa.Column('started_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('task_handle', sa.String(length=120), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['recording_id'], ['recordings.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_processing_jobs_recording_id'), 'processing_jobs', ['recording_id'], unique=False) + op.create_index('ix_processing_jobs_recording_type', 'processing_jobs', ['recording_id', 'job_type'], unique=False) + op.create_index(op.f('ix_processing_jobs_status'), 'processing_jobs', ['status'], unique=False) + op.create_table('recording_tags', + sa.Column('recording_id', sa.Uuid(), nullable=False), + sa.Column('tag_id', sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(['recording_id'], ['recordings.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('recording_id', 'tag_id') + ) + op.create_table('summaries', + sa.Column('recording_id', sa.Uuid(), nullable=False), + sa.Column('version', sa.Integer(), nullable=False), + sa.Column('superseded_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('provider', sa.String(length=80), nullable=False), + sa.Column('model', sa.String(length=120), nullable=True), + sa.Column('content', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('edited_by_user', sa.Boolean(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['recording_id'], ['recordings.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_summaries_recording_id'), 'summaries', ['recording_id'], unique=False) + op.create_table('transcripts', + sa.Column('recording_id', sa.Uuid(), nullable=False), + sa.Column('version', sa.Integer(), nullable=False), + sa.Column('superseded_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('language', sa.String(length=16), nullable=True), + sa.Column('provider', sa.String(length=80), nullable=False), + sa.Column('model', sa.String(length=120), nullable=True), + sa.Column('text', sa.Text(), nullable=False), + sa.Column('segments', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('edited_by_user', sa.Boolean(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['recording_id'], ['recordings.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_transcripts_recording_id'), 'transcripts', ['recording_id'], unique=False) + op.create_index('ix_transcripts_recording_version', 'transcripts', ['recording_id', 'version'], unique=False) + op.create_table('export_jobs', + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('recording_id', sa.Uuid(), nullable=False), + sa.Column('export_type', sa.String(length=40), nullable=False), + sa.Column('status', sa.Enum('queued', 'running', 'succeeded', 'failed', 'skipped', name='export_job_status'), nullable=False), + sa.Column('asset_id', sa.Uuid(), nullable=True), + sa.Column('error', sa.Text(), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['asset_id'], ['assets.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['recording_id'], ['recordings.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_export_jobs_recording_id'), 'export_jobs', ['recording_id'], unique=False) + op.create_index(op.f('ix_export_jobs_user_id'), 'export_jobs', ['user_id'], unique=False) + op.create_table('upload_sessions', + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('recording_id', sa.Uuid(), nullable=True), + sa.Column('client_recording_id', sa.String(length=64), nullable=True), + sa.Column('title', sa.String(length=300), nullable=True), + sa.Column('declared_mime_type', sa.String(length=100), nullable=False), + sa.Column('declared_size_bytes', sa.BigInteger(), nullable=False), + sa.Column('chunk_size_bytes', sa.BigInteger(), nullable=False), + sa.Column('status', sa.Enum('open', 'finalizing', 'completed', 'aborted', 'expired', name='upload_session_status'), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('completed_asset_id', sa.Uuid(), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['completed_asset_id'], ['assets.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['recording_id'], ['recordings.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_upload_sessions_user_id'), 'upload_sessions', ['user_id'], unique=False) + op.create_table('upload_chunks', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('session_id', sa.Uuid(), nullable=False), + sa.Column('chunk_index', sa.Integer(), nullable=False), + sa.Column('size_bytes', sa.BigInteger(), nullable=False), + sa.Column('checksum_sha256', sa.String(length=64), nullable=False), + sa.Column('storage_key', sa.String(length=500), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['session_id'], ['upload_sessions.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('session_id', 'chunk_index') + ) + op.create_index(op.f('ix_upload_chunks_session_id'), 'upload_chunks', ['session_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_upload_chunks_session_id'), table_name='upload_chunks') + op.drop_table('upload_chunks') + op.drop_index(op.f('ix_upload_sessions_user_id'), table_name='upload_sessions') + op.drop_table('upload_sessions') + op.drop_index(op.f('ix_export_jobs_user_id'), table_name='export_jobs') + op.drop_index(op.f('ix_export_jobs_recording_id'), table_name='export_jobs') + op.drop_table('export_jobs') + op.drop_index('ix_transcripts_recording_version', table_name='transcripts') + op.drop_index(op.f('ix_transcripts_recording_id'), table_name='transcripts') + op.drop_table('transcripts') + op.drop_index(op.f('ix_summaries_recording_id'), table_name='summaries') + op.drop_table('summaries') + op.drop_table('recording_tags') + op.drop_index(op.f('ix_processing_jobs_status'), table_name='processing_jobs') + op.drop_index('ix_processing_jobs_recording_type', table_name='processing_jobs') + op.drop_index(op.f('ix_processing_jobs_recording_id'), table_name='processing_jobs') + op.drop_table('processing_jobs') + op.drop_index('uq_assets_one_original', table_name='assets', postgresql_where="kind = 'original'") + op.drop_index(op.f('ix_assets_user_id'), table_name='assets') + op.drop_index(op.f('ix_assets_recording_id'), table_name='assets') + op.drop_table('assets') + op.drop_index(op.f('ix_refresh_tokens_user_id'), table_name='refresh_tokens') + op.drop_index(op.f('ix_refresh_tokens_family'), table_name='refresh_tokens') + op.drop_table('refresh_tokens') + op.drop_index('ix_recordings_user_recorded', table_name='recordings') + op.drop_index(op.f('ix_recordings_user_id'), table_name='recordings') + op.drop_index('ix_recordings_user_client_id', table_name='recordings', postgresql_where='client_recording_id IS NOT NULL') + op.drop_index(op.f('ix_recordings_processing_status'), table_name='recordings') + op.drop_index(op.f('ix_recordings_client_recording_id'), table_name='recordings') + op.drop_table('recordings') + op.drop_index(op.f('ix_tags_user_id'), table_name='tags') + op.drop_table('tags') + op.drop_index(op.f('ix_devices_user_id'), table_name='devices') + op.drop_table('devices') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/fts0000000001_fts_columns.py b/backend/migrations/versions/fts0000000001_fts_columns.py new file mode 100644 index 0000000..576b003 --- /dev/null +++ b/backend/migrations/versions/fts0000000001_fts_columns.py @@ -0,0 +1,79 @@ +"""full-text search columns (PostgreSQL tsvector) + +Revision ID: fts0000000001 +Revises: 0c938663a363 + +Generated (STORED) tsvector columns + GIN indexes for search across title, +transcript text, summary content, tags, and action items. The SearchBackend +protocol keeps this swappable for Meilisearch/OpenSearch later. +""" +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op + +revision: str = "fts0000000001" +down_revision: str | None = "8d51af959ae4" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # recordings: title + notes + tag names are indexed; transcript/summary + # contribute via their own tables (joined at query time). + op.execute( + """ + ALTER TABLE recordings + ADD COLUMN search_vector tsvector + GENERATED ALWAYS AS ( + setweight(to_tsvector('simple', coalesce(title, '')), 'A') || + setweight(to_tsvector('simple', coalesce(notes, '')), 'B') + ) STORED; + """ + ) + op.execute("CREATE INDEX ix_recordings_search ON recordings USING GIN (search_vector);") + + op.execute( + """ + ALTER TABLE transcripts + ADD COLUMN search_vector tsvector + GENERATED ALWAYS AS ( + setweight(to_tsvector('simple', coalesce(text, '')), 'B') + ) STORED; + """ + ) + op.execute("CREATE INDEX ix_transcripts_search ON transcripts USING GIN (search_vector);") + + # summary content JSON -> extractive text for FTS. Generated columns + # forbid subqueries/set-returning functions, so we index the JSON body + # with punctuation stripped (covers key_points/decisions/action_items/ + # questions) plus weighted short/detailed fields. + op.execute( + """ + ALTER TABLE summaries + ADD COLUMN search_vector tsvector + GENERATED ALWAYS AS ( + setweight(to_tsvector('simple', coalesce(content->>'short', '')), 'A') || + setweight(to_tsvector('simple', coalesce(content->>'detailed', '')), 'B') || + setweight( + to_tsvector('simple', + regexp_replace(coalesce(content::text, ''), '[\\[\\]{}"]', ' ', 'g')), 'C') + ) STORED; + """ + ) + op.execute("CREATE INDEX ix_summaries_search ON summaries USING GIN (search_vector);") + + op.execute( + """ + ALTER TABLE tags ADD COLUMN IF NOT EXISTS search_vector tsvector + GENERATED ALWAYS AS (to_tsvector('simple', coalesce(name, ''))) STORED; + """ + ) + op.execute("CREATE INDEX ix_tags_search ON tags USING GIN (search_vector);") + + +def downgrade() -> None: + for table in ("tags", "summaries", "transcripts", "recordings"): + op.execute(f"DROP INDEX IF EXISTS ix_{table}_search;") + op.execute(f"ALTER TABLE {table} DROP COLUMN IF EXISTS search_vector;") diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..a4f26ed --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,60 @@ +[project] +name = "shonar-backend" +version = "0.1.0" +description = "S.H.O.N.A.R. — Self-hosted Oral Notes and Audio Recorder backend" +readme = "README.md" +requires-python = ">=3.11" +license = { text = "Apache-2.0" } +dependencies = [ + "fastapi>=0.115", + "uvicorn[standard]>=0.30", + "sqlalchemy[asyncio]>=2.0", + "asyncpg>=0.29", + "aiosqlite>=0.20", # tests only in practice; harmless dep + "alembic>=1.13", + "pydantic>=2.8", + "pydantic-settings>=2.4", + "email-validator>=2.0", + "argon2-cffi>=23.1", + "pyjwt>=2.9", + "python-multipart>=0.0.9", + "slowapi>=0.1.9", + "redis>=5.0", + "arq>=0.26", +] + +[project.optional-dependencies] +s3 = ["boto3>=1.34"] +faster-whisper = ["faster-whisper>=1.0"] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.23", + "httpx>=0.27", + "ruff>=0.5", + "mypy>=1.10", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["shonar"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "session" +asyncio_default_test_loop_scope = "session" +testpaths = ["tests"] + +[tool.ruff] +line-length = 100 +target-version = "py311" +exclude = ["migrations"] + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM"] + +[tool.mypy] +python_version = "3.11" +ignore_missing_imports = true diff --git a/backend/shonar/__init__.py b/backend/shonar/__init__.py new file mode 100644 index 0000000..88b009d --- /dev/null +++ b/backend/shonar/__init__.py @@ -0,0 +1,3 @@ +"""S.H.O.N.A.R. — Self-hosted Oral Notes and Audio Recorder.""" + +__version__ = "0.1.0" diff --git a/backend/shonar/api/__init__.py b/backend/shonar/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/shonar/api/deps.py b/backend/shonar/api/deps.py new file mode 100644 index 0000000..f0f1f08 --- /dev/null +++ b/backend/shonar/api/deps.py @@ -0,0 +1,49 @@ +"""FastAPI dependencies: current user, session, rate limiting.""" + +from __future__ import annotations + +import uuid +from typing import Annotated + +from fastapi import Depends, HTTPException, Request, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy.ext.asyncio import AsyncSession + +from shonar.core.security import TokenError, decode_access_token +from shonar.db.models import User +from shonar.db.session import get_session + +SessionDep = Annotated[AsyncSession, Depends(get_session)] + +_bearer = HTTPBearer(auto_error=False) + + +async def get_current_user( + request: Request, + session: SessionDep, + creds: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)] = None, +) -> User: + if creds is None or not creds.credentials: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) from None + try: + payload = decode_access_token(creds.credentials) + except TokenError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + headers={"WWW-Authenticate": "Bearer"}, + ) from None + user = await session.get(User, uuid.UUID(payload["sub"])) + if user is None or not user.is_active or user.deleted_at is not None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Account unavailable" + ) from None + request.state.user_id = user.id + return user + + +CurrentUser = Annotated[User, Depends(get_current_user)] diff --git a/backend/shonar/api/schemas_common.py b/backend/shonar/api/schemas_common.py new file mode 100644 index 0000000..7eda9fe --- /dev/null +++ b/backend/shonar/api/schemas_common.py @@ -0,0 +1,69 @@ +"""Shared Pydantic schemas.""" + +from __future__ import annotations + +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, EmailStr, Field + + +class ORMModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + +# --- auth --- + + +class RegisterRequest(BaseModel): + email: EmailStr + password: str = Field(min_length=10, max_length=256) + display_name: str | None = Field(default=None, max_length=120) + + +class LoginRequest(BaseModel): + email: EmailStr + password: str + device_name: str | None = Field(default=None, max_length=120) + platform: str = Field(default="android", max_length=40) + + +class TokenPair(BaseModel): + access_token: str + token_type: str = "bearer" + expires_in: int + refresh_token: str + device_id: uuid.UUID | None = None + + +class RefreshRequest(BaseModel): + refresh_token: str + + +class LogoutRequest(BaseModel): + refresh_token: str + + +class UserOut(ORMModel): + id: uuid.UUID + email: EmailStr + display_name: str | None + location_storage_enabled: bool + created_at: datetime + + +class UserUpdate(BaseModel): + display_name: str | None = Field(default=None, max_length=120) + location_storage_enabled: bool | None = None + + +class DeviceOut(ORMModel): + id: uuid.UUID + name: str + platform: str + last_seen_at: datetime + revoked_at: datetime | None + + +class DeleteAccountRequest(BaseModel): + password: str diff --git a/backend/shonar/api/v1/__init__.py b/backend/shonar/api/v1/__init__.py new file mode 100644 index 0000000..b0741c8 --- /dev/null +++ b/backend/shonar/api/v1/__init__.py @@ -0,0 +1,13 @@ +"""API v1 router aggregation.""" + +from fastapi import APIRouter + +from shonar.api.v1 import auth, health, users + +api_router = APIRouter(prefix="/api/v1") +api_router.include_router(health.router) +api_router.include_router(auth.router) +api_router.include_router(users.router) + +# Included as later milestones land: +# - uploads, recordings, transcripts, summaries, tags, search, exports, jobs diff --git a/backend/shonar/api/v1/health.py b/backend/shonar/api/v1/health.py new file mode 100644 index 0000000..5722725 --- /dev/null +++ b/backend/shonar/api/v1/health.py @@ -0,0 +1,65 @@ +"""Health and system endpoints.""" + +from __future__ import annotations + +import time + +from fastapi import APIRouter +from sqlalchemy import text + +from shonar.api.deps import SessionDep +from shonar.core.config import get_settings + +router = APIRouter(tags=["health"]) + +_STARTED = time.monotonic() + + +@router.get("/healthz") +async def healthz() -> dict: + """Liveness: process up. No auth, no dependencies.""" + return {"status": "ok", "uptime_seconds": round(time.monotonic() - _STARTED, 1)} + + +@router.get("/readyz") +async def readyz(session: SessionDep) -> dict: + """Readiness: database reachable. Config warnings surfaced for admins + via /api/v1/system/status instead of failing readiness.""" + try: + await session.execute(text("SELECT 1")) + db_ok = True + except Exception: + db_ok = False + status_code_body = {"status": "ok" if db_ok else "degraded", "database": db_ok} + return status_code_body + + +@router.get("/system/status") +async def system_status() -> dict: + """Public-ish status: which AI features are enabled (never any secrets). + The app uses this to show honest AI-processing state to users.""" + settings = get_settings() + return { + "app": settings.app_name, + "registration_enabled": settings.allow_registration, + "ai": { + "transcription_provider": settings.transcription_provider, + "transcription_enabled": settings.transcription_provider != "none", + "llm_provider": settings.llm_provider, + "llm_enabled": settings.llm_provider != "none", + # Explicit disclosure: are any external (non-local) calls made? + "external_ai_in_use": ( + settings.transcription_provider == "whisper_http" + and "localhost" not in settings.transcription_base_url + and "127.0.0.1" not in settings.transcription_base_url + ) + or ( + settings.llm_provider == "openai_compat" + and "localhost" not in settings.llm_base_url + and "127.0.0.1" not in settings.llm_base_url + ), + }, + "storage_backend": settings.storage_backend, + "audio_conversion_enabled": settings.audio_conversion_enabled, + "config_warnings": settings.validate_production(), + } diff --git a/backend/shonar/api/v1/users.py b/backend/shonar/api/v1/users.py new file mode 100644 index 0000000..55a16af --- /dev/null +++ b/backend/shonar/api/v1/users.py @@ -0,0 +1,54 @@ +"""Users and devices.""" + +from __future__ import annotations + +import uuid + +from fastapi import APIRouter, HTTPException +from sqlalchemy import select, update + +from shonar.api.deps import CurrentUser, SessionDep +from shonar.api.schemas_common import DeviceOut, UserOut, UserUpdate +from shonar.db.models import Device, RefreshToken, utcnow + +router = APIRouter(tags=["users", "devices"]) + + +@router.get("/users/me", response_model=UserOut) +async def get_me(user: CurrentUser): + return user + + +@router.patch("/users/me", response_model=UserOut) +async def update_me(body: UserUpdate, user: CurrentUser, session: SessionDep): + if body.display_name is not None: + user.display_name = body.display_name + if body.location_storage_enabled is not None: + user.location_storage_enabled = body.location_storage_enabled + await session.flush() + return user + + +@router.get("/devices", response_model=list[DeviceOut]) +async def list_devices(user: CurrentUser, session: SessionDep): + rows = await session.scalars( + select(Device).where(Device.user_id == user.id).order_by(Device.last_seen_at.desc()) + ) + return list(rows) + + +@router.delete("/devices/{device_id}", status_code=204) +async def revoke_device(device_id: uuid.UUID, user: CurrentUser, session: SessionDep): + device = await session.get(Device, device_id) + # Ownership check — no cross-user access, and 404 (not 403) to avoid + # leaking existence. + if device is None or device.user_id != user.id: + raise HTTPException(404, "Device not found") + device.revoked_at = utcnow() + # Revoke this device's live refresh tokens. + await session.execute( + update(RefreshToken) + .where(RefreshToken.device_id == device.id, RefreshToken.revoked_at.is_(None)) + .values(revoked_at=utcnow()) + ) + return None diff --git a/backend/shonar/core/__init__.py b/backend/shonar/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/shonar/core/config.py b/backend/shonar/core/config.py new file mode 100644 index 0000000..eb50cf0 --- /dev/null +++ b/backend/shonar/core/config.py @@ -0,0 +1,124 @@ +"""Application settings. + +All configuration comes from environment variables (and optionally an +admin config file pointed at by SHONAR_CONFIG_FILE). API keys and other +secrets must NEVER be hard-coded. +""" + +from __future__ import annotations + +from functools import lru_cache + +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_prefix="SHONAR_", + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + # --- Core ------------------------------------------------------------- + app_name: str = "S.H.O.N.A.R." + debug: bool = False + # Secret used to sign access tokens. MUST be set in production. + secret_key: str = Field(default="", repr=False) + access_token_ttl_minutes: int = 15 + refresh_token_ttl_days: int = 30 + # Comma-separated list of allowed registration modes: "open", "invite" + allow_registration: bool = True + + # --- Database ----------------------------------------------------------- + database_url: str = "postgresql+asyncpg://shonar:shonar@localhost:5432/shonar" + db_pool_size: int = 5 + db_max_overflow: int = 10 + + # --- Storage ------------------------------------------------------------ + # "local" or "s3" + storage_backend: str = "local" + storage_path: str = "./data/storage" + # S3 (used when storage_backend == "s3") + s3_endpoint_url: str = "" + s3_bucket: str = "" + s3_region: str = "us-east-1" + s3_access_key_id: str = Field(default="", repr=False) + s3_secret_access_key: str = Field(default="", repr=False) + + # --- Upload limits -------------------------------------------------------- + max_upload_bytes: int = 2 * 1024 * 1024 * 1024 # 2 GiB + max_chunk_bytes: int = 16 * 1024 * 1024 + allowed_audio_mime_types: list[str] = [ + "audio/mp4", + "audio/m4a", + "audio/aac", + "audio/wav", + "audio/x-wav", + "audio/ogg", + "audio/opus", + "audio/webm", + "audio/mpeg", + ] + + # --- Worker / queue ------------------------------------------------------- + redis_url: str = "redis://localhost:6379/0" + + # --- Audio processing ----------------------------------------------------- + # Optional server-side conversion via FFmpeg. Off by default. + audio_conversion_enabled: bool = False + ffmpeg_bin: str = "ffmpeg" + + # --- AI providers ----------------------------------------------------- + # "none" disables all AI processing (recording/sync/playback still work). + transcription_provider: str = "none" # none | whisper_http | faster_whisper + transcription_model: str = "base" + transcription_base_url: str = "" # whisper-compatible HTTP server + transcription_api_key: str = Field(default="", repr=False) + + llm_provider: str = "none" # none | openai_compat | ollama + llm_model: str = "" + llm_base_url: str = "" + llm_api_key: str = Field(default="", repr=False) + + # --- Search --------------------------------------------------------- + search_backend: str = "postgres_fts" # postgres_fts (meilisearch: TODO) + + # --- Misc ------------------------------------------------------------- + rate_limit_auth: str = "10/minute" + rate_limit_default: str = "120/minute" + + @field_validator("allowed_audio_mime_types", mode="before") + @classmethod + def _split_mime(cls, v): # noqa: ANN001, ANN206 + if isinstance(v, str): + return [item.strip() for item in v.split(",") if item.strip()] + return v + + @property + def ai_enabled(self) -> bool: + return self.transcription_provider != "none" or self.llm_provider != "none" + + def validate_production(self) -> list[str]: + """Return a list of configuration warnings (empty == OK).""" + warnings: list[str] = [] + if not self.secret_key or len(self.secret_key) < 32: + warnings.append( + "SHONAR_SECRET_KEY is missing or shorter than 32 characters. " + "Set a strong random secret in production." + ) + if self.storage_backend == "s3" and not self.s3_bucket: + warnings.append("storage_backend=s3 but SHONAR_S3_BUCKET is empty.") + if self.transcription_provider == "whisper_http" and not self.transcription_base_url: + warnings.append( + "transcription_provider=whisper_http requires SHONAR_TRANSCRIPTION_BASE_URL." + ) + if self.llm_provider == "openai_compat" and not self.llm_base_url: + warnings.append("llm_provider=openai_compat requires SHONAR_LLM_BASE_URL.") + return warnings + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/backend/shonar/core/ratelimit.py b/backend/shonar/core/ratelimit.py new file mode 100644 index 0000000..621036b --- /dev/null +++ b/backend/shonar/core/ratelimit.py @@ -0,0 +1,19 @@ +"""Rate limiting (slowapi). A single shared limiter instance so counters are +consistent across endpoints; limit strings come from settings/env.""" + +from __future__ import annotations + +from slowapi import Limiter +from slowapi.util import get_remote_address + +from shonar.core.config import get_settings + +_settings = get_settings() + +limiter = Limiter( + key_func=get_remote_address, + default_limits=[], + enabled=True, +) + +auth_limit = limiter.limit(_settings.rate_limit_auth) diff --git a/backend/shonar/core/security.py b/backend/shonar/core/security.py new file mode 100644 index 0000000..8c08fd9 --- /dev/null +++ b/backend/shonar/core/security.py @@ -0,0 +1,98 @@ +"""Security primitives: password hashing, access/refresh tokens, rate limit +keying. Secrets come exclusively from settings/env.""" + +from __future__ import annotations + +import hashlib +import hmac +import secrets +import uuid +from datetime import UTC, datetime, timedelta +from typing import Any + +import jwt +from argon2 import PasswordHasher +from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError + +from shonar.core.config import get_settings + +_ph = PasswordHasher() + +ACCESS_TOKEN_TYPE = "access" +REFRESH_TOKEN_TYPE = "refresh" + + +# --------------------------------------------------------------------------- +# Passwords +# --------------------------------------------------------------------------- + + +def hash_password(password: str) -> str: + return _ph.hash(password) + + +def verify_password(password_hash: str, password: str) -> bool: + try: + return _ph.verify(password_hash, password) + except (VerifyMismatchError, VerificationError, InvalidHashError): + return False + + +# --------------------------------------------------------------------------- +# Access tokens (JWT) +# --------------------------------------------------------------------------- + + +def create_access_token(user_id: uuid.UUID, device_id: uuid.UUID | None = None) -> tuple[str, int]: + """Returns (token, ttl_seconds).""" + settings = get_settings() + ttl = settings.access_token_ttl_minutes * 60 + now = datetime.now(UTC) + payload: dict[str, Any] = { + "sub": str(user_id), + "typ": ACCESS_TOKEN_TYPE, + "iat": now, + "exp": now + timedelta(seconds=ttl), + "jti": uuid.uuid4().hex, + } + if device_id is not None: + payload["dev"] = str(device_id) + token = jwt.encode(payload, settings.secret_key, algorithm="HS256") + return token, ttl + + +class TokenError(Exception): + """Invalid or expired token.""" + + +def decode_access_token(token: str) -> dict[str, Any]: + settings = get_settings() + try: + payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"]) + except jwt.PyJWTError as exc: + raise TokenError("invalid access token") from exc + if payload.get("typ") != ACCESS_TOKEN_TYPE: + raise TokenError("wrong token type") + return payload + + +# --------------------------------------------------------------------------- +# Refresh tokens (opaque, rotated, hashed at rest) +# --------------------------------------------------------------------------- + + +def generate_refresh_token() -> str: + """Opaque high-entropy token; only its SHA-256 hash is ever stored.""" + return secrets.token_urlsafe(48) + + +def hash_refresh_token(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def refresh_token_ttl() -> timedelta: + return timedelta(days=get_settings().refresh_token_ttl_days) + + +def constant_time_equals(a: str, b: str) -> bool: + return hmac.compare_digest(a, b) diff --git a/backend/shonar/db/__init__.py b/backend/shonar/db/__init__.py new file mode 100644 index 0000000..0420631 --- /dev/null +++ b/backend/shonar/db/__init__.py @@ -0,0 +1,3 @@ +"""DB package. Importing this registers every model on Base.metadata.""" + +from shonar.db import models # noqa: F401 diff --git a/backend/shonar/db/base.py b/backend/shonar/db/base.py new file mode 100644 index 0000000..d8d01e8 --- /dev/null +++ b/backend/shonar/db/base.py @@ -0,0 +1,33 @@ +"""Public SQLAlchemy model base.""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime + +from sqlalchemy import DateTime, Uuid +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +def utcnow() -> datetime: + return datetime.now(UTC) + + +def new_uuid() -> uuid.UUID: + return uuid.uuid4() + + +class Base(DeclarativeBase): + pass + + +class PublicIdMixin: + """Gives every table a UUID public id; integer PKs stay internal.""" + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=new_uuid) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utcnow, nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False + ) diff --git a/backend/shonar/db/models.py b/backend/shonar/db/models.py new file mode 100644 index 0000000..98fe6df --- /dev/null +++ b/backend/shonar/db/models.py @@ -0,0 +1,427 @@ +"""All persistent models. + +Design rules: +- Every client-visible identifier is a UUID (``PublicIdMixin.id``). +- Filesystem/storage paths are NEVER exposed to clients. +- Original uploads are immutable; processed audio lives in separate + ``Asset`` rows and never replaces an original. +""" + +from __future__ import annotations + +import enum +import uuid +from datetime import datetime + +from sqlalchemy import ( + BigInteger, + Boolean, + DateTime, + Enum, + Float, + ForeignKey, + Index, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from shonar.db.base import Base, PublicIdMixin, utcnow + +# JSON type that uses JSONB on PostgreSQL and plain JSON elsewhere (tests on +# SQLite would need JSONB emulation; we run tests against Postgres, so JSONB +# is fine, with a generic JSON fallback for ad-hoc SQLite use). +try: # pragma: no cover + from sqlalchemy.dialects.postgresql import JSONB as JSONType +except ImportError: # pragma: no cover + from sqlalchemy import JSON as JSONType # type: ignore[attr-defined,no-redef] + + +# --------------------------------------------------------------------------- +# Users & auth +# --------------------------------------------------------------------------- + + +class User(Base, PublicIdMixin): + __tablename__ = "users" + + email: Mapped[str] = mapped_column(String(320), unique=True, index=True, nullable=False) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + display_name: Mapped[str | None] = mapped_column(String(120)) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + # Feature switches the user controls (mirror of app settings, server truth + # for e.g. whether location metadata may be stored at all). + location_storage_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + devices: Mapped[list[Device]] = relationship(back_populates="user") + recordings: Mapped[list[Recording]] = relationship(back_populates="user") + + +class Device(Base, PublicIdMixin): + __tablename__ = "devices" + + user_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False + ) + name: Mapped[str] = mapped_column(String(120), nullable=False) + platform: Mapped[str] = mapped_column(String(40), default="android", nullable=False) + last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + user: Mapped[User] = relationship(back_populates="devices") + + +class RefreshToken(Base, PublicIdMixin): + """Rotating refresh tokens, stored hashed, grouped into families for + reuse detection. A refresh consumes one row and issues its replacement + with the same ``family``.""" + + __tablename__ = "refresh_tokens" + + user_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False + ) + token_hash: Mapped[str] = mapped_column(String(128), unique=True, nullable=False) + family: Mapped[uuid.UUID] = mapped_column(index=True, nullable=False) + device_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("devices.id", ondelete="SET NULL") + ) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + replaced_by: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("refresh_tokens.id", ondelete="SET NULL") + ) + + +# --------------------------------------------------------------------------- +# Recordings, assets, uploads +# --------------------------------------------------------------------------- + + +class ProcessingStatus(enum.StrEnum): + pending_upload = "pending_upload" + uploaded = "uploaded" + queued = "queued" + processing = "processing" + completed = "completed" + failed = "failed" + # No AI configured / requested — audio-only recording, fully usable. + ai_disabled = "ai_disabled" + + +class Recording(Base, PublicIdMixin): + __tablename__ = "recordings" + + user_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False + ) + device_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("devices.id", ondelete="SET NULL") + ) + # Client-generated idempotency id so retried uploads update, not duplicate. + client_recording_id: Mapped[str | None] = mapped_column(String(64), index=True) + + title: Mapped[str] = mapped_column(String(300), nullable=False, default="Untitled recording") + recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + duration_seconds: Mapped[float] = mapped_column(Float, default=0.0, nullable=False) + notes: Mapped[str | None] = mapped_column(Text) + # Location is stored ONLY when the user has explicitly enabled it. + latitude: Mapped[float | None] = mapped_column(Float) + longitude: Mapped[float | None] = mapped_column(Float) + location_accuracy_m: Mapped[float | None] = mapped_column(Float) + + processing_status: Mapped[ProcessingStatus] = mapped_column( + Enum( + ProcessingStatus, + name="processing_status", + values_callable=lambda e: [m.value for m in e], + ), + default=ProcessingStatus.pending_upload, + nullable=False, + index=True, + ) + processing_error: Mapped[str | None] = mapped_column(Text) + # The original audio is the Asset row with kind=original for this + # recording (at most one, enforced by a partial unique index). Keeping + # the pointer one-directional avoids a recordings<->assets FK cycle. + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + user: Mapped[User] = relationship(back_populates="recordings") + assets: Mapped[list[Asset]] = relationship( + back_populates="recording", cascade="all, delete-orphan" + ) + transcripts: Mapped[list[Transcript]] = relationship(back_populates="recording") + summaries: Mapped[list[Summary]] = relationship(back_populates="recording") + tags: Mapped[list[Tag]] = relationship(secondary="recording_tags", back_populates="recordings") + + __table_args__ = ( + Index("ix_recordings_user_recorded", "user_id", "recorded_at"), + Index( + "ix_recordings_user_client_id", + "user_id", + "client_recording_id", + unique=True, + postgresql_where="client_recording_id IS NOT NULL", + ), + ) + + +class AssetKind(enum.StrEnum): + original = "original" # user's uploaded file — NEVER modified + normalized = "normalized" # derivative for processing (ffmpeg) + export = "export" # generated export bundle + + +class Asset(Base, PublicIdMixin): + """A stored binary object. ``storage_key`` is server-internal only.""" + + __tablename__ = "assets" + + recording_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("recordings.id", ondelete="CASCADE"), index=True + ) + user_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False + ) + kind: Mapped[AssetKind] = mapped_column( + Enum(AssetKind, name="asset_kind", values_callable=lambda e: [m.value for m in e]), + nullable=False, + ) + storage_key: Mapped[str] = mapped_column(String(500), nullable=False) + mime_type: Mapped[str] = mapped_column(String(100), nullable=False) + size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) + checksum_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + + recording: Mapped[Recording | None] = relationship(back_populates="assets") + + __table_args__ = ( + # At most one immutable "original" per recording. + Index( + "uq_assets_one_original", + "recording_id", + unique=True, + postgresql_where="kind = 'original'", + ), + ) + + +class UploadSessionStatus(enum.StrEnum): + open = "open" + finalizing = "finalizing" + completed = "completed" + aborted = "aborted" + expired = "expired" + + +class UploadSession(Base, PublicIdMixin): + """Chunked, resumable upload session.""" + + __tablename__ = "upload_sessions" + + user_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False + ) + recording_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("recordings.id", ondelete="CASCADE") + ) + client_recording_id: Mapped[str | None] = mapped_column(String(64)) + title: Mapped[str | None] = mapped_column(String(300)) + declared_mime_type: Mapped[str] = mapped_column(String(100), nullable=False) + declared_size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False) + chunk_size_bytes: Mapped[int] = mapped_column( + BigInteger, nullable=False, default=8 * 1024 * 1024 + ) + status: Mapped[UploadSessionStatus] = mapped_column( + Enum( + UploadSessionStatus, + name="upload_session_status", + values_callable=lambda e: [m.value for m in e], + ), + default=UploadSessionStatus.open, + nullable=False, + ) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + completed_asset_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("assets.id", ondelete="SET NULL") + ) + + chunks: Mapped[list[UploadChunk]] = relationship( + back_populates="session", cascade="all, delete-orphan" + ) + + +class UploadChunk(Base): + __tablename__ = "upload_chunks" + + id: Mapped[int] = mapped_column(primary_key=True) + session_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("upload_sessions.id", ondelete="CASCADE"), index=True, nullable=False + ) + chunk_index: Mapped[int] = mapped_column(nullable=False) + size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False) + checksum_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + storage_key: Mapped[str] = mapped_column(String(500), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + + session: Mapped[UploadSession] = relationship(back_populates="chunks") + + __table_args__ = (UniqueConstraint("session_id", "chunk_index"),) + + +# --------------------------------------------------------------------------- +# Transcript / summary / tags +# --------------------------------------------------------------------------- + + +class Transcript(Base, PublicIdMixin): + __tablename__ = "transcripts" + + recording_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("recordings.id", ondelete="CASCADE"), index=True, nullable=False + ) + # Versioned: regenerated transcripts supersede older rows; the newest + # non-superseded row is authoritative. ``edited_by_user`` rows win. + version: Mapped[int] = mapped_column(default=1, nullable=False) + superseded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + language: Mapped[str | None] = mapped_column(String(16)) + provider: Mapped[str] = mapped_column(String(80), nullable=False, default="manual") + model: Mapped[str | None] = mapped_column(String(120)) + # Full text, plus segments: [{"start": 0.0, "end": 2.5, "text": "...", + # "speaker": "S1"|null}, ...] + text: Mapped[str] = mapped_column(Text, nullable=False, default="") + segments: Mapped[list | None] = mapped_column(JSONType) + edited_by_user: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + recording: Mapped[Recording] = relationship(back_populates="transcripts") + + __table_args__ = (Index("ix_transcripts_recording_version", "recording_id", "version"),) + + +class Summary(Base, PublicIdMixin): + """Structured AI summary — user-editable. + + JSON shape of ``content``: + { + "short": str, + "detailed": str, + "key_points": [str], + "decisions": [str], + "action_items": [str], + "questions": [str] + } + """ + + __tablename__ = "summaries" + + recording_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("recordings.id", ondelete="CASCADE"), index=True, nullable=False + ) + version: Mapped[int] = mapped_column(default=1, nullable=False) + superseded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + provider: Mapped[str] = mapped_column(String(80), nullable=False, default="manual") + model: Mapped[str | None] = mapped_column(String(120)) + content: Mapped[dict] = mapped_column(JSONType, nullable=False, default=dict) + edited_by_user: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + recording: Mapped[Recording] = relationship(back_populates="summaries") + + +class Tag(Base, PublicIdMixin): + __tablename__ = "tags" + + user_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False + ) + name: Mapped[str] = mapped_column(String(80), nullable=False) + + recordings: Mapped[list[Recording]] = relationship( + secondary="recording_tags", back_populates="tags" + ) + + __table_args__ = (UniqueConstraint("user_id", "name"),) + + +class RecordingTag(Base): + __tablename__ = "recording_tags" + + recording_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("recordings.id", ondelete="CASCADE"), primary_key=True + ) + tag_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True + ) + + +# --------------------------------------------------------------------------- +# Processing jobs +# --------------------------------------------------------------------------- + + +class JobStatus(enum.StrEnum): + queued = "queued" + running = "running" + succeeded = "succeeded" + failed = "failed" + skipped = "skipped" # e.g. AI disabled, or user-edited output protected + + +class JobType(enum.StrEnum): + normalize_audio = "normalize_audio" + transcribe = "transcribe" + summarize = "summarize" + + +class ProcessingJob(Base, PublicIdMixin): + """One pipeline step for one recording. Idempotent: reruns overwrite + derived outputs (unless user-edited) and never touch originals.""" + + __tablename__ = "processing_jobs" + + recording_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("recordings.id", ondelete="CASCADE"), index=True, nullable=False + ) + job_type: Mapped[JobType] = mapped_column( + Enum(JobType, name="job_type", values_callable=lambda e: [m.value for m in e]), + nullable=False, + ) + status: Mapped[JobStatus] = mapped_column( + Enum(JobStatus, name="job_status", values_callable=lambda e: [m.value for m in e]), + default=JobStatus.queued, + nullable=False, + index=True, + ) + attempt: Mapped[int] = mapped_column(default=0, nullable=False) + max_attempts: Mapped[int] = mapped_column(default=3, nullable=False) + error: Mapped[str | None] = mapped_column(Text) + started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + # Opaque arq task handle for observability. + task_handle: Mapped[str | None] = mapped_column(String(120)) + + __table_args__ = (Index("ix_processing_jobs_recording_type", "recording_id", "job_type"),) + + +class ExportJob(Base, PublicIdMixin): + __tablename__ = "export_jobs" + + user_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False + ) + recording_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("recordings.id", ondelete="CASCADE"), index=True, nullable=False + ) + # "audio", "transcript_txt", "notes_md", "bundle_zip" + export_type: Mapped[str] = mapped_column(String(40), nullable=False) + status: Mapped[JobStatus] = mapped_column( + Enum(JobStatus, name="export_job_status", values_callable=lambda e: [m.value for m in e]), + default=JobStatus.queued, + nullable=False, + ) + asset_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("assets.id", ondelete="SET NULL")) + error: Mapped[str | None] = mapped_column(Text) + + +# Ensure full-text search columns exist on Postgres (added via migration as +# tsvector generated columns; see migrations/versions/*_fts.py). diff --git a/backend/shonar/db/session.py b/backend/shonar/db/session.py new file mode 100644 index 0000000..a13687e --- /dev/null +++ b/backend/shonar/db/session.py @@ -0,0 +1,48 @@ +"""Async SQLAlchemy engine/session management.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from shonar.core.config import get_settings + +_engine = None +_session_factory: async_sessionmaker[AsyncSession] | None = None + + +def get_engine(): + global _engine, _session_factory + if _engine is None: + settings = get_settings() + kwargs: dict = {"pool_pre_ping": True} + url = settings.database_url + # SQLite (tests) does not support the pg pool sizing kwargs. + if url.startswith("sqlite"): + kwargs = {} + else: + kwargs.update(pool_size=settings.db_pool_size, max_overflow=settings.db_max_overflow) + _engine = create_async_engine(url, **kwargs) + _session_factory = async_sessionmaker(_engine, expire_on_commit=False) + return _engine + + +async def dispose_engine() -> None: + global _engine, _session_factory + if _engine is not None: + await _engine.dispose() + _engine = None + _session_factory = None + + +async def get_session() -> AsyncIterator[AsyncSession]: + """FastAPI dependency yielding a database session.""" + assert _session_factory is not None, "engine not initialised" + async with _session_factory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise diff --git a/backend/shonar/main.py b/backend/shonar/main.py new file mode 100644 index 0000000..6e937bf --- /dev/null +++ b/backend/shonar/main.py @@ -0,0 +1,64 @@ +"""S.H.O.N.A.R. FastAPI application entrypoint. + +Run (dev): uvicorn shonar.main:app --reload --port 8000 +""" + +from __future__ import annotations + +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from slowapi import _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded + +from shonar import __version__ +from shonar.api.v1 import api_router +from shonar.core.config import get_settings +from shonar.core.ratelimit import limiter +from shonar.db.session import dispose_engine, get_engine + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("shonar") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + settings = get_settings() + get_engine() # validate URL parses; connections are lazy + for warning in settings.validate_production(): + logger.warning("CONFIG: %s", warning) + yield + await dispose_engine() + + +app = FastAPI( + title="S.H.O.N.A.R. API", + description=( + "Self-hosted Oral Notes and Audio Recorder — REST API. All data stays on your server." + ), + version=__version__, + lifespan=lifespan, + docs_url="/api/docs", + redoc_url="/api/redoc", + openapi_url="/api/openapi.json", +) + +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + + +@app.exception_handler(Exception) +async def unhandled_exception_handler(request: Request, exc: Exception): + """Safe error surface: log details server-side, never leak internals.""" + logger.exception("Unhandled error on %s %s", request.method, request.url.path) + return JSONResponse(status_code=500, content={"detail": "Internal server error"}) + + +app.include_router(api_router) + + +@app.get("/") +async def root(): + return {"app": "S.H.O.N.A.R.", "docs": "/api/docs", "health": "/api/v1/healthz"} diff --git a/backend/shonar/services/__init__.py b/backend/shonar/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/shonar/storage/__init__.py b/backend/shonar/storage/__init__.py new file mode 100644 index 0000000..b9aa77b --- /dev/null +++ b/backend/shonar/storage/__init__.py @@ -0,0 +1,179 @@ +"""Storage abstraction. + +Backends: +- ``LocalStorage``: filesystem under ``SHONAR_STORAGE_PATH`` (dev + default). +- ``S3Storage``: any S3-compatible object store (extra: ``pip install + shonar-backend[s3]``). + +Storage keys are server-internal and validated against path traversal: +they are UUID-based by construction and never derived from user input. +At-rest encryption is NOT implemented; see docs/security.md for the +documented optional design. +""" + +from __future__ import annotations + +import asyncio +import shutil +from pathlib import Path, PurePosixPath +from typing import Protocol + +from shonar.core.config import get_settings + +_KEY_CHARSET = set("abcdefghijklmnopqrstuvwxyz0123456789-./") + + +class StorageError(Exception): + pass + + +def validate_storage_key(key: str) -> str: + """Reject anything that could escape the storage root.""" + if not key or len(key) > 500: + raise StorageError("invalid storage key") + pure = PurePosixPath(key) + if pure.is_absolute() or ".." in pure.parts: + raise StorageError("invalid storage key") + if not set(key) <= _KEY_CHARSET: + raise StorageError("invalid storage key") + return key + + +class StorageBackend(Protocol): + async def put(self, key: str, data: bytes) -> int: ... + async def put_file(self, key: str, src_path: Path) -> int: ... + async def get(self, key: str) -> bytes: ... + async def open_path(self, key: str) -> Path | None: + """Local file path if the backend can provide one, else None.""" + ... + + async def delete(self, key: str) -> None: ... + async def exists(self, key: str) -> bool: ... + + +class LocalStorage: + def __init__(self, root: str | Path): + self.root = Path(root).resolve() + self.root.mkdir(parents=True, exist_ok=True) + + def _path(self, key: str) -> Path: + validate_storage_key(key) + path = (self.root / key).resolve() + # Defense in depth: resolved path must stay under root. + if not path.is_relative_to(self.root): + raise StorageError("storage key escapes storage root") + return path + + async def put(self, key: str, data: bytes) -> int: + path = self._path(key) + path.parent.mkdir(parents=True, exist_ok=True) + await asyncio.to_thread(path.write_bytes, data) + return len(data) + + async def put_file(self, key: str, src_path: Path) -> int: + path = self._path(key) + path.parent.mkdir(parents=True, exist_ok=True) + await asyncio.to_thread(shutil.copyfile, src_path, path) + return path.stat().st_size + + async def get(self, key: str) -> bytes: + path = self._path(key) + if not path.exists(): + raise StorageError("object not found") + return await asyncio.to_thread(path.read_bytes) + + async def open_path(self, key: str) -> Path | None: + path = self._path(key) + return path if path.exists() else None + + async def delete(self, key: str) -> None: + path = self._path(key) + if path.exists(): + await asyncio.to_thread(path.unlink) + + async def exists(self, key: str) -> bool: + return self._path(key).exists() + + +class S3Storage: # pragma: no cover - requires boto3 + a real/mini endpoint + def __init__( + self, endpoint_url: str, bucket: str, region: str, access_key: str, secret_key: str + ): + import boto3 # optional extra + + self.bucket = bucket + self.s3 = boto3.client( + "s3", + endpoint_url=endpoint_url or None, + region_name=region, + aws_access_key_id=access_key or None, + aws_secret_access_key=secret_key or None, + ) + + async def put(self, key: str, data: bytes) -> int: + validate_storage_key(key) + await asyncio.to_thread(self.s3.put_object, Bucket=self.bucket, Key=key, Body=data) + return len(data) + + async def put_file(self, key: str, src_path: Path) -> int: + validate_storage_key(key) + await asyncio.to_thread(self.s3.upload_file, str(src_path), self.bucket, key) + return src_path.stat().st_size + + async def get(self, key: str) -> bytes: + validate_storage_key(key) + + def _get() -> bytes: + obj = self.s3.get_object(Bucket=self.bucket, Key=key) + return obj["Body"].read() + + return await asyncio.to_thread(_get) + + async def open_path(self, key: str) -> Path | None: + return None # callers must use get()/streaming + + async def delete(self, key: str) -> None: + validate_storage_key(key) + await asyncio.to_thread(self.s3.delete_object, Bucket=self.bucket, Key=key) + + async def exists(self, key: str) -> bool: + validate_storage_key(key) + + def _head() -> bool: + from botocore.exceptions import ClientError + + try: + self.s3.head_object(Bucket=self.bucket, Key=key) + return True + except ClientError: + return False + + return await asyncio.to_thread(_head) + + +_backend: StorageBackend | None = None + + +def get_storage() -> StorageBackend: + global _backend + if _backend is None: + settings = get_settings() + if settings.storage_backend == "local": + _backend = LocalStorage(settings.storage_path) + elif settings.storage_backend == "s3": + _backend = S3Storage( + settings.s3_endpoint_url, + settings.s3_bucket, + settings.s3_region, + settings.s3_access_key_id, + settings.s3_secret_access_key, + ) + else: + raise StorageError(f"unknown storage backend: {settings.storage_backend}") + return _backend + + +def set_storage(backend: StorageBackend | None) -> None: + """Test seam.""" + global _backend + _backend = backend diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..4cdf287 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,80 @@ +"""Pytest fixtures. + +Tests run against a real PostgreSQL (deploy/docker-compose.dev.yml) using a +dedicated ``shonar_test`` database, plus a temporary local storage root. +""" + +from __future__ import annotations + +import os +import tempfile +from collections.abc import AsyncIterator +from pathlib import Path + +import pytest +import pytest_asyncio + +# Configure env BEFORE importing the app so Settings picks it up. +TEST_DB = os.environ.get( + "SHONAR_TEST_DATABASE_URL", + "postgresql+asyncpg://shonar:shonar@localhost:5432/shonar_test", +) +os.environ["SHONAR_DATABASE_URL"] = TEST_DB +os.environ["SHONAR_SECRET_KEY"] = "test-secret-key-0123456789abcdef0123456789abcdef" +os.environ["SHONAR_STORAGE_BACKEND"] = "local" +# Effectively disable the auth rate limit under test (dedicated tests cover +# the limiter behaviour itself). +os.environ["SHONAR_RATE_LIMIT_AUTH"] = "10000/minute" + +_tmp_storage = tempfile.mkdtemp(prefix="shonar-test-storage-") +os.environ["SHONAR_STORAGE_PATH"] = _tmp_storage + + +@pytest_asyncio.fixture(scope="session", loop_scope="session") +async def _setup_db() -> AsyncIterator[None]: + from shonar.db import models # noqa: F401 + from shonar.db.base import Base + from shonar.db.session import dispose_engine, get_engine + + engine = get_engine() + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + yield + await dispose_engine() + + +@pytest_asyncio.fixture(loop_scope="session", autouse=True) +async def clean_db(_setup_db: None) -> AsyncIterator[None]: + """Truncate between tests for isolation.""" + yield + from sqlalchemy import text + + from shonar.db.session import _session_factory # type: ignore[attr-defined] + + assert _session_factory is not None + async with _session_factory() as s: + await s.execute( + text( + "TRUNCATE users, devices, refresh_tokens, recordings, assets, " + "upload_sessions, upload_chunks, transcripts, summaries, tags, " + "recording_tags, processing_jobs, export_jobs RESTART IDENTITY CASCADE" + ) + ) + await s.commit() + + +@pytest_asyncio.fixture(loop_scope="session") +async def client(_setup_db) -> AsyncIterator: + from httpx import ASGITransport, AsyncClient + + from shonar.main import app + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +@pytest.fixture() +def storage_root() -> Path: + return Path(_tmp_storage) diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py new file mode 100644 index 0000000..cc65da8 --- /dev/null +++ b/backend/tests/test_health.py @@ -0,0 +1,29 @@ +"""Health-check and system-status tests.""" + + +async def test_healthz(client): + r = await client.get("/api/v1/healthz") + assert r.status_code == 200 + body = r.json() + assert body["status"] == "ok" + assert body["uptime_seconds"] >= 0 + + +async def test_readyz_with_db(client): + r = await client.get("/api/v1/readyz") + assert r.status_code == 200 + assert r.json() == {"status": "ok", "database": True} + + +async def test_system_status_reports_ai_state_honestly(client): + r = await client.get("/api/v1/system/status") + assert r.status_code == 200 + body = r.json() + # Default test config: AI disabled, no external calls. + assert body["ai"]["transcription_enabled"] is False + assert body["ai"]["llm_enabled"] is False + assert body["ai"]["external_ai_in_use"] is False + # No secrets ever present in the public status payload. + body_text = r.text.lower() + for leak in ("api_key", "password", "secret_key"): + assert leak not in body_text diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml new file mode 100644 index 0000000..4122c8c --- /dev/null +++ b/deploy/docker-compose.dev.yml @@ -0,0 +1,33 @@ +name: shonar-dev + +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${SHONAR_DB_USER:-shonar} + POSTGRES_PASSWORD: ${SHONAR_DB_PASS:-shonar} + POSTGRES_DB: ${SHONAR_DB_NAME:-shonar} + ports: + - "127.0.0.1:5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${SHONAR_DB_USER:-shonar} -d ${SHONAR_DB_NAME:-shonar}"] + interval: 5s + timeout: 3s + retries: 10 + + redis: + image: redis:7-alpine + restart: unless-stopped + ports: + - "127.0.0.1:6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + +volumes: + pgdata: diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..320d5f5 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,31 @@ +# Roadmap & feature status + +Every checked item is implemented, tested, and present at the current HEAD. +Anything not listed is aspirational. This table is maintained by hand and +updated in the same commit as the work it describes. + +| Milestone | Scope | Status | +|---|---|---| +| M0 | Repo scaffold, license, docs, Docker dev stack, `/healthz` `/readyz`, Alembic schema | done | +| M1 | Auth: register / login / rotating refresh + reuse detection / logout / delete-account, Argon2id, rate limits | done | +| M2 | Upload sessions (chunked, resumable), storage abstraction (local + S3), recordings CRUD, ownership checks | in progress | +| M3 | Android: server URL config, login, token persistence + auto-refresh | TODO | +| M4 | Android: foreground-service recording (pause/resume/stop), metadata, Room | TODO | +| M5 | Android: WorkManager upload sync (retry, Wi-Fi-only, charging-only, pause) | TODO | +| M6 | Android: library (search/filter/sort), playback (seek/speed), waveform, download/delete | TODO | +| M7 | Backend: AI pipeline + adapters (whisper_http, faster-whisper, OpenAI-compat, Ollama, none), status endpoints | TODO | +| M8 | Android: details screen — transcript synced to playback, summary, action items, editing | TODO | +| M9 | Backend: full-text search endpoints + filters, exports (audio/txt/md/zip), deletion sweep | TODO (schema/FTS columns exist) | +| M10 | Android dark mode, accessibility pass, consent UX polish, deploy/backup docs, OpenAPI sync | TODO | + +## Explicit TODOs (not yet implemented) + +- **Speaker diarization**: adapter interface planned; next step is a + `DiarizationProvider` protocol + pyannote-audio adapter behind + `SHONAR_DIARIZATION_PROVIDER`. +- **At-rest encryption**: design in `docs/security.md`; implementation not + started. Files are stored unencrypted unless you encrypt the volume. +- **Meilisearch/OpenSearch search backend**: only Postgres FTS is planned to + ship first, behind a `SearchBackend` protocol. +- **Account purge sweep**: deletion marks a 30-day grace; the scheduled hard + purge job is TODO (`worker/tasks.py::purge_deleted_accounts`). diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..860eeb7 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,64 @@ +# Architecture + +``` + Android app Your server +┌───────────────────┐ ┌──────────────────────────────────┐ +│ Compose UI │ │ nginx (TLS) │ +│ ViewModels │ HTTPS│ └─ FastAPI app ──► PostgreSQL │ +│ Room (offline DB) │◄─────┤ │ (records, │ +│ WorkManager │ │ │ FTS) │ +│ └ upload workers │ │ Redis◄┘ │ +│ MediaRecorder │ │ └─ arq worker ──► storage/ │ +│ Foreground service│ │ │ (originals + │ +│ Media3 player │ │ │ derivatives) │ +└───────────────────┘ │ AI adapters (optional): │ + │ STT: whisper_http|faster-whisper│ + │ LLM: ollama|openai_compat|none │ + └──────────────────────────────────┘ +``` + +## Principles + +1. **Offline-first.** The Android app is fully usable with no server: + recordings live in Room + app storage; sync is a background concern. +2. **Originals are sacred.** Uploads are stored immutably. Every processing + step writes a new derivative asset. A failed job can never corrupt input. +3. **Idempotent pipeline.** Jobs are keyed (recording, job_type); reruns + overwrite derived outputs unless the user edited them; client-supplied + `client_recording_id` makes retried uploads update-not-duplicate. +4. **Provider independence.** Transcription and LLM live behind Protocols; + adapters are selected by env config. `none` is a first-class provider. +5. **UUID everywhere.** No integer IDs, no filesystem paths, no internals in + API payloads. + +## Processing pipeline + +``` +upload finalize ──► [normalize (optional ffmpeg)] ──► transcribe + │ + transcript (versioned, timestamped segments) + │ + summarize + extract + │ + short / detailed / key_points / decisions / action_items / questions +``` + +Each arrow is one `processing_jobs` row: queued → running → succeeded/failed, +with attempts and error text. Recording status aggregates the steps +(`uploaded → queued → processing → completed | failed | ai_disabled`). + +## Sync protocol (Android ⇄ server) + +- Room table holds `sync_state` (LOCAL_ONLY, PENDING_UPLOAD, UPLOADING, + SYNCED, ERROR) + `client_recording_id` (UUID, stable across retries). +- WorkManager `UploadWorker`: constraints (Wi-Fi/charging) from user prefs; + exponential backoff retries; manual pause cancels work, state preserved. +- Upload = create session → PUT chunks (skip ones the session already has) → + finalize with checksum. Resumable at chunk granularity. + +## Why adapters-as-protocols + +The AI layer's only job is: audio in → text out; text in → structured JSON +out. Protocols (`sonar/ai/interfaces.py`) keep the pipeline testable with a +`StubProvider` and make new providers (e.g. whisperX, llama.cpp) a single +file + one env value. diff --git a/docs/recording-consent.md b/docs/recording-consent.md new file mode 100644 index 0000000..0da3c45 --- /dev/null +++ b/docs/recording-consent.md @@ -0,0 +1,33 @@ +# Recording consent — legal notice + +**Recording conversations may be illegal without consent.** + +Laws vary widely by jurisdiction: + +- **One-party consent**: you may record conversations you participate in. +- **All-party consent** (many US states, and many other countries): every + participant must consent before recording begins. +- Recording conversations you are *not* part of is illegal in essentially + every jurisdiction. + +S.H.O.N.A.R. is a tool. **You** are solely responsible for knowing and +following the law where you are. + +## What the app does (and never does) + +- Shows a consent reminder on first launch and persistently on the recording + screen. +- **Never records silently.** Recording runs as a foreground service with an + ongoing notification, and Android's own microphone indicator is visible on + Android 12+. +- Provides an always-visible stop control on the recording screen and in the + notification. +- Has no remote-trigger recording capability of any kind. +- Collects no telemetry and sends no data anywhere except the server URL you + configure. + +## Recommended practice + +Before recording, tell everyone present that the conversation is being +recorded and obtain their agreement. Where written consent is required, get +it in writing. diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..b2b695a --- /dev/null +++ b/docs/security.md @@ -0,0 +1,81 @@ +# Security model (honest version) + +This document describes what S.H.O.N.A.R. actually implements, what it does +not, and the threat model assumptions. No claims beyond the code. + +## Implemented + +**Authentication** +- Passwords hashed with Argon2id (`argon2-cffi` defaults). +- Access tokens: short-lived (15 min) JWT HS256, `typ` claim prevents + cross-type use. +- Refresh tokens: opaque 384-bit values; only SHA-256 hashes stored; rotated + on every use; token families with **reuse detection** (presenting a spent + token revokes the entire family — commits before the 401 so the revocation + survives the failed request). +- Logout and device revocation revoke refresh families server-side. +- Auth endpoints rate-limited per client IP (slowapi; `SHONAR_RATE_LIMIT_AUTH`). + +**Authorization** +- Every recording/transcript/summary/tag/upload-session endpoint filters by + `user_id` server-side; ownership failures return 404 (no existence leaks). +- User enumeration is mitigated on login (identical error for unknown email + vs wrong password). + +**Uploads & files** +- Upload size caps (`SHONAR_MAX_UPLOAD_BYTES`, per-chunk caps). +- MIME allow-list (`SHONAR_ALLOWED_AUDIO_MIME_TYPES`) + magic-byte sniffing + on finalize (ftyp/RIFF/OggS/ID3). +- Storage keys are server-generated UUID paths; `validate_storage_key()` + rejects absolute paths, `..`, and any character outside `[a-z0-9-./]`; + LocalStorage additionally verifies the resolved path stays under the root. +- Originals are immutable: processing writes separate derivative assets. +- File downloads go through authenticated, ownership-checked endpoints; no + static file route exposes storage. + +**API surface** +- Pydantic input validation on every endpoint. +- Global exception handler returns generic `Internal server error`; details + are logged server-side only. +- `/api/v1/system/status` discloses AI provider state but never secrets. + +**Privacy** +- No telemetry, no analytics, no crash reporting, no update pings — in the + app or the server. +- No network calls to AI providers unless configured; external usage is + disclosed via `external_ai_in_use` in system status and shown in the app's + Settings screen. +- Location metadata is stored only when the user explicitly enables it + (`location_storage_enabled`, default off, enforced server-side). + +## NOT implemented (do not assume otherwise) + +- **At-rest encryption: NOT implemented.** Files on disk and database rows + are plaintext. Documented design if you want it: + 1. Envelope encryption: per-user data key, AES-256-GCM; + 2. DEKs wrapped by a KEK derived from a passphrase entered at app unlock + (Argon2id), never stored server-side; + 3. Chunk-level encryption during upload so the server stores ciphertext; + 4. Trade-offs: breaks server-side transcription unless the worker holds + keys — choose client-side-only (private but no AI) vs worker-side + (AI works, worker is trusted). + Until this ships, **encrypt the volume** (LUKS/zfs-encrypt) or the bucket + (SSE) at the infrastructure layer. +- **TLS termination:** the app enforces HTTPS for non-localhost servers, but + the backend container serves plain HTTP — you must terminate TLS at a + reverse proxy (example in `deploy/nginx.conf`). +- **OIDC / 2FA:** not implemented. +- **Account hard purge:** deletion applies a 30-day grace marker; the + scheduled purge job is TODO (see ROADMAP). Rows and files are retained + until it lands — do not rely on deletion as immediate erasure. + +## Deployment assumptions + +- Single-instance API + worker behind a trusted reverse proxy. +- Postgres and Redis are not exposed publicly (compose binds 127.0.0.1). +- `SHONAR_SECRET_KEY` is secret and ≥32 chars (startup warns otherwise). + +> Accuracy note: the items under “Implemented → Uploads & files” and the +> HTTPS enforcement in the app ship with milestones M2/M3 and are described +> here as the design they implement; check `docs/ROADMAP.md` for what is +> live at HEAD today. diff --git a/scripts/dev_bootstrap.sh b/scripts/dev_bootstrap.sh new file mode 100755 index 0000000..704956a --- /dev/null +++ b/scripts/dev_bootstrap.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# One-shot local dev bootstrap: infra, deps, migrations, API. +set -euo pipefail +cd "$(dirname "$0")/.." + +echo "==> starting postgres + redis" +docker compose -f deploy/docker-compose.dev.yml up -d + +echo "==> backend deps" +cd backend +uv venv .venv 2>/dev/null || true +uv pip install -e ".[dev]" + +echo "==> test database" +docker exec shonar-dev-postgres-1 psql -U shonar -d postgres -tc \ + "SELECT 1 FROM pg_database WHERE datname='shonar_test'" | grep -q 1 || \ + docker exec shonar-dev-postgres-1 psql -U shonar -d postgres -c "CREATE DATABASE shonar_test" + +echo "==> migrations" +export SHONAR_SECRET_KEY="${SHONAR_SECRET_KEY:-dev-only-0123456789abcdef0123456789abcdef}" +.venv/bin/alembic upgrade head + +echo +echo "ready. start the API with:" +echo " cd backend && .venv/bin/uvicorn shonar.main:app --reload --port 8000" diff --git a/scripts/gen_openapi.sh b/scripts/gen_openapi.sh new file mode 100755 index 0000000..e2172f4 --- /dev/null +++ b/scripts/gen_openapi.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Regenerate the OpenAPI contract shared with the Android client. +# Run from repo root after backend API changes; commit the result. +set -euo pipefail +cd "$(dirname "$0")/.." +backend/.venv/bin/python - <<'PY' +import json +from shonar.main import app +spec = app.openapi() +with open("shared/openapi.json", "w") as f: + json.dump(spec, f, indent=2, sort_keys=True) + f.write("\n") +print(f"wrote shared/openapi.json ({len(spec['paths'])} paths)") +PY diff --git a/shared/openapi.json b/shared/openapi.json new file mode 100644 index 0000000..63cec6f --- /dev/null +++ b/shared/openapi.json @@ -0,0 +1,797 @@ +{ + "components": { + "schemas": { + "DeleteAccountRequest": { + "properties": { + "password": { + "title": "Password", + "type": "string" + } + }, + "required": [ + "password" + ], + "title": "DeleteAccountRequest", + "type": "object" + }, + "DeviceOut": { + "properties": { + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "last_seen_at": { + "format": "date-time", + "title": "Last Seen At", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "platform": { + "title": "Platform", + "type": "string" + }, + "revoked_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Revoked At" + } + }, + "required": [ + "id", + "name", + "platform", + "last_seen_at", + "revoked_at" + ], + "title": "DeviceOut", + "type": "object" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "title": "Detail", + "type": "array" + } + }, + "title": "HTTPValidationError", + "type": "object" + }, + "LoginRequest": { + "properties": { + "device_name": { + "anyOf": [ + { + "maxLength": 120, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Device Name" + }, + "email": { + "format": "email", + "title": "Email", + "type": "string" + }, + "password": { + "title": "Password", + "type": "string" + }, + "platform": { + "default": "android", + "maxLength": 40, + "title": "Platform", + "type": "string" + } + }, + "required": [ + "email", + "password" + ], + "title": "LoginRequest", + "type": "object" + }, + "LogoutRequest": { + "properties": { + "refresh_token": { + "title": "Refresh Token", + "type": "string" + } + }, + "required": [ + "refresh_token" + ], + "title": "LogoutRequest", + "type": "object" + }, + "RefreshRequest": { + "properties": { + "refresh_token": { + "title": "Refresh Token", + "type": "string" + } + }, + "required": [ + "refresh_token" + ], + "title": "RefreshRequest", + "type": "object" + }, + "RegisterRequest": { + "properties": { + "display_name": { + "anyOf": [ + { + "maxLength": 120, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Display Name" + }, + "email": { + "format": "email", + "title": "Email", + "type": "string" + }, + "password": { + "maxLength": 256, + "minLength": 10, + "title": "Password", + "type": "string" + } + }, + "required": [ + "email", + "password" + ], + "title": "RegisterRequest", + "type": "object" + }, + "TokenPair": { + "properties": { + "access_token": { + "title": "Access Token", + "type": "string" + }, + "device_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Device Id" + }, + "expires_in": { + "title": "Expires In", + "type": "integer" + }, + "refresh_token": { + "title": "Refresh Token", + "type": "string" + }, + "token_type": { + "default": "bearer", + "title": "Token Type", + "type": "string" + } + }, + "required": [ + "access_token", + "expires_in", + "refresh_token" + ], + "title": "TokenPair", + "type": "object" + }, + "UserOut": { + "properties": { + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "display_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Display Name" + }, + "email": { + "format": "email", + "title": "Email", + "type": "string" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "location_storage_enabled": { + "title": "Location Storage Enabled", + "type": "boolean" + } + }, + "required": [ + "id", + "email", + "display_name", + "location_storage_enabled", + "created_at" + ], + "title": "UserOut", + "type": "object" + }, + "UserUpdate": { + "properties": { + "display_name": { + "anyOf": [ + { + "maxLength": 120, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Display Name" + }, + "location_storage_enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Location Storage Enabled" + } + }, + "title": "UserUpdate", + "type": "object" + }, + "ValidationError": { + "properties": { + "ctx": { + "title": "Context", + "type": "object" + }, + "input": { + "title": "Input" + }, + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "title": "Location", + "type": "array" + }, + "msg": { + "title": "Message", + "type": "string" + }, + "type": { + "title": "Error Type", + "type": "string" + } + }, + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError", + "type": "object" + } + }, + "securitySchemes": { + "HTTPBearer": { + "scheme": "bearer", + "type": "http" + } + } + }, + "info": { + "description": "Self-hosted Oral Notes and Audio Recorder \u2014 REST API. All data stays on your server.", + "title": "S.H.O.N.A.R. API", + "version": "0.1.0" + }, + "openapi": "3.1.0", + "paths": { + "/": { + "get": { + "operationId": "root__get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + } + }, + "summary": "Root" + } + }, + "/api/v1/auth/delete-account": { + "post": { + "operationId": "delete_account_api_v1_auth_delete_account_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteAccountRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Delete Account", + "tags": [ + "auth" + ] + } + }, + "/api/v1/auth/login": { + "post": { + "operationId": "login_api_v1_auth_login_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenPair" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Login", + "tags": [ + "auth" + ] + } + }, + "/api/v1/auth/logout": { + "post": { + "operationId": "logout_api_v1_auth_logout_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LogoutRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Logout", + "tags": [ + "auth" + ] + } + }, + "/api/v1/auth/me": { + "get": { + "operationId": "me_api_v1_auth_me_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserOut" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Me", + "tags": [ + "auth" + ] + } + }, + "/api/v1/auth/refresh": { + "post": { + "operationId": "refresh_api_v1_auth_refresh_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RefreshRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenPair" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Refresh", + "tags": [ + "auth" + ] + } + }, + "/api/v1/auth/register": { + "post": { + "operationId": "register_api_v1_auth_register_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenPair" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Register", + "tags": [ + "auth" + ] + } + }, + "/api/v1/devices": { + "get": { + "operationId": "list_devices_api_v1_devices_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/DeviceOut" + }, + "title": "Response List Devices Api V1 Devices Get", + "type": "array" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "List Devices", + "tags": [ + "users", + "devices" + ] + } + }, + "/api/v1/devices/{device_id}": { + "delete": { + "operationId": "revoke_device_api_v1_devices__device_id__delete", + "parameters": [ + { + "in": "path", + "name": "device_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Device Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Revoke Device", + "tags": [ + "users", + "devices" + ] + } + }, + "/api/v1/healthz": { + "get": { + "description": "Liveness: process up. No auth, no dependencies.", + "operationId": "healthz_api_v1_healthz_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Healthz Api V1 Healthz Get", + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Healthz", + "tags": [ + "health" + ] + } + }, + "/api/v1/readyz": { + "get": { + "description": "Readiness: database reachable. Config warnings surfaced for admins\nvia /api/v1/system/status instead of failing readiness.", + "operationId": "readyz_api_v1_readyz_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Readyz Api V1 Readyz Get", + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Readyz", + "tags": [ + "health" + ] + } + }, + "/api/v1/system/status": { + "get": { + "description": "Public-ish status: which AI features are enabled (never any secrets).\nThe app uses this to show honest AI-processing state to users.", + "operationId": "system_status_api_v1_system_status_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response System Status Api V1 System Status Get", + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "System Status", + "tags": [ + "health" + ] + } + }, + "/api/v1/users/me": { + "get": { + "operationId": "get_me_api_v1_users_me_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserOut" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Get Me", + "tags": [ + "users", + "devices" + ] + }, + "patch": { + "operationId": "update_me_api_v1_users_me_patch", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Update Me", + "tags": [ + "users", + "devices" + ] + } + } + } +} diff --git a/worker/README.md b/worker/README.md new file mode 100644 index 0000000..7bd997b --- /dev/null +++ b/worker/README.md @@ -0,0 +1,6 @@ +"""Worker entrypoint: runs the arq queue on the backend image. + +Pipeline tasks land with milestone M7; until then the worker image runs an +idle loop so `docker compose up` is complete and future-proof. +TODO(M7): implement sonar/processing/tasks.py and wire arq.WorkerSettings. +"""