KeyStone commited on
Commit ·
294c0e5
0
Parent(s):
feat: initial KeyStone implementation - all 8 milestones
Browse files- FastAPI backend with JWT auth, upload pipeline, gallery, albums, sync, search
- Expo React Native app (iOS + Android + Web)
- SQLAlchemy models: users, devices, photos, upload_jobs, albums
- Alembic migrations
- Supabase auth + HF Bucket storage
- SHA256 deduplication
- Background sync engine with Wi-Fi-only + battery-aware
- Infinite scroll gallery with search
- AI services scaffold (CLIP, OCR, face detection stubs)
- Docker + Docker Compose
- GitHub Actions CI
This view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +5 -0
- .github/workflows/ci.yml +77 -0
- .gitignore +59 -0
- KeyStone_Photo_Cloud_Project_Plan.md +363 -0
- README.md +106 -0
- backend/.env.example +39 -0
- backend/Dockerfile +39 -0
- backend/README.md +30 -0
- backend/alembic.ini +88 -0
- backend/alembic/env.py +73 -0
- backend/alembic/versions/001_initial.py +141 -0
- backend/app/config.py +76 -0
- backend/app/database.py +49 -0
- backend/app/main.py +84 -0
- backend/app/middleware/__init__.py +1 -0
- backend/app/middleware/auth.py +81 -0
- backend/app/models/__init__.py +9 -0
- backend/app/models/album.py +71 -0
- backend/app/models/device.py +36 -0
- backend/app/models/photo.py +64 -0
- backend/app/models/upload_job.py +65 -0
- backend/app/models/user.py +35 -0
- backend/app/routers/__init__.py +1 -0
- backend/app/routers/albums.py +154 -0
- backend/app/routers/auth.py +21 -0
- backend/app/routers/gallery.py +167 -0
- backend/app/routers/search.py +67 -0
- backend/app/routers/sync.py +96 -0
- backend/app/routers/upload.py +208 -0
- backend/app/schemas/__init__.py +17 -0
- backend/app/schemas/album.py +42 -0
- backend/app/schemas/photo.py +59 -0
- backend/app/schemas/sync.py +33 -0
- backend/app/schemas/upload.py +43 -0
- backend/app/schemas/user.py +22 -0
- backend/app/services/__init__.py +1 -0
- backend/app/services/ai.py +120 -0
- backend/app/services/dedup.py +63 -0
- backend/app/services/storage.py +131 -0
- backend/app/services/thumbnail.py +77 -0
- backend/pytest.ini +3 -0
- backend/requirements.txt +37 -0
- backend/tests/__init__.py +1 -0
- backend/tests/test_health.py +36 -0
- docker-compose.yml +38 -0
- mobile/.env.example +10 -0
- mobile/app.json +60 -0
- mobile/app/(auth)/_layout.tsx +12 -0
- mobile/app/(auth)/forgot-password.tsx +94 -0
- mobile/app/(auth)/login.tsx +207 -0
.gitattributes
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.gif filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ico filter=lfs diff=lfs merge=lfs -text
|
.github/workflows/ci.yml
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: KeyStone CI
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [main]
|
| 6 |
+
pull_request:
|
| 7 |
+
branches: [main]
|
| 8 |
+
|
| 9 |
+
jobs:
|
| 10 |
+
backend-test:
|
| 11 |
+
name: Backend Tests
|
| 12 |
+
runs-on: ubuntu-latest
|
| 13 |
+
|
| 14 |
+
services:
|
| 15 |
+
postgres:
|
| 16 |
+
image: postgres:16
|
| 17 |
+
env:
|
| 18 |
+
POSTGRES_USER: postgres
|
| 19 |
+
POSTGRES_PASSWORD: postgres
|
| 20 |
+
POSTGRES_DB: keystone_test
|
| 21 |
+
ports:
|
| 22 |
+
- 5432:5432
|
| 23 |
+
options: >-
|
| 24 |
+
--health-cmd pg_isready
|
| 25 |
+
--health-interval 10s
|
| 26 |
+
--health-timeout 5s
|
| 27 |
+
--health-retries 5
|
| 28 |
+
|
| 29 |
+
steps:
|
| 30 |
+
- uses: actions/checkout@v4
|
| 31 |
+
|
| 32 |
+
- name: Set up Python 3.11
|
| 33 |
+
uses: actions/setup-python@v5
|
| 34 |
+
with:
|
| 35 |
+
python-version: "3.11"
|
| 36 |
+
|
| 37 |
+
- name: Install dependencies
|
| 38 |
+
run: |
|
| 39 |
+
cd backend
|
| 40 |
+
pip install --upgrade pip
|
| 41 |
+
pip install -r requirements.txt
|
| 42 |
+
|
| 43 |
+
- name: Run tests
|
| 44 |
+
env:
|
| 45 |
+
DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/keystone_test
|
| 46 |
+
SUPABASE_URL: https://test.supabase.co
|
| 47 |
+
SUPABASE_ANON_KEY: test-key
|
| 48 |
+
SUPABASE_JWT_SECRET: test-secret-at-least-32-characters-long
|
| 49 |
+
HF_TOKEN: hf_test
|
| 50 |
+
HF_DATASET_REPO: test/repo
|
| 51 |
+
run: |
|
| 52 |
+
cd backend
|
| 53 |
+
pytest tests/ -v --tb=short
|
| 54 |
+
|
| 55 |
+
mobile-typecheck:
|
| 56 |
+
name: Mobile TypeScript Check
|
| 57 |
+
runs-on: ubuntu-latest
|
| 58 |
+
|
| 59 |
+
steps:
|
| 60 |
+
- uses: actions/checkout@v4
|
| 61 |
+
|
| 62 |
+
- name: Set up Node.js
|
| 63 |
+
uses: actions/setup-node@v4
|
| 64 |
+
with:
|
| 65 |
+
node-version: "20"
|
| 66 |
+
cache: "npm"
|
| 67 |
+
cache-dependency-path: mobile/package-lock.json
|
| 68 |
+
|
| 69 |
+
- name: Install dependencies
|
| 70 |
+
run: |
|
| 71 |
+
cd mobile
|
| 72 |
+
npm install
|
| 73 |
+
|
| 74 |
+
- name: TypeScript type check
|
| 75 |
+
run: |
|
| 76 |
+
cd mobile
|
| 77 |
+
npx tsc --noEmit
|
.gitignore
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ── Python ────────────────────────────────────────────────────────────────────
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*.pyo
|
| 5 |
+
*.pyd
|
| 6 |
+
.Python
|
| 7 |
+
*.egg
|
| 8 |
+
*.egg-info/
|
| 9 |
+
dist/
|
| 10 |
+
build/
|
| 11 |
+
.eggs/
|
| 12 |
+
.pytest_cache/
|
| 13 |
+
.mypy_cache/
|
| 14 |
+
.ruff_cache/
|
| 15 |
+
htmlcov/
|
| 16 |
+
.coverage
|
| 17 |
+
.coverage.*
|
| 18 |
+
|
| 19 |
+
# ── Virtual Environments ──────────────────────────────────────────────────────
|
| 20 |
+
.venv/
|
| 21 |
+
venv/
|
| 22 |
+
env/
|
| 23 |
+
ENV/
|
| 24 |
+
|
| 25 |
+
# ── Environment Files ─────────────────────────────────────────────────────────
|
| 26 |
+
.env
|
| 27 |
+
.env.local
|
| 28 |
+
.env.production
|
| 29 |
+
*.env
|
| 30 |
+
|
| 31 |
+
# ── Node / React Native / Expo ────────────────────────────────────────────────
|
| 32 |
+
node_modules/
|
| 33 |
+
.expo/
|
| 34 |
+
dist/
|
| 35 |
+
.expo-shared/
|
| 36 |
+
*.jks
|
| 37 |
+
*.p8
|
| 38 |
+
*.p12
|
| 39 |
+
*.key
|
| 40 |
+
*.mobileprovision
|
| 41 |
+
*.orig.*
|
| 42 |
+
web-build/
|
| 43 |
+
android/
|
| 44 |
+
ios/
|
| 45 |
+
|
| 46 |
+
# ── Editor ────────────────────────────────────────────────────────────────────
|
| 47 |
+
.vscode/
|
| 48 |
+
.idea/
|
| 49 |
+
*.swp
|
| 50 |
+
*.swo
|
| 51 |
+
.DS_Store
|
| 52 |
+
Thumbs.db
|
| 53 |
+
|
| 54 |
+
# ── Docker ────────────────────────────────────────────────────────────────────
|
| 55 |
+
docker-compose.override.yml
|
| 56 |
+
|
| 57 |
+
# ── Logs ─────────────────────────────────────────────────────────────────────
|
| 58 |
+
*.log
|
| 59 |
+
logs/
|
KeyStone_Photo_Cloud_Project_Plan.md
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Personal Photo Cloud – Project Plan
|
| 2 |
+
|
| 3 |
+
## Vision
|
| 4 |
+
|
| 5 |
+
Build a private Google Photos alternative that automatically backs up photos directly from mobile devices to cloud storage under the user's control.
|
| 6 |
+
|
| 7 |
+
### Goals
|
| 8 |
+
|
| 9 |
+
- Automatic background backup
|
| 10 |
+
- Android, iOS, and Web support
|
| 11 |
+
- No dependency on Google Photos APIs
|
| 12 |
+
- Fast uploads with resumable sync
|
| 13 |
+
- Deduplication
|
| 14 |
+
- AI-ready architecture
|
| 15 |
+
- Self-hostable on Hugging Face Spaces
|
| 16 |
+
|
| 17 |
+
---
|
| 18 |
+
|
| 19 |
+
# Technology Stack
|
| 20 |
+
|
| 21 |
+
## Frontend
|
| 22 |
+
|
| 23 |
+
- Expo
|
| 24 |
+
- React Native
|
| 25 |
+
- React Native Web
|
| 26 |
+
- Expo Router
|
| 27 |
+
- Expo Media Library
|
| 28 |
+
- React Query
|
| 29 |
+
- Zustand
|
| 30 |
+
- Supabase JS SDK
|
| 31 |
+
|
| 32 |
+
## Backend
|
| 33 |
+
|
| 34 |
+
- FastAPI
|
| 35 |
+
- SQLAlchemy
|
| 36 |
+
- Alembic
|
| 37 |
+
- Pillow
|
| 38 |
+
- Uvicorn
|
| 39 |
+
- BackgroundTasks
|
| 40 |
+
|
| 41 |
+
## Authentication
|
| 42 |
+
|
| 43 |
+
- Supabase Auth
|
| 44 |
+
- Email/Password
|
| 45 |
+
- Google OAuth
|
| 46 |
+
- JWT Verification
|
| 47 |
+
|
| 48 |
+
## Database
|
| 49 |
+
|
| 50 |
+
Supabase PostgreSQL
|
| 51 |
+
|
| 52 |
+
## Storage
|
| 53 |
+
|
| 54 |
+
Hugging Face Bucket
|
| 55 |
+
|
| 56 |
+
```
|
| 57 |
+
originals/
|
| 58 |
+
thumbnails/
|
| 59 |
+
previews/
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
---
|
| 63 |
+
|
| 64 |
+
# High-Level Architecture
|
| 65 |
+
|
| 66 |
+
```text
|
| 67 |
+
React Native/Web
|
| 68 |
+
│
|
| 69 |
+
Supabase Auth
|
| 70 |
+
│
|
| 71 |
+
JWT Access Token
|
| 72 |
+
│
|
| 73 |
+
FastAPI Backend
|
| 74 |
+
│
|
| 75 |
+
├── Upload API
|
| 76 |
+
├── Sync Engine
|
| 77 |
+
├── Gallery API
|
| 78 |
+
├── AI Services
|
| 79 |
+
│
|
| 80 |
+
├── Supabase PostgreSQL
|
| 81 |
+
└── Hugging Face Bucket
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
---
|
| 85 |
+
|
| 86 |
+
# Milestone 1 — Foundation
|
| 87 |
+
|
| 88 |
+
- Repository setup
|
| 89 |
+
- Docker
|
| 90 |
+
- Environment variables
|
| 91 |
+
- CI
|
| 92 |
+
- FastAPI scaffold
|
| 93 |
+
- Expo scaffold
|
| 94 |
+
|
| 95 |
+
Deliverable:
|
| 96 |
+
- Login screen
|
| 97 |
+
- Health endpoint
|
| 98 |
+
- Docker deployment
|
| 99 |
+
|
| 100 |
+
---
|
| 101 |
+
|
| 102 |
+
# Milestone 2 — Authentication
|
| 103 |
+
|
| 104 |
+
Frontend:
|
| 105 |
+
- Login
|
| 106 |
+
- Register
|
| 107 |
+
- Forgot password
|
| 108 |
+
- Session persistence
|
| 109 |
+
|
| 110 |
+
Backend:
|
| 111 |
+
- Verify Supabase JWT
|
| 112 |
+
- User middleware
|
| 113 |
+
- Protected routes
|
| 114 |
+
|
| 115 |
+
Deliverable:
|
| 116 |
+
- Secure authenticated API
|
| 117 |
+
|
| 118 |
+
---
|
| 119 |
+
|
| 120 |
+
# Milestone 3 — Database
|
| 121 |
+
|
| 122 |
+
Tables:
|
| 123 |
+
|
| 124 |
+
## users
|
| 125 |
+
|
| 126 |
+
- id
|
| 127 |
+
- supabase_id
|
| 128 |
+
- email
|
| 129 |
+
- created_at
|
| 130 |
+
|
| 131 |
+
## devices
|
| 132 |
+
|
| 133 |
+
- id
|
| 134 |
+
- user_id
|
| 135 |
+
- device_name
|
| 136 |
+
- platform
|
| 137 |
+
- last_sync
|
| 138 |
+
|
| 139 |
+
## photos
|
| 140 |
+
|
| 141 |
+
- id
|
| 142 |
+
- user_id
|
| 143 |
+
- sha256
|
| 144 |
+
- filename
|
| 145 |
+
- bucket_path
|
| 146 |
+
- thumbnail_path
|
| 147 |
+
- width
|
| 148 |
+
- height
|
| 149 |
+
- size
|
| 150 |
+
- created_at
|
| 151 |
+
- uploaded_at
|
| 152 |
+
- deleted
|
| 153 |
+
|
| 154 |
+
## upload_jobs
|
| 155 |
+
|
| 156 |
+
- id
|
| 157 |
+
- status
|
| 158 |
+
- retries
|
| 159 |
+
|
| 160 |
+
Deliverable:
|
| 161 |
+
- Database migrations
|
| 162 |
+
- CRUD models
|
| 163 |
+
|
| 164 |
+
---
|
| 165 |
+
|
| 166 |
+
# Milestone 4 — Upload Pipeline
|
| 167 |
+
|
| 168 |
+
Workflow
|
| 169 |
+
|
| 170 |
+
Camera Roll
|
| 171 |
+
|
| 172 |
+
↓
|
| 173 |
+
|
| 174 |
+
Find new photos
|
| 175 |
+
|
| 176 |
+
↓
|
| 177 |
+
|
| 178 |
+
Compute SHA256
|
| 179 |
+
|
| 180 |
+
↓
|
| 181 |
+
|
| 182 |
+
Check duplicate
|
| 183 |
+
|
| 184 |
+
↓
|
| 185 |
+
|
| 186 |
+
Upload
|
| 187 |
+
|
| 188 |
+
↓
|
| 189 |
+
|
| 190 |
+
Generate thumbnail
|
| 191 |
+
|
| 192 |
+
↓
|
| 193 |
+
|
| 194 |
+
Save metadata
|
| 195 |
+
|
| 196 |
+
↓
|
| 197 |
+
|
| 198 |
+
Complete
|
| 199 |
+
|
| 200 |
+
Requirements
|
| 201 |
+
|
| 202 |
+
- Multipart uploads
|
| 203 |
+
- Progress reporting
|
| 204 |
+
- Retry queue
|
| 205 |
+
- Idempotency
|
| 206 |
+
|
| 207 |
+
Deliverable:
|
| 208 |
+
- Reliable uploads
|
| 209 |
+
|
| 210 |
+
---
|
| 211 |
+
|
| 212 |
+
# Milestone 5 — Mobile Sync
|
| 213 |
+
|
| 214 |
+
Features
|
| 215 |
+
|
| 216 |
+
- Camera Roll permission
|
| 217 |
+
- Background scanning
|
| 218 |
+
- Queue management
|
| 219 |
+
- Pause/resume
|
| 220 |
+
- Retry failed uploads
|
| 221 |
+
|
| 222 |
+
Rules
|
| 223 |
+
|
| 224 |
+
- Never upload duplicate hashes
|
| 225 |
+
- Resume interrupted uploads
|
| 226 |
+
- Battery-aware syncing
|
| 227 |
+
- Wi-Fi only option
|
| 228 |
+
|
| 229 |
+
Deliverable:
|
| 230 |
+
- Automatic backup
|
| 231 |
+
|
| 232 |
+
---
|
| 233 |
+
|
| 234 |
+
# Milestone 6 — Gallery
|
| 235 |
+
|
| 236 |
+
Features
|
| 237 |
+
|
| 238 |
+
- Infinite scrolling
|
| 239 |
+
- Lazy loading
|
| 240 |
+
- Cached thumbnails
|
| 241 |
+
- Favorites
|
| 242 |
+
- Albums
|
| 243 |
+
- Search
|
| 244 |
+
- Timeline
|
| 245 |
+
|
| 246 |
+
Deliverable:
|
| 247 |
+
- Responsive gallery
|
| 248 |
+
|
| 249 |
+
---
|
| 250 |
+
|
| 251 |
+
# Milestone 7 — Settings
|
| 252 |
+
|
| 253 |
+
- Auto sync toggle
|
| 254 |
+
- Cellular upload toggle
|
| 255 |
+
- Thumbnail quality
|
| 256 |
+
- Storage usage
|
| 257 |
+
- Logout
|
| 258 |
+
- Device management
|
| 259 |
+
|
| 260 |
+
---
|
| 261 |
+
|
| 262 |
+
# Milestone 8 — AI
|
| 263 |
+
|
| 264 |
+
- CLIP embeddings
|
| 265 |
+
- OCR
|
| 266 |
+
- Face clustering
|
| 267 |
+
- Duplicate detection
|
| 268 |
+
- Blur detection
|
| 269 |
+
- Natural language search
|
| 270 |
+
- Auto albums
|
| 271 |
+
|
| 272 |
+
---
|
| 273 |
+
|
| 274 |
+
# API Design
|
| 275 |
+
|
| 276 |
+
Authentication
|
| 277 |
+
|
| 278 |
+
- POST /auth/verify
|
| 279 |
+
|
| 280 |
+
Sync
|
| 281 |
+
|
| 282 |
+
- POST /sync/check
|
| 283 |
+
- POST /sync/start
|
| 284 |
+
|
| 285 |
+
Uploads
|
| 286 |
+
|
| 287 |
+
- POST /upload
|
| 288 |
+
- POST /upload/batch
|
| 289 |
+
|
| 290 |
+
Gallery
|
| 291 |
+
|
| 292 |
+
- GET /photos
|
| 293 |
+
- GET /photos/{id}
|
| 294 |
+
- DELETE /photos/{id}
|
| 295 |
+
|
| 296 |
+
Albums
|
| 297 |
+
|
| 298 |
+
- GET /albums
|
| 299 |
+
- POST /albums
|
| 300 |
+
|
| 301 |
+
Health
|
| 302 |
+
|
| 303 |
+
- GET /health
|
| 304 |
+
|
| 305 |
+
---
|
| 306 |
+
|
| 307 |
+
# Security
|
| 308 |
+
|
| 309 |
+
- JWT validation
|
| 310 |
+
- HTTPS only
|
| 311 |
+
- Rate limiting
|
| 312 |
+
- SHA256 integrity
|
| 313 |
+
- File type validation
|
| 314 |
+
- Size limits
|
| 315 |
+
- Signed URLs (future)
|
| 316 |
+
|
| 317 |
+
---
|
| 318 |
+
|
| 319 |
+
# Future Enhancements
|
| 320 |
+
|
| 321 |
+
- Video transcoding
|
| 322 |
+
- Live Photos
|
| 323 |
+
- HEIC support
|
| 324 |
+
- Shared albums
|
| 325 |
+
- End-to-end encryption
|
| 326 |
+
- Desktop sync client
|
| 327 |
+
- NAS support
|
| 328 |
+
- S3-compatible storage
|
| 329 |
+
- Object versioning
|
| 330 |
+
|
| 331 |
+
---
|
| 332 |
+
|
| 333 |
+
# Deployment
|
| 334 |
+
|
| 335 |
+
Frontend
|
| 336 |
+
|
| 337 |
+
- Hugging Face Space (Static)
|
| 338 |
+
|
| 339 |
+
Backend
|
| 340 |
+
|
| 341 |
+
- Hugging Face Space (Docker)
|
| 342 |
+
|
| 343 |
+
Services
|
| 344 |
+
|
| 345 |
+
- Supabase Auth
|
| 346 |
+
- Supabase PostgreSQL
|
| 347 |
+
- Hugging Face Bucket
|
| 348 |
+
|
| 349 |
+
---
|
| 350 |
+
|
| 351 |
+
# Definition of Done
|
| 352 |
+
|
| 353 |
+
- User can register and log in.
|
| 354 |
+
- Photos sync automatically from the mobile camera roll.
|
| 355 |
+
- Duplicate photos are skipped.
|
| 356 |
+
- Uploads resume after interruptions.
|
| 357 |
+
- Thumbnails are generated.
|
| 358 |
+
- Gallery loads quickly with pagination.
|
| 359 |
+
- Metadata is stored in PostgreSQL.
|
| 360 |
+
- Originals are stored in the Hugging Face bucket.
|
| 361 |
+
- Backend validates Supabase JWTs.
|
| 362 |
+
- Docker deployment runs successfully on Hugging Face Spaces.
|
| 363 |
+
- Architecture supports future AI-powered organization and search.
|
README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# KeyStone 🔷 – Private Photo Cloud
|
| 2 |
+
|
| 3 |
+
> Your photos. Your cloud. Zero dependencies on Google or Apple.
|
| 4 |
+
|
| 5 |
+
KeyStone is a self-hosted Google Photos alternative with automatic mobile backup, deduplication, and AI-ready architecture.
|
| 6 |
+
|
| 7 |
+
## Architecture
|
| 8 |
+
|
| 9 |
+
```
|
| 10 |
+
Expo App (iOS + Android + Web)
|
| 11 |
+
│
|
| 12 |
+
Supabase Auth (JWT)
|
| 13 |
+
│
|
| 14 |
+
FastAPI Backend ─────► Hugging Face Bucket (storage)
|
| 15 |
+
│
|
| 16 |
+
Supabase PostgreSQL
|
| 17 |
+
```
|
| 18 |
+
|
| 19 |
+
## Live Deployment
|
| 20 |
+
|
| 21 |
+
| Service | URL |
|
| 22 |
+
|---|---|
|
| 23 |
+
| 🚀 Backend API | https://dpv007-keystone.hf.space |
|
| 24 |
+
| 📖 API Docs | https://dpv007-keystone.hf.space/docs |
|
| 25 |
+
|
| 26 |
+
## Quick Start
|
| 27 |
+
|
| 28 |
+
### Backend (local dev)
|
| 29 |
+
|
| 30 |
+
```bash
|
| 31 |
+
cd backend
|
| 32 |
+
cp .env.example .env # fill in your credentials
|
| 33 |
+
docker compose up # starts FastAPI + PostgreSQL
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
### Run database migrations
|
| 37 |
+
|
| 38 |
+
```bash
|
| 39 |
+
cd backend
|
| 40 |
+
alembic upgrade head
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
### Mobile App
|
| 44 |
+
|
| 45 |
+
```bash
|
| 46 |
+
cd mobile
|
| 47 |
+
cp .env.example .env # fill in Supabase keys
|
| 48 |
+
npm install
|
| 49 |
+
npm start # Expo dev server (iOS/Android/Web)
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
Open in browser: http://localhost:8081 (web), or scan QR in Expo Go.
|
| 53 |
+
|
| 54 |
+
## Environment Variables
|
| 55 |
+
|
| 56 |
+
### Backend (`backend/.env`)
|
| 57 |
+
|
| 58 |
+
| Variable | Description |
|
| 59 |
+
|---|---|
|
| 60 |
+
| `DATABASE_URL` | Supabase PostgreSQL connection string |
|
| 61 |
+
| `SUPABASE_URL` | Your Supabase project URL |
|
| 62 |
+
| `SUPABASE_JWT_SECRET` | JWT secret from Supabase Dashboard → Settings → API |
|
| 63 |
+
| `HF_TOKEN` | Hugging Face token with write access |
|
| 64 |
+
| `HF_DATASET_REPO` | HF Dataset repo for photo storage (e.g. `user/keystone-photos`) |
|
| 65 |
+
|
| 66 |
+
### Mobile (`mobile/.env`)
|
| 67 |
+
|
| 68 |
+
| Variable | Description |
|
| 69 |
+
|---|---|
|
| 70 |
+
| `EXPO_PUBLIC_API_URL` | Backend URL (defaults to HF Space URL) |
|
| 71 |
+
| `EXPO_PUBLIC_SUPABASE_URL` | Your Supabase project URL |
|
| 72 |
+
| `EXPO_PUBLIC_SUPABASE_ANON_KEY` | Supabase anon key |
|
| 73 |
+
|
| 74 |
+
## Features
|
| 75 |
+
|
| 76 |
+
- ✅ Email/password authentication via Supabase
|
| 77 |
+
- ✅ JWT verification on every API request
|
| 78 |
+
- ✅ SHA256 deduplication (never upload the same photo twice)
|
| 79 |
+
- ✅ Background sync (iOS + Android) with Wi-Fi-only option
|
| 80 |
+
- ✅ Battery-aware syncing
|
| 81 |
+
- ✅ Thumbnail generation (Pillow)
|
| 82 |
+
- ✅ Infinite-scroll gallery
|
| 83 |
+
- ✅ Albums with photo management
|
| 84 |
+
- ✅ Photo search (filename + AI tags)
|
| 85 |
+
- ✅ Favorites
|
| 86 |
+
- ✅ Soft delete
|
| 87 |
+
- ✅ Works on iOS, Android, and Web (same codebase)
|
| 88 |
+
- 🔜 CLIP semantic search
|
| 89 |
+
- 🔜 Face clustering
|
| 90 |
+
- 🔜 OCR text extraction
|
| 91 |
+
- 🔜 Auto albums
|
| 92 |
+
|
| 93 |
+
## Deployment to Hugging Face Spaces
|
| 94 |
+
|
| 95 |
+
The backend is a Docker Space. Push this repo and set the following secrets in the Space settings:
|
| 96 |
+
|
| 97 |
+
- `DATABASE_URL`
|
| 98 |
+
- `SUPABASE_URL`
|
| 99 |
+
- `SUPABASE_JWT_SECRET`
|
| 100 |
+
- `SUPABASE_SERVICE_ROLE_KEY`
|
| 101 |
+
- `HF_TOKEN`
|
| 102 |
+
- `HF_DATASET_REPO`
|
| 103 |
+
|
| 104 |
+
## License
|
| 105 |
+
|
| 106 |
+
MIT
|
backend/.env.example
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ── App ───────────────────────────────────────────────────────────────────────
|
| 2 |
+
APP_NAME="KeyStone Photo Cloud"
|
| 3 |
+
APP_VERSION="0.1.0"
|
| 4 |
+
DEBUG=false
|
| 5 |
+
ENVIRONMENT=production
|
| 6 |
+
|
| 7 |
+
# ── Database (Supabase PostgreSQL) ────────────────────────────────────────────
|
| 8 |
+
# Use the "Session Mode" connection string from Supabase Dashboard > Settings > Database
|
| 9 |
+
DATABASE_URL=postgresql+asyncpg://postgres.YOURPROJECT:PASSWORD@aws-0-us-east-1.pooler.supabase.com:5432/postgres
|
| 10 |
+
|
| 11 |
+
# ── Supabase ──────────────────────────────────────────────────────────────────
|
| 12 |
+
SUPABASE_URL=https://YOURPROJECT.supabase.co
|
| 13 |
+
SUPABASE_ANON_KEY=your-supabase-anon-key
|
| 14 |
+
SUPABASE_SERVICE_ROLE_KEY=your-supabase-service-role-key
|
| 15 |
+
# Found at: Supabase Dashboard → Settings → API → JWT Settings → JWT Secret
|
| 16 |
+
SUPABASE_JWT_SECRET=your-supabase-jwt-secret
|
| 17 |
+
|
| 18 |
+
# ── Hugging Face Bucket (S3-compatible) ───────────────────────────────────────
|
| 19 |
+
HF_TOKEN=hf_your_token_here
|
| 20 |
+
HF_DATASET_REPO=your-username/keystone-photos
|
| 21 |
+
HF_BUCKET_NAME=keystone-photos
|
| 22 |
+
|
| 23 |
+
# HF S3 credentials (generate at: https://huggingface.co/settings/tokens)
|
| 24 |
+
HF_ACCESS_KEY_ID=your-hf-s3-access-key
|
| 25 |
+
HF_SECRET_ACCESS_KEY=your-hf-s3-secret-key
|
| 26 |
+
HF_S3_ENDPOINT=https://huggingface.co
|
| 27 |
+
|
| 28 |
+
# ── Upload Limits ─────────────────────────────────────────────────────────────
|
| 29 |
+
MAX_UPLOAD_SIZE_MB=50
|
| 30 |
+
THUMBNAIL_SIZE=512
|
| 31 |
+
THUMBNAIL_QUALITY=85
|
| 32 |
+
|
| 33 |
+
# ── Rate Limiting ─────────────────────────────────────────────────────────────
|
| 34 |
+
RATE_LIMIT_PER_MINUTE=60
|
| 35 |
+
RATE_LIMIT_UPLOADS_PER_MINUTE=20
|
| 36 |
+
|
| 37 |
+
# ── CORS ──────────────────────────────────────────────────────────────────────
|
| 38 |
+
# Comma-separated list of allowed origins. Use * for development only.
|
| 39 |
+
CORS_ORIGINS=*
|
backend/Dockerfile
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# KeyStone Backend – Dockerfile
|
| 2 |
+
# Deployed on Hugging Face Spaces (Docker SDK)
|
| 3 |
+
# Port 7860 is required by Hugging Face Spaces
|
| 4 |
+
|
| 5 |
+
FROM python:3.11-slim
|
| 6 |
+
|
| 7 |
+
# System dependencies for Pillow + python-magic
|
| 8 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 9 |
+
libmagic1 \
|
| 10 |
+
libmagic-dev \
|
| 11 |
+
libjpeg-dev \
|
| 12 |
+
zlib1g-dev \
|
| 13 |
+
libwebp-dev \
|
| 14 |
+
curl \
|
| 15 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 16 |
+
|
| 17 |
+
WORKDIR /app
|
| 18 |
+
|
| 19 |
+
# Install Python dependencies first (layer caching)
|
| 20 |
+
COPY requirements.txt .
|
| 21 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
| 22 |
+
pip install --no-cache-dir -r requirements.txt
|
| 23 |
+
|
| 24 |
+
# Copy application code
|
| 25 |
+
COPY . .
|
| 26 |
+
|
| 27 |
+
# Create non-root user for security
|
| 28 |
+
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
|
| 29 |
+
USER appuser
|
| 30 |
+
|
| 31 |
+
# Hugging Face Spaces requires port 7860
|
| 32 |
+
EXPOSE 7860
|
| 33 |
+
|
| 34 |
+
# Health check
|
| 35 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
| 36 |
+
CMD curl -f http://localhost:7860/health || exit 1
|
| 37 |
+
|
| 38 |
+
# Start FastAPI with uvicorn
|
| 39 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "2"]
|
backend/README.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: KeyStone Photo Cloud API
|
| 3 |
+
emoji: 📸
|
| 4 |
+
colorFrom: purple
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
+
app_port: 7860
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# KeyStone Photo Cloud – Backend API
|
| 13 |
+
|
| 14 |
+
Private Google Photos alternative backend. See the [full documentation](/docs) at `/docs`.
|
| 15 |
+
|
| 16 |
+
## Endpoints
|
| 17 |
+
|
| 18 |
+
| Method | Path | Description |
|
| 19 |
+
|--------|------|-------------|
|
| 20 |
+
| GET | /health | Health check |
|
| 21 |
+
| POST | /auth/verify | Verify Supabase JWT |
|
| 22 |
+
| POST | /sync/start | Register device |
|
| 23 |
+
| POST | /sync/check | Dedup hash check |
|
| 24 |
+
| POST | /upload | Upload a photo |
|
| 25 |
+
| GET | /photos | List photos |
|
| 26 |
+
| GET | /photos/{id} | Get photo |
|
| 27 |
+
| DELETE | /photos/{id} | Delete photo |
|
| 28 |
+
| GET | /albums | List albums |
|
| 29 |
+
| POST | /albums | Create album |
|
| 30 |
+
| GET | /search | Search photos |
|
backend/alembic.ini
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Alembic configuration file
|
| 2 |
+
# See: https://alembic.sqlalchemy.org/en/latest/tutorial.html
|
| 3 |
+
|
| 4 |
+
[alembic]
|
| 5 |
+
# path to migration scripts
|
| 6 |
+
script_location = alembic
|
| 7 |
+
|
| 8 |
+
# template used to generate migration file names
|
| 9 |
+
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
|
| 10 |
+
|
| 11 |
+
# timezone to use when rendering the date within the migration file
|
| 12 |
+
# leave blank for current timezone
|
| 13 |
+
# timezone =
|
| 14 |
+
|
| 15 |
+
# max length of characters to apply to the
|
| 16 |
+
# "slug" field
|
| 17 |
+
truncate_slug_length = 40
|
| 18 |
+
|
| 19 |
+
# set to 'true' to run the environment during
|
| 20 |
+
# the 'revision' command, regardless of autogenerate
|
| 21 |
+
# revision_environment = false
|
| 22 |
+
|
| 23 |
+
# set to 'true' to allow .pyc and .pyo files without
|
| 24 |
+
# a source .py file to be detected as revisions in the
|
| 25 |
+
# versions/ directory
|
| 26 |
+
# sourceless = false
|
| 27 |
+
|
| 28 |
+
# version location specification; This defaults
|
| 29 |
+
# to alembic/versions. When using multiple version
|
| 30 |
+
# directories, initial revisions must specify a --version-path.
|
| 31 |
+
# version_path_separator = os # Use os.pathsep. Default configuration
|
| 32 |
+
# used for new projects.
|
| 33 |
+
# version_path_separator = :
|
| 34 |
+
# version_path_separator = ;
|
| 35 |
+
# version_path_separator = space
|
| 36 |
+
version_locations = %(here)s/alembic/versions
|
| 37 |
+
|
| 38 |
+
# the output encoding used when revision files
|
| 39 |
+
# are written from script.py.mako
|
| 40 |
+
# output_encoding = utf-8
|
| 41 |
+
|
| 42 |
+
sqlalchemy.url = driver://user:pass@localhost/dbname
|
| 43 |
+
|
| 44 |
+
[post_write_hooks]
|
| 45 |
+
# post_write_hooks defines scripts or Python functions that are run
|
| 46 |
+
# on newly generated revision scripts. See the documentation for further
|
| 47 |
+
# detail and examples
|
| 48 |
+
|
| 49 |
+
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
| 50 |
+
# hooks = black
|
| 51 |
+
# black.type = console_scripts
|
| 52 |
+
# black.entrypoint = black
|
| 53 |
+
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
| 54 |
+
|
| 55 |
+
# Logging configuration
|
| 56 |
+
[loggers]
|
| 57 |
+
keys = root,sqlalchemy,alembic
|
| 58 |
+
|
| 59 |
+
[handlers]
|
| 60 |
+
keys = console
|
| 61 |
+
|
| 62 |
+
[formatters]
|
| 63 |
+
keys = generic
|
| 64 |
+
|
| 65 |
+
[logger_root]
|
| 66 |
+
level = WARN
|
| 67 |
+
handlers = console
|
| 68 |
+
qualname =
|
| 69 |
+
|
| 70 |
+
[logger_sqlalchemy]
|
| 71 |
+
level = WARN
|
| 72 |
+
handlers =
|
| 73 |
+
qualname = sqlalchemy.engine
|
| 74 |
+
|
| 75 |
+
[logger_alembic]
|
| 76 |
+
level = INFO
|
| 77 |
+
handlers =
|
| 78 |
+
qualname = alembic
|
| 79 |
+
|
| 80 |
+
[handler_console]
|
| 81 |
+
class = StreamHandler
|
| 82 |
+
args = (sys.stderr,)
|
| 83 |
+
level = NOTSET
|
| 84 |
+
formatter = generic
|
| 85 |
+
|
| 86 |
+
[formatter_generic]
|
| 87 |
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
| 88 |
+
datefmt = %H:%M:%S
|
backend/alembic/env.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Alembic Environment Configuration
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import os
|
| 7 |
+
from logging.config import fileConfig
|
| 8 |
+
|
| 9 |
+
from sqlalchemy import pool
|
| 10 |
+
from sqlalchemy.engine import Connection
|
| 11 |
+
from sqlalchemy.ext.asyncio import async_engine_from_config
|
| 12 |
+
|
| 13 |
+
from alembic import context
|
| 14 |
+
|
| 15 |
+
# Load models so Alembic can detect them
|
| 16 |
+
import sys
|
| 17 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
| 18 |
+
|
| 19 |
+
from app.database import Base
|
| 20 |
+
from app.models import User, Device, Photo, UploadJob, Album, AlbumPhoto # noqa
|
| 21 |
+
|
| 22 |
+
# Alembic Config object
|
| 23 |
+
config = context.config
|
| 24 |
+
|
| 25 |
+
# Override sqlalchemy.url from environment variable
|
| 26 |
+
database_url = os.environ.get("DATABASE_URL", "")
|
| 27 |
+
if database_url:
|
| 28 |
+
config.set_main_option("sqlalchemy.url", database_url)
|
| 29 |
+
|
| 30 |
+
# Interpret the config file for logging
|
| 31 |
+
if config.config_file_name is not None:
|
| 32 |
+
fileConfig(config.config_file_name)
|
| 33 |
+
|
| 34 |
+
target_metadata = Base.metadata
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def run_migrations_offline() -> None:
|
| 38 |
+
url = config.get_main_option("sqlalchemy.url")
|
| 39 |
+
context.configure(
|
| 40 |
+
url=url,
|
| 41 |
+
target_metadata=target_metadata,
|
| 42 |
+
literal_binds=True,
|
| 43 |
+
dialect_opts={"paramstyle": "named"},
|
| 44 |
+
)
|
| 45 |
+
with context.begin_transaction():
|
| 46 |
+
context.run_migrations()
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def do_run_migrations(connection: Connection) -> None:
|
| 50 |
+
context.configure(connection=connection, target_metadata=target_metadata)
|
| 51 |
+
with context.begin_transaction():
|
| 52 |
+
context.run_migrations()
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
async def run_async_migrations() -> None:
|
| 56 |
+
connectable = async_engine_from_config(
|
| 57 |
+
config.get_section(config.config_ini_section, {}),
|
| 58 |
+
prefix="sqlalchemy.",
|
| 59 |
+
poolclass=pool.NullPool,
|
| 60 |
+
)
|
| 61 |
+
async with connectable.connect() as connection:
|
| 62 |
+
await connection.run_sync(do_run_migrations)
|
| 63 |
+
await connectable.dispose()
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def run_migrations_online() -> None:
|
| 67 |
+
asyncio.run(run_async_migrations())
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
if context.is_offline_mode():
|
| 71 |
+
run_migrations_offline()
|
| 72 |
+
else:
|
| 73 |
+
run_migrations_online()
|
backend/alembic/versions/001_initial.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Initial migration – create all tables
|
| 2 |
+
|
| 3 |
+
Revision ID: 001_initial
|
| 4 |
+
Revises:
|
| 5 |
+
Create Date: 2026-07-16
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from alembic import op
|
| 9 |
+
import sqlalchemy as sa
|
| 10 |
+
from sqlalchemy.dialects import postgresql
|
| 11 |
+
|
| 12 |
+
revision = "001_initial"
|
| 13 |
+
down_revision = None
|
| 14 |
+
branch_labels = None
|
| 15 |
+
depends_on = None
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def upgrade() -> None:
|
| 19 |
+
# ── users ─────────────────────────────────────────────────────────────────
|
| 20 |
+
op.create_table(
|
| 21 |
+
"users",
|
| 22 |
+
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
| 23 |
+
sa.Column("supabase_id", sa.String(255), nullable=False),
|
| 24 |
+
sa.Column("email", sa.String(320), nullable=False),
|
| 25 |
+
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
| 26 |
+
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
|
| 27 |
+
sa.PrimaryKeyConstraint("id"),
|
| 28 |
+
sa.UniqueConstraint("supabase_id"),
|
| 29 |
+
sa.UniqueConstraint("email"),
|
| 30 |
+
)
|
| 31 |
+
op.create_index("ix_users_supabase_id", "users", ["supabase_id"])
|
| 32 |
+
op.create_index("ix_users_email", "users", ["email"])
|
| 33 |
+
|
| 34 |
+
# ── devices ───────────────────────────────────────────────────────────────
|
| 35 |
+
op.create_table(
|
| 36 |
+
"devices",
|
| 37 |
+
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
| 38 |
+
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
| 39 |
+
sa.Column("device_name", sa.String(255), nullable=False),
|
| 40 |
+
sa.Column("platform", sa.String(50), nullable=False),
|
| 41 |
+
sa.Column("device_token", sa.String(512), nullable=True),
|
| 42 |
+
sa.Column("last_sync", sa.DateTime(timezone=True), nullable=True),
|
| 43 |
+
sa.Column("registered_at", sa.DateTime(timezone=True), nullable=False),
|
| 44 |
+
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
| 45 |
+
sa.PrimaryKeyConstraint("id"),
|
| 46 |
+
)
|
| 47 |
+
op.create_index("ix_devices_user_id", "devices", ["user_id"])
|
| 48 |
+
|
| 49 |
+
# ── photos ────────────────────────────────────────────────────────────────
|
| 50 |
+
op.create_table(
|
| 51 |
+
"photos",
|
| 52 |
+
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
| 53 |
+
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
| 54 |
+
sa.Column("sha256", sa.String(64), nullable=False),
|
| 55 |
+
sa.Column("filename", sa.String(512), nullable=False),
|
| 56 |
+
sa.Column("mime_type", sa.String(128), nullable=False),
|
| 57 |
+
sa.Column("bucket_path", sa.Text(), nullable=False),
|
| 58 |
+
sa.Column("thumbnail_path", sa.Text(), nullable=True),
|
| 59 |
+
sa.Column("preview_path", sa.Text(), nullable=True),
|
| 60 |
+
sa.Column("width", sa.Integer(), nullable=True),
|
| 61 |
+
sa.Column("height", sa.Integer(), nullable=True),
|
| 62 |
+
sa.Column("size", sa.BigInteger(), nullable=False),
|
| 63 |
+
sa.Column("taken_at", sa.DateTime(timezone=True), nullable=True),
|
| 64 |
+
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
| 65 |
+
sa.Column("uploaded_at", sa.DateTime(timezone=True), nullable=False),
|
| 66 |
+
sa.Column("deleted", sa.Boolean(), nullable=False, server_default="false"),
|
| 67 |
+
sa.Column("is_favorite", sa.Boolean(), nullable=False, server_default="false"),
|
| 68 |
+
sa.Column("ai_description", sa.Text(), nullable=True),
|
| 69 |
+
sa.Column("ai_tags", sa.Text(), nullable=True),
|
| 70 |
+
sa.Column("clip_embedding", sa.Text(), nullable=True),
|
| 71 |
+
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
| 72 |
+
sa.PrimaryKeyConstraint("id"),
|
| 73 |
+
)
|
| 74 |
+
op.create_index("ix_photos_user_id", "photos", ["user_id"])
|
| 75 |
+
op.create_index("ix_photos_sha256", "photos", ["sha256"])
|
| 76 |
+
|
| 77 |
+
# ── upload_jobs ───────────────────────────────────────────────────────────
|
| 78 |
+
op.create_table(
|
| 79 |
+
"upload_jobs",
|
| 80 |
+
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
| 81 |
+
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
| 82 |
+
sa.Column("photo_id", postgresql.UUID(as_uuid=True), nullable=True),
|
| 83 |
+
sa.Column("filename", sa.String(512), nullable=False),
|
| 84 |
+
sa.Column("sha256", sa.String(64), nullable=False),
|
| 85 |
+
sa.Column("status", sa.Enum(
|
| 86 |
+
"pending", "uploading", "processing", "completed", "failed", "duplicate",
|
| 87 |
+
name="uploadstatus"
|
| 88 |
+
), nullable=False),
|
| 89 |
+
sa.Column("retries", sa.Integer(), nullable=False, server_default="0"),
|
| 90 |
+
sa.Column("max_retries", sa.Integer(), nullable=False, server_default="3"),
|
| 91 |
+
sa.Column("error_message", sa.Text(), nullable=True),
|
| 92 |
+
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
| 93 |
+
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
| 94 |
+
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
| 95 |
+
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
| 96 |
+
sa.ForeignKeyConstraint(["photo_id"], ["photos.id"], ondelete="SET NULL"),
|
| 97 |
+
sa.PrimaryKeyConstraint("id"),
|
| 98 |
+
)
|
| 99 |
+
op.create_index("ix_upload_jobs_user_id", "upload_jobs", ["user_id"])
|
| 100 |
+
op.create_index("ix_upload_jobs_sha256", "upload_jobs", ["sha256"])
|
| 101 |
+
|
| 102 |
+
# ── albums ────────────────────────────────────────────────────────────────
|
| 103 |
+
op.create_table(
|
| 104 |
+
"albums",
|
| 105 |
+
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
| 106 |
+
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
| 107 |
+
sa.Column("name", sa.String(255), nullable=False),
|
| 108 |
+
sa.Column("description", sa.Text(), nullable=True),
|
| 109 |
+
sa.Column("cover_photo_id", postgresql.UUID(as_uuid=True), nullable=True),
|
| 110 |
+
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
| 111 |
+
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
| 112 |
+
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
| 113 |
+
sa.ForeignKeyConstraint(["cover_photo_id"], ["photos.id"], ondelete="SET NULL"),
|
| 114 |
+
sa.PrimaryKeyConstraint("id"),
|
| 115 |
+
)
|
| 116 |
+
op.create_index("ix_albums_user_id", "albums", ["user_id"])
|
| 117 |
+
|
| 118 |
+
# ── album_photos ──────────────────────────────────────────────────────────
|
| 119 |
+
op.create_table(
|
| 120 |
+
"album_photos",
|
| 121 |
+
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
| 122 |
+
sa.Column("album_id", postgresql.UUID(as_uuid=True), nullable=False),
|
| 123 |
+
sa.Column("photo_id", postgresql.UUID(as_uuid=True), nullable=False),
|
| 124 |
+
sa.Column("added_at", sa.DateTime(timezone=True), nullable=False),
|
| 125 |
+
sa.ForeignKeyConstraint(["album_id"], ["albums.id"], ondelete="CASCADE"),
|
| 126 |
+
sa.ForeignKeyConstraint(["photo_id"], ["photos.id"], ondelete="CASCADE"),
|
| 127 |
+
sa.PrimaryKeyConstraint("id"),
|
| 128 |
+
sa.UniqueConstraint("album_id", "photo_id", name="uq_album_photo"),
|
| 129 |
+
)
|
| 130 |
+
op.create_index("ix_album_photos_album_id", "album_photos", ["album_id"])
|
| 131 |
+
op.create_index("ix_album_photos_photo_id", "album_photos", ["photo_id"])
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def downgrade() -> None:
|
| 135 |
+
op.drop_table("album_photos")
|
| 136 |
+
op.drop_table("albums")
|
| 137 |
+
op.drop_table("upload_jobs")
|
| 138 |
+
op.drop_table("photos")
|
| 139 |
+
op.drop_table("devices")
|
| 140 |
+
op.drop_table("users")
|
| 141 |
+
op.execute("DROP TYPE IF EXISTS uploadstatus")
|
backend/app/config.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
KeyStone – Application Configuration
|
| 3 |
+
All settings are read from environment variables (or a .env file via python-dotenv).
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from functools import lru_cache
|
| 7 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class Settings(BaseSettings):
|
| 11 |
+
model_config = SettingsConfigDict(
|
| 12 |
+
env_file=".env",
|
| 13 |
+
env_file_encoding="utf-8",
|
| 14 |
+
case_sensitive=False,
|
| 15 |
+
extra="ignore",
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
# ── App ──────────────────────────────────────────────────────────────────
|
| 19 |
+
app_name: str = "KeyStone Photo Cloud"
|
| 20 |
+
app_version: str = "0.1.0"
|
| 21 |
+
debug: bool = False
|
| 22 |
+
environment: str = "production" # development | production
|
| 23 |
+
|
| 24 |
+
# ── Database (Supabase PostgreSQL) ───────────────────────────────────────
|
| 25 |
+
database_url: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/keystone"
|
| 26 |
+
|
| 27 |
+
# ── Supabase ─────────────────────────────────────────────────────────────
|
| 28 |
+
supabase_url: str = "https://your-project.supabase.co"
|
| 29 |
+
supabase_anon_key: str = "your-supabase-anon-key"
|
| 30 |
+
supabase_service_role_key: str = "your-supabase-service-role-key"
|
| 31 |
+
supabase_jwt_secret: str = "your-supabase-jwt-secret"
|
| 32 |
+
|
| 33 |
+
# ── Hugging Face Bucket (S3-compatible) ──────────────────────────────────
|
| 34 |
+
hf_token: str = "hf_your_token_here"
|
| 35 |
+
hf_dataset_repo: str = "your-username/keystone-photos"
|
| 36 |
+
hf_endpoint_url: str = "https://huggingface.co"
|
| 37 |
+
|
| 38 |
+
# S3-compatible access (HF Datasets S3 gateway)
|
| 39 |
+
hf_s3_endpoint: str = "https://huggingface.co/datasets"
|
| 40 |
+
hf_access_key_id: str = "your-hf-access-key"
|
| 41 |
+
hf_secret_access_key: str = "your-hf-secret-key"
|
| 42 |
+
hf_bucket_name: str = "keystone-photos"
|
| 43 |
+
|
| 44 |
+
# ── Upload Settings ───────────────────────────────────────────────────────
|
| 45 |
+
max_upload_size_mb: int = 50
|
| 46 |
+
thumbnail_size: int = 512
|
| 47 |
+
thumbnail_quality: int = 85
|
| 48 |
+
allowed_mime_types: list[str] = [
|
| 49 |
+
"image/jpeg",
|
| 50 |
+
"image/png",
|
| 51 |
+
"image/gif",
|
| 52 |
+
"image/webp",
|
| 53 |
+
"image/heic",
|
| 54 |
+
"image/heif",
|
| 55 |
+
"image/tiff",
|
| 56 |
+
]
|
| 57 |
+
|
| 58 |
+
# ── Rate Limiting ─────────────────────────────────────────────────────────
|
| 59 |
+
rate_limit_per_minute: int = 60
|
| 60 |
+
rate_limit_uploads_per_minute: int = 20
|
| 61 |
+
|
| 62 |
+
# ── CORS ─────────────────────────────────────────────────────────────────
|
| 63 |
+
cors_origins: list[str] = ["*"]
|
| 64 |
+
|
| 65 |
+
@property
|
| 66 |
+
def max_upload_size_bytes(self) -> int:
|
| 67 |
+
return self.max_upload_size_mb * 1024 * 1024
|
| 68 |
+
|
| 69 |
+
@property
|
| 70 |
+
def is_development(self) -> bool:
|
| 71 |
+
return self.environment == "development"
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
@lru_cache
|
| 75 |
+
def get_settings() -> Settings:
|
| 76 |
+
return Settings()
|
backend/app/database.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
KeyStone – Async Database Session
|
| 3 |
+
Uses SQLAlchemy 2.x async engine with asyncpg driver.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
| 7 |
+
from sqlalchemy.orm import DeclarativeBase
|
| 8 |
+
|
| 9 |
+
from app.config import get_settings
|
| 10 |
+
|
| 11 |
+
settings = get_settings()
|
| 12 |
+
|
| 13 |
+
engine = create_async_engine(
|
| 14 |
+
settings.database_url,
|
| 15 |
+
echo=settings.debug,
|
| 16 |
+
pool_pre_ping=True,
|
| 17 |
+
pool_size=10,
|
| 18 |
+
max_overflow=20,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
AsyncSessionLocal = async_sessionmaker(
|
| 22 |
+
bind=engine,
|
| 23 |
+
class_=AsyncSession,
|
| 24 |
+
expire_on_commit=False,
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class Base(DeclarativeBase):
|
| 29 |
+
"""Base class for all SQLAlchemy models."""
|
| 30 |
+
pass
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
async def get_db() -> AsyncSession: # type: ignore[return]
|
| 34 |
+
"""FastAPI dependency that yields an async database session."""
|
| 35 |
+
async with AsyncSessionLocal() as session:
|
| 36 |
+
try:
|
| 37 |
+
yield session
|
| 38 |
+
await session.commit()
|
| 39 |
+
except Exception:
|
| 40 |
+
await session.rollback()
|
| 41 |
+
raise
|
| 42 |
+
finally:
|
| 43 |
+
await session.close()
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
async def create_tables() -> None:
|
| 47 |
+
"""Create all tables (for dev/testing; migrations handled by Alembic in prod)."""
|
| 48 |
+
async with engine.begin() as conn:
|
| 49 |
+
await conn.run_sync(Base.metadata.create_all)
|
backend/app/main.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
KeyStone – FastAPI Application Entry Point
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from contextlib import asynccontextmanager
|
| 6 |
+
|
| 7 |
+
from fastapi import FastAPI, Request, status
|
| 8 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 9 |
+
from fastapi.responses import JSONResponse
|
| 10 |
+
from slowapi import Limiter, _rate_limit_exceeded_handler
|
| 11 |
+
from slowapi.errors import RateLimitExceeded
|
| 12 |
+
from slowapi.util import get_remote_address
|
| 13 |
+
|
| 14 |
+
from app.config import get_settings
|
| 15 |
+
from app.database import create_tables
|
| 16 |
+
from app.routers import auth, upload, gallery, albums, sync, search
|
| 17 |
+
|
| 18 |
+
settings = get_settings()
|
| 19 |
+
|
| 20 |
+
limiter = Limiter(key_func=get_remote_address)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@asynccontextmanager
|
| 24 |
+
async def lifespan(app: FastAPI):
|
| 25 |
+
"""Application lifespan: startup / shutdown."""
|
| 26 |
+
# Startup
|
| 27 |
+
if settings.is_development:
|
| 28 |
+
await create_tables()
|
| 29 |
+
yield
|
| 30 |
+
# Shutdown – nothing special needed
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
app = FastAPI(
|
| 34 |
+
title=settings.app_name,
|
| 35 |
+
version=settings.app_version,
|
| 36 |
+
description="Private Google Photos alternative – self-hosted on Hugging Face Spaces.",
|
| 37 |
+
docs_url="/docs",
|
| 38 |
+
redoc_url="/redoc",
|
| 39 |
+
lifespan=lifespan,
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
# ── Rate Limiter ──────────────────────────────────────────────────────────────
|
| 43 |
+
app.state.limiter = limiter
|
| 44 |
+
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
| 45 |
+
|
| 46 |
+
# ── CORS ──────────────────────────────────────────────────────────────────────
|
| 47 |
+
app.add_middleware(
|
| 48 |
+
CORSMiddleware,
|
| 49 |
+
allow_origins=settings.cors_origins,
|
| 50 |
+
allow_credentials=True,
|
| 51 |
+
allow_methods=["*"],
|
| 52 |
+
allow_headers=["*"],
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
# ── Routers ───────────────────────────────────────────────────────────────────
|
| 56 |
+
app.include_router(auth.router, prefix="/auth", tags=["Authentication"])
|
| 57 |
+
app.include_router(sync.router, prefix="/sync", tags=["Sync"])
|
| 58 |
+
app.include_router(upload.router, prefix="/upload", tags=["Upload"])
|
| 59 |
+
app.include_router(gallery.router, prefix="/photos", tags=["Gallery"])
|
| 60 |
+
app.include_router(albums.router, prefix="/albums", tags=["Albums"])
|
| 61 |
+
app.include_router(search.router, prefix="/search", tags=["Search"])
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# ── Health ────────────────────────────────────────────────────────────────────
|
| 65 |
+
@app.get("/health", tags=["Health"])
|
| 66 |
+
async def health(request: Request):
|
| 67 |
+
"""Health check endpoint – returns app info and status."""
|
| 68 |
+
return {
|
| 69 |
+
"status": "ok",
|
| 70 |
+
"service": settings.app_name,
|
| 71 |
+
"version": settings.app_version,
|
| 72 |
+
"environment": settings.environment,
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# ── Global Exception Handler ──────────────────────────────────────────────────
|
| 77 |
+
@app.exception_handler(Exception)
|
| 78 |
+
async def global_exception_handler(request: Request, exc: Exception):
|
| 79 |
+
if settings.debug:
|
| 80 |
+
raise exc
|
| 81 |
+
return JSONResponse(
|
| 82 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 83 |
+
content={"detail": "An unexpected error occurred."},
|
| 84 |
+
)
|
backend/app/middleware/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""KeyStone – Middleware Package"""
|
backend/app/middleware/auth.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
KeyStone – JWT Authentication Middleware
|
| 3 |
+
Verifies Supabase-issued JWTs using the project's JWT secret.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
from typing import Annotated
|
| 8 |
+
|
| 9 |
+
import httpx
|
| 10 |
+
from fastapi import Depends, HTTPException, status
|
| 11 |
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
| 12 |
+
from jose import JWTError, jwt
|
| 13 |
+
from sqlalchemy import select
|
| 14 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 15 |
+
|
| 16 |
+
from app.config import get_settings
|
| 17 |
+
from app.database import get_db
|
| 18 |
+
from app.models.user import User
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
settings = get_settings()
|
| 22 |
+
|
| 23 |
+
security = HTTPBearer()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _decode_jwt(token: str) -> dict:
|
| 27 |
+
"""Decode and verify a Supabase JWT using the project JWT secret."""
|
| 28 |
+
try:
|
| 29 |
+
payload = jwt.decode(
|
| 30 |
+
token,
|
| 31 |
+
settings.supabase_jwt_secret,
|
| 32 |
+
algorithms=["HS256"],
|
| 33 |
+
options={"verify_aud": False}, # Supabase uses custom audience
|
| 34 |
+
)
|
| 35 |
+
return payload
|
| 36 |
+
except JWTError as exc:
|
| 37 |
+
logger.warning("JWT decode failed: %s", exc)
|
| 38 |
+
raise HTTPException(
|
| 39 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 40 |
+
detail="Invalid or expired token.",
|
| 41 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 42 |
+
) from exc
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
async def get_current_user(
|
| 46 |
+
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
|
| 47 |
+
db: Annotated[AsyncSession, Depends(get_db)],
|
| 48 |
+
) -> User:
|
| 49 |
+
"""
|
| 50 |
+
FastAPI dependency.
|
| 51 |
+
1. Decodes the Bearer JWT.
|
| 52 |
+
2. Extracts sub (Supabase user ID) and email.
|
| 53 |
+
3. Upserts the user in our database.
|
| 54 |
+
4. Returns the User ORM object.
|
| 55 |
+
"""
|
| 56 |
+
payload = _decode_jwt(credentials.credentials)
|
| 57 |
+
|
| 58 |
+
supabase_id: str | None = payload.get("sub")
|
| 59 |
+
email: str | None = payload.get("email")
|
| 60 |
+
|
| 61 |
+
if not supabase_id:
|
| 62 |
+
raise HTTPException(
|
| 63 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 64 |
+
detail="Token missing subject claim.",
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
# Upsert user in local DB
|
| 68 |
+
result = await db.execute(select(User).where(User.supabase_id == supabase_id))
|
| 69 |
+
user = result.scalar_one_or_none()
|
| 70 |
+
|
| 71 |
+
if user is None:
|
| 72 |
+
user = User(supabase_id=supabase_id, email=email or "")
|
| 73 |
+
db.add(user)
|
| 74 |
+
await db.flush()
|
| 75 |
+
logger.info("New user created: supabase_id=%s", supabase_id)
|
| 76 |
+
|
| 77 |
+
return user
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# Convenient type alias for route dependencies
|
| 81 |
+
CurrentUser = Annotated[User, Depends(get_current_user)]
|
backend/app/models/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""KeyStone SQLAlchemy Models Package"""
|
| 2 |
+
|
| 3 |
+
from app.models.user import User
|
| 4 |
+
from app.models.device import Device
|
| 5 |
+
from app.models.photo import Photo
|
| 6 |
+
from app.models.upload_job import UploadJob
|
| 7 |
+
from app.models.album import Album, AlbumPhoto
|
| 8 |
+
|
| 9 |
+
__all__ = ["User", "Device", "Photo", "UploadJob", "Album", "AlbumPhoto"]
|
backend/app/models/album.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
KeyStone – Album Model
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import uuid
|
| 6 |
+
from datetime import datetime, timezone
|
| 7 |
+
|
| 8 |
+
from sqlalchemy import String, DateTime, Text, ForeignKey, UniqueConstraint
|
| 9 |
+
from sqlalchemy.dialects.postgresql import UUID
|
| 10 |
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
| 11 |
+
|
| 12 |
+
from app.database import Base
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class Album(Base):
|
| 16 |
+
__tablename__ = "albums"
|
| 17 |
+
|
| 18 |
+
id: Mapped[uuid.UUID] = mapped_column(
|
| 19 |
+
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
| 20 |
+
)
|
| 21 |
+
user_id: Mapped[uuid.UUID] = mapped_column(
|
| 22 |
+
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
| 23 |
+
)
|
| 24 |
+
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
| 25 |
+
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
| 26 |
+
cover_photo_id: Mapped[uuid.UUID | None] = mapped_column(
|
| 27 |
+
UUID(as_uuid=True), ForeignKey("photos.id", ondelete="SET NULL"), nullable=True
|
| 28 |
+
)
|
| 29 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 30 |
+
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
| 31 |
+
)
|
| 32 |
+
updated_at: Mapped[datetime] = mapped_column(
|
| 33 |
+
DateTime(timezone=True),
|
| 34 |
+
default=lambda: datetime.now(timezone.utc),
|
| 35 |
+
onupdate=lambda: datetime.now(timezone.utc),
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
# ── Relationships ─────────────────────────────────────────────────────────
|
| 39 |
+
user: Mapped["User"] = relationship("User", back_populates="albums")
|
| 40 |
+
album_photos: Mapped[list["AlbumPhoto"]] = relationship(
|
| 41 |
+
"AlbumPhoto", back_populates="album", cascade="all, delete-orphan"
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
def __repr__(self) -> str:
|
| 45 |
+
return f"<Album id={self.id} name={self.name}>"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class AlbumPhoto(Base):
|
| 49 |
+
"""Junction table between albums and photos."""
|
| 50 |
+
|
| 51 |
+
__tablename__ = "album_photos"
|
| 52 |
+
__table_args__ = (
|
| 53 |
+
UniqueConstraint("album_id", "photo_id", name="uq_album_photo"),
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
id: Mapped[uuid.UUID] = mapped_column(
|
| 57 |
+
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
| 58 |
+
)
|
| 59 |
+
album_id: Mapped[uuid.UUID] = mapped_column(
|
| 60 |
+
UUID(as_uuid=True), ForeignKey("albums.id", ondelete="CASCADE"), nullable=False, index=True
|
| 61 |
+
)
|
| 62 |
+
photo_id: Mapped[uuid.UUID] = mapped_column(
|
| 63 |
+
UUID(as_uuid=True), ForeignKey("photos.id", ondelete="CASCADE"), nullable=False, index=True
|
| 64 |
+
)
|
| 65 |
+
added_at: Mapped[datetime] = mapped_column(
|
| 66 |
+
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
# ── Relationships ─────────────────────────────────────────────────────────
|
| 70 |
+
album: Mapped["Album"] = relationship("Album", back_populates="album_photos")
|
| 71 |
+
photo: Mapped["Photo"] = relationship("Photo", back_populates="album_photos")
|
backend/app/models/device.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
KeyStone – Device Model
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import uuid
|
| 6 |
+
from datetime import datetime, timezone
|
| 7 |
+
|
| 8 |
+
from sqlalchemy import String, DateTime, ForeignKey
|
| 9 |
+
from sqlalchemy.dialects.postgresql import UUID
|
| 10 |
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
| 11 |
+
|
| 12 |
+
from app.database import Base
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class Device(Base):
|
| 16 |
+
__tablename__ = "devices"
|
| 17 |
+
|
| 18 |
+
id: Mapped[uuid.UUID] = mapped_column(
|
| 19 |
+
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
| 20 |
+
)
|
| 21 |
+
user_id: Mapped[uuid.UUID] = mapped_column(
|
| 22 |
+
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
| 23 |
+
)
|
| 24 |
+
device_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
| 25 |
+
platform: Mapped[str] = mapped_column(String(50), nullable=False) # android | ios | web
|
| 26 |
+
device_token: Mapped[str | None] = mapped_column(String(512), nullable=True) # push token
|
| 27 |
+
last_sync: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
| 28 |
+
registered_at: Mapped[datetime] = mapped_column(
|
| 29 |
+
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
# ── Relationships ─────────────────────────────────────────────────────────
|
| 33 |
+
user: Mapped["User"] = relationship("User", back_populates="devices")
|
| 34 |
+
|
| 35 |
+
def __repr__(self) -> str:
|
| 36 |
+
return f"<Device id={self.id} name={self.device_name} platform={self.platform}>"
|
backend/app/models/photo.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
KeyStone – Photo Model
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import uuid
|
| 6 |
+
from datetime import datetime, timezone
|
| 7 |
+
|
| 8 |
+
from sqlalchemy import String, DateTime, Integer, Boolean, BigInteger, ForeignKey, Text
|
| 9 |
+
from sqlalchemy.dialects.postgresql import UUID
|
| 10 |
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
| 11 |
+
|
| 12 |
+
from app.database import Base
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class Photo(Base):
|
| 16 |
+
__tablename__ = "photos"
|
| 17 |
+
|
| 18 |
+
id: Mapped[uuid.UUID] = mapped_column(
|
| 19 |
+
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
| 20 |
+
)
|
| 21 |
+
user_id: Mapped[uuid.UUID] = mapped_column(
|
| 22 |
+
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
# ── File Identity ─────────────────────────────────────────────────────────
|
| 26 |
+
sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
| 27 |
+
filename: Mapped[str] = mapped_column(String(512), nullable=False)
|
| 28 |
+
mime_type: Mapped[str] = mapped_column(String(128), nullable=False)
|
| 29 |
+
|
| 30 |
+
# ── Storage Paths ─────────────────────────────────────────────────────────
|
| 31 |
+
bucket_path: Mapped[str] = mapped_column(Text, nullable=False) # originals/...
|
| 32 |
+
thumbnail_path: Mapped[str | None] = mapped_column(Text, nullable=True) # thumbnails/...
|
| 33 |
+
preview_path: Mapped[str | None] = mapped_column(Text, nullable=True) # previews/...
|
| 34 |
+
|
| 35 |
+
# ── Image Metadata ────────────────────────────────────────────────────────
|
| 36 |
+
width: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
| 37 |
+
height: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
| 38 |
+
size: Mapped[int] = mapped_column(BigInteger, nullable=False) # bytes
|
| 39 |
+
|
| 40 |
+
# ── Timestamps ────────────────────────────────────────────────────────────
|
| 41 |
+
taken_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
| 42 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 43 |
+
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
| 44 |
+
)
|
| 45 |
+
uploaded_at: Mapped[datetime] = mapped_column(
|
| 46 |
+
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
# ── Soft Delete / Status ──────────────────────────────────────────────────
|
| 50 |
+
deleted: Mapped[bool] = mapped_column(Boolean, default=False)
|
| 51 |
+
is_favorite: Mapped[bool] = mapped_column(Boolean, default=False)
|
| 52 |
+
|
| 53 |
+
# ── AI Metadata (future) ──────────────────────────────────────────────────
|
| 54 |
+
ai_description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
| 55 |
+
ai_tags: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON array as string
|
| 56 |
+
clip_embedding: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON float array
|
| 57 |
+
|
| 58 |
+
# ── Relationships ─────────────────────────────────────────────────────────
|
| 59 |
+
user: Mapped["User"] = relationship("User", back_populates="photos")
|
| 60 |
+
album_photos: Mapped[list["AlbumPhoto"]] = relationship("AlbumPhoto", back_populates="photo", cascade="all, delete-orphan")
|
| 61 |
+
upload_jobs: Mapped[list["UploadJob"]] = relationship("UploadJob", back_populates="photo", lazy="select")
|
| 62 |
+
|
| 63 |
+
def __repr__(self) -> str:
|
| 64 |
+
return f"<Photo id={self.id} filename={self.filename} sha256={self.sha256[:8]}...>"
|
backend/app/models/upload_job.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
KeyStone – UploadJob Model
|
| 3 |
+
Tracks each upload attempt for retry logic and progress reporting.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import uuid
|
| 7 |
+
from datetime import datetime, timezone
|
| 8 |
+
|
| 9 |
+
from sqlalchemy import String, DateTime, Integer, ForeignKey, Text, Enum
|
| 10 |
+
from sqlalchemy.dialects.postgresql import UUID
|
| 11 |
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
| 12 |
+
import enum
|
| 13 |
+
|
| 14 |
+
from app.database import Base
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class UploadStatus(str, enum.Enum):
|
| 18 |
+
PENDING = "pending"
|
| 19 |
+
UPLOADING = "uploading"
|
| 20 |
+
PROCESSING = "processing"
|
| 21 |
+
COMPLETED = "completed"
|
| 22 |
+
FAILED = "failed"
|
| 23 |
+
DUPLICATE = "duplicate"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class UploadJob(Base):
|
| 27 |
+
__tablename__ = "upload_jobs"
|
| 28 |
+
|
| 29 |
+
id: Mapped[uuid.UUID] = mapped_column(
|
| 30 |
+
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
| 31 |
+
)
|
| 32 |
+
user_id: Mapped[uuid.UUID] = mapped_column(
|
| 33 |
+
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
| 34 |
+
)
|
| 35 |
+
photo_id: Mapped[uuid.UUID | None] = mapped_column(
|
| 36 |
+
UUID(as_uuid=True), ForeignKey("photos.id", ondelete="SET NULL"), nullable=True
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
# ── Job Metadata ──────────────────────────────────────────────────────────
|
| 40 |
+
filename: Mapped[str] = mapped_column(String(512), nullable=False)
|
| 41 |
+
sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
| 42 |
+
status: Mapped[UploadStatus] = mapped_column(
|
| 43 |
+
Enum(UploadStatus), default=UploadStatus.PENDING, nullable=False
|
| 44 |
+
)
|
| 45 |
+
retries: Mapped[int] = mapped_column(Integer, default=0)
|
| 46 |
+
max_retries: Mapped[int] = mapped_column(Integer, default=3)
|
| 47 |
+
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
| 48 |
+
|
| 49 |
+
# ── Timestamps ────────────────────────────────────────────────────────────
|
| 50 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 51 |
+
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
| 52 |
+
)
|
| 53 |
+
updated_at: Mapped[datetime] = mapped_column(
|
| 54 |
+
DateTime(timezone=True),
|
| 55 |
+
default=lambda: datetime.now(timezone.utc),
|
| 56 |
+
onupdate=lambda: datetime.now(timezone.utc),
|
| 57 |
+
)
|
| 58 |
+
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
| 59 |
+
|
| 60 |
+
# ── Relationships ─────────────────────────────────────────────────────────
|
| 61 |
+
user: Mapped["User"] = relationship("User", back_populates="upload_jobs")
|
| 62 |
+
photo: Mapped["Photo | None"] = relationship("Photo", back_populates="upload_jobs")
|
| 63 |
+
|
| 64 |
+
def __repr__(self) -> str:
|
| 65 |
+
return f"<UploadJob id={self.id} filename={self.filename} status={self.status}>"
|
backend/app/models/user.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
KeyStone – User Model
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import uuid
|
| 6 |
+
from datetime import datetime, timezone
|
| 7 |
+
|
| 8 |
+
from sqlalchemy import String, DateTime, Boolean
|
| 9 |
+
from sqlalchemy.dialects.postgresql import UUID
|
| 10 |
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
| 11 |
+
|
| 12 |
+
from app.database import Base
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class User(Base):
|
| 16 |
+
__tablename__ = "users"
|
| 17 |
+
|
| 18 |
+
id: Mapped[uuid.UUID] = mapped_column(
|
| 19 |
+
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
| 20 |
+
)
|
| 21 |
+
supabase_id: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
| 22 |
+
email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False, index=True)
|
| 23 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 24 |
+
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
| 25 |
+
)
|
| 26 |
+
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
| 27 |
+
|
| 28 |
+
# ── Relationships ─────────────────────────────────────────────────────────
|
| 29 |
+
photos: Mapped[list["Photo"]] = relationship("Photo", back_populates="user", lazy="select")
|
| 30 |
+
devices: Mapped[list["Device"]] = relationship("Device", back_populates="user", lazy="select")
|
| 31 |
+
albums: Mapped[list["Album"]] = relationship("Album", back_populates="user", lazy="select")
|
| 32 |
+
upload_jobs: Mapped[list["UploadJob"]] = relationship("UploadJob", back_populates="user", lazy="select")
|
| 33 |
+
|
| 34 |
+
def __repr__(self) -> str:
|
| 35 |
+
return f"<User id={self.id} email={self.email}>"
|
backend/app/routers/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""KeyStone – Routers Package"""
|
backend/app/routers/albums.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
KeyStone – Albums Router
|
| 3 |
+
GET /albums — list user's albums
|
| 4 |
+
POST /albums — create album
|
| 5 |
+
GET /albums/{id}/photos — list photos in album
|
| 6 |
+
POST /albums/{id}/photos — add photos to album
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import uuid
|
| 10 |
+
from typing import Annotated
|
| 11 |
+
|
| 12 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 13 |
+
from sqlalchemy import select, func
|
| 14 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 15 |
+
|
| 16 |
+
from app.database import get_db
|
| 17 |
+
from app.middleware.auth import CurrentUser
|
| 18 |
+
from app.models.album import Album, AlbumPhoto
|
| 19 |
+
from app.models.photo import Photo
|
| 20 |
+
from app.schemas.album import AlbumCreate, AlbumOut, AlbumUpdate, AlbumWithPhotos, AddPhotosToAlbumRequest
|
| 21 |
+
from app.schemas.photo import PhotoPage
|
| 22 |
+
from app.routers.gallery import _enrich
|
| 23 |
+
|
| 24 |
+
router = APIRouter()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@router.get("", response_model=list[AlbumOut], summary="List all albums")
|
| 28 |
+
async def list_albums(
|
| 29 |
+
current_user: CurrentUser,
|
| 30 |
+
db: Annotated[AsyncSession, Depends(get_db)],
|
| 31 |
+
) -> list[AlbumOut]:
|
| 32 |
+
result = await db.execute(
|
| 33 |
+
select(Album).where(Album.user_id == current_user.id).order_by(Album.updated_at.desc())
|
| 34 |
+
)
|
| 35 |
+
albums = result.scalars().all()
|
| 36 |
+
|
| 37 |
+
out = []
|
| 38 |
+
for album in albums:
|
| 39 |
+
count_res = await db.execute(
|
| 40 |
+
select(func.count()).select_from(AlbumPhoto).where(AlbumPhoto.album_id == album.id)
|
| 41 |
+
)
|
| 42 |
+
photo_count = count_res.scalar_one()
|
| 43 |
+
a = AlbumOut.model_validate(album)
|
| 44 |
+
a.photo_count = photo_count
|
| 45 |
+
out.append(a)
|
| 46 |
+
return out
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@router.post("", response_model=AlbumOut, status_code=status.HTTP_201_CREATED, summary="Create an album")
|
| 50 |
+
async def create_album(
|
| 51 |
+
body: AlbumCreate,
|
| 52 |
+
current_user: CurrentUser,
|
| 53 |
+
db: Annotated[AsyncSession, Depends(get_db)],
|
| 54 |
+
) -> AlbumOut:
|
| 55 |
+
album = Album(user_id=current_user.id, name=body.name, description=body.description)
|
| 56 |
+
db.add(album)
|
| 57 |
+
await db.flush()
|
| 58 |
+
return AlbumOut.model_validate(album)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@router.get("/{album_id}", response_model=AlbumWithPhotos, summary="Get album with photos")
|
| 62 |
+
async def get_album(
|
| 63 |
+
album_id: uuid.UUID,
|
| 64 |
+
current_user: CurrentUser,
|
| 65 |
+
db: Annotated[AsyncSession, Depends(get_db)],
|
| 66 |
+
) -> AlbumWithPhotos:
|
| 67 |
+
result = await db.execute(
|
| 68 |
+
select(Album).where(Album.id == album_id, Album.user_id == current_user.id)
|
| 69 |
+
)
|
| 70 |
+
album = result.scalar_one_or_none()
|
| 71 |
+
if not album:
|
| 72 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Album not found.")
|
| 73 |
+
|
| 74 |
+
photos_res = await db.execute(
|
| 75 |
+
select(Photo)
|
| 76 |
+
.join(AlbumPhoto, AlbumPhoto.photo_id == Photo.id)
|
| 77 |
+
.where(AlbumPhoto.album_id == album_id, Photo.deleted == False) # noqa
|
| 78 |
+
)
|
| 79 |
+
photos = photos_res.scalars().all()
|
| 80 |
+
a = AlbumWithPhotos.model_validate(album)
|
| 81 |
+
a.photos = [_enrich(p) for p in photos]
|
| 82 |
+
a.photo_count = len(photos)
|
| 83 |
+
return a
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@router.patch("/{album_id}", response_model=AlbumOut, summary="Update album metadata")
|
| 87 |
+
async def update_album(
|
| 88 |
+
album_id: uuid.UUID,
|
| 89 |
+
body: AlbumUpdate,
|
| 90 |
+
current_user: CurrentUser,
|
| 91 |
+
db: Annotated[AsyncSession, Depends(get_db)],
|
| 92 |
+
) -> AlbumOut:
|
| 93 |
+
result = await db.execute(
|
| 94 |
+
select(Album).where(Album.id == album_id, Album.user_id == current_user.id)
|
| 95 |
+
)
|
| 96 |
+
album = result.scalar_one_or_none()
|
| 97 |
+
if not album:
|
| 98 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Album not found.")
|
| 99 |
+
if body.name is not None:
|
| 100 |
+
album.name = body.name
|
| 101 |
+
if body.description is not None:
|
| 102 |
+
album.description = body.description
|
| 103 |
+
if body.cover_photo_id is not None:
|
| 104 |
+
album.cover_photo_id = body.cover_photo_id
|
| 105 |
+
return AlbumOut.model_validate(album)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
@router.delete("/{album_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete an album")
|
| 109 |
+
async def delete_album(
|
| 110 |
+
album_id: uuid.UUID,
|
| 111 |
+
current_user: CurrentUser,
|
| 112 |
+
db: Annotated[AsyncSession, Depends(get_db)],
|
| 113 |
+
) -> None:
|
| 114 |
+
result = await db.execute(
|
| 115 |
+
select(Album).where(Album.id == album_id, Album.user_id == current_user.id)
|
| 116 |
+
)
|
| 117 |
+
album = result.scalar_one_or_none()
|
| 118 |
+
if not album:
|
| 119 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Album not found.")
|
| 120 |
+
await db.delete(album)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
@router.post("/{album_id}/photos", status_code=status.HTTP_200_OK, summary="Add photos to album")
|
| 124 |
+
async def add_photos_to_album(
|
| 125 |
+
album_id: uuid.UUID,
|
| 126 |
+
body: AddPhotosToAlbumRequest,
|
| 127 |
+
current_user: CurrentUser,
|
| 128 |
+
db: Annotated[AsyncSession, Depends(get_db)],
|
| 129 |
+
) -> dict:
|
| 130 |
+
result = await db.execute(
|
| 131 |
+
select(Album).where(Album.id == album_id, Album.user_id == current_user.id)
|
| 132 |
+
)
|
| 133 |
+
album = result.scalar_one_or_none()
|
| 134 |
+
if not album:
|
| 135 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Album not found.")
|
| 136 |
+
|
| 137 |
+
added = 0
|
| 138 |
+
for photo_id in body.photo_ids:
|
| 139 |
+
# Verify photo belongs to user
|
| 140 |
+
p_res = await db.execute(
|
| 141 |
+
select(Photo).where(Photo.id == photo_id, Photo.user_id == current_user.id)
|
| 142 |
+
)
|
| 143 |
+
photo = p_res.scalar_one_or_none()
|
| 144 |
+
if not photo:
|
| 145 |
+
continue
|
| 146 |
+
# Check not already in album
|
| 147 |
+
ap_res = await db.execute(
|
| 148 |
+
select(AlbumPhoto).where(AlbumPhoto.album_id == album_id, AlbumPhoto.photo_id == photo_id)
|
| 149 |
+
)
|
| 150 |
+
if ap_res.scalar_one_or_none() is None:
|
| 151 |
+
db.add(AlbumPhoto(album_id=album_id, photo_id=photo_id))
|
| 152 |
+
added += 1
|
| 153 |
+
|
| 154 |
+
return {"added": added, "message": f"Added {added} photo(s) to album."}
|
backend/app/routers/auth.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
KeyStone – Auth Router
|
| 3 |
+
POST /auth/verify — validates JWT and returns user profile
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter
|
| 7 |
+
|
| 8 |
+
from app.middleware.auth import CurrentUser
|
| 9 |
+
from app.schemas.user import UserOut
|
| 10 |
+
|
| 11 |
+
router = APIRouter()
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@router.post("/verify", response_model=UserOut, summary="Verify JWT and return user profile")
|
| 15 |
+
async def verify_token(current_user: CurrentUser) -> UserOut:
|
| 16 |
+
"""
|
| 17 |
+
Validates the Bearer token (issued by Supabase) and returns the
|
| 18 |
+
authenticated user's profile. Also auto-creates the user record on
|
| 19 |
+
first login.
|
| 20 |
+
"""
|
| 21 |
+
return UserOut.model_validate(current_user)
|
backend/app/routers/gallery.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
KeyStone – Gallery Router
|
| 3 |
+
GET /photos — paginated photo list (cursor-based)
|
| 4 |
+
GET /photos/{id} — single photo
|
| 5 |
+
DELETE /photos/{id} — soft-delete
|
| 6 |
+
PATCH /photos/{id}/favorite — toggle favorite
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import uuid
|
| 10 |
+
from typing import Annotated, Optional
|
| 11 |
+
|
| 12 |
+
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
| 13 |
+
from sqlalchemy import select, func, desc
|
| 14 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 15 |
+
|
| 16 |
+
from app.database import get_db
|
| 17 |
+
from app.middleware.auth import CurrentUser
|
| 18 |
+
from app.models.photo import Photo
|
| 19 |
+
from app.schemas.photo import PhotoOut, PhotoPage, PhotoUpdate
|
| 20 |
+
from app.services.storage import get_download_url
|
| 21 |
+
|
| 22 |
+
router = APIRouter()
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _enrich(photo: Photo) -> PhotoOut:
|
| 26 |
+
"""Convert ORM model to schema, injecting public download URLs."""
|
| 27 |
+
data = PhotoOut.model_validate(photo)
|
| 28 |
+
# Rewrite paths to public URLs
|
| 29 |
+
if photo.bucket_path:
|
| 30 |
+
data.bucket_path = get_download_url(photo.bucket_path)
|
| 31 |
+
if photo.thumbnail_path:
|
| 32 |
+
data.thumbnail_path = get_download_url(photo.thumbnail_path)
|
| 33 |
+
if photo.preview_path:
|
| 34 |
+
data.preview_path = get_download_url(photo.preview_path)
|
| 35 |
+
return data
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@router.get(
|
| 39 |
+
"",
|
| 40 |
+
response_model=PhotoPage,
|
| 41 |
+
summary="List photos (paginated, newest first)",
|
| 42 |
+
)
|
| 43 |
+
async def list_photos(
|
| 44 |
+
current_user: CurrentUser,
|
| 45 |
+
db: Annotated[AsyncSession, Depends(get_db)],
|
| 46 |
+
limit: int = Query(50, ge=1, le=200),
|
| 47 |
+
cursor: Optional[str] = Query(None, description="Opaque pagination cursor (photo ID)"),
|
| 48 |
+
favorites_only: bool = Query(False),
|
| 49 |
+
) -> PhotoPage:
|
| 50 |
+
"""
|
| 51 |
+
Returns a paginated list of photos for the authenticated user.
|
| 52 |
+
Uses cursor-based pagination for efficient infinite scroll.
|
| 53 |
+
"""
|
| 54 |
+
query = (
|
| 55 |
+
select(Photo)
|
| 56 |
+
.where(Photo.user_id == current_user.id, Photo.deleted == False) # noqa
|
| 57 |
+
.order_by(desc(Photo.uploaded_at))
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
if favorites_only:
|
| 61 |
+
query = query.where(Photo.is_favorite == True) # noqa
|
| 62 |
+
|
| 63 |
+
if cursor:
|
| 64 |
+
try:
|
| 65 |
+
cursor_id = uuid.UUID(cursor)
|
| 66 |
+
# Get the uploaded_at of the cursor photo
|
| 67 |
+
cur_res = await db.execute(select(Photo.uploaded_at).where(Photo.id == cursor_id))
|
| 68 |
+
cursor_time = cur_res.scalar_one_or_none()
|
| 69 |
+
if cursor_time:
|
| 70 |
+
query = query.where(Photo.uploaded_at < cursor_time)
|
| 71 |
+
except (ValueError, Exception):
|
| 72 |
+
pass # Invalid cursor – ignore and start from beginning
|
| 73 |
+
|
| 74 |
+
result = await db.execute(query.limit(limit + 1))
|
| 75 |
+
photos = result.scalars().all()
|
| 76 |
+
|
| 77 |
+
has_more = len(photos) > limit
|
| 78 |
+
photos = photos[:limit]
|
| 79 |
+
|
| 80 |
+
# Count total (non-deleted) for display
|
| 81 |
+
count_q = select(func.count()).where(Photo.user_id == current_user.id, Photo.deleted == False) # noqa
|
| 82 |
+
total = (await db.execute(count_q)).scalar_one()
|
| 83 |
+
|
| 84 |
+
next_cursor = str(photos[-1].id) if has_more and photos else None
|
| 85 |
+
|
| 86 |
+
return PhotoPage(
|
| 87 |
+
items=[_enrich(p) for p in photos],
|
| 88 |
+
total=total,
|
| 89 |
+
next_cursor=next_cursor,
|
| 90 |
+
has_more=has_more,
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
@router.get(
|
| 95 |
+
"/{photo_id}",
|
| 96 |
+
response_model=PhotoOut,
|
| 97 |
+
summary="Get a single photo by ID",
|
| 98 |
+
)
|
| 99 |
+
async def get_photo(
|
| 100 |
+
photo_id: uuid.UUID,
|
| 101 |
+
current_user: CurrentUser,
|
| 102 |
+
db: Annotated[AsyncSession, Depends(get_db)],
|
| 103 |
+
) -> PhotoOut:
|
| 104 |
+
result = await db.execute(
|
| 105 |
+
select(Photo).where(
|
| 106 |
+
Photo.id == photo_id,
|
| 107 |
+
Photo.user_id == current_user.id,
|
| 108 |
+
Photo.deleted == False, # noqa
|
| 109 |
+
)
|
| 110 |
+
)
|
| 111 |
+
photo = result.scalar_one_or_none()
|
| 112 |
+
if not photo:
|
| 113 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Photo not found.")
|
| 114 |
+
return _enrich(photo)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
@router.delete(
|
| 118 |
+
"/{photo_id}",
|
| 119 |
+
status_code=status.HTTP_204_NO_CONTENT,
|
| 120 |
+
summary="Soft-delete a photo",
|
| 121 |
+
)
|
| 122 |
+
async def delete_photo(
|
| 123 |
+
photo_id: uuid.UUID,
|
| 124 |
+
current_user: CurrentUser,
|
| 125 |
+
db: Annotated[AsyncSession, Depends(get_db)],
|
| 126 |
+
) -> None:
|
| 127 |
+
result = await db.execute(
|
| 128 |
+
select(Photo).where(Photo.id == photo_id, Photo.user_id == current_user.id)
|
| 129 |
+
)
|
| 130 |
+
photo = result.scalar_one_or_none()
|
| 131 |
+
if not photo:
|
| 132 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Photo not found.")
|
| 133 |
+
photo.deleted = True
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
@router.patch(
|
| 137 |
+
"/{photo_id}",
|
| 138 |
+
response_model=PhotoOut,
|
| 139 |
+
summary="Update photo metadata (favorite, etc.)",
|
| 140 |
+
)
|
| 141 |
+
async def update_photo(
|
| 142 |
+
photo_id: uuid.UUID,
|
| 143 |
+
body: PhotoUpdate,
|
| 144 |
+
current_user: CurrentUser,
|
| 145 |
+
db: Annotated[AsyncSession, Depends(get_db)],
|
| 146 |
+
) -> PhotoOut:
|
| 147 |
+
result = await db.execute(
|
| 148 |
+
select(Photo).where(
|
| 149 |
+
Photo.id == photo_id,
|
| 150 |
+
Photo.user_id == current_user.id,
|
| 151 |
+
Photo.deleted == False, # noqa
|
| 152 |
+
)
|
| 153 |
+
)
|
| 154 |
+
photo = result.scalar_one_or_none()
|
| 155 |
+
if not photo:
|
| 156 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Photo not found.")
|
| 157 |
+
|
| 158 |
+
if body.is_favorite is not None:
|
| 159 |
+
photo.is_favorite = body.is_favorite
|
| 160 |
+
if body.deleted is not None:
|
| 161 |
+
photo.deleted = body.deleted
|
| 162 |
+
if body.ai_description is not None:
|
| 163 |
+
photo.ai_description = body.ai_description
|
| 164 |
+
if body.ai_tags is not None:
|
| 165 |
+
photo.ai_tags = body.ai_tags
|
| 166 |
+
|
| 167 |
+
return _enrich(photo)
|
backend/app/routers/search.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
KeyStone – Search Router
|
| 3 |
+
GET /search?q= — text search across photos (AI-ready, text-based initially)
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from typing import Annotated, Optional
|
| 7 |
+
|
| 8 |
+
from fastapi import APIRouter, Depends, Query
|
| 9 |
+
from sqlalchemy import select, or_
|
| 10 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 11 |
+
|
| 12 |
+
from app.database import get_db
|
| 13 |
+
from app.middleware.auth import CurrentUser
|
| 14 |
+
from app.models.photo import Photo
|
| 15 |
+
from app.schemas.photo import PhotoPage
|
| 16 |
+
from app.routers.gallery import _enrich
|
| 17 |
+
|
| 18 |
+
router = APIRouter()
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@router.get("", response_model=PhotoPage, summary="Search photos by filename or AI tags")
|
| 22 |
+
async def search_photos(
|
| 23 |
+
current_user: CurrentUser,
|
| 24 |
+
db: Annotated[AsyncSession, Depends(get_db)],
|
| 25 |
+
q: str = Query(..., min_length=1, description="Search query"),
|
| 26 |
+
limit: int = Query(50, ge=1, le=200),
|
| 27 |
+
offset: int = Query(0, ge=0),
|
| 28 |
+
) -> PhotoPage:
|
| 29 |
+
"""
|
| 30 |
+
Text-based search across:
|
| 31 |
+
- filename
|
| 32 |
+
- ai_description
|
| 33 |
+
- ai_tags
|
| 34 |
+
|
| 35 |
+
CLIP-based semantic search will be added in Milestone 8 using the
|
| 36 |
+
clip_embedding column.
|
| 37 |
+
"""
|
| 38 |
+
search_term = f"%{q.lower()}%"
|
| 39 |
+
|
| 40 |
+
query = (
|
| 41 |
+
select(Photo)
|
| 42 |
+
.where(
|
| 43 |
+
Photo.user_id == current_user.id,
|
| 44 |
+
Photo.deleted == False, # noqa
|
| 45 |
+
or_(
|
| 46 |
+
Photo.filename.ilike(search_term),
|
| 47 |
+
Photo.ai_description.ilike(search_term),
|
| 48 |
+
Photo.ai_tags.ilike(search_term),
|
| 49 |
+
),
|
| 50 |
+
)
|
| 51 |
+
.order_by(Photo.uploaded_at.desc())
|
| 52 |
+
.offset(offset)
|
| 53 |
+
.limit(limit + 1)
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
result = await db.execute(query)
|
| 57 |
+
photos = result.scalars().all()
|
| 58 |
+
|
| 59 |
+
has_more = len(photos) > limit
|
| 60 |
+
photos = photos[:limit]
|
| 61 |
+
|
| 62 |
+
return PhotoPage(
|
| 63 |
+
items=[_enrich(p) for p in photos],
|
| 64 |
+
total=len(photos),
|
| 65 |
+
next_cursor=None,
|
| 66 |
+
has_more=has_more,
|
| 67 |
+
)
|
backend/app/routers/sync.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
KeyStone – Sync Router
|
| 3 |
+
POST /sync/start — register / update a device
|
| 4 |
+
POST /sync/check — check which hashes need uploading
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import logging
|
| 8 |
+
from datetime import datetime, timezone
|
| 9 |
+
from typing import Annotated
|
| 10 |
+
|
| 11 |
+
from fastapi import APIRouter, Depends, status
|
| 12 |
+
from sqlalchemy import select
|
| 13 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 14 |
+
|
| 15 |
+
from app.database import get_db
|
| 16 |
+
from app.middleware.auth import CurrentUser
|
| 17 |
+
from app.models.device import Device
|
| 18 |
+
from app.schemas.sync import (
|
| 19 |
+
SyncCheckRequest, SyncCheckResponse,
|
| 20 |
+
SyncStartRequest, SyncStartResponse,
|
| 21 |
+
)
|
| 22 |
+
from app.services.dedup import filter_missing_hashes
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
router = APIRouter()
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@router.post(
|
| 29 |
+
"/start",
|
| 30 |
+
response_model=SyncStartResponse,
|
| 31 |
+
status_code=status.HTTP_200_OK,
|
| 32 |
+
summary="Register or update a device for sync",
|
| 33 |
+
)
|
| 34 |
+
async def sync_start(
|
| 35 |
+
body: SyncStartRequest,
|
| 36 |
+
current_user: CurrentUser,
|
| 37 |
+
db: Annotated[AsyncSession, Depends(get_db)],
|
| 38 |
+
) -> SyncStartResponse:
|
| 39 |
+
"""
|
| 40 |
+
Register a device (or update its last_sync timestamp).
|
| 41 |
+
Called once at app launch before beginning a sync session.
|
| 42 |
+
"""
|
| 43 |
+
# Find existing device by name + platform for this user
|
| 44 |
+
result = await db.execute(
|
| 45 |
+
select(Device).where(
|
| 46 |
+
Device.user_id == current_user.id,
|
| 47 |
+
Device.device_name == body.device_name,
|
| 48 |
+
Device.platform == body.platform,
|
| 49 |
+
)
|
| 50 |
+
)
|
| 51 |
+
device = result.scalar_one_or_none()
|
| 52 |
+
|
| 53 |
+
if device is None:
|
| 54 |
+
device = Device(
|
| 55 |
+
user_id=current_user.id,
|
| 56 |
+
device_name=body.device_name,
|
| 57 |
+
platform=body.platform,
|
| 58 |
+
device_token=body.device_token,
|
| 59 |
+
)
|
| 60 |
+
db.add(device)
|
| 61 |
+
await db.flush()
|
| 62 |
+
logger.info("Registered new device: %s (%s)", body.device_name, body.platform)
|
| 63 |
+
else:
|
| 64 |
+
device.last_sync = datetime.now(timezone.utc)
|
| 65 |
+
if body.device_token:
|
| 66 |
+
device.device_token = body.device_token
|
| 67 |
+
|
| 68 |
+
return SyncStartResponse(
|
| 69 |
+
device_id=device.id,
|
| 70 |
+
message="Device registered. Ready to sync.",
|
| 71 |
+
last_sync=device.last_sync,
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
@router.post(
|
| 76 |
+
"/check",
|
| 77 |
+
response_model=SyncCheckResponse,
|
| 78 |
+
status_code=status.HTTP_200_OK,
|
| 79 |
+
summary="Check which photo hashes need uploading",
|
| 80 |
+
)
|
| 81 |
+
async def sync_check(
|
| 82 |
+
body: SyncCheckRequest,
|
| 83 |
+
current_user: CurrentUser,
|
| 84 |
+
db: Annotated[AsyncSession, Depends(get_db)],
|
| 85 |
+
) -> SyncCheckResponse:
|
| 86 |
+
"""
|
| 87 |
+
Accepts a list of SHA256 hashes from the client's camera roll.
|
| 88 |
+
Returns only the hashes the server does NOT already have.
|
| 89 |
+
This enables the mobile client to skip uploading duplicates.
|
| 90 |
+
"""
|
| 91 |
+
missing = await filter_missing_hashes(db, current_user.id, body.hashes)
|
| 92 |
+
return SyncCheckResponse(
|
| 93 |
+
missing_hashes=missing,
|
| 94 |
+
existing_count=len(body.hashes) - len(missing),
|
| 95 |
+
missing_count=len(missing),
|
| 96 |
+
)
|
backend/app/routers/upload.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
KeyStone – Upload Router
|
| 3 |
+
POST /upload — single file upload
|
| 4 |
+
POST /upload/batch — batch metadata check (actual bytes uploaded per-file)
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import logging
|
| 8 |
+
import uuid
|
| 9 |
+
from datetime import datetime, timezone
|
| 10 |
+
from typing import Annotated
|
| 11 |
+
|
| 12 |
+
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, Request, UploadFile, status
|
| 13 |
+
from slowapi import Limiter
|
| 14 |
+
from slowapi.util import get_remote_address
|
| 15 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 16 |
+
|
| 17 |
+
from app.config import get_settings
|
| 18 |
+
from app.database import get_db
|
| 19 |
+
from app.middleware.auth import CurrentUser
|
| 20 |
+
from app.models.photo import Photo
|
| 21 |
+
from app.models.upload_job import UploadJob, UploadStatus
|
| 22 |
+
from app.schemas.upload import UploadResponse
|
| 23 |
+
from app.services import dedup as dedup_svc
|
| 24 |
+
from app.services import storage as storage_svc
|
| 25 |
+
from app.services import thumbnail as thumb_svc
|
| 26 |
+
|
| 27 |
+
logger = logging.getLogger(__name__)
|
| 28 |
+
settings = get_settings()
|
| 29 |
+
limiter = Limiter(key_func=get_remote_address)
|
| 30 |
+
|
| 31 |
+
router = APIRouter()
|
| 32 |
+
|
| 33 |
+
ALLOWED_MIME_TYPES = set(settings.allowed_mime_types)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _validate_file(file: UploadFile, file_bytes: bytes) -> None:
|
| 37 |
+
"""Validate MIME type and file size."""
|
| 38 |
+
if file.content_type not in ALLOWED_MIME_TYPES:
|
| 39 |
+
raise HTTPException(
|
| 40 |
+
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
| 41 |
+
detail=f"Unsupported file type: {file.content_type}",
|
| 42 |
+
)
|
| 43 |
+
if len(file_bytes) > settings.max_upload_size_bytes:
|
| 44 |
+
raise HTTPException(
|
| 45 |
+
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
| 46 |
+
detail=f"File too large. Maximum size is {settings.max_upload_size_mb} MB.",
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
async def _process_upload_background(
|
| 51 |
+
photo_id: uuid.UUID,
|
| 52 |
+
job_id: uuid.UUID,
|
| 53 |
+
file_bytes: bytes,
|
| 54 |
+
filename: str,
|
| 55 |
+
mime_type: str,
|
| 56 |
+
user_id: uuid.UUID,
|
| 57 |
+
sha256: str,
|
| 58 |
+
taken_at_str: str | None,
|
| 59 |
+
db: AsyncSession,
|
| 60 |
+
) -> None:
|
| 61 |
+
"""Background task: generate thumbnail and update photo record."""
|
| 62 |
+
try:
|
| 63 |
+
# Generate thumbnail
|
| 64 |
+
thumb_bytes, width, height = thumb_svc.generate_thumbnail(file_bytes)
|
| 65 |
+
thumb_path = await storage_svc.upload_thumbnail(thumb_bytes, user_id, photo_id)
|
| 66 |
+
|
| 67 |
+
# Update photo record
|
| 68 |
+
from sqlalchemy import select
|
| 69 |
+
result = await db.execute(select(Photo).where(Photo.id == photo_id))
|
| 70 |
+
photo = result.scalar_one_or_none()
|
| 71 |
+
if photo:
|
| 72 |
+
photo.thumbnail_path = thumb_path
|
| 73 |
+
photo.width = width
|
| 74 |
+
photo.height = height
|
| 75 |
+
await db.commit()
|
| 76 |
+
|
| 77 |
+
# Mark job complete
|
| 78 |
+
result = await db.execute(select(UploadJob).where(UploadJob.id == job_id))
|
| 79 |
+
job = result.scalar_one_or_none()
|
| 80 |
+
if job:
|
| 81 |
+
job.status = UploadStatus.COMPLETED
|
| 82 |
+
job.completed_at = datetime.now(timezone.utc)
|
| 83 |
+
await db.commit()
|
| 84 |
+
|
| 85 |
+
except Exception as exc:
|
| 86 |
+
logger.error("Background processing failed for photo %s: %s", photo_id, exc)
|
| 87 |
+
from sqlalchemy import select
|
| 88 |
+
result = await db.execute(select(UploadJob).where(UploadJob.id == job_id))
|
| 89 |
+
job = result.scalar_one_or_none()
|
| 90 |
+
if job:
|
| 91 |
+
job.status = UploadStatus.FAILED
|
| 92 |
+
job.error_message = str(exc)
|
| 93 |
+
job.retries += 1
|
| 94 |
+
await db.commit()
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
@router.post(
|
| 98 |
+
"",
|
| 99 |
+
response_model=UploadResponse,
|
| 100 |
+
status_code=status.HTTP_201_CREATED,
|
| 101 |
+
summary="Upload a single photo",
|
| 102 |
+
)
|
| 103 |
+
@limiter.limit(f"{settings.rate_limit_uploads_per_minute}/minute")
|
| 104 |
+
async def upload_photo(
|
| 105 |
+
request: Request,
|
| 106 |
+
background_tasks: BackgroundTasks,
|
| 107 |
+
current_user: CurrentUser,
|
| 108 |
+
db: Annotated[AsyncSession, Depends(get_db)],
|
| 109 |
+
file: UploadFile = File(..., description="The photo file to upload"),
|
| 110 |
+
sha256: str = Form(..., description="SHA256 hash of the file for integrity check"),
|
| 111 |
+
taken_at: str | None = Form(None, description="ISO 8601 timestamp when photo was taken"),
|
| 112 |
+
) -> UploadResponse:
|
| 113 |
+
"""
|
| 114 |
+
Upload a single photo file.
|
| 115 |
+
|
| 116 |
+
- Validates MIME type and file size
|
| 117 |
+
- Checks for duplicate SHA256 hash
|
| 118 |
+
- Stores original in HF Bucket
|
| 119 |
+
- Generates thumbnail in background
|
| 120 |
+
- Returns immediately with job ID
|
| 121 |
+
"""
|
| 122 |
+
file_bytes = await file.read()
|
| 123 |
+
_validate_file(file, file_bytes)
|
| 124 |
+
|
| 125 |
+
# Verify SHA256 integrity
|
| 126 |
+
computed = dedup_svc.compute_sha256(file_bytes)
|
| 127 |
+
if computed != sha256:
|
| 128 |
+
raise HTTPException(
|
| 129 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 130 |
+
detail="SHA256 mismatch – file may be corrupted.",
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
# Check for duplicate
|
| 134 |
+
existing = await dedup_svc.is_duplicate(db, current_user.id, sha256)
|
| 135 |
+
if existing:
|
| 136 |
+
return UploadResponse(
|
| 137 |
+
job_id=uuid.uuid4(),
|
| 138 |
+
photo_id=existing.id,
|
| 139 |
+
status=UploadStatus.DUPLICATE,
|
| 140 |
+
duplicate=True,
|
| 141 |
+
message="Photo already uploaded (duplicate SHA256).",
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
# Upload original to HF Bucket
|
| 145 |
+
photo_id = uuid.uuid4()
|
| 146 |
+
try:
|
| 147 |
+
bucket_path = await storage_svc.upload_original(
|
| 148 |
+
file_bytes, file.filename or "photo.jpg",
|
| 149 |
+
file.content_type or "image/jpeg",
|
| 150 |
+
current_user.id, photo_id,
|
| 151 |
+
)
|
| 152 |
+
except Exception as exc:
|
| 153 |
+
raise HTTPException(
|
| 154 |
+
status_code=status.HTTP_502_BAD_GATEWAY,
|
| 155 |
+
detail=f"Storage upload failed: {exc}",
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
# Get image dimensions
|
| 159 |
+
dims = thumb_svc.get_image_dimensions(file_bytes)
|
| 160 |
+
width, height = (dims if dims else (None, None))
|
| 161 |
+
|
| 162 |
+
# Create Photo record
|
| 163 |
+
photo = Photo(
|
| 164 |
+
id=photo_id,
|
| 165 |
+
user_id=current_user.id,
|
| 166 |
+
sha256=sha256,
|
| 167 |
+
filename=file.filename or "photo.jpg",
|
| 168 |
+
mime_type=file.content_type or "image/jpeg",
|
| 169 |
+
bucket_path=bucket_path,
|
| 170 |
+
size=len(file_bytes),
|
| 171 |
+
width=width,
|
| 172 |
+
height=height,
|
| 173 |
+
taken_at=datetime.fromisoformat(taken_at) if taken_at else None,
|
| 174 |
+
)
|
| 175 |
+
db.add(photo)
|
| 176 |
+
|
| 177 |
+
# Create UploadJob record
|
| 178 |
+
job = UploadJob(
|
| 179 |
+
user_id=current_user.id,
|
| 180 |
+
photo_id=photo_id,
|
| 181 |
+
filename=file.filename or "photo.jpg",
|
| 182 |
+
sha256=sha256,
|
| 183 |
+
status=UploadStatus.PROCESSING,
|
| 184 |
+
)
|
| 185 |
+
db.add(job)
|
| 186 |
+
await db.flush()
|
| 187 |
+
|
| 188 |
+
# Queue background thumbnail generation
|
| 189 |
+
background_tasks.add_task(
|
| 190 |
+
_process_upload_background,
|
| 191 |
+
photo_id=photo_id,
|
| 192 |
+
job_id=job.id,
|
| 193 |
+
file_bytes=file_bytes,
|
| 194 |
+
filename=file.filename or "photo.jpg",
|
| 195 |
+
mime_type=file.content_type or "image/jpeg",
|
| 196 |
+
user_id=current_user.id,
|
| 197 |
+
sha256=sha256,
|
| 198 |
+
taken_at_str=taken_at,
|
| 199 |
+
db=db,
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
return UploadResponse(
|
| 203 |
+
job_id=job.id,
|
| 204 |
+
photo_id=photo_id,
|
| 205 |
+
status=UploadStatus.PROCESSING,
|
| 206 |
+
duplicate=False,
|
| 207 |
+
message="Upload successful. Thumbnail being generated.",
|
| 208 |
+
)
|
backend/app/schemas/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
KeyStone – Pydantic Schemas Package
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from app.schemas.user import UserOut, UserCreate
|
| 6 |
+
from app.schemas.photo import PhotoOut, PhotoCreate, PhotoUpdate, PhotoPage
|
| 7 |
+
from app.schemas.upload import UploadResponse, BatchUploadRequest, BatchUploadResponse
|
| 8 |
+
from app.schemas.album import AlbumOut, AlbumCreate, AlbumUpdate
|
| 9 |
+
from app.schemas.sync import SyncCheckRequest, SyncCheckResponse, SyncStartRequest, SyncStartResponse
|
| 10 |
+
|
| 11 |
+
__all__ = [
|
| 12 |
+
"UserOut", "UserCreate",
|
| 13 |
+
"PhotoOut", "PhotoCreate", "PhotoUpdate", "PhotoPage",
|
| 14 |
+
"UploadResponse", "BatchUploadRequest", "BatchUploadResponse",
|
| 15 |
+
"AlbumOut", "AlbumCreate", "AlbumUpdate",
|
| 16 |
+
"SyncCheckRequest", "SyncCheckResponse", "SyncStartRequest", "SyncStartResponse",
|
| 17 |
+
]
|
backend/app/schemas/album.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
KeyStone – Album Schemas
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import uuid
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
from typing import Optional
|
| 8 |
+
from pydantic import BaseModel
|
| 9 |
+
|
| 10 |
+
from app.schemas.photo import PhotoOut
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class AlbumCreate(BaseModel):
|
| 14 |
+
name: str
|
| 15 |
+
description: Optional[str] = None
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class AlbumUpdate(BaseModel):
|
| 19 |
+
name: Optional[str] = None
|
| 20 |
+
description: Optional[str] = None
|
| 21 |
+
cover_photo_id: Optional[uuid.UUID] = None
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class AlbumOut(BaseModel):
|
| 25 |
+
model_config = {"from_attributes": True}
|
| 26 |
+
|
| 27 |
+
id: uuid.UUID
|
| 28 |
+
user_id: uuid.UUID
|
| 29 |
+
name: str
|
| 30 |
+
description: Optional[str]
|
| 31 |
+
cover_photo_id: Optional[uuid.UUID]
|
| 32 |
+
created_at: datetime
|
| 33 |
+
updated_at: datetime
|
| 34 |
+
photo_count: int = 0
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class AlbumWithPhotos(AlbumOut):
|
| 38 |
+
photos: list[PhotoOut] = []
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class AddPhotosToAlbumRequest(BaseModel):
|
| 42 |
+
photo_ids: list[uuid.UUID]
|
backend/app/schemas/photo.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
KeyStone – Photo Schemas
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import uuid
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
from typing import Optional
|
| 8 |
+
from pydantic import BaseModel
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class PhotoCreate(BaseModel):
|
| 12 |
+
sha256: str
|
| 13 |
+
filename: str
|
| 14 |
+
mime_type: str
|
| 15 |
+
bucket_path: str
|
| 16 |
+
thumbnail_path: Optional[str] = None
|
| 17 |
+
preview_path: Optional[str] = None
|
| 18 |
+
width: Optional[int] = None
|
| 19 |
+
height: Optional[int] = None
|
| 20 |
+
size: int
|
| 21 |
+
taken_at: Optional[datetime] = None
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class PhotoUpdate(BaseModel):
|
| 25 |
+
is_favorite: Optional[bool] = None
|
| 26 |
+
deleted: Optional[bool] = None
|
| 27 |
+
ai_description: Optional[str] = None
|
| 28 |
+
ai_tags: Optional[str] = None
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class PhotoOut(BaseModel):
|
| 32 |
+
model_config = {"from_attributes": True}
|
| 33 |
+
|
| 34 |
+
id: uuid.UUID
|
| 35 |
+
user_id: uuid.UUID
|
| 36 |
+
sha256: str
|
| 37 |
+
filename: str
|
| 38 |
+
mime_type: str
|
| 39 |
+
bucket_path: str
|
| 40 |
+
thumbnail_path: Optional[str]
|
| 41 |
+
preview_path: Optional[str]
|
| 42 |
+
width: Optional[int]
|
| 43 |
+
height: Optional[int]
|
| 44 |
+
size: int
|
| 45 |
+
taken_at: Optional[datetime]
|
| 46 |
+
created_at: datetime
|
| 47 |
+
uploaded_at: datetime
|
| 48 |
+
deleted: bool
|
| 49 |
+
is_favorite: bool
|
| 50 |
+
ai_description: Optional[str]
|
| 51 |
+
ai_tags: Optional[str]
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class PhotoPage(BaseModel):
|
| 55 |
+
"""Paginated list of photos."""
|
| 56 |
+
items: list[PhotoOut]
|
| 57 |
+
total: int
|
| 58 |
+
next_cursor: Optional[str] = None
|
| 59 |
+
has_more: bool
|
backend/app/schemas/sync.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
KeyStone – Sync Schemas
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import uuid
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
from typing import Optional
|
| 8 |
+
from pydantic import BaseModel
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class SyncCheckRequest(BaseModel):
|
| 12 |
+
"""Client sends an array of SHA256 hashes; server replies with which are missing."""
|
| 13 |
+
hashes: list[str]
|
| 14 |
+
device_id: Optional[str] = None
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class SyncCheckResponse(BaseModel):
|
| 18 |
+
"""Hashes that the server does NOT have yet (need uploading)."""
|
| 19 |
+
missing_hashes: list[str]
|
| 20 |
+
existing_count: int
|
| 21 |
+
missing_count: int
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class SyncStartRequest(BaseModel):
|
| 25 |
+
device_name: str
|
| 26 |
+
platform: str # android | ios | web
|
| 27 |
+
device_token: Optional[str] = None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class SyncStartResponse(BaseModel):
|
| 31 |
+
device_id: uuid.UUID
|
| 32 |
+
message: str
|
| 33 |
+
last_sync: Optional[datetime]
|
backend/app/schemas/upload.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
KeyStone – Upload Schemas
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import uuid
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
from typing import Optional
|
| 8 |
+
from pydantic import BaseModel
|
| 9 |
+
|
| 10 |
+
from app.models.upload_job import UploadStatus
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class UploadResponse(BaseModel):
|
| 14 |
+
"""Returned after a successful upload."""
|
| 15 |
+
job_id: uuid.UUID
|
| 16 |
+
photo_id: Optional[uuid.UUID]
|
| 17 |
+
status: UploadStatus
|
| 18 |
+
duplicate: bool
|
| 19 |
+
message: str
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class BatchUploadItem(BaseModel):
|
| 23 |
+
sha256: str
|
| 24 |
+
filename: str
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class BatchUploadRequest(BaseModel):
|
| 28 |
+
files: list[BatchUploadItem]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class BatchUploadResponseItem(BaseModel):
|
| 32 |
+
sha256: str
|
| 33 |
+
filename: str
|
| 34 |
+
status: UploadStatus
|
| 35 |
+
photo_id: Optional[uuid.UUID]
|
| 36 |
+
duplicate: bool
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class BatchUploadResponse(BaseModel):
|
| 40 |
+
results: list[BatchUploadResponseItem]
|
| 41 |
+
uploaded: int
|
| 42 |
+
skipped: int
|
| 43 |
+
failed: int
|
backend/app/schemas/user.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
KeyStone – User Schemas
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import uuid
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
from pydantic import BaseModel, EmailStr
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class UserCreate(BaseModel):
|
| 11 |
+
supabase_id: str
|
| 12 |
+
email: EmailStr
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class UserOut(BaseModel):
|
| 16 |
+
model_config = {"from_attributes": True}
|
| 17 |
+
|
| 18 |
+
id: uuid.UUID
|
| 19 |
+
supabase_id: str
|
| 20 |
+
email: str
|
| 21 |
+
created_at: datetime
|
| 22 |
+
is_active: bool
|
backend/app/services/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""KeyStone – Services Package"""
|
backend/app/services/ai.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
KeyStone – AI Services (Milestone 8 Scaffold)
|
| 3 |
+
|
| 4 |
+
This module provides stub interfaces for all planned AI features.
|
| 5 |
+
Each function is wired to FastAPI BackgroundTasks and ready to be
|
| 6 |
+
filled with real model calls (CLIP, Tesseract, face_recognition, etc.).
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
from typing import Optional
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
async def generate_clip_embedding(image_bytes: bytes) -> Optional[list[float]]:
|
| 16 |
+
"""
|
| 17 |
+
Generate a CLIP embedding vector for semantic image search.
|
| 18 |
+
|
| 19 |
+
TODO: Implement with:
|
| 20 |
+
from transformers import CLIPProcessor, CLIPModel
|
| 21 |
+
model = CLIPModel.from_pretrained("openai/clip-vit-base-patch32")
|
| 22 |
+
processor = CLIPProcessor.from_pretrained("openai/clip-vit-base-patch32")
|
| 23 |
+
"""
|
| 24 |
+
logger.info("[AI] CLIP embedding requested (not yet implemented)")
|
| 25 |
+
return None
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
async def run_ocr(image_bytes: bytes) -> Optional[str]:
|
| 29 |
+
"""
|
| 30 |
+
Extract text from image using OCR.
|
| 31 |
+
|
| 32 |
+
TODO: Implement with pytesseract or easyocr:
|
| 33 |
+
import pytesseract
|
| 34 |
+
from PIL import Image
|
| 35 |
+
import io
|
| 36 |
+
img = Image.open(io.BytesIO(image_bytes))
|
| 37 |
+
return pytesseract.image_to_string(img)
|
| 38 |
+
"""
|
| 39 |
+
logger.info("[AI] OCR requested (not yet implemented)")
|
| 40 |
+
return None
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
async def detect_blur(image_bytes: bytes) -> Optional[float]:
|
| 44 |
+
"""
|
| 45 |
+
Return a blur score (higher = sharper). Blur detection using Laplacian variance.
|
| 46 |
+
|
| 47 |
+
TODO: Implement with OpenCV:
|
| 48 |
+
import cv2
|
| 49 |
+
import numpy as np
|
| 50 |
+
arr = np.frombuffer(image_bytes, np.uint8)
|
| 51 |
+
img = cv2.imdecode(arr, cv2.IMREAD_GRAYSCALE)
|
| 52 |
+
return float(cv2.Laplacian(img, cv2.CV_64F).var())
|
| 53 |
+
"""
|
| 54 |
+
logger.info("[AI] Blur detection requested (not yet implemented)")
|
| 55 |
+
return None
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
async def generate_description(image_bytes: bytes) -> Optional[str]:
|
| 59 |
+
"""
|
| 60 |
+
Generate a natural-language description for the photo.
|
| 61 |
+
|
| 62 |
+
TODO: Implement with a BLIP or LLaVA model:
|
| 63 |
+
from transformers import BlipProcessor, BlipForConditionalGeneration
|
| 64 |
+
"""
|
| 65 |
+
logger.info("[AI] Description generation requested (not yet implemented)")
|
| 66 |
+
return None
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
async def detect_faces(image_bytes: bytes) -> Optional[list[dict]]:
|
| 70 |
+
"""
|
| 71 |
+
Detect and encode faces for clustering.
|
| 72 |
+
|
| 73 |
+
TODO: Implement with face_recognition or deepface:
|
| 74 |
+
import face_recognition
|
| 75 |
+
img = face_recognition.load_image_file(io.BytesIO(image_bytes))
|
| 76 |
+
return face_recognition.face_locations(img)
|
| 77 |
+
"""
|
| 78 |
+
logger.info("[AI] Face detection requested (not yet implemented)")
|
| 79 |
+
return None
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
async def auto_tag(image_bytes: bytes) -> list[str]:
|
| 83 |
+
"""
|
| 84 |
+
Generate tags for the photo (objects, scene, etc.).
|
| 85 |
+
|
| 86 |
+
TODO: Implement with CLIP zero-shot classification:
|
| 87 |
+
candidate_labels = ["beach", "mountain", "food", "people", ...]
|
| 88 |
+
"""
|
| 89 |
+
logger.info("[AI] Auto-tagging requested (not yet implemented)")
|
| 90 |
+
return []
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
async def process_photo_ai(photo_id: str, image_bytes: bytes) -> dict:
|
| 94 |
+
"""
|
| 95 |
+
Run the full AI processing pipeline on a photo.
|
| 96 |
+
Called as a background task after successful upload.
|
| 97 |
+
|
| 98 |
+
Returns a dict of all AI results to be stored in the Photo record.
|
| 99 |
+
"""
|
| 100 |
+
results = {}
|
| 101 |
+
|
| 102 |
+
description = await generate_description(image_bytes)
|
| 103 |
+
if description:
|
| 104 |
+
results["ai_description"] = description
|
| 105 |
+
|
| 106 |
+
tags = await auto_tag(image_bytes)
|
| 107 |
+
if tags:
|
| 108 |
+
import json
|
| 109 |
+
results["ai_tags"] = json.dumps(tags)
|
| 110 |
+
|
| 111 |
+
embedding = await generate_clip_embedding(image_bytes)
|
| 112 |
+
if embedding:
|
| 113 |
+
import json
|
| 114 |
+
results["clip_embedding"] = json.dumps(embedding)
|
| 115 |
+
|
| 116 |
+
blur_score = await detect_blur(image_bytes)
|
| 117 |
+
if blur_score is not None:
|
| 118 |
+
logger.info("[AI] Photo %s blur score: %.2f", photo_id, blur_score)
|
| 119 |
+
|
| 120 |
+
return results
|
backend/app/services/dedup.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
KeyStone – Deduplication Service
|
| 3 |
+
Checks SHA256 hashes against existing photos to prevent duplicate uploads.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import hashlib
|
| 7 |
+
import logging
|
| 8 |
+
import uuid
|
| 9 |
+
from typing import Optional
|
| 10 |
+
|
| 11 |
+
from sqlalchemy import select
|
| 12 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 13 |
+
|
| 14 |
+
from app.models.photo import Photo
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
async def is_duplicate(
|
| 20 |
+
db: AsyncSession,
|
| 21 |
+
user_id: uuid.UUID,
|
| 22 |
+
sha256: str,
|
| 23 |
+
) -> Optional[Photo]:
|
| 24 |
+
"""
|
| 25 |
+
Check if a photo with the given SHA256 hash already exists for this user.
|
| 26 |
+
Returns the existing Photo if found, else None.
|
| 27 |
+
"""
|
| 28 |
+
result = await db.execute(
|
| 29 |
+
select(Photo).where(
|
| 30 |
+
Photo.user_id == user_id,
|
| 31 |
+
Photo.sha256 == sha256,
|
| 32 |
+
Photo.deleted == False, # noqa: E712
|
| 33 |
+
)
|
| 34 |
+
)
|
| 35 |
+
return result.scalar_one_or_none()
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
async def filter_missing_hashes(
|
| 39 |
+
db: AsyncSession,
|
| 40 |
+
user_id: uuid.UUID,
|
| 41 |
+
hashes: list[str],
|
| 42 |
+
) -> list[str]:
|
| 43 |
+
"""
|
| 44 |
+
Given a list of SHA256 hashes, return only the ones NOT already stored
|
| 45 |
+
for this user. Used by the sync check endpoint.
|
| 46 |
+
"""
|
| 47 |
+
if not hashes:
|
| 48 |
+
return []
|
| 49 |
+
|
| 50 |
+
result = await db.execute(
|
| 51 |
+
select(Photo.sha256).where(
|
| 52 |
+
Photo.user_id == user_id,
|
| 53 |
+
Photo.sha256.in_(hashes),
|
| 54 |
+
Photo.deleted == False, # noqa: E712
|
| 55 |
+
)
|
| 56 |
+
)
|
| 57 |
+
existing_hashes = {row[0] for row in result.fetchall()}
|
| 58 |
+
return [h for h in hashes if h not in existing_hashes]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def compute_sha256(data: bytes) -> str:
|
| 62 |
+
"""Compute SHA256 hash of raw bytes."""
|
| 63 |
+
return hashlib.sha256(data).hexdigest()
|
backend/app/services/storage.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
KeyStone – Storage Service
|
| 3 |
+
Handles uploads to Hugging Face Dataset repository (S3-compatible).
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import io
|
| 7 |
+
import logging
|
| 8 |
+
import uuid
|
| 9 |
+
from pathlib import PurePosixPath
|
| 10 |
+
|
| 11 |
+
import boto3
|
| 12 |
+
from botocore.config import Config
|
| 13 |
+
from botocore.exceptions import BotoCoreError, ClientError
|
| 14 |
+
|
| 15 |
+
from app.config import get_settings
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
settings = get_settings()
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _get_s3_client():
|
| 22 |
+
"""Create a boto3 S3 client pointed at Hugging Face's S3-compatible endpoint."""
|
| 23 |
+
return boto3.client(
|
| 24 |
+
"s3",
|
| 25 |
+
endpoint_url=f"https://huggingface.co/datasets/{settings.hf_dataset_repo}/resolve/main",
|
| 26 |
+
aws_access_key_id=settings.hf_token,
|
| 27 |
+
aws_secret_access_key=settings.hf_token,
|
| 28 |
+
config=Config(signature_version="v4"),
|
| 29 |
+
region_name="us-east-1",
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _get_hf_client():
|
| 34 |
+
"""Hugging Face Hub client for file uploads."""
|
| 35 |
+
try:
|
| 36 |
+
from huggingface_hub import HfApi
|
| 37 |
+
return HfApi(token=settings.hf_token)
|
| 38 |
+
except ImportError:
|
| 39 |
+
raise RuntimeError("huggingface_hub package is required for storage.")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _originals_path(user_id: str, photo_id: str, filename: str) -> str:
|
| 43 |
+
ext = PurePosixPath(filename).suffix.lower()
|
| 44 |
+
return f"originals/{user_id}/{photo_id}{ext}"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _thumbnails_path(user_id: str, photo_id: str) -> str:
|
| 48 |
+
return f"thumbnails/{user_id}/{photo_id}.jpg"
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _previews_path(user_id: str, photo_id: str) -> str:
|
| 52 |
+
return f"previews/{user_id}/{photo_id}.jpg"
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
async def upload_original(
|
| 56 |
+
file_bytes: bytes,
|
| 57 |
+
filename: str,
|
| 58 |
+
mime_type: str,
|
| 59 |
+
user_id: uuid.UUID,
|
| 60 |
+
photo_id: uuid.UUID,
|
| 61 |
+
) -> str:
|
| 62 |
+
"""
|
| 63 |
+
Upload the original photo file to HF Dataset repository.
|
| 64 |
+
Returns the bucket path (relative key).
|
| 65 |
+
"""
|
| 66 |
+
api = _get_hf_client()
|
| 67 |
+
path = _originals_path(str(user_id), str(photo_id), filename)
|
| 68 |
+
|
| 69 |
+
try:
|
| 70 |
+
api.upload_file(
|
| 71 |
+
path_or_fileobj=io.BytesIO(file_bytes),
|
| 72 |
+
path_in_repo=path,
|
| 73 |
+
repo_id=settings.hf_dataset_repo,
|
| 74 |
+
repo_type="dataset",
|
| 75 |
+
token=settings.hf_token,
|
| 76 |
+
)
|
| 77 |
+
logger.info("Uploaded original: %s", path)
|
| 78 |
+
return path
|
| 79 |
+
except Exception as exc:
|
| 80 |
+
logger.error("Failed to upload original %s: %s", filename, exc)
|
| 81 |
+
raise RuntimeError(f"Storage upload failed: {exc}") from exc
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
async def upload_thumbnail(
|
| 85 |
+
thumbnail_bytes: bytes,
|
| 86 |
+
user_id: uuid.UUID,
|
| 87 |
+
photo_id: uuid.UUID,
|
| 88 |
+
) -> str:
|
| 89 |
+
"""Upload thumbnail to HF Dataset repository. Returns the bucket path."""
|
| 90 |
+
api = _get_hf_client()
|
| 91 |
+
path = _thumbnails_path(str(user_id), str(photo_id))
|
| 92 |
+
|
| 93 |
+
try:
|
| 94 |
+
api.upload_file(
|
| 95 |
+
path_or_fileobj=io.BytesIO(thumbnail_bytes),
|
| 96 |
+
path_in_repo=path,
|
| 97 |
+
repo_id=settings.hf_dataset_repo,
|
| 98 |
+
repo_type="dataset",
|
| 99 |
+
token=settings.hf_token,
|
| 100 |
+
)
|
| 101 |
+
logger.info("Uploaded thumbnail: %s", path)
|
| 102 |
+
return path
|
| 103 |
+
except Exception as exc:
|
| 104 |
+
logger.error("Failed to upload thumbnail: %s", exc)
|
| 105 |
+
raise RuntimeError(f"Thumbnail upload failed: {exc}") from exc
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def get_download_url(bucket_path: str) -> str:
|
| 109 |
+
"""
|
| 110 |
+
Build a public download URL for a file in the HF Dataset repo.
|
| 111 |
+
Format: https://huggingface.co/datasets/{repo}/resolve/main/{path}
|
| 112 |
+
"""
|
| 113 |
+
return (
|
| 114 |
+
f"https://huggingface.co/datasets/{settings.hf_dataset_repo}"
|
| 115 |
+
f"/resolve/main/{bucket_path}"
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
async def delete_file(bucket_path: str) -> None:
|
| 120 |
+
"""Delete a file from the HF Dataset repository."""
|
| 121 |
+
api = _get_hf_client()
|
| 122 |
+
try:
|
| 123 |
+
api.delete_file(
|
| 124 |
+
path_in_repo=bucket_path,
|
| 125 |
+
repo_id=settings.hf_dataset_repo,
|
| 126 |
+
repo_type="dataset",
|
| 127 |
+
token=settings.hf_token,
|
| 128 |
+
)
|
| 129 |
+
logger.info("Deleted file: %s", bucket_path)
|
| 130 |
+
except Exception as exc:
|
| 131 |
+
logger.warning("Failed to delete %s: %s", bucket_path, exc)
|
backend/app/services/thumbnail.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
KeyStone – Thumbnail Generation Service
|
| 3 |
+
Uses Pillow to generate resized JPEG thumbnails and previews.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import io
|
| 7 |
+
import logging
|
| 8 |
+
from typing import Optional, Tuple
|
| 9 |
+
|
| 10 |
+
from PIL import Image, ImageOps
|
| 11 |
+
|
| 12 |
+
from app.config import get_settings
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
settings = get_settings()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _open_image(file_bytes: bytes) -> Image.Image:
|
| 19 |
+
img = Image.open(io.BytesIO(file_bytes))
|
| 20 |
+
# Auto-rotate based on EXIF orientation
|
| 21 |
+
img = ImageOps.exif_transpose(img)
|
| 22 |
+
# Convert to RGB (handles RGBA, palette, etc.)
|
| 23 |
+
if img.mode not in ("RGB", "L"):
|
| 24 |
+
img = img.convert("RGB")
|
| 25 |
+
return img
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def generate_thumbnail(
|
| 29 |
+
file_bytes: bytes,
|
| 30 |
+
size: int = 0,
|
| 31 |
+
quality: int = 0,
|
| 32 |
+
) -> Tuple[bytes, int, int]:
|
| 33 |
+
"""
|
| 34 |
+
Generate a square-cropped thumbnail.
|
| 35 |
+
|
| 36 |
+
Returns:
|
| 37 |
+
(jpeg_bytes, width, height) of the *original* image dimensions.
|
| 38 |
+
"""
|
| 39 |
+
size = size or settings.thumbnail_size
|
| 40 |
+
quality = quality or settings.thumbnail_quality
|
| 41 |
+
|
| 42 |
+
img = _open_image(file_bytes)
|
| 43 |
+
orig_width, orig_height = img.size
|
| 44 |
+
|
| 45 |
+
# Thumbnail (preserves aspect ratio, fits within size×size)
|
| 46 |
+
thumb = img.copy()
|
| 47 |
+
thumb.thumbnail((size, size), Image.LANCZOS)
|
| 48 |
+
|
| 49 |
+
buf = io.BytesIO()
|
| 50 |
+
thumb.save(buf, format="JPEG", quality=quality, optimize=True)
|
| 51 |
+
return buf.getvalue(), orig_width, orig_height
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def generate_preview(
|
| 55 |
+
file_bytes: bytes,
|
| 56 |
+
max_dimension: int = 1920,
|
| 57 |
+
quality: int = 80,
|
| 58 |
+
) -> bytes:
|
| 59 |
+
"""
|
| 60 |
+
Generate a full-resolution preview (max 1920px on the long edge, JPEG).
|
| 61 |
+
Used for the lightbox view in the gallery.
|
| 62 |
+
"""
|
| 63 |
+
img = _open_image(file_bytes)
|
| 64 |
+
img.thumbnail((max_dimension, max_dimension), Image.LANCZOS)
|
| 65 |
+
buf = io.BytesIO()
|
| 66 |
+
img.save(buf, format="JPEG", quality=quality, optimize=True)
|
| 67 |
+
return buf.getvalue()
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def get_image_dimensions(file_bytes: bytes) -> Optional[Tuple[int, int]]:
|
| 71 |
+
"""Return (width, height) of image without fully decoding it."""
|
| 72 |
+
try:
|
| 73 |
+
img = Image.open(io.BytesIO(file_bytes))
|
| 74 |
+
return img.size
|
| 75 |
+
except Exception as exc:
|
| 76 |
+
logger.warning("Could not read image dimensions: %s", exc)
|
| 77 |
+
return None
|
backend/pytest.ini
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[pytest]
|
| 2 |
+
asyncio_mode = auto
|
| 3 |
+
testpaths = tests
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ── Web Framework ─────────────────────────────────────────────────────────────
|
| 2 |
+
fastapi==0.115.6
|
| 3 |
+
uvicorn[standard]==0.32.1
|
| 4 |
+
python-multipart==0.0.20
|
| 5 |
+
|
| 6 |
+
# ── Database ──────────────────────────────────────────────────────────────────
|
| 7 |
+
sqlalchemy[asyncio]==2.0.36
|
| 8 |
+
asyncpg==0.30.0
|
| 9 |
+
alembic==1.14.0
|
| 10 |
+
|
| 11 |
+
# ── Configuration ─────────────────────────────────────────────────────────────
|
| 12 |
+
pydantic==2.10.3
|
| 13 |
+
pydantic-settings==2.7.0
|
| 14 |
+
python-dotenv==1.0.1
|
| 15 |
+
|
| 16 |
+
# ── Authentication ────────────────────────────────────────────────────────────
|
| 17 |
+
python-jose[cryptography]==3.3.0
|
| 18 |
+
httpx==0.28.1
|
| 19 |
+
|
| 20 |
+
# ── Storage (S3-compatible / Hugging Face) ────────────────────────────────────
|
| 21 |
+
boto3==1.35.88
|
| 22 |
+
huggingface_hub==0.27.0
|
| 23 |
+
|
| 24 |
+
# ── Image Processing ──────────────────────────────────────────────────────────
|
| 25 |
+
Pillow==11.1.0
|
| 26 |
+
|
| 27 |
+
# ── Rate Limiting ─────────────────────────────────────────────────────────────
|
| 28 |
+
slowapi==0.1.9
|
| 29 |
+
|
| 30 |
+
# ── Utilities ─────────────────────────────────────────────────────────────────
|
| 31 |
+
python-magic==0.4.27
|
| 32 |
+
aiofiles==24.1.0
|
| 33 |
+
|
| 34 |
+
# ── Testing ───────────────────────────────────────────────────────────────────
|
| 35 |
+
pytest==8.3.4
|
| 36 |
+
pytest-asyncio==0.25.2
|
| 37 |
+
httpx==0.28.1
|
backend/tests/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""KeyStone – Tests Package"""
|
backend/tests/test_health.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
KeyStone – Health Endpoint Tests
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
from httpx import AsyncClient, ASGITransport
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@pytest.mark.asyncio
|
| 10 |
+
async def test_health_endpoint():
|
| 11 |
+
"""Health endpoint should return 200 with status=ok."""
|
| 12 |
+
from app.main import app
|
| 13 |
+
|
| 14 |
+
async with AsyncClient(
|
| 15 |
+
transport=ASGITransport(app=app), base_url="http://test"
|
| 16 |
+
) as client:
|
| 17 |
+
response = await client.get("/health")
|
| 18 |
+
|
| 19 |
+
assert response.status_code == 200
|
| 20 |
+
data = response.json()
|
| 21 |
+
assert data["status"] == "ok"
|
| 22 |
+
assert "version" in data
|
| 23 |
+
assert "service" in data
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@pytest.mark.asyncio
|
| 27 |
+
async def test_docs_available():
|
| 28 |
+
"""Swagger docs should be accessible."""
|
| 29 |
+
from app.main import app
|
| 30 |
+
|
| 31 |
+
async with AsyncClient(
|
| 32 |
+
transport=ASGITransport(app=app), base_url="http://test"
|
| 33 |
+
) as client:
|
| 34 |
+
response = await client.get("/docs")
|
| 35 |
+
|
| 36 |
+
assert response.status_code == 200
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: "3.9"
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
backend:
|
| 5 |
+
build:
|
| 6 |
+
context: ./backend
|
| 7 |
+
dockerfile: Dockerfile
|
| 8 |
+
ports:
|
| 9 |
+
- "7860:7860"
|
| 10 |
+
env_file:
|
| 11 |
+
- ./backend/.env
|
| 12 |
+
environment:
|
| 13 |
+
- ENVIRONMENT=development
|
| 14 |
+
- DEBUG=true
|
| 15 |
+
volumes:
|
| 16 |
+
- ./backend:/app
|
| 17 |
+
depends_on:
|
| 18 |
+
- db
|
| 19 |
+
restart: unless-stopped
|
| 20 |
+
|
| 21 |
+
db:
|
| 22 |
+
image: postgres:16-alpine
|
| 23 |
+
environment:
|
| 24 |
+
POSTGRES_USER: postgres
|
| 25 |
+
POSTGRES_PASSWORD: postgres
|
| 26 |
+
POSTGRES_DB: keystone
|
| 27 |
+
ports:
|
| 28 |
+
- "5432:5432"
|
| 29 |
+
volumes:
|
| 30 |
+
- postgres_data:/var/lib/postgresql/data
|
| 31 |
+
healthcheck:
|
| 32 |
+
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
| 33 |
+
interval: 10s
|
| 34 |
+
timeout: 5s
|
| 35 |
+
retries: 5
|
| 36 |
+
|
| 37 |
+
volumes:
|
| 38 |
+
postgres_data:
|
mobile/.env.example
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Mobile App Environment Variables
|
| 2 |
+
# Copy this file to .env and fill in your values
|
| 3 |
+
|
| 4 |
+
# KeyStone Backend API URL
|
| 5 |
+
EXPO_PUBLIC_API_URL=https://dpv007-keystone.hf.space
|
| 6 |
+
|
| 7 |
+
# Supabase
|
| 8 |
+
EXPO_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImtmZmFuaGtiZGJhYnhhbndubW1lIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODQyMTExNTYsImV4cCI6MjA5OTc4NzE1Nn0.W4LgjVQekUxlkz2ZPTg4BpckCBp_Z7QEKXN-XOH1CvE
|
| 9 |
+
EXPO_PUBLIC_SUPABASE_URL=https://kffanhkbdbabxanwnmme.supabase.co
|
| 10 |
+
EXPO_PUBLIC_SUPABASE_KEY=sb_publishable_pTshogCZaZas2Im0POcPDw_fkMdTqSe
|
mobile/app.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"expo": {
|
| 3 |
+
"name": "KeyStone",
|
| 4 |
+
"slug": "keystone",
|
| 5 |
+
"version": "1.0.0",
|
| 6 |
+
"orientation": "portrait",
|
| 7 |
+
"icon": "./assets/icon.png",
|
| 8 |
+
"scheme": "keystone",
|
| 9 |
+
"userInterfaceStyle": "automatic",
|
| 10 |
+
"splash": {
|
| 11 |
+
"image": "./assets/splash.png",
|
| 12 |
+
"resizeMode": "contain",
|
| 13 |
+
"backgroundColor": "#0f0f23"
|
| 14 |
+
},
|
| 15 |
+
"assetBundlePatterns": ["**/*"],
|
| 16 |
+
"ios": {
|
| 17 |
+
"supportsTablet": true,
|
| 18 |
+
"bundleIdentifier": "com.dpv007.keystone",
|
| 19 |
+
"infoPlist": {
|
| 20 |
+
"NSPhotoLibraryUsageDescription": "KeyStone needs access to your photo library to back up your photos.",
|
| 21 |
+
"NSPhotoLibraryAddUsageDescription": "KeyStone needs access to save photos.",
|
| 22 |
+
"UIBackgroundModes": ["fetch", "processing"]
|
| 23 |
+
}
|
| 24 |
+
},
|
| 25 |
+
"android": {
|
| 26 |
+
"adaptiveIcon": {
|
| 27 |
+
"foregroundImage": "./assets/adaptive-icon.png",
|
| 28 |
+
"backgroundColor": "#0f0f23"
|
| 29 |
+
},
|
| 30 |
+
"package": "com.dpv007.keystone",
|
| 31 |
+
"permissions": [
|
| 32 |
+
"android.permission.READ_MEDIA_IMAGES",
|
| 33 |
+
"android.permission.READ_MEDIA_VIDEO",
|
| 34 |
+
"android.permission.READ_EXTERNAL_STORAGE",
|
| 35 |
+
"android.permission.ACCESS_NETWORK_STATE",
|
| 36 |
+
"android.permission.RECEIVE_BOOT_COMPLETED",
|
| 37 |
+
"android.permission.FOREGROUND_SERVICE"
|
| 38 |
+
]
|
| 39 |
+
},
|
| 40 |
+
"web": {
|
| 41 |
+
"bundler": "metro",
|
| 42 |
+
"output": "static",
|
| 43 |
+
"favicon": "./assets/favicon.png"
|
| 44 |
+
},
|
| 45 |
+
"plugins": [
|
| 46 |
+
"expo-router",
|
| 47 |
+
[
|
| 48 |
+
"expo-media-library",
|
| 49 |
+
{
|
| 50 |
+
"photosPermission": "Allow KeyStone to access your photos.",
|
| 51 |
+
"savePhotosPermission": "Allow KeyStone to save photos.",
|
| 52 |
+
"isAccessMediaLocationEnabled": true
|
| 53 |
+
}
|
| 54 |
+
]
|
| 55 |
+
],
|
| 56 |
+
"experiments": {
|
| 57 |
+
"typedRoutes": true
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
}
|
mobile/app/(auth)/_layout.tsx
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* KeyStone – Auth Layout
|
| 3 |
+
* Wraps all auth screens with a shared gradient background.
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import { Stack } from 'expo-router';
|
| 7 |
+
|
| 8 |
+
export default function AuthLayout() {
|
| 9 |
+
return (
|
| 10 |
+
<Stack screenOptions={{ headerShown: false }} />
|
| 11 |
+
);
|
| 12 |
+
}
|
mobile/app/(auth)/forgot-password.tsx
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* KeyStone – Forgot Password Screen
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import {
|
| 6 |
+
View, Text, TextInput, TouchableOpacity, StyleSheet,
|
| 7 |
+
KeyboardAvoidingView, Platform, ScrollView, ActivityIndicator,
|
| 8 |
+
} from 'react-native';
|
| 9 |
+
import { useState } from 'react';
|
| 10 |
+
import { Link } from 'expo-router';
|
| 11 |
+
import { LinearGradient } from 'expo-linear-gradient';
|
| 12 |
+
import { useAuthStore } from '../../store/authStore';
|
| 13 |
+
|
| 14 |
+
export default function ForgotPasswordScreen() {
|
| 15 |
+
const [email, setEmail] = useState('');
|
| 16 |
+
const [error, setError] = useState('');
|
| 17 |
+
const [sent, setSent] = useState(false);
|
| 18 |
+
const { resetPassword, isLoading } = useAuthStore();
|
| 19 |
+
|
| 20 |
+
const handleReset = async () => {
|
| 21 |
+
setError('');
|
| 22 |
+
if (!email) { setError('Please enter your email address.'); return; }
|
| 23 |
+
try {
|
| 24 |
+
await resetPassword(email);
|
| 25 |
+
setSent(true);
|
| 26 |
+
} catch (err: any) {
|
| 27 |
+
setError(err.message || 'Failed to send reset email.');
|
| 28 |
+
}
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
return (
|
| 32 |
+
<LinearGradient colors={['#0f0f23', '#1a0a2e', '#16213e']} style={styles.gradient}>
|
| 33 |
+
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={styles.flex}>
|
| 34 |
+
<ScrollView contentContainerStyle={styles.scroll}>
|
| 35 |
+
<View style={styles.container}>
|
| 36 |
+
<View style={styles.card}>
|
| 37 |
+
<Text style={styles.cardTitle}>{sent ? '📨 Email sent!' : '🔑 Reset password'}</Text>
|
| 38 |
+
<Text style={styles.cardSubtitle}>
|
| 39 |
+
{sent
|
| 40 |
+
? `We sent a password reset link to ${email}.`
|
| 41 |
+
: "Enter your email and we'll send you a reset link."}
|
| 42 |
+
</Text>
|
| 43 |
+
|
| 44 |
+
{!sent && (
|
| 45 |
+
<>
|
| 46 |
+
{error ? <View style={styles.errorBox}><Text style={styles.errorText}>{error}</Text></View> : null}
|
| 47 |
+
<View style={styles.inputGroup}>
|
| 48 |
+
<Text style={styles.label}>Email</Text>
|
| 49 |
+
<TextInput
|
| 50 |
+
style={styles.input}
|
| 51 |
+
placeholder="you@example.com"
|
| 52 |
+
placeholderTextColor="#4a4a6a"
|
| 53 |
+
value={email}
|
| 54 |
+
onChangeText={setEmail}
|
| 55 |
+
autoCapitalize="none"
|
| 56 |
+
keyboardType="email-address"
|
| 57 |
+
/>
|
| 58 |
+
</View>
|
| 59 |
+
<TouchableOpacity style={[styles.btn, isLoading && styles.btnDisabled]} onPress={handleReset} disabled={isLoading} activeOpacity={0.85}>
|
| 60 |
+
<LinearGradient colors={['#7c3aed', '#4f46e5']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 0 }} style={styles.btnGradient}>
|
| 61 |
+
{isLoading ? <ActivityIndicator color="#fff" /> : <Text style={styles.btnText}>Send Reset Link</Text>}
|
| 62 |
+
</LinearGradient>
|
| 63 |
+
</TouchableOpacity>
|
| 64 |
+
</>
|
| 65 |
+
)}
|
| 66 |
+
|
| 67 |
+
<Link href="/(auth)/login" style={styles.backLink}>← Back to Sign In</Link>
|
| 68 |
+
</View>
|
| 69 |
+
</View>
|
| 70 |
+
</ScrollView>
|
| 71 |
+
</KeyboardAvoidingView>
|
| 72 |
+
</LinearGradient>
|
| 73 |
+
);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
const styles = StyleSheet.create({
|
| 77 |
+
gradient: { flex: 1 },
|
| 78 |
+
flex: { flex: 1 },
|
| 79 |
+
scroll: { flexGrow: 1, justifyContent: 'center' },
|
| 80 |
+
container: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 20 },
|
| 81 |
+
card: { width: '100%', maxWidth: 420, backgroundColor: 'rgba(255,255,255,0.04)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.08)', borderRadius: 24, padding: 28 },
|
| 82 |
+
cardTitle: { fontSize: 22, fontWeight: '700', color: '#fff', marginBottom: 8 },
|
| 83 |
+
cardSubtitle: { fontSize: 14, color: '#8888aa', marginBottom: 24, lineHeight: 20 },
|
| 84 |
+
errorBox: { backgroundColor: 'rgba(239,68,68,0.15)', borderWidth: 1, borderColor: 'rgba(239,68,68,0.3)', borderRadius: 10, padding: 12, marginBottom: 16 },
|
| 85 |
+
errorText: { color: '#fca5a5', fontSize: 13 },
|
| 86 |
+
inputGroup: { marginBottom: 16 },
|
| 87 |
+
label: { fontSize: 13, fontWeight: '600', color: '#c4c4d4', marginBottom: 8 },
|
| 88 |
+
input: { backgroundColor: 'rgba(255,255,255,0.06)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.1)', borderRadius: 12, paddingHorizontal: 16, paddingVertical: 13, color: '#fff', fontSize: 15 },
|
| 89 |
+
btn: { borderRadius: 14, overflow: 'hidden', marginBottom: 20 },
|
| 90 |
+
btnDisabled: { opacity: 0.6 },
|
| 91 |
+
btnGradient: { paddingVertical: 15, alignItems: 'center', borderRadius: 14 },
|
| 92 |
+
btnText: { color: '#fff', fontWeight: '700', fontSize: 16 },
|
| 93 |
+
backLink: { color: '#7c3aed', fontSize: 14, textAlign: 'center', marginTop: 8 },
|
| 94 |
+
});
|
mobile/app/(auth)/login.tsx
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* KeyStone – Login Screen
|
| 3 |
+
* Works on Android, iOS, and Web (React Native Web).
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import {
|
| 7 |
+
View, Text, TextInput, TouchableOpacity, StyleSheet,
|
| 8 |
+
KeyboardAvoidingView, Platform, ScrollView, ActivityIndicator,
|
| 9 |
+
Alert, Dimensions,
|
| 10 |
+
} from 'react-native';
|
| 11 |
+
import { useState } from 'react';
|
| 12 |
+
import { Link, useRouter } from 'expo-router';
|
| 13 |
+
import { LinearGradient } from 'expo-linear-gradient';
|
| 14 |
+
import { useAuthStore } from '../../store/authStore';
|
| 15 |
+
|
| 16 |
+
const { width } = Dimensions.get('window');
|
| 17 |
+
const isWeb = Platform.OS === 'web';
|
| 18 |
+
|
| 19 |
+
export default function LoginScreen() {
|
| 20 |
+
const [email, setEmail] = useState('');
|
| 21 |
+
const [password, setPassword] = useState('');
|
| 22 |
+
const [error, setError] = useState('');
|
| 23 |
+
const { signIn, isLoading } = useAuthStore();
|
| 24 |
+
const router = useRouter();
|
| 25 |
+
|
| 26 |
+
const handleLogin = async () => {
|
| 27 |
+
setError('');
|
| 28 |
+
if (!email || !password) {
|
| 29 |
+
setError('Please fill in all fields.');
|
| 30 |
+
return;
|
| 31 |
+
}
|
| 32 |
+
try {
|
| 33 |
+
await signIn(email, password);
|
| 34 |
+
router.replace('/(tabs)/gallery');
|
| 35 |
+
} catch (err: any) {
|
| 36 |
+
setError(err.message || 'Login failed. Please try again.');
|
| 37 |
+
}
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
return (
|
| 41 |
+
<LinearGradient colors={['#0f0f23', '#1a0a2e', '#16213e']} style={styles.gradient}>
|
| 42 |
+
<KeyboardAvoidingView
|
| 43 |
+
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
| 44 |
+
style={styles.flex}
|
| 45 |
+
>
|
| 46 |
+
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
|
| 47 |
+
<View style={styles.container}>
|
| 48 |
+
|
| 49 |
+
{/* Logo / Brand */}
|
| 50 |
+
<View style={styles.logoArea}>
|
| 51 |
+
<View style={styles.logoIcon}>
|
| 52 |
+
<Text style={styles.logoEmoji}>🔷</Text>
|
| 53 |
+
</View>
|
| 54 |
+
<Text style={styles.brandName}>KeyStone</Text>
|
| 55 |
+
<Text style={styles.brandTagline}>Your private photo cloud</Text>
|
| 56 |
+
</View>
|
| 57 |
+
|
| 58 |
+
{/* Card */}
|
| 59 |
+
<View style={styles.card}>
|
| 60 |
+
<Text style={styles.cardTitle}>Welcome back</Text>
|
| 61 |
+
<Text style={styles.cardSubtitle}>Sign in to your account</Text>
|
| 62 |
+
|
| 63 |
+
{error ? (
|
| 64 |
+
<View style={styles.errorBox}>
|
| 65 |
+
<Text style={styles.errorText}>{error}</Text>
|
| 66 |
+
</View>
|
| 67 |
+
) : null}
|
| 68 |
+
|
| 69 |
+
<View style={styles.inputGroup}>
|
| 70 |
+
<Text style={styles.label}>Email</Text>
|
| 71 |
+
<TextInput
|
| 72 |
+
style={styles.input}
|
| 73 |
+
placeholder="you@example.com"
|
| 74 |
+
placeholderTextColor="#4a4a6a"
|
| 75 |
+
value={email}
|
| 76 |
+
onChangeText={setEmail}
|
| 77 |
+
autoCapitalize="none"
|
| 78 |
+
keyboardType="email-address"
|
| 79 |
+
autoComplete="email"
|
| 80 |
+
/>
|
| 81 |
+
</View>
|
| 82 |
+
|
| 83 |
+
<View style={styles.inputGroup}>
|
| 84 |
+
<View style={styles.labelRow}>
|
| 85 |
+
<Text style={styles.label}>Password</Text>
|
| 86 |
+
<Link href="/(auth)/forgot-password" style={styles.forgotLink}>
|
| 87 |
+
Forgot password?
|
| 88 |
+
</Link>
|
| 89 |
+
</View>
|
| 90 |
+
<TextInput
|
| 91 |
+
style={styles.input}
|
| 92 |
+
placeholder="••••••••"
|
| 93 |
+
placeholderTextColor="#4a4a6a"
|
| 94 |
+
value={password}
|
| 95 |
+
onChangeText={setPassword}
|
| 96 |
+
secureTextEntry
|
| 97 |
+
autoComplete="current-password"
|
| 98 |
+
/>
|
| 99 |
+
</View>
|
| 100 |
+
|
| 101 |
+
<TouchableOpacity
|
| 102 |
+
style={[styles.btn, isLoading && styles.btnDisabled]}
|
| 103 |
+
onPress={handleLogin}
|
| 104 |
+
disabled={isLoading}
|
| 105 |
+
activeOpacity={0.85}
|
| 106 |
+
>
|
| 107 |
+
<LinearGradient
|
| 108 |
+
colors={['#7c3aed', '#4f46e5']}
|
| 109 |
+
start={{ x: 0, y: 0 }}
|
| 110 |
+
end={{ x: 1, y: 0 }}
|
| 111 |
+
style={styles.btnGradient}
|
| 112 |
+
>
|
| 113 |
+
{isLoading ? (
|
| 114 |
+
<ActivityIndicator color="#fff" />
|
| 115 |
+
) : (
|
| 116 |
+
<Text style={styles.btnText}>Sign In</Text>
|
| 117 |
+
)}
|
| 118 |
+
</LinearGradient>
|
| 119 |
+
</TouchableOpacity>
|
| 120 |
+
|
| 121 |
+
<View style={styles.footerRow}>
|
| 122 |
+
<Text style={styles.footerText}>Don't have an account? </Text>
|
| 123 |
+
<Link href="/(auth)/register" style={styles.footerLink}>
|
| 124 |
+
Sign up
|
| 125 |
+
</Link>
|
| 126 |
+
</View>
|
| 127 |
+
</View>
|
| 128 |
+
|
| 129 |
+
</View>
|
| 130 |
+
</ScrollView>
|
| 131 |
+
</KeyboardAvoidingView>
|
| 132 |
+
</LinearGradient>
|
| 133 |
+
);
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
const styles = StyleSheet.create({
|
| 137 |
+
gradient: { flex: 1 },
|
| 138 |
+
flex: { flex: 1 },
|
| 139 |
+
scroll: { flexGrow: 1, justifyContent: 'center' },
|
| 140 |
+
container: {
|
| 141 |
+
flex: 1,
|
| 142 |
+
alignItems: 'center',
|
| 143 |
+
justifyContent: 'center',
|
| 144 |
+
paddingHorizontal: 20,
|
| 145 |
+
paddingVertical: 40,
|
| 146 |
+
},
|
| 147 |
+
logoArea: { alignItems: 'center', marginBottom: 40 },
|
| 148 |
+
logoIcon: {
|
| 149 |
+
width: 72, height: 72,
|
| 150 |
+
borderRadius: 20,
|
| 151 |
+
backgroundColor: 'rgba(124,58,237,0.2)',
|
| 152 |
+
borderWidth: 1,
|
| 153 |
+
borderColor: 'rgba(124,58,237,0.5)',
|
| 154 |
+
alignItems: 'center',
|
| 155 |
+
justifyContent: 'center',
|
| 156 |
+
marginBottom: 12,
|
| 157 |
+
},
|
| 158 |
+
logoEmoji: { fontSize: 36 },
|
| 159 |
+
brandName: { fontSize: 32, fontWeight: '800', color: '#fff', letterSpacing: -0.5 },
|
| 160 |
+
brandTagline: { fontSize: 14, color: '#8888aa', marginTop: 4 },
|
| 161 |
+
|
| 162 |
+
card: {
|
| 163 |
+
width: '100%',
|
| 164 |
+
maxWidth: 420,
|
| 165 |
+
backgroundColor: 'rgba(255,255,255,0.04)',
|
| 166 |
+
borderWidth: 1,
|
| 167 |
+
borderColor: 'rgba(255,255,255,0.08)',
|
| 168 |
+
borderRadius: 24,
|
| 169 |
+
padding: 28,
|
| 170 |
+
},
|
| 171 |
+
cardTitle: { fontSize: 24, fontWeight: '700', color: '#fff', marginBottom: 4 },
|
| 172 |
+
cardSubtitle: { fontSize: 14, color: '#8888aa', marginBottom: 24 },
|
| 173 |
+
|
| 174 |
+
errorBox: {
|
| 175 |
+
backgroundColor: 'rgba(239,68,68,0.15)',
|
| 176 |
+
borderWidth: 1,
|
| 177 |
+
borderColor: 'rgba(239,68,68,0.3)',
|
| 178 |
+
borderRadius: 10,
|
| 179 |
+
padding: 12,
|
| 180 |
+
marginBottom: 16,
|
| 181 |
+
},
|
| 182 |
+
errorText: { color: '#fca5a5', fontSize: 13 },
|
| 183 |
+
|
| 184 |
+
inputGroup: { marginBottom: 16 },
|
| 185 |
+
labelRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 },
|
| 186 |
+
label: { fontSize: 13, fontWeight: '600', color: '#c4c4d4', marginBottom: 8 },
|
| 187 |
+
forgotLink: { fontSize: 12, color: '#7c3aed' },
|
| 188 |
+
input: {
|
| 189 |
+
backgroundColor: 'rgba(255,255,255,0.06)',
|
| 190 |
+
borderWidth: 1,
|
| 191 |
+
borderColor: 'rgba(255,255,255,0.1)',
|
| 192 |
+
borderRadius: 12,
|
| 193 |
+
paddingHorizontal: 16,
|
| 194 |
+
paddingVertical: 13,
|
| 195 |
+
color: '#fff',
|
| 196 |
+
fontSize: 15,
|
| 197 |
+
},
|
| 198 |
+
|
| 199 |
+
btn: { borderRadius: 14, overflow: 'hidden', marginTop: 8 },
|
| 200 |
+
btnDisabled: { opacity: 0.6 },
|
| 201 |
+
btnGradient: { paddingVertical: 15, alignItems: 'center' },
|
| 202 |
+
btnText: { color: '#fff', fontWeight: '700', fontSize: 16 },
|
| 203 |
+
|
| 204 |
+
footerRow: { flexDirection: 'row', justifyContent: 'center', marginTop: 20 },
|
| 205 |
+
footerText: { color: '#8888aa', fontSize: 13 },
|
| 206 |
+
footerLink: { color: '#7c3aed', fontWeight: '600', fontSize: 13 },
|
| 207 |
+
});
|