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
Files changed (50) hide show
  1. .gitattributes +5 -0
  2. .github/workflows/ci.yml +77 -0
  3. .gitignore +59 -0
  4. KeyStone_Photo_Cloud_Project_Plan.md +363 -0
  5. README.md +106 -0
  6. backend/.env.example +39 -0
  7. backend/Dockerfile +39 -0
  8. backend/README.md +30 -0
  9. backend/alembic.ini +88 -0
  10. backend/alembic/env.py +73 -0
  11. backend/alembic/versions/001_initial.py +141 -0
  12. backend/app/config.py +76 -0
  13. backend/app/database.py +49 -0
  14. backend/app/main.py +84 -0
  15. backend/app/middleware/__init__.py +1 -0
  16. backend/app/middleware/auth.py +81 -0
  17. backend/app/models/__init__.py +9 -0
  18. backend/app/models/album.py +71 -0
  19. backend/app/models/device.py +36 -0
  20. backend/app/models/photo.py +64 -0
  21. backend/app/models/upload_job.py +65 -0
  22. backend/app/models/user.py +35 -0
  23. backend/app/routers/__init__.py +1 -0
  24. backend/app/routers/albums.py +154 -0
  25. backend/app/routers/auth.py +21 -0
  26. backend/app/routers/gallery.py +167 -0
  27. backend/app/routers/search.py +67 -0
  28. backend/app/routers/sync.py +96 -0
  29. backend/app/routers/upload.py +208 -0
  30. backend/app/schemas/__init__.py +17 -0
  31. backend/app/schemas/album.py +42 -0
  32. backend/app/schemas/photo.py +59 -0
  33. backend/app/schemas/sync.py +33 -0
  34. backend/app/schemas/upload.py +43 -0
  35. backend/app/schemas/user.py +22 -0
  36. backend/app/services/__init__.py +1 -0
  37. backend/app/services/ai.py +120 -0
  38. backend/app/services/dedup.py +63 -0
  39. backend/app/services/storage.py +131 -0
  40. backend/app/services/thumbnail.py +77 -0
  41. backend/pytest.ini +3 -0
  42. backend/requirements.txt +37 -0
  43. backend/tests/__init__.py +1 -0
  44. backend/tests/test_health.py +36 -0
  45. docker-compose.yml +38 -0
  46. mobile/.env.example +10 -0
  47. mobile/app.json +60 -0
  48. mobile/app/(auth)/_layout.tsx +12 -0
  49. mobile/app/(auth)/forgot-password.tsx +94 -0
  50. 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
+ });