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
This commit is contained in:
commit
5fab96e824
46 changed files with 3616 additions and 0 deletions
67
.env.example
Normal file
67
.env.example
Normal file
|
|
@ -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
|
||||||
28
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
28
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
|
|
@ -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.
|
||||||
17
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
17
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
|
|
@ -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
|
||||||
49
.github/workflows/ci.yml
vendored
Normal file
49
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -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') }}
|
||||||
32
.gitignore
vendored
Normal file
32
.gitignore
vendored
Normal file
|
|
@ -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
|
||||||
29
CODE_OF_CONDUCT.md
Normal file
29
CODE_OF_CONDUCT.md
Normal file
|
|
@ -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/
|
||||||
53
CONTRIBUTING.md
Normal file
53
CONTRIBUTING.md
Normal file
|
|
@ -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.
|
||||||
202
LICENSE
Normal file
202
LICENSE
Normal file
|
|
@ -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.
|
||||||
121
README.md
Normal file
121
README.md
Normal file
|
|
@ -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.
|
||||||
23
SECURITY.md
Normal file
23
SECURITY.md
Normal file
|
|
@ -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).
|
||||||
16
backend/README.md
Normal file
16
backend/README.md
Normal file
|
|
@ -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`
|
||||||
37
backend/alembic.ini
Normal file
37
backend/alembic.ini
Normal file
|
|
@ -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
|
||||||
62
backend/migrations/env.py
Normal file
62
backend/migrations/env.py
Normal file
|
|
@ -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()
|
||||||
26
backend/migrations/script.py.mako
Normal file
26
backend/migrations/script.py.mako
Normal file
|
|
@ -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"}
|
||||||
274
backend/migrations/versions/8d51af959ae4_initial_schema.py
Normal file
274
backend/migrations/versions/8d51af959ae4_initial_schema.py
Normal file
|
|
@ -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 ###
|
||||||
79
backend/migrations/versions/fts0000000001_fts_columns.py
Normal file
79
backend/migrations/versions/fts0000000001_fts_columns.py
Normal file
|
|
@ -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;")
|
||||||
60
backend/pyproject.toml
Normal file
60
backend/pyproject.toml
Normal file
|
|
@ -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
|
||||||
3
backend/shonar/__init__.py
Normal file
3
backend/shonar/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""S.H.O.N.A.R. — Self-hosted Oral Notes and Audio Recorder."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
0
backend/shonar/api/__init__.py
Normal file
0
backend/shonar/api/__init__.py
Normal file
49
backend/shonar/api/deps.py
Normal file
49
backend/shonar/api/deps.py
Normal file
|
|
@ -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)]
|
||||||
69
backend/shonar/api/schemas_common.py
Normal file
69
backend/shonar/api/schemas_common.py
Normal file
|
|
@ -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
|
||||||
13
backend/shonar/api/v1/__init__.py
Normal file
13
backend/shonar/api/v1/__init__.py
Normal file
|
|
@ -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
|
||||||
65
backend/shonar/api/v1/health.py
Normal file
65
backend/shonar/api/v1/health.py
Normal file
|
|
@ -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(),
|
||||||
|
}
|
||||||
54
backend/shonar/api/v1/users.py
Normal file
54
backend/shonar/api/v1/users.py
Normal file
|
|
@ -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
|
||||||
0
backend/shonar/core/__init__.py
Normal file
0
backend/shonar/core/__init__.py
Normal file
124
backend/shonar/core/config.py
Normal file
124
backend/shonar/core/config.py
Normal file
|
|
@ -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()
|
||||||
19
backend/shonar/core/ratelimit.py
Normal file
19
backend/shonar/core/ratelimit.py
Normal file
|
|
@ -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)
|
||||||
98
backend/shonar/core/security.py
Normal file
98
backend/shonar/core/security.py
Normal file
|
|
@ -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)
|
||||||
3
backend/shonar/db/__init__.py
Normal file
3
backend/shonar/db/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""DB package. Importing this registers every model on Base.metadata."""
|
||||||
|
|
||||||
|
from shonar.db import models # noqa: F401
|
||||||
33
backend/shonar/db/base.py
Normal file
33
backend/shonar/db/base.py
Normal file
|
|
@ -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
|
||||||
|
)
|
||||||
427
backend/shonar/db/models.py
Normal file
427
backend/shonar/db/models.py
Normal file
|
|
@ -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).
|
||||||
48
backend/shonar/db/session.py
Normal file
48
backend/shonar/db/session.py
Normal file
|
|
@ -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
|
||||||
64
backend/shonar/main.py
Normal file
64
backend/shonar/main.py
Normal file
|
|
@ -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"}
|
||||||
0
backend/shonar/services/__init__.py
Normal file
0
backend/shonar/services/__init__.py
Normal file
179
backend/shonar/storage/__init__.py
Normal file
179
backend/shonar/storage/__init__.py
Normal file
|
|
@ -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
|
||||||
80
backend/tests/conftest.py
Normal file
80
backend/tests/conftest.py
Normal file
|
|
@ -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)
|
||||||
29
backend/tests/test_health.py
Normal file
29
backend/tests/test_health.py
Normal file
|
|
@ -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
|
||||||
33
deploy/docker-compose.dev.yml
Normal file
33
deploy/docker-compose.dev.yml
Normal file
|
|
@ -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:
|
||||||
31
docs/ROADMAP.md
Normal file
31
docs/ROADMAP.md
Normal file
|
|
@ -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`).
|
||||||
64
docs/architecture.md
Normal file
64
docs/architecture.md
Normal file
|
|
@ -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.
|
||||||
33
docs/recording-consent.md
Normal file
33
docs/recording-consent.md
Normal file
|
|
@ -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.
|
||||||
81
docs/security.md
Normal file
81
docs/security.md
Normal file
|
|
@ -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.
|
||||||
25
scripts/dev_bootstrap.sh
Executable file
25
scripts/dev_bootstrap.sh
Executable file
|
|
@ -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"
|
||||||
14
scripts/gen_openapi.sh
Executable file
14
scripts/gen_openapi.sh
Executable file
|
|
@ -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
|
||||||
797
shared/openapi.json
Normal file
797
shared/openapi.json
Normal file
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
worker/README.md
Normal file
6
worker/README.md
Normal file
|
|
@ -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.
|
||||||
|
"""
|
||||||
Loading…
Add table
Add a link
Reference in a new issue