Delete backend
Browse files- backend/.env.example +0 -39
- backend/Dockerfile +0 -39
- backend/README.md +0 -30
- backend/alembic.ini +0 -88
- backend/alembic/env.py +0 -73
- backend/alembic/versions/001_initial.py +0 -141
- backend/app/config.py +0 -76
- backend/app/database.py +0 -49
- backend/app/main.py +0 -87
- backend/app/middleware/__init__.py +0 -1
- backend/app/middleware/auth.py +0 -81
- backend/app/models/__init__.py +0 -9
- backend/app/models/album.py +0 -71
- backend/app/models/device.py +0 -36
- backend/app/models/photo.py +0 -64
- backend/app/models/upload_job.py +0 -65
- backend/app/models/user.py +0 -35
- backend/app/routers/__init__.py +0 -1
- backend/app/routers/albums.py +0 -154
- backend/app/routers/auth.py +0 -21
- backend/app/routers/gallery.py +0 -167
- backend/app/routers/search.py +0 -67
- backend/app/routers/sync.py +0 -96
- backend/app/routers/upload.py +0 -208
- backend/app/schemas/__init__.py +0 -17
- backend/app/schemas/album.py +0 -42
- backend/app/schemas/photo.py +0 -59
- backend/app/schemas/sync.py +0 -33
- backend/app/schemas/upload.py +0 -43
- backend/app/schemas/user.py +0 -22
- backend/app/services/__init__.py +0 -1
- backend/app/services/ai.py +0 -120
- backend/app/services/dedup.py +0 -63
- backend/app/services/storage.py +0 -131
- backend/app/services/thumbnail.py +0 -77
- backend/pytest.ini +0 -3
- backend/requirements.txt +0 -38
- backend/tests/__init__.py +0 -1
- backend/tests/test_health.py +0 -36
backend/.env.example
DELETED
|
@@ -1,39 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,39 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,30 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,88 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,73 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,141 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,76 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,49 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,87 +0,0 @@
|
|
| 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 fastapi.staticfiles import StaticFiles
|
| 11 |
-
from slowapi import Limiter, _rate_limit_exceeded_handler
|
| 12 |
-
from slowapi.errors import RateLimitExceeded
|
| 13 |
-
from slowapi.util import get_remote_address
|
| 14 |
-
|
| 15 |
-
from app.config import get_settings
|
| 16 |
-
from app.database import create_tables
|
| 17 |
-
from app.routers import auth, upload, gallery, albums, sync, search
|
| 18 |
-
|
| 19 |
-
settings = get_settings()
|
| 20 |
-
|
| 21 |
-
limiter = Limiter(key_func=get_remote_address)
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
@asynccontextmanager
|
| 25 |
-
async def lifespan(app: FastAPI):
|
| 26 |
-
"""Application lifespan: startup / shutdown."""
|
| 27 |
-
# Startup
|
| 28 |
-
if settings.is_development:
|
| 29 |
-
await create_tables()
|
| 30 |
-
yield
|
| 31 |
-
# Shutdown β nothing special needed
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
app = FastAPI(
|
| 35 |
-
title=settings.app_name,
|
| 36 |
-
version=settings.app_version,
|
| 37 |
-
description="Private Google Photos alternative β self-hosted on Hugging Face Spaces.",
|
| 38 |
-
docs_url="/docs",
|
| 39 |
-
redoc_url="/redoc",
|
| 40 |
-
lifespan=lifespan,
|
| 41 |
-
)
|
| 42 |
-
|
| 43 |
-
# ββ Rate Limiter ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 44 |
-
app.state.limiter = limiter
|
| 45 |
-
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
| 46 |
-
|
| 47 |
-
# ββ CORS ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 48 |
-
app.add_middleware(
|
| 49 |
-
CORSMiddleware,
|
| 50 |
-
allow_origins=settings.cors_origins,
|
| 51 |
-
allow_credentials=True,
|
| 52 |
-
allow_methods=["*"],
|
| 53 |
-
allow_headers=["*"],
|
| 54 |
-
)
|
| 55 |
-
|
| 56 |
-
# ββ Routers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 57 |
-
app.include_router(auth.router, prefix="/auth", tags=["Authentication"])
|
| 58 |
-
app.include_router(sync.router, prefix="/sync", tags=["Sync"])
|
| 59 |
-
app.include_router(upload.router, prefix="/upload", tags=["Upload"])
|
| 60 |
-
app.include_router(gallery.router, prefix="/photos", tags=["Gallery"])
|
| 61 |
-
app.include_router(albums.router, prefix="/albums", tags=["Albums"])
|
| 62 |
-
app.include_router(search.router, prefix="/search", tags=["Search"])
|
| 63 |
-
|
| 64 |
-
# Serve Expo web build
|
| 65 |
-
app.mount("/", StaticFiles(directory="web", html=True), name="web")
|
| 66 |
-
|
| 67 |
-
# ββ Health ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 68 |
-
@app.get("/health", tags=["Health"])
|
| 69 |
-
async def health(request: Request):
|
| 70 |
-
"""Health check endpoint β returns app info and status."""
|
| 71 |
-
return {
|
| 72 |
-
"status": "ok",
|
| 73 |
-
"service": settings.app_name,
|
| 74 |
-
"version": settings.app_version,
|
| 75 |
-
"environment": settings.environment,
|
| 76 |
-
}
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
# ββ Global Exception Handler ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 80 |
-
@app.exception_handler(Exception)
|
| 81 |
-
async def global_exception_handler(request: Request, exc: Exception):
|
| 82 |
-
if settings.debug:
|
| 83 |
-
raise exc
|
| 84 |
-
return JSONResponse(
|
| 85 |
-
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 86 |
-
content={"detail": "An unexpected error occurred."},
|
| 87 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/app/middleware/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
"""KeyStone β Middleware Package"""
|
|
|
|
|
|
backend/app/middleware/auth.py
DELETED
|
@@ -1,81 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,9 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,71 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,36 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,64 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,65 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,35 +0,0 @@
|
|
| 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
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
"""KeyStone β Routers Package"""
|
|
|
|
|
|
backend/app/routers/albums.py
DELETED
|
@@ -1,154 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,21 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,167 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,67 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,96 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,208 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,17 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,42 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,59 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,33 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,43 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,22 +0,0 @@
|
|
| 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
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
"""KeyStone β Services Package"""
|
|
|
|
|
|
backend/app/services/ai.py
DELETED
|
@@ -1,120 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,63 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,131 +0,0 @@
|
|
| 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"dpv007/kystn/data/originals/{user_id}/{photo_id}{ext}"
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
def _thumbnails_path(user_id: str, photo_id: str) -> str:
|
| 48 |
-
return f"dpv007/kystn/data/thumbnails/{user_id}/{photo_id}.jpg"
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
def _previews_path(user_id: str, photo_id: str) -> str:
|
| 52 |
-
return f"dpv007/kystn/data/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
DELETED
|
@@ -1,77 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
[pytest]
|
| 2 |
-
asyncio_mode = auto
|
| 3 |
-
testpaths = tests
|
|
|
|
|
|
|
|
|
|
|
|
backend/requirements.txt
DELETED
|
@@ -1,38 +0,0 @@
|
|
| 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
|
| 13 |
-
email-validator==2.3.0
|
| 14 |
-
pydantic-settings==2.7.0
|
| 15 |
-
python-dotenv==1.0.1
|
| 16 |
-
|
| 17 |
-
# ββ Authentication ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 18 |
-
python-jose[cryptography]==3.3.0
|
| 19 |
-
httpx==0.28.1
|
| 20 |
-
|
| 21 |
-
# ββ Storage (S3-compatible / Hugging Face) ββββββββββββββββββββββββββββββββββββ
|
| 22 |
-
boto3==1.35.88
|
| 23 |
-
huggingface_hub==0.27.0
|
| 24 |
-
|
| 25 |
-
# ββ Image Processing ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 26 |
-
Pillow==11.1.0
|
| 27 |
-
|
| 28 |
-
# ββ Rate Limiting βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 29 |
-
slowapi==0.1.9
|
| 30 |
-
|
| 31 |
-
# ββ Utilities βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 32 |
-
python-magic==0.4.27
|
| 33 |
-
aiofiles==24.1.0
|
| 34 |
-
|
| 35 |
-
# ββ Testing βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 36 |
-
pytest==8.3.4
|
| 37 |
-
pytest-asyncio==0.25.2
|
| 38 |
-
httpx==0.28.1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/tests/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
"""KeyStone β Tests Package"""
|
|
|
|
|
|
backend/tests/test_health.py
DELETED
|
@@ -1,36 +0,0 @@
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|