dpv007 commited on
Commit
7c159f4
Β·
unverified Β·
1 Parent(s): b3091b3

Delete backend

Browse files
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