diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..6f8ab5378427cd74efdf18805d188514902d885b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +*.png filter=lfs diff=lfs merge=lfs -text +*.jpg filter=lfs diff=lfs merge=lfs -text +*.jpeg filter=lfs diff=lfs merge=lfs -text +*.gif filter=lfs diff=lfs merge=lfs -text +*.ico filter=lfs diff=lfs merge=lfs -text diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..264c308e1a83e3730d62fbd44df45e527116bc67 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,77 @@ +name: KeyStone CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + backend-test: + name: Backend Tests + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: keystone_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + cd backend + pip install --upgrade pip + pip install -r requirements.txt + + - name: Run tests + env: + DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/keystone_test + SUPABASE_URL: https://test.supabase.co + SUPABASE_ANON_KEY: test-key + SUPABASE_JWT_SECRET: test-secret-at-least-32-characters-long + HF_TOKEN: hf_test + HF_DATASET_REPO: test/repo + run: | + cd backend + pytest tests/ -v --tb=short + + mobile-typecheck: + name: Mobile TypeScript Check + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: mobile/package-lock.json + + - name: Install dependencies + run: | + cd mobile + npm install + + - name: TypeScript type check + run: | + cd mobile + npx tsc --noEmit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..4fef629a41dc486a6405a420454235e6cd3fd75d --- /dev/null +++ b/.gitignore @@ -0,0 +1,59 @@ +# ── Python ──────────────────────────────────────────────────────────────────── +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.Python +*.egg +*.egg-info/ +dist/ +build/ +.eggs/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +htmlcov/ +.coverage +.coverage.* + +# ── Virtual Environments ────────────────────────────────────────────────────── +.venv/ +venv/ +env/ +ENV/ + +# ── Environment Files ───────────────────────────────────────────────────────── +.env +.env.local +.env.production +*.env + +# ── Node / React Native / Expo ──────────────────────────────────────────────── +node_modules/ +.expo/ +dist/ +.expo-shared/ +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision +*.orig.* +web-build/ +android/ +ios/ + +# ── Editor ──────────────────────────────────────────────────────────────────── +.vscode/ +.idea/ +*.swp +*.swo +.DS_Store +Thumbs.db + +# ── Docker ──────────────────────────────────────────────────────────────────── +docker-compose.override.yml + +# ── Logs ───────────────────────────────────────────────────────────────────── +*.log +logs/ diff --git a/KeyStone_Photo_Cloud_Project_Plan.md b/KeyStone_Photo_Cloud_Project_Plan.md new file mode 100644 index 0000000000000000000000000000000000000000..39c9b338b41cae7829cba3a20bfe40cc8d70bd5d --- /dev/null +++ b/KeyStone_Photo_Cloud_Project_Plan.md @@ -0,0 +1,363 @@ +# Personal Photo Cloud – Project Plan + +## Vision + +Build a private Google Photos alternative that automatically backs up photos directly from mobile devices to cloud storage under the user's control. + +### Goals + +- Automatic background backup +- Android, iOS, and Web support +- No dependency on Google Photos APIs +- Fast uploads with resumable sync +- Deduplication +- AI-ready architecture +- Self-hostable on Hugging Face Spaces + +--- + +# Technology Stack + +## Frontend + +- Expo +- React Native +- React Native Web +- Expo Router +- Expo Media Library +- React Query +- Zustand +- Supabase JS SDK + +## Backend + +- FastAPI +- SQLAlchemy +- Alembic +- Pillow +- Uvicorn +- BackgroundTasks + +## Authentication + +- Supabase Auth +- Email/Password +- Google OAuth +- JWT Verification + +## Database + +Supabase PostgreSQL + +## Storage + +Hugging Face Bucket + +``` +originals/ +thumbnails/ +previews/ +``` + +--- + +# High-Level Architecture + +```text +React Native/Web + │ + Supabase Auth + │ + JWT Access Token + │ + FastAPI Backend + │ + ├── Upload API + ├── Sync Engine + ├── Gallery API + ├── AI Services + │ + ├── Supabase PostgreSQL + └── Hugging Face Bucket +``` + +--- + +# Milestone 1 — Foundation + +- Repository setup +- Docker +- Environment variables +- CI +- FastAPI scaffold +- Expo scaffold + +Deliverable: +- Login screen +- Health endpoint +- Docker deployment + +--- + +# Milestone 2 — Authentication + +Frontend: +- Login +- Register +- Forgot password +- Session persistence + +Backend: +- Verify Supabase JWT +- User middleware +- Protected routes + +Deliverable: +- Secure authenticated API + +--- + +# Milestone 3 — Database + +Tables: + +## users + +- id +- supabase_id +- email +- created_at + +## devices + +- id +- user_id +- device_name +- platform +- last_sync + +## photos + +- id +- user_id +- sha256 +- filename +- bucket_path +- thumbnail_path +- width +- height +- size +- created_at +- uploaded_at +- deleted + +## upload_jobs + +- id +- status +- retries + +Deliverable: +- Database migrations +- CRUD models + +--- + +# Milestone 4 — Upload Pipeline + +Workflow + +Camera Roll + +↓ + +Find new photos + +↓ + +Compute SHA256 + +↓ + +Check duplicate + +↓ + +Upload + +↓ + +Generate thumbnail + +↓ + +Save metadata + +↓ + +Complete + +Requirements + +- Multipart uploads +- Progress reporting +- Retry queue +- Idempotency + +Deliverable: +- Reliable uploads + +--- + +# Milestone 5 — Mobile Sync + +Features + +- Camera Roll permission +- Background scanning +- Queue management +- Pause/resume +- Retry failed uploads + +Rules + +- Never upload duplicate hashes +- Resume interrupted uploads +- Battery-aware syncing +- Wi-Fi only option + +Deliverable: +- Automatic backup + +--- + +# Milestone 6 — Gallery + +Features + +- Infinite scrolling +- Lazy loading +- Cached thumbnails +- Favorites +- Albums +- Search +- Timeline + +Deliverable: +- Responsive gallery + +--- + +# Milestone 7 — Settings + +- Auto sync toggle +- Cellular upload toggle +- Thumbnail quality +- Storage usage +- Logout +- Device management + +--- + +# Milestone 8 — AI + +- CLIP embeddings +- OCR +- Face clustering +- Duplicate detection +- Blur detection +- Natural language search +- Auto albums + +--- + +# API Design + +Authentication + +- POST /auth/verify + +Sync + +- POST /sync/check +- POST /sync/start + +Uploads + +- POST /upload +- POST /upload/batch + +Gallery + +- GET /photos +- GET /photos/{id} +- DELETE /photos/{id} + +Albums + +- GET /albums +- POST /albums + +Health + +- GET /health + +--- + +# Security + +- JWT validation +- HTTPS only +- Rate limiting +- SHA256 integrity +- File type validation +- Size limits +- Signed URLs (future) + +--- + +# Future Enhancements + +- Video transcoding +- Live Photos +- HEIC support +- Shared albums +- End-to-end encryption +- Desktop sync client +- NAS support +- S3-compatible storage +- Object versioning + +--- + +# Deployment + +Frontend + +- Hugging Face Space (Static) + +Backend + +- Hugging Face Space (Docker) + +Services + +- Supabase Auth +- Supabase PostgreSQL +- Hugging Face Bucket + +--- + +# Definition of Done + +- User can register and log in. +- Photos sync automatically from the mobile camera roll. +- Duplicate photos are skipped. +- Uploads resume after interruptions. +- Thumbnails are generated. +- Gallery loads quickly with pagination. +- Metadata is stored in PostgreSQL. +- Originals are stored in the Hugging Face bucket. +- Backend validates Supabase JWTs. +- Docker deployment runs successfully on Hugging Face Spaces. +- Architecture supports future AI-powered organization and search. diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..5d84ef53d4bdd0631c0844984a08848f5b6b2b39 --- /dev/null +++ b/README.md @@ -0,0 +1,106 @@ +# KeyStone 🔷 – Private Photo Cloud + +> Your photos. Your cloud. Zero dependencies on Google or Apple. + +KeyStone is a self-hosted Google Photos alternative with automatic mobile backup, deduplication, and AI-ready architecture. + +## Architecture + +``` +Expo App (iOS + Android + Web) + │ + Supabase Auth (JWT) + │ + FastAPI Backend ─────► Hugging Face Bucket (storage) + │ + Supabase PostgreSQL +``` + +## Live Deployment + +| Service | URL | +|---|---| +| 🚀 Backend API | https://dpv007-keystone.hf.space | +| 📖 API Docs | https://dpv007-keystone.hf.space/docs | + +## Quick Start + +### Backend (local dev) + +```bash +cd backend +cp .env.example .env # fill in your credentials +docker compose up # starts FastAPI + PostgreSQL +``` + +### Run database migrations + +```bash +cd backend +alembic upgrade head +``` + +### Mobile App + +```bash +cd mobile +cp .env.example .env # fill in Supabase keys +npm install +npm start # Expo dev server (iOS/Android/Web) +``` + +Open in browser: http://localhost:8081 (web), or scan QR in Expo Go. + +## Environment Variables + +### Backend (`backend/.env`) + +| Variable | Description | +|---|---| +| `DATABASE_URL` | Supabase PostgreSQL connection string | +| `SUPABASE_URL` | Your Supabase project URL | +| `SUPABASE_JWT_SECRET` | JWT secret from Supabase Dashboard → Settings → API | +| `HF_TOKEN` | Hugging Face token with write access | +| `HF_DATASET_REPO` | HF Dataset repo for photo storage (e.g. `user/keystone-photos`) | + +### Mobile (`mobile/.env`) + +| Variable | Description | +|---|---| +| `EXPO_PUBLIC_API_URL` | Backend URL (defaults to HF Space URL) | +| `EXPO_PUBLIC_SUPABASE_URL` | Your Supabase project URL | +| `EXPO_PUBLIC_SUPABASE_ANON_KEY` | Supabase anon key | + +## Features + +- ✅ Email/password authentication via Supabase +- ✅ JWT verification on every API request +- ✅ SHA256 deduplication (never upload the same photo twice) +- ✅ Background sync (iOS + Android) with Wi-Fi-only option +- ✅ Battery-aware syncing +- ✅ Thumbnail generation (Pillow) +- ✅ Infinite-scroll gallery +- ✅ Albums with photo management +- ✅ Photo search (filename + AI tags) +- ✅ Favorites +- ✅ Soft delete +- ✅ Works on iOS, Android, and Web (same codebase) +- 🔜 CLIP semantic search +- 🔜 Face clustering +- 🔜 OCR text extraction +- 🔜 Auto albums + +## Deployment to Hugging Face Spaces + +The backend is a Docker Space. Push this repo and set the following secrets in the Space settings: + +- `DATABASE_URL` +- `SUPABASE_URL` +- `SUPABASE_JWT_SECRET` +- `SUPABASE_SERVICE_ROLE_KEY` +- `HF_TOKEN` +- `HF_DATASET_REPO` + +## License + +MIT diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..de57ce293125db1d2e0e36fd59a7398b2a289b23 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,39 @@ +# ── App ─────────────────────────────────────────────────────────────────────── +APP_NAME="KeyStone Photo Cloud" +APP_VERSION="0.1.0" +DEBUG=false +ENVIRONMENT=production + +# ── Database (Supabase PostgreSQL) ──────────────────────────────────────────── +# Use the "Session Mode" connection string from Supabase Dashboard > Settings > Database +DATABASE_URL=postgresql+asyncpg://postgres.YOURPROJECT:PASSWORD@aws-0-us-east-1.pooler.supabase.com:5432/postgres + +# ── Supabase ────────────────────────────────────────────────────────────────── +SUPABASE_URL=https://YOURPROJECT.supabase.co +SUPABASE_ANON_KEY=your-supabase-anon-key +SUPABASE_SERVICE_ROLE_KEY=your-supabase-service-role-key +# Found at: Supabase Dashboard → Settings → API → JWT Settings → JWT Secret +SUPABASE_JWT_SECRET=your-supabase-jwt-secret + +# ── Hugging Face Bucket (S3-compatible) ─────────────────────────────────────── +HF_TOKEN=hf_your_token_here +HF_DATASET_REPO=your-username/keystone-photos +HF_BUCKET_NAME=keystone-photos + +# HF S3 credentials (generate at: https://huggingface.co/settings/tokens) +HF_ACCESS_KEY_ID=your-hf-s3-access-key +HF_SECRET_ACCESS_KEY=your-hf-s3-secret-key +HF_S3_ENDPOINT=https://huggingface.co + +# ── Upload Limits ───────────────────────────────────────────────────────────── +MAX_UPLOAD_SIZE_MB=50 +THUMBNAIL_SIZE=512 +THUMBNAIL_QUALITY=85 + +# ── Rate Limiting ───────────────────────────────────────────────────────────── +RATE_LIMIT_PER_MINUTE=60 +RATE_LIMIT_UPLOADS_PER_MINUTE=20 + +# ── CORS ────────────────────────────────────────────────────────────────────── +# Comma-separated list of allowed origins. Use * for development only. +CORS_ORIGINS=* diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..7aca04ed7b1ac7846bddd2549cdc065f05147d1e --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,39 @@ +# KeyStone Backend – Dockerfile +# Deployed on Hugging Face Spaces (Docker SDK) +# Port 7860 is required by Hugging Face Spaces + +FROM python:3.11-slim + +# System dependencies for Pillow + python-magic +RUN apt-get update && apt-get install -y --no-install-recommends \ + libmagic1 \ + libmagic-dev \ + libjpeg-dev \ + zlib1g-dev \ + libwebp-dev \ + curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Install Python dependencies first (layer caching) +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Create non-root user for security +RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app +USER appuser + +# Hugging Face Spaces requires port 7860 +EXPOSE 7860 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:7860/health || exit 1 + +# Start FastAPI with uvicorn +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "2"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000000000000000000000000000000000000..81ba8e515150bf2394f6963375c92051cf0fe169 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,30 @@ +--- +title: KeyStone Photo Cloud API +emoji: 📸 +colorFrom: purple +colorTo: indigo +sdk: docker +pinned: false +license: mit +app_port: 7860 +--- + +# KeyStone Photo Cloud – Backend API + +Private Google Photos alternative backend. See the [full documentation](/docs) at `/docs`. + +## Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| GET | /health | Health check | +| POST | /auth/verify | Verify Supabase JWT | +| POST | /sync/start | Register device | +| POST | /sync/check | Dedup hash check | +| POST | /upload | Upload a photo | +| GET | /photos | List photos | +| GET | /photos/{id} | Get photo | +| DELETE | /photos/{id} | Delete photo | +| GET | /albums | List albums | +| POST | /albums | Create album | +| GET | /search | Search photos | diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000000000000000000000000000000000000..2cb3cfcbac959c496a4e9959c4c774e5af018019 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,88 @@ +# Alembic configuration file +# See: https://alembic.sqlalchemy.org/en/latest/tutorial.html + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration file names +file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s + +# timezone to use when rendering the date within the migration file +# leave blank for current timezone +# timezone = + +# max length of characters to apply to the +# "slug" field +truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must specify a --version-path. +# version_path_separator = os # Use os.pathsep. Default configuration +# used for new projects. +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_locations = %(here)s/alembic/versions + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = driver://user:pass@localhost/dbname + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000000000000000000000000000000000000..cab4dbf66a2b86d37546ffc6659ae51fe7f7d840 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,73 @@ +""" +Alembic Environment Configuration +""" + +import asyncio +import os +from logging.config import fileConfig + +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context + +# Load models so Alembic can detect them +import sys +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +from app.database import Base +from app.models import User, Device, Photo, UploadJob, Album, AlbumPhoto # noqa + +# Alembic Config object +config = context.config + +# Override sqlalchemy.url from environment variable +database_url = os.environ.get("DATABASE_URL", "") +if database_url: + config.set_main_option("sqlalchemy.url", database_url) + +# Interpret the config file for logging +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + + +def run_migrations_online() -> None: + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/versions/001_initial.py b/backend/alembic/versions/001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..c4722f08d5c96ae7f4ab091a9206e4f173a0cd99 --- /dev/null +++ b/backend/alembic/versions/001_initial.py @@ -0,0 +1,141 @@ +"""Initial migration – create all tables + +Revision ID: 001_initial +Revises: +Create Date: 2026-07-16 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = "001_initial" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ── users ───────────────────────────────────────────────────────────────── + op.create_table( + "users", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("supabase_id", sa.String(255), nullable=False), + sa.Column("email", sa.String(320), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("supabase_id"), + sa.UniqueConstraint("email"), + ) + op.create_index("ix_users_supabase_id", "users", ["supabase_id"]) + op.create_index("ix_users_email", "users", ["email"]) + + # ── devices ─────────────────────────────────────────────────────────────── + op.create_table( + "devices", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("device_name", sa.String(255), nullable=False), + sa.Column("platform", sa.String(50), nullable=False), + sa.Column("device_token", sa.String(512), nullable=True), + sa.Column("last_sync", sa.DateTime(timezone=True), nullable=True), + sa.Column("registered_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_devices_user_id", "devices", ["user_id"]) + + # ── photos ──────────────────────────────────────────────────────────────── + op.create_table( + "photos", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("sha256", sa.String(64), nullable=False), + sa.Column("filename", sa.String(512), nullable=False), + sa.Column("mime_type", sa.String(128), nullable=False), + sa.Column("bucket_path", sa.Text(), nullable=False), + sa.Column("thumbnail_path", sa.Text(), nullable=True), + sa.Column("preview_path", sa.Text(), nullable=True), + sa.Column("width", sa.Integer(), nullable=True), + sa.Column("height", sa.Integer(), nullable=True), + sa.Column("size", sa.BigInteger(), nullable=False), + sa.Column("taken_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("uploaded_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("deleted", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("is_favorite", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("ai_description", sa.Text(), nullable=True), + sa.Column("ai_tags", sa.Text(), nullable=True), + sa.Column("clip_embedding", sa.Text(), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_photos_user_id", "photos", ["user_id"]) + op.create_index("ix_photos_sha256", "photos", ["sha256"]) + + # ── upload_jobs ─────────────────────────────────────────────────────────── + op.create_table( + "upload_jobs", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("photo_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("filename", sa.String(512), nullable=False), + sa.Column("sha256", sa.String(64), nullable=False), + sa.Column("status", sa.Enum( + "pending", "uploading", "processing", "completed", "failed", "duplicate", + name="uploadstatus" + ), nullable=False), + sa.Column("retries", sa.Integer(), nullable=False, server_default="0"), + sa.Column("max_retries", sa.Integer(), nullable=False, server_default="3"), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["photo_id"], ["photos.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_upload_jobs_user_id", "upload_jobs", ["user_id"]) + op.create_index("ix_upload_jobs_sha256", "upload_jobs", ["sha256"]) + + # ── albums ──────────────────────────────────────────────────────────────── + op.create_table( + "albums", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("cover_photo_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["cover_photo_id"], ["photos.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_albums_user_id", "albums", ["user_id"]) + + # ── album_photos ────────────────────────────────────────────────────────── + op.create_table( + "album_photos", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("album_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("photo_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("added_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["album_id"], ["albums.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["photo_id"], ["photos.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("album_id", "photo_id", name="uq_album_photo"), + ) + op.create_index("ix_album_photos_album_id", "album_photos", ["album_id"]) + op.create_index("ix_album_photos_photo_id", "album_photos", ["photo_id"]) + + +def downgrade() -> None: + op.drop_table("album_photos") + op.drop_table("albums") + op.drop_table("upload_jobs") + op.drop_table("photos") + op.drop_table("devices") + op.drop_table("users") + op.execute("DROP TYPE IF EXISTS uploadstatus") diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000000000000000000000000000000000000..d8df94b5cb44ba26519cac44605cbd25c9145096 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,76 @@ +""" +KeyStone – Application Configuration +All settings are read from environment variables (or a .env file via python-dotenv). +""" + +from functools import lru_cache +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", + ) + + # ── App ────────────────────────────────────────────────────────────────── + app_name: str = "KeyStone Photo Cloud" + app_version: str = "0.1.0" + debug: bool = False + environment: str = "production" # development | production + + # ── Database (Supabase PostgreSQL) ─────────────────────────────────────── + database_url: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/keystone" + + # ── Supabase ───────────────────────────────────────────────────────────── + supabase_url: str = "https://your-project.supabase.co" + supabase_anon_key: str = "your-supabase-anon-key" + supabase_service_role_key: str = "your-supabase-service-role-key" + supabase_jwt_secret: str = "your-supabase-jwt-secret" + + # ── Hugging Face Bucket (S3-compatible) ────────────────────────────────── + hf_token: str = "hf_your_token_here" + hf_dataset_repo: str = "your-username/keystone-photos" + hf_endpoint_url: str = "https://huggingface.co" + + # S3-compatible access (HF Datasets S3 gateway) + hf_s3_endpoint: str = "https://huggingface.co/datasets" + hf_access_key_id: str = "your-hf-access-key" + hf_secret_access_key: str = "your-hf-secret-key" + hf_bucket_name: str = "keystone-photos" + + # ── Upload Settings ─────────────────────────────────────────────────────── + max_upload_size_mb: int = 50 + thumbnail_size: int = 512 + thumbnail_quality: int = 85 + allowed_mime_types: list[str] = [ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "image/heic", + "image/heif", + "image/tiff", + ] + + # ── Rate Limiting ───────────────────────────────────────────────────────── + rate_limit_per_minute: int = 60 + rate_limit_uploads_per_minute: int = 20 + + # ── CORS ───────────────────────────────────────────────────────────────── + cors_origins: list[str] = ["*"] + + @property + def max_upload_size_bytes(self) -> int: + return self.max_upload_size_mb * 1024 * 1024 + + @property + def is_development(self) -> bool: + return self.environment == "development" + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000000000000000000000000000000000000..a96d0c46725d084e32a1bf372a8a47cc7a3901a1 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,49 @@ +""" +KeyStone – Async Database Session +Uses SQLAlchemy 2.x async engine with asyncpg driver. +""" + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase + +from app.config import get_settings + +settings = get_settings() + +engine = create_async_engine( + settings.database_url, + echo=settings.debug, + pool_pre_ping=True, + pool_size=10, + max_overflow=20, +) + +AsyncSessionLocal = async_sessionmaker( + bind=engine, + class_=AsyncSession, + expire_on_commit=False, +) + + +class Base(DeclarativeBase): + """Base class for all SQLAlchemy models.""" + pass + + +async def get_db() -> AsyncSession: # type: ignore[return] + """FastAPI dependency that yields an async database session.""" + async with AsyncSessionLocal() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() + + +async def create_tables() -> None: + """Create all tables (for dev/testing; migrations handled by Alembic in prod).""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000000000000000000000000000000000000..cbb5b5e4267611a35ec32658db8d8449712fa528 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,84 @@ +""" +KeyStone – FastAPI Application Entry Point +""" + +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request, status +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded +from slowapi.util import get_remote_address + +from app.config import get_settings +from app.database import create_tables +from app.routers import auth, upload, gallery, albums, sync, search + +settings = get_settings() + +limiter = Limiter(key_func=get_remote_address) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application lifespan: startup / shutdown.""" + # Startup + if settings.is_development: + await create_tables() + yield + # Shutdown – nothing special needed + + +app = FastAPI( + title=settings.app_name, + version=settings.app_version, + description="Private Google Photos alternative – self-hosted on Hugging Face Spaces.", + docs_url="/docs", + redoc_url="/redoc", + lifespan=lifespan, +) + +# ── Rate Limiter ────────────────────────────────────────────────────────────── +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + +# ── CORS ────────────────────────────────────────────────────────────────────── +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# ── Routers ─────────────────────────────────────────────────────────────────── +app.include_router(auth.router, prefix="/auth", tags=["Authentication"]) +app.include_router(sync.router, prefix="/sync", tags=["Sync"]) +app.include_router(upload.router, prefix="/upload", tags=["Upload"]) +app.include_router(gallery.router, prefix="/photos", tags=["Gallery"]) +app.include_router(albums.router, prefix="/albums", tags=["Albums"]) +app.include_router(search.router, prefix="/search", tags=["Search"]) + + +# ── Health ──────────────────────────────────────────────────────────────────── +@app.get("/health", tags=["Health"]) +async def health(request: Request): + """Health check endpoint – returns app info and status.""" + return { + "status": "ok", + "service": settings.app_name, + "version": settings.app_version, + "environment": settings.environment, + } + + +# ── Global Exception Handler ────────────────────────────────────────────────── +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception): + if settings.debug: + raise exc + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={"detail": "An unexpected error occurred."}, + ) diff --git a/backend/app/middleware/__init__.py b/backend/app/middleware/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a3d42a603d42841ee4066888d0f915cf430fd071 --- /dev/null +++ b/backend/app/middleware/__init__.py @@ -0,0 +1 @@ +"""KeyStone – Middleware Package""" diff --git a/backend/app/middleware/auth.py b/backend/app/middleware/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..31e28996d738ae7a6bbd9d19b751bc3c5e8869c0 --- /dev/null +++ b/backend/app/middleware/auth.py @@ -0,0 +1,81 @@ +""" +KeyStone – JWT Authentication Middleware +Verifies Supabase-issued JWTs using the project's JWT secret. +""" + +import logging +from typing import Annotated + +import httpx +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from jose import JWTError, jwt +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import get_settings +from app.database import get_db +from app.models.user import User + +logger = logging.getLogger(__name__) +settings = get_settings() + +security = HTTPBearer() + + +def _decode_jwt(token: str) -> dict: + """Decode and verify a Supabase JWT using the project JWT secret.""" + try: + payload = jwt.decode( + token, + settings.supabase_jwt_secret, + algorithms=["HS256"], + options={"verify_aud": False}, # Supabase uses custom audience + ) + return payload + except JWTError as exc: + logger.warning("JWT decode failed: %s", exc) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token.", + headers={"WWW-Authenticate": "Bearer"}, + ) from exc + + +async def get_current_user( + credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> User: + """ + FastAPI dependency. + 1. Decodes the Bearer JWT. + 2. Extracts sub (Supabase user ID) and email. + 3. Upserts the user in our database. + 4. Returns the User ORM object. + """ + payload = _decode_jwt(credentials.credentials) + + supabase_id: str | None = payload.get("sub") + email: str | None = payload.get("email") + + if not supabase_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token missing subject claim.", + ) + + # Upsert user in local DB + result = await db.execute(select(User).where(User.supabase_id == supabase_id)) + user = result.scalar_one_or_none() + + if user is None: + user = User(supabase_id=supabase_id, email=email or "") + db.add(user) + await db.flush() + logger.info("New user created: supabase_id=%s", supabase_id) + + return user + + +# Convenient type alias for route dependencies +CurrentUser = Annotated[User, Depends(get_current_user)] diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..c9af9bfbee94f7644b1a6941dcbd0b87f830eef8 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,9 @@ +"""KeyStone SQLAlchemy Models Package""" + +from app.models.user import User +from app.models.device import Device +from app.models.photo import Photo +from app.models.upload_job import UploadJob +from app.models.album import Album, AlbumPhoto + +__all__ = ["User", "Device", "Photo", "UploadJob", "Album", "AlbumPhoto"] diff --git a/backend/app/models/album.py b/backend/app/models/album.py new file mode 100644 index 0000000000000000000000000000000000000000..cd5be9ef7c58f3a2153f0774e044e4710d56eb36 --- /dev/null +++ b/backend/app/models/album.py @@ -0,0 +1,71 @@ +""" +KeyStone – Album Model +""" + +import uuid +from datetime import datetime, timezone + +from sqlalchemy import String, DateTime, Text, ForeignKey, UniqueConstraint +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class Album(Base): + __tablename__ = "albums" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + cover_photo_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("photos.id", ondelete="SET NULL"), nullable=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + # ── Relationships ───────────────────────────────────────────────────────── + user: Mapped["User"] = relationship("User", back_populates="albums") + album_photos: Mapped[list["AlbumPhoto"]] = relationship( + "AlbumPhoto", back_populates="album", cascade="all, delete-orphan" + ) + + def __repr__(self) -> str: + return f"" + + +class AlbumPhoto(Base): + """Junction table between albums and photos.""" + + __tablename__ = "album_photos" + __table_args__ = ( + UniqueConstraint("album_id", "photo_id", name="uq_album_photo"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + album_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("albums.id", ondelete="CASCADE"), nullable=False, index=True + ) + photo_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("photos.id", ondelete="CASCADE"), nullable=False, index=True + ) + added_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + + # ── Relationships ───────────────────────────────────────────────────────── + album: Mapped["Album"] = relationship("Album", back_populates="album_photos") + photo: Mapped["Photo"] = relationship("Photo", back_populates="album_photos") diff --git a/backend/app/models/device.py b/backend/app/models/device.py new file mode 100644 index 0000000000000000000000000000000000000000..3829ab1d8c796b3caad54a2ebc770fed22c51057 --- /dev/null +++ b/backend/app/models/device.py @@ -0,0 +1,36 @@ +""" +KeyStone – Device Model +""" + +import uuid +from datetime import datetime, timezone + +from sqlalchemy import String, DateTime, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class Device(Base): + __tablename__ = "devices" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + device_name: Mapped[str] = mapped_column(String(255), nullable=False) + platform: Mapped[str] = mapped_column(String(50), nullable=False) # android | ios | web + device_token: Mapped[str | None] = mapped_column(String(512), nullable=True) # push token + last_sync: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + registered_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + + # ── Relationships ───────────────────────────────────────────────────────── + user: Mapped["User"] = relationship("User", back_populates="devices") + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/models/photo.py b/backend/app/models/photo.py new file mode 100644 index 0000000000000000000000000000000000000000..60f5cd3380f1dedb5f797806e6e286a284f5f8bb --- /dev/null +++ b/backend/app/models/photo.py @@ -0,0 +1,64 @@ +""" +KeyStone – Photo Model +""" + +import uuid +from datetime import datetime, timezone + +from sqlalchemy import String, DateTime, Integer, Boolean, BigInteger, ForeignKey, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class Photo(Base): + __tablename__ = "photos" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + + # ── File Identity ───────────────────────────────────────────────────────── + sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + filename: Mapped[str] = mapped_column(String(512), nullable=False) + mime_type: Mapped[str] = mapped_column(String(128), nullable=False) + + # ── Storage Paths ───────────────────────────────────────────────────────── + bucket_path: Mapped[str] = mapped_column(Text, nullable=False) # originals/... + thumbnail_path: Mapped[str | None] = mapped_column(Text, nullable=True) # thumbnails/... + preview_path: Mapped[str | None] = mapped_column(Text, nullable=True) # previews/... + + # ── Image Metadata ──────────────────────────────────────────────────────── + width: Mapped[int | None] = mapped_column(Integer, nullable=True) + height: Mapped[int | None] = mapped_column(Integer, nullable=True) + size: Mapped[int] = mapped_column(BigInteger, nullable=False) # bytes + + # ── Timestamps ──────────────────────────────────────────────────────────── + taken_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + uploaded_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + + # ── Soft Delete / Status ────────────────────────────────────────────────── + deleted: Mapped[bool] = mapped_column(Boolean, default=False) + is_favorite: Mapped[bool] = mapped_column(Boolean, default=False) + + # ── AI Metadata (future) ────────────────────────────────────────────────── + ai_description: Mapped[str | None] = mapped_column(Text, nullable=True) + ai_tags: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON array as string + clip_embedding: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON float array + + # ── Relationships ───────────────────────────────────────────────────────── + user: Mapped["User"] = relationship("User", back_populates="photos") + album_photos: Mapped[list["AlbumPhoto"]] = relationship("AlbumPhoto", back_populates="photo", cascade="all, delete-orphan") + upload_jobs: Mapped[list["UploadJob"]] = relationship("UploadJob", back_populates="photo", lazy="select") + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/models/upload_job.py b/backend/app/models/upload_job.py new file mode 100644 index 0000000000000000000000000000000000000000..f0d9aeb8af15b87c5d9bf3a795e6e03f286e0508 --- /dev/null +++ b/backend/app/models/upload_job.py @@ -0,0 +1,65 @@ +""" +KeyStone – UploadJob Model +Tracks each upload attempt for retry logic and progress reporting. +""" + +import uuid +from datetime import datetime, timezone + +from sqlalchemy import String, DateTime, Integer, ForeignKey, Text, Enum +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship +import enum + +from app.database import Base + + +class UploadStatus(str, enum.Enum): + PENDING = "pending" + UPLOADING = "uploading" + PROCESSING = "processing" + COMPLETED = "completed" + FAILED = "failed" + DUPLICATE = "duplicate" + + +class UploadJob(Base): + __tablename__ = "upload_jobs" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + photo_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("photos.id", ondelete="SET NULL"), nullable=True + ) + + # ── Job Metadata ────────────────────────────────────────────────────────── + filename: Mapped[str] = mapped_column(String(512), nullable=False) + sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + status: Mapped[UploadStatus] = mapped_column( + Enum(UploadStatus), default=UploadStatus.PENDING, nullable=False + ) + retries: Mapped[int] = mapped_column(Integer, default=0) + max_retries: Mapped[int] = mapped_column(Integer, default=3) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + + # ── Timestamps ──────────────────────────────────────────────────────────── + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + # ── Relationships ───────────────────────────────────────────────────────── + user: Mapped["User"] = relationship("User", back_populates="upload_jobs") + photo: Mapped["Photo | None"] = relationship("Photo", back_populates="upload_jobs") + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000000000000000000000000000000000000..7735007734986decbce4f356516318d599a7344a --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,35 @@ +""" +KeyStone – User Model +""" + +import uuid +from datetime import datetime, timezone + +from sqlalchemy import String, DateTime, Boolean +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class User(Base): + __tablename__ = "users" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + supabase_id: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) + email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False, index=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + + # ── Relationships ───────────────────────────────────────────────────────── + photos: Mapped[list["Photo"]] = relationship("Photo", back_populates="user", lazy="select") + devices: Mapped[list["Device"]] = relationship("Device", back_populates="user", lazy="select") + albums: Mapped[list["Album"]] = relationship("Album", back_populates="user", lazy="select") + upload_jobs: Mapped[list["UploadJob"]] = relationship("UploadJob", back_populates="user", lazy="select") + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..1ea79b95b993cab73480542bbc1243718e762f55 --- /dev/null +++ b/backend/app/routers/__init__.py @@ -0,0 +1 @@ +"""KeyStone – Routers Package""" diff --git a/backend/app/routers/albums.py b/backend/app/routers/albums.py new file mode 100644 index 0000000000000000000000000000000000000000..698cd003392b65daaf4f8fbf07252ae6b2dbe45e --- /dev/null +++ b/backend/app/routers/albums.py @@ -0,0 +1,154 @@ +""" +KeyStone – Albums Router +GET /albums — list user's albums +POST /albums — create album +GET /albums/{id}/photos — list photos in album +POST /albums/{id}/photos — add photos to album +""" + +import uuid +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.middleware.auth import CurrentUser +from app.models.album import Album, AlbumPhoto +from app.models.photo import Photo +from app.schemas.album import AlbumCreate, AlbumOut, AlbumUpdate, AlbumWithPhotos, AddPhotosToAlbumRequest +from app.schemas.photo import PhotoPage +from app.routers.gallery import _enrich + +router = APIRouter() + + +@router.get("", response_model=list[AlbumOut], summary="List all albums") +async def list_albums( + current_user: CurrentUser, + db: Annotated[AsyncSession, Depends(get_db)], +) -> list[AlbumOut]: + result = await db.execute( + select(Album).where(Album.user_id == current_user.id).order_by(Album.updated_at.desc()) + ) + albums = result.scalars().all() + + out = [] + for album in albums: + count_res = await db.execute( + select(func.count()).select_from(AlbumPhoto).where(AlbumPhoto.album_id == album.id) + ) + photo_count = count_res.scalar_one() + a = AlbumOut.model_validate(album) + a.photo_count = photo_count + out.append(a) + return out + + +@router.post("", response_model=AlbumOut, status_code=status.HTTP_201_CREATED, summary="Create an album") +async def create_album( + body: AlbumCreate, + current_user: CurrentUser, + db: Annotated[AsyncSession, Depends(get_db)], +) -> AlbumOut: + album = Album(user_id=current_user.id, name=body.name, description=body.description) + db.add(album) + await db.flush() + return AlbumOut.model_validate(album) + + +@router.get("/{album_id}", response_model=AlbumWithPhotos, summary="Get album with photos") +async def get_album( + album_id: uuid.UUID, + current_user: CurrentUser, + db: Annotated[AsyncSession, Depends(get_db)], +) -> AlbumWithPhotos: + result = await db.execute( + select(Album).where(Album.id == album_id, Album.user_id == current_user.id) + ) + album = result.scalar_one_or_none() + if not album: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Album not found.") + + photos_res = await db.execute( + select(Photo) + .join(AlbumPhoto, AlbumPhoto.photo_id == Photo.id) + .where(AlbumPhoto.album_id == album_id, Photo.deleted == False) # noqa + ) + photos = photos_res.scalars().all() + a = AlbumWithPhotos.model_validate(album) + a.photos = [_enrich(p) for p in photos] + a.photo_count = len(photos) + return a + + +@router.patch("/{album_id}", response_model=AlbumOut, summary="Update album metadata") +async def update_album( + album_id: uuid.UUID, + body: AlbumUpdate, + current_user: CurrentUser, + db: Annotated[AsyncSession, Depends(get_db)], +) -> AlbumOut: + result = await db.execute( + select(Album).where(Album.id == album_id, Album.user_id == current_user.id) + ) + album = result.scalar_one_or_none() + if not album: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Album not found.") + if body.name is not None: + album.name = body.name + if body.description is not None: + album.description = body.description + if body.cover_photo_id is not None: + album.cover_photo_id = body.cover_photo_id + return AlbumOut.model_validate(album) + + +@router.delete("/{album_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete an album") +async def delete_album( + album_id: uuid.UUID, + current_user: CurrentUser, + db: Annotated[AsyncSession, Depends(get_db)], +) -> None: + result = await db.execute( + select(Album).where(Album.id == album_id, Album.user_id == current_user.id) + ) + album = result.scalar_one_or_none() + if not album: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Album not found.") + await db.delete(album) + + +@router.post("/{album_id}/photos", status_code=status.HTTP_200_OK, summary="Add photos to album") +async def add_photos_to_album( + album_id: uuid.UUID, + body: AddPhotosToAlbumRequest, + current_user: CurrentUser, + db: Annotated[AsyncSession, Depends(get_db)], +) -> dict: + result = await db.execute( + select(Album).where(Album.id == album_id, Album.user_id == current_user.id) + ) + album = result.scalar_one_or_none() + if not album: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Album not found.") + + added = 0 + for photo_id in body.photo_ids: + # Verify photo belongs to user + p_res = await db.execute( + select(Photo).where(Photo.id == photo_id, Photo.user_id == current_user.id) + ) + photo = p_res.scalar_one_or_none() + if not photo: + continue + # Check not already in album + ap_res = await db.execute( + select(AlbumPhoto).where(AlbumPhoto.album_id == album_id, AlbumPhoto.photo_id == photo_id) + ) + if ap_res.scalar_one_or_none() is None: + db.add(AlbumPhoto(album_id=album_id, photo_id=photo_id)) + added += 1 + + return {"added": added, "message": f"Added {added} photo(s) to album."} diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..d39a965d36cdf95008d112f35460e4f85122d56b --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,21 @@ +""" +KeyStone – Auth Router +POST /auth/verify — validates JWT and returns user profile +""" + +from fastapi import APIRouter + +from app.middleware.auth import CurrentUser +from app.schemas.user import UserOut + +router = APIRouter() + + +@router.post("/verify", response_model=UserOut, summary="Verify JWT and return user profile") +async def verify_token(current_user: CurrentUser) -> UserOut: + """ + Validates the Bearer token (issued by Supabase) and returns the + authenticated user's profile. Also auto-creates the user record on + first login. + """ + return UserOut.model_validate(current_user) diff --git a/backend/app/routers/gallery.py b/backend/app/routers/gallery.py new file mode 100644 index 0000000000000000000000000000000000000000..1aa148c7af37b41cf00c0b0f6777b9d03e50c9fd --- /dev/null +++ b/backend/app/routers/gallery.py @@ -0,0 +1,167 @@ +""" +KeyStone – Gallery Router +GET /photos — paginated photo list (cursor-based) +GET /photos/{id} — single photo +DELETE /photos/{id} — soft-delete +PATCH /photos/{id}/favorite — toggle favorite +""" + +import uuid +from typing import Annotated, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import select, func, desc +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.middleware.auth import CurrentUser +from app.models.photo import Photo +from app.schemas.photo import PhotoOut, PhotoPage, PhotoUpdate +from app.services.storage import get_download_url + +router = APIRouter() + + +def _enrich(photo: Photo) -> PhotoOut: + """Convert ORM model to schema, injecting public download URLs.""" + data = PhotoOut.model_validate(photo) + # Rewrite paths to public URLs + if photo.bucket_path: + data.bucket_path = get_download_url(photo.bucket_path) + if photo.thumbnail_path: + data.thumbnail_path = get_download_url(photo.thumbnail_path) + if photo.preview_path: + data.preview_path = get_download_url(photo.preview_path) + return data + + +@router.get( + "", + response_model=PhotoPage, + summary="List photos (paginated, newest first)", +) +async def list_photos( + current_user: CurrentUser, + db: Annotated[AsyncSession, Depends(get_db)], + limit: int = Query(50, ge=1, le=200), + cursor: Optional[str] = Query(None, description="Opaque pagination cursor (photo ID)"), + favorites_only: bool = Query(False), +) -> PhotoPage: + """ + Returns a paginated list of photos for the authenticated user. + Uses cursor-based pagination for efficient infinite scroll. + """ + query = ( + select(Photo) + .where(Photo.user_id == current_user.id, Photo.deleted == False) # noqa + .order_by(desc(Photo.uploaded_at)) + ) + + if favorites_only: + query = query.where(Photo.is_favorite == True) # noqa + + if cursor: + try: + cursor_id = uuid.UUID(cursor) + # Get the uploaded_at of the cursor photo + cur_res = await db.execute(select(Photo.uploaded_at).where(Photo.id == cursor_id)) + cursor_time = cur_res.scalar_one_or_none() + if cursor_time: + query = query.where(Photo.uploaded_at < cursor_time) + except (ValueError, Exception): + pass # Invalid cursor – ignore and start from beginning + + result = await db.execute(query.limit(limit + 1)) + photos = result.scalars().all() + + has_more = len(photos) > limit + photos = photos[:limit] + + # Count total (non-deleted) for display + count_q = select(func.count()).where(Photo.user_id == current_user.id, Photo.deleted == False) # noqa + total = (await db.execute(count_q)).scalar_one() + + next_cursor = str(photos[-1].id) if has_more and photos else None + + return PhotoPage( + items=[_enrich(p) for p in photos], + total=total, + next_cursor=next_cursor, + has_more=has_more, + ) + + +@router.get( + "/{photo_id}", + response_model=PhotoOut, + summary="Get a single photo by ID", +) +async def get_photo( + photo_id: uuid.UUID, + current_user: CurrentUser, + db: Annotated[AsyncSession, Depends(get_db)], +) -> PhotoOut: + result = await db.execute( + select(Photo).where( + Photo.id == photo_id, + Photo.user_id == current_user.id, + Photo.deleted == False, # noqa + ) + ) + photo = result.scalar_one_or_none() + if not photo: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Photo not found.") + return _enrich(photo) + + +@router.delete( + "/{photo_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Soft-delete a photo", +) +async def delete_photo( + photo_id: uuid.UUID, + current_user: CurrentUser, + db: Annotated[AsyncSession, Depends(get_db)], +) -> None: + result = await db.execute( + select(Photo).where(Photo.id == photo_id, Photo.user_id == current_user.id) + ) + photo = result.scalar_one_or_none() + if not photo: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Photo not found.") + photo.deleted = True + + +@router.patch( + "/{photo_id}", + response_model=PhotoOut, + summary="Update photo metadata (favorite, etc.)", +) +async def update_photo( + photo_id: uuid.UUID, + body: PhotoUpdate, + current_user: CurrentUser, + db: Annotated[AsyncSession, Depends(get_db)], +) -> PhotoOut: + result = await db.execute( + select(Photo).where( + Photo.id == photo_id, + Photo.user_id == current_user.id, + Photo.deleted == False, # noqa + ) + ) + photo = result.scalar_one_or_none() + if not photo: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Photo not found.") + + if body.is_favorite is not None: + photo.is_favorite = body.is_favorite + if body.deleted is not None: + photo.deleted = body.deleted + if body.ai_description is not None: + photo.ai_description = body.ai_description + if body.ai_tags is not None: + photo.ai_tags = body.ai_tags + + return _enrich(photo) diff --git a/backend/app/routers/search.py b/backend/app/routers/search.py new file mode 100644 index 0000000000000000000000000000000000000000..ca51c2ae0db59182362b1d517d5b7a9bc3a40ae9 --- /dev/null +++ b/backend/app/routers/search.py @@ -0,0 +1,67 @@ +""" +KeyStone – Search Router +GET /search?q= — text search across photos (AI-ready, text-based initially) +""" + +from typing import Annotated, Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import select, or_ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.middleware.auth import CurrentUser +from app.models.photo import Photo +from app.schemas.photo import PhotoPage +from app.routers.gallery import _enrich + +router = APIRouter() + + +@router.get("", response_model=PhotoPage, summary="Search photos by filename or AI tags") +async def search_photos( + current_user: CurrentUser, + db: Annotated[AsyncSession, Depends(get_db)], + q: str = Query(..., min_length=1, description="Search query"), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), +) -> PhotoPage: + """ + Text-based search across: + - filename + - ai_description + - ai_tags + + CLIP-based semantic search will be added in Milestone 8 using the + clip_embedding column. + """ + search_term = f"%{q.lower()}%" + + query = ( + select(Photo) + .where( + Photo.user_id == current_user.id, + Photo.deleted == False, # noqa + or_( + Photo.filename.ilike(search_term), + Photo.ai_description.ilike(search_term), + Photo.ai_tags.ilike(search_term), + ), + ) + .order_by(Photo.uploaded_at.desc()) + .offset(offset) + .limit(limit + 1) + ) + + result = await db.execute(query) + photos = result.scalars().all() + + has_more = len(photos) > limit + photos = photos[:limit] + + return PhotoPage( + items=[_enrich(p) for p in photos], + total=len(photos), + next_cursor=None, + has_more=has_more, + ) diff --git a/backend/app/routers/sync.py b/backend/app/routers/sync.py new file mode 100644 index 0000000000000000000000000000000000000000..648e8fa37fa3e301e85d731610133bed564fee39 --- /dev/null +++ b/backend/app/routers/sync.py @@ -0,0 +1,96 @@ +""" +KeyStone – Sync Router +POST /sync/start — register / update a device +POST /sync/check — check which hashes need uploading +""" + +import logging +from datetime import datetime, timezone +from typing import Annotated + +from fastapi import APIRouter, Depends, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.middleware.auth import CurrentUser +from app.models.device import Device +from app.schemas.sync import ( + SyncCheckRequest, SyncCheckResponse, + SyncStartRequest, SyncStartResponse, +) +from app.services.dedup import filter_missing_hashes + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.post( + "/start", + response_model=SyncStartResponse, + status_code=status.HTTP_200_OK, + summary="Register or update a device for sync", +) +async def sync_start( + body: SyncStartRequest, + current_user: CurrentUser, + db: Annotated[AsyncSession, Depends(get_db)], +) -> SyncStartResponse: + """ + Register a device (or update its last_sync timestamp). + Called once at app launch before beginning a sync session. + """ + # Find existing device by name + platform for this user + result = await db.execute( + select(Device).where( + Device.user_id == current_user.id, + Device.device_name == body.device_name, + Device.platform == body.platform, + ) + ) + device = result.scalar_one_or_none() + + if device is None: + device = Device( + user_id=current_user.id, + device_name=body.device_name, + platform=body.platform, + device_token=body.device_token, + ) + db.add(device) + await db.flush() + logger.info("Registered new device: %s (%s)", body.device_name, body.platform) + else: + device.last_sync = datetime.now(timezone.utc) + if body.device_token: + device.device_token = body.device_token + + return SyncStartResponse( + device_id=device.id, + message="Device registered. Ready to sync.", + last_sync=device.last_sync, + ) + + +@router.post( + "/check", + response_model=SyncCheckResponse, + status_code=status.HTTP_200_OK, + summary="Check which photo hashes need uploading", +) +async def sync_check( + body: SyncCheckRequest, + current_user: CurrentUser, + db: Annotated[AsyncSession, Depends(get_db)], +) -> SyncCheckResponse: + """ + Accepts a list of SHA256 hashes from the client's camera roll. + Returns only the hashes the server does NOT already have. + This enables the mobile client to skip uploading duplicates. + """ + missing = await filter_missing_hashes(db, current_user.id, body.hashes) + return SyncCheckResponse( + missing_hashes=missing, + existing_count=len(body.hashes) - len(missing), + missing_count=len(missing), + ) diff --git a/backend/app/routers/upload.py b/backend/app/routers/upload.py new file mode 100644 index 0000000000000000000000000000000000000000..61e84bf0fd6058f4210a8a3e5315a97aa705fe41 --- /dev/null +++ b/backend/app/routers/upload.py @@ -0,0 +1,208 @@ +""" +KeyStone – Upload Router +POST /upload — single file upload +POST /upload/batch — batch metadata check (actual bytes uploaded per-file) +""" + +import logging +import uuid +from datetime import datetime, timezone +from typing import Annotated + +from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, Request, UploadFile, status +from slowapi import Limiter +from slowapi.util import get_remote_address +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import get_settings +from app.database import get_db +from app.middleware.auth import CurrentUser +from app.models.photo import Photo +from app.models.upload_job import UploadJob, UploadStatus +from app.schemas.upload import UploadResponse +from app.services import dedup as dedup_svc +from app.services import storage as storage_svc +from app.services import thumbnail as thumb_svc + +logger = logging.getLogger(__name__) +settings = get_settings() +limiter = Limiter(key_func=get_remote_address) + +router = APIRouter() + +ALLOWED_MIME_TYPES = set(settings.allowed_mime_types) + + +def _validate_file(file: UploadFile, file_bytes: bytes) -> None: + """Validate MIME type and file size.""" + if file.content_type not in ALLOWED_MIME_TYPES: + raise HTTPException( + status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + detail=f"Unsupported file type: {file.content_type}", + ) + if len(file_bytes) > settings.max_upload_size_bytes: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=f"File too large. Maximum size is {settings.max_upload_size_mb} MB.", + ) + + +async def _process_upload_background( + photo_id: uuid.UUID, + job_id: uuid.UUID, + file_bytes: bytes, + filename: str, + mime_type: str, + user_id: uuid.UUID, + sha256: str, + taken_at_str: str | None, + db: AsyncSession, +) -> None: + """Background task: generate thumbnail and update photo record.""" + try: + # Generate thumbnail + thumb_bytes, width, height = thumb_svc.generate_thumbnail(file_bytes) + thumb_path = await storage_svc.upload_thumbnail(thumb_bytes, user_id, photo_id) + + # Update photo record + from sqlalchemy import select + result = await db.execute(select(Photo).where(Photo.id == photo_id)) + photo = result.scalar_one_or_none() + if photo: + photo.thumbnail_path = thumb_path + photo.width = width + photo.height = height + await db.commit() + + # Mark job complete + result = await db.execute(select(UploadJob).where(UploadJob.id == job_id)) + job = result.scalar_one_or_none() + if job: + job.status = UploadStatus.COMPLETED + job.completed_at = datetime.now(timezone.utc) + await db.commit() + + except Exception as exc: + logger.error("Background processing failed for photo %s: %s", photo_id, exc) + from sqlalchemy import select + result = await db.execute(select(UploadJob).where(UploadJob.id == job_id)) + job = result.scalar_one_or_none() + if job: + job.status = UploadStatus.FAILED + job.error_message = str(exc) + job.retries += 1 + await db.commit() + + +@router.post( + "", + response_model=UploadResponse, + status_code=status.HTTP_201_CREATED, + summary="Upload a single photo", +) +@limiter.limit(f"{settings.rate_limit_uploads_per_minute}/minute") +async def upload_photo( + request: Request, + background_tasks: BackgroundTasks, + current_user: CurrentUser, + db: Annotated[AsyncSession, Depends(get_db)], + file: UploadFile = File(..., description="The photo file to upload"), + sha256: str = Form(..., description="SHA256 hash of the file for integrity check"), + taken_at: str | None = Form(None, description="ISO 8601 timestamp when photo was taken"), +) -> UploadResponse: + """ + Upload a single photo file. + + - Validates MIME type and file size + - Checks for duplicate SHA256 hash + - Stores original in HF Bucket + - Generates thumbnail in background + - Returns immediately with job ID + """ + file_bytes = await file.read() + _validate_file(file, file_bytes) + + # Verify SHA256 integrity + computed = dedup_svc.compute_sha256(file_bytes) + if computed != sha256: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="SHA256 mismatch – file may be corrupted.", + ) + + # Check for duplicate + existing = await dedup_svc.is_duplicate(db, current_user.id, sha256) + if existing: + return UploadResponse( + job_id=uuid.uuid4(), + photo_id=existing.id, + status=UploadStatus.DUPLICATE, + duplicate=True, + message="Photo already uploaded (duplicate SHA256).", + ) + + # Upload original to HF Bucket + photo_id = uuid.uuid4() + try: + bucket_path = await storage_svc.upload_original( + file_bytes, file.filename or "photo.jpg", + file.content_type or "image/jpeg", + current_user.id, photo_id, + ) + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Storage upload failed: {exc}", + ) + + # Get image dimensions + dims = thumb_svc.get_image_dimensions(file_bytes) + width, height = (dims if dims else (None, None)) + + # Create Photo record + photo = Photo( + id=photo_id, + user_id=current_user.id, + sha256=sha256, + filename=file.filename or "photo.jpg", + mime_type=file.content_type or "image/jpeg", + bucket_path=bucket_path, + size=len(file_bytes), + width=width, + height=height, + taken_at=datetime.fromisoformat(taken_at) if taken_at else None, + ) + db.add(photo) + + # Create UploadJob record + job = UploadJob( + user_id=current_user.id, + photo_id=photo_id, + filename=file.filename or "photo.jpg", + sha256=sha256, + status=UploadStatus.PROCESSING, + ) + db.add(job) + await db.flush() + + # Queue background thumbnail generation + background_tasks.add_task( + _process_upload_background, + photo_id=photo_id, + job_id=job.id, + file_bytes=file_bytes, + filename=file.filename or "photo.jpg", + mime_type=file.content_type or "image/jpeg", + user_id=current_user.id, + sha256=sha256, + taken_at_str=taken_at, + db=db, + ) + + return UploadResponse( + job_id=job.id, + photo_id=photo_id, + status=UploadStatus.PROCESSING, + duplicate=False, + message="Upload successful. Thumbnail being generated.", + ) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..696dfa07d1b559dbdfebcffcf790be058e3979b3 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1,17 @@ +""" +KeyStone – Pydantic Schemas Package +""" + +from app.schemas.user import UserOut, UserCreate +from app.schemas.photo import PhotoOut, PhotoCreate, PhotoUpdate, PhotoPage +from app.schemas.upload import UploadResponse, BatchUploadRequest, BatchUploadResponse +from app.schemas.album import AlbumOut, AlbumCreate, AlbumUpdate +from app.schemas.sync import SyncCheckRequest, SyncCheckResponse, SyncStartRequest, SyncStartResponse + +__all__ = [ + "UserOut", "UserCreate", + "PhotoOut", "PhotoCreate", "PhotoUpdate", "PhotoPage", + "UploadResponse", "BatchUploadRequest", "BatchUploadResponse", + "AlbumOut", "AlbumCreate", "AlbumUpdate", + "SyncCheckRequest", "SyncCheckResponse", "SyncStartRequest", "SyncStartResponse", +] diff --git a/backend/app/schemas/album.py b/backend/app/schemas/album.py new file mode 100644 index 0000000000000000000000000000000000000000..396c40acf7a460ef94de86e116445330f29c6671 --- /dev/null +++ b/backend/app/schemas/album.py @@ -0,0 +1,42 @@ +""" +KeyStone – Album Schemas +""" + +import uuid +from datetime import datetime +from typing import Optional +from pydantic import BaseModel + +from app.schemas.photo import PhotoOut + + +class AlbumCreate(BaseModel): + name: str + description: Optional[str] = None + + +class AlbumUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + cover_photo_id: Optional[uuid.UUID] = None + + +class AlbumOut(BaseModel): + model_config = {"from_attributes": True} + + id: uuid.UUID + user_id: uuid.UUID + name: str + description: Optional[str] + cover_photo_id: Optional[uuid.UUID] + created_at: datetime + updated_at: datetime + photo_count: int = 0 + + +class AlbumWithPhotos(AlbumOut): + photos: list[PhotoOut] = [] + + +class AddPhotosToAlbumRequest(BaseModel): + photo_ids: list[uuid.UUID] diff --git a/backend/app/schemas/photo.py b/backend/app/schemas/photo.py new file mode 100644 index 0000000000000000000000000000000000000000..947d954d223b2c272d8e27c7fb20c8da05e16d1f --- /dev/null +++ b/backend/app/schemas/photo.py @@ -0,0 +1,59 @@ +""" +KeyStone – Photo Schemas +""" + +import uuid +from datetime import datetime +from typing import Optional +from pydantic import BaseModel + + +class PhotoCreate(BaseModel): + sha256: str + filename: str + mime_type: str + bucket_path: str + thumbnail_path: Optional[str] = None + preview_path: Optional[str] = None + width: Optional[int] = None + height: Optional[int] = None + size: int + taken_at: Optional[datetime] = None + + +class PhotoUpdate(BaseModel): + is_favorite: Optional[bool] = None + deleted: Optional[bool] = None + ai_description: Optional[str] = None + ai_tags: Optional[str] = None + + +class PhotoOut(BaseModel): + model_config = {"from_attributes": True} + + id: uuid.UUID + user_id: uuid.UUID + sha256: str + filename: str + mime_type: str + bucket_path: str + thumbnail_path: Optional[str] + preview_path: Optional[str] + width: Optional[int] + height: Optional[int] + size: int + taken_at: Optional[datetime] + created_at: datetime + uploaded_at: datetime + deleted: bool + is_favorite: bool + ai_description: Optional[str] + ai_tags: Optional[str] + + +class PhotoPage(BaseModel): + """Paginated list of photos.""" + items: list[PhotoOut] + total: int + next_cursor: Optional[str] = None + has_more: bool diff --git a/backend/app/schemas/sync.py b/backend/app/schemas/sync.py new file mode 100644 index 0000000000000000000000000000000000000000..be958b3401916affc1084750a3743069c77d67d1 --- /dev/null +++ b/backend/app/schemas/sync.py @@ -0,0 +1,33 @@ +""" +KeyStone – Sync Schemas +""" + +import uuid +from datetime import datetime +from typing import Optional +from pydantic import BaseModel + + +class SyncCheckRequest(BaseModel): + """Client sends an array of SHA256 hashes; server replies with which are missing.""" + hashes: list[str] + device_id: Optional[str] = None + + +class SyncCheckResponse(BaseModel): + """Hashes that the server does NOT have yet (need uploading).""" + missing_hashes: list[str] + existing_count: int + missing_count: int + + +class SyncStartRequest(BaseModel): + device_name: str + platform: str # android | ios | web + device_token: Optional[str] = None + + +class SyncStartResponse(BaseModel): + device_id: uuid.UUID + message: str + last_sync: Optional[datetime] diff --git a/backend/app/schemas/upload.py b/backend/app/schemas/upload.py new file mode 100644 index 0000000000000000000000000000000000000000..3a16abc50026369046f4ee52f9bf31ec2b5e1a1d --- /dev/null +++ b/backend/app/schemas/upload.py @@ -0,0 +1,43 @@ +""" +KeyStone – Upload Schemas +""" + +import uuid +from datetime import datetime +from typing import Optional +from pydantic import BaseModel + +from app.models.upload_job import UploadStatus + + +class UploadResponse(BaseModel): + """Returned after a successful upload.""" + job_id: uuid.UUID + photo_id: Optional[uuid.UUID] + status: UploadStatus + duplicate: bool + message: str + + +class BatchUploadItem(BaseModel): + sha256: str + filename: str + + +class BatchUploadRequest(BaseModel): + files: list[BatchUploadItem] + + +class BatchUploadResponseItem(BaseModel): + sha256: str + filename: str + status: UploadStatus + photo_id: Optional[uuid.UUID] + duplicate: bool + + +class BatchUploadResponse(BaseModel): + results: list[BatchUploadResponseItem] + uploaded: int + skipped: int + failed: int diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000000000000000000000000000000000000..e5328e3cb0f6f8337413cd776e5128c26ee4d67c --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,22 @@ +""" +KeyStone – User Schemas +""" + +import uuid +from datetime import datetime +from pydantic import BaseModel, EmailStr + + +class UserCreate(BaseModel): + supabase_id: str + email: EmailStr + + +class UserOut(BaseModel): + model_config = {"from_attributes": True} + + id: uuid.UUID + supabase_id: str + email: str + created_at: datetime + is_active: bool diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..9a04e3821a9637213070c4565b10279e9b7ecf1e --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ +"""KeyStone – Services Package""" diff --git a/backend/app/services/ai.py b/backend/app/services/ai.py new file mode 100644 index 0000000000000000000000000000000000000000..b36a78c5684dd648e96f075863a135272a7b1b34 --- /dev/null +++ b/backend/app/services/ai.py @@ -0,0 +1,120 @@ +""" +KeyStone – AI Services (Milestone 8 Scaffold) + +This module provides stub interfaces for all planned AI features. +Each function is wired to FastAPI BackgroundTasks and ready to be +filled with real model calls (CLIP, Tesseract, face_recognition, etc.). +""" + +import logging +from typing import Optional + +logger = logging.getLogger(__name__) + + +async def generate_clip_embedding(image_bytes: bytes) -> Optional[list[float]]: + """ + Generate a CLIP embedding vector for semantic image search. + + TODO: Implement with: + from transformers import CLIPProcessor, CLIPModel + model = CLIPModel.from_pretrained("openai/clip-vit-base-patch32") + processor = CLIPProcessor.from_pretrained("openai/clip-vit-base-patch32") + """ + logger.info("[AI] CLIP embedding requested (not yet implemented)") + return None + + +async def run_ocr(image_bytes: bytes) -> Optional[str]: + """ + Extract text from image using OCR. + + TODO: Implement with pytesseract or easyocr: + import pytesseract + from PIL import Image + import io + img = Image.open(io.BytesIO(image_bytes)) + return pytesseract.image_to_string(img) + """ + logger.info("[AI] OCR requested (not yet implemented)") + return None + + +async def detect_blur(image_bytes: bytes) -> Optional[float]: + """ + Return a blur score (higher = sharper). Blur detection using Laplacian variance. + + TODO: Implement with OpenCV: + import cv2 + import numpy as np + arr = np.frombuffer(image_bytes, np.uint8) + img = cv2.imdecode(arr, cv2.IMREAD_GRAYSCALE) + return float(cv2.Laplacian(img, cv2.CV_64F).var()) + """ + logger.info("[AI] Blur detection requested (not yet implemented)") + return None + + +async def generate_description(image_bytes: bytes) -> Optional[str]: + """ + Generate a natural-language description for the photo. + + TODO: Implement with a BLIP or LLaVA model: + from transformers import BlipProcessor, BlipForConditionalGeneration + """ + logger.info("[AI] Description generation requested (not yet implemented)") + return None + + +async def detect_faces(image_bytes: bytes) -> Optional[list[dict]]: + """ + Detect and encode faces for clustering. + + TODO: Implement with face_recognition or deepface: + import face_recognition + img = face_recognition.load_image_file(io.BytesIO(image_bytes)) + return face_recognition.face_locations(img) + """ + logger.info("[AI] Face detection requested (not yet implemented)") + return None + + +async def auto_tag(image_bytes: bytes) -> list[str]: + """ + Generate tags for the photo (objects, scene, etc.). + + TODO: Implement with CLIP zero-shot classification: + candidate_labels = ["beach", "mountain", "food", "people", ...] + """ + logger.info("[AI] Auto-tagging requested (not yet implemented)") + return [] + + +async def process_photo_ai(photo_id: str, image_bytes: bytes) -> dict: + """ + Run the full AI processing pipeline on a photo. + Called as a background task after successful upload. + + Returns a dict of all AI results to be stored in the Photo record. + """ + results = {} + + description = await generate_description(image_bytes) + if description: + results["ai_description"] = description + + tags = await auto_tag(image_bytes) + if tags: + import json + results["ai_tags"] = json.dumps(tags) + + embedding = await generate_clip_embedding(image_bytes) + if embedding: + import json + results["clip_embedding"] = json.dumps(embedding) + + blur_score = await detect_blur(image_bytes) + if blur_score is not None: + logger.info("[AI] Photo %s blur score: %.2f", photo_id, blur_score) + + return results diff --git a/backend/app/services/dedup.py b/backend/app/services/dedup.py new file mode 100644 index 0000000000000000000000000000000000000000..3addd2e511c798b094377973aee6bef3a71db81b --- /dev/null +++ b/backend/app/services/dedup.py @@ -0,0 +1,63 @@ +""" +KeyStone – Deduplication Service +Checks SHA256 hashes against existing photos to prevent duplicate uploads. +""" + +import hashlib +import logging +import uuid +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.photo import Photo + +logger = logging.getLogger(__name__) + + +async def is_duplicate( + db: AsyncSession, + user_id: uuid.UUID, + sha256: str, +) -> Optional[Photo]: + """ + Check if a photo with the given SHA256 hash already exists for this user. + Returns the existing Photo if found, else None. + """ + result = await db.execute( + select(Photo).where( + Photo.user_id == user_id, + Photo.sha256 == sha256, + Photo.deleted == False, # noqa: E712 + ) + ) + return result.scalar_one_or_none() + + +async def filter_missing_hashes( + db: AsyncSession, + user_id: uuid.UUID, + hashes: list[str], +) -> list[str]: + """ + Given a list of SHA256 hashes, return only the ones NOT already stored + for this user. Used by the sync check endpoint. + """ + if not hashes: + return [] + + result = await db.execute( + select(Photo.sha256).where( + Photo.user_id == user_id, + Photo.sha256.in_(hashes), + Photo.deleted == False, # noqa: E712 + ) + ) + existing_hashes = {row[0] for row in result.fetchall()} + return [h for h in hashes if h not in existing_hashes] + + +def compute_sha256(data: bytes) -> str: + """Compute SHA256 hash of raw bytes.""" + return hashlib.sha256(data).hexdigest() diff --git a/backend/app/services/storage.py b/backend/app/services/storage.py new file mode 100644 index 0000000000000000000000000000000000000000..154bfb5aa4079f2086d813be6ccbe9c27c71e796 --- /dev/null +++ b/backend/app/services/storage.py @@ -0,0 +1,131 @@ +""" +KeyStone – Storage Service +Handles uploads to Hugging Face Dataset repository (S3-compatible). +""" + +import io +import logging +import uuid +from pathlib import PurePosixPath + +import boto3 +from botocore.config import Config +from botocore.exceptions import BotoCoreError, ClientError + +from app.config import get_settings + +logger = logging.getLogger(__name__) +settings = get_settings() + + +def _get_s3_client(): + """Create a boto3 S3 client pointed at Hugging Face's S3-compatible endpoint.""" + return boto3.client( + "s3", + endpoint_url=f"https://huggingface.co/datasets/{settings.hf_dataset_repo}/resolve/main", + aws_access_key_id=settings.hf_token, + aws_secret_access_key=settings.hf_token, + config=Config(signature_version="v4"), + region_name="us-east-1", + ) + + +def _get_hf_client(): + """Hugging Face Hub client for file uploads.""" + try: + from huggingface_hub import HfApi + return HfApi(token=settings.hf_token) + except ImportError: + raise RuntimeError("huggingface_hub package is required for storage.") + + +def _originals_path(user_id: str, photo_id: str, filename: str) -> str: + ext = PurePosixPath(filename).suffix.lower() + return f"originals/{user_id}/{photo_id}{ext}" + + +def _thumbnails_path(user_id: str, photo_id: str) -> str: + return f"thumbnails/{user_id}/{photo_id}.jpg" + + +def _previews_path(user_id: str, photo_id: str) -> str: + return f"previews/{user_id}/{photo_id}.jpg" + + +async def upload_original( + file_bytes: bytes, + filename: str, + mime_type: str, + user_id: uuid.UUID, + photo_id: uuid.UUID, +) -> str: + """ + Upload the original photo file to HF Dataset repository. + Returns the bucket path (relative key). + """ + api = _get_hf_client() + path = _originals_path(str(user_id), str(photo_id), filename) + + try: + api.upload_file( + path_or_fileobj=io.BytesIO(file_bytes), + path_in_repo=path, + repo_id=settings.hf_dataset_repo, + repo_type="dataset", + token=settings.hf_token, + ) + logger.info("Uploaded original: %s", path) + return path + except Exception as exc: + logger.error("Failed to upload original %s: %s", filename, exc) + raise RuntimeError(f"Storage upload failed: {exc}") from exc + + +async def upload_thumbnail( + thumbnail_bytes: bytes, + user_id: uuid.UUID, + photo_id: uuid.UUID, +) -> str: + """Upload thumbnail to HF Dataset repository. Returns the bucket path.""" + api = _get_hf_client() + path = _thumbnails_path(str(user_id), str(photo_id)) + + try: + api.upload_file( + path_or_fileobj=io.BytesIO(thumbnail_bytes), + path_in_repo=path, + repo_id=settings.hf_dataset_repo, + repo_type="dataset", + token=settings.hf_token, + ) + logger.info("Uploaded thumbnail: %s", path) + return path + except Exception as exc: + logger.error("Failed to upload thumbnail: %s", exc) + raise RuntimeError(f"Thumbnail upload failed: {exc}") from exc + + +def get_download_url(bucket_path: str) -> str: + """ + Build a public download URL for a file in the HF Dataset repo. + Format: https://huggingface.co/datasets/{repo}/resolve/main/{path} + """ + return ( + f"https://huggingface.co/datasets/{settings.hf_dataset_repo}" + f"/resolve/main/{bucket_path}" + ) + + +async def delete_file(bucket_path: str) -> None: + """Delete a file from the HF Dataset repository.""" + api = _get_hf_client() + try: + api.delete_file( + path_in_repo=bucket_path, + repo_id=settings.hf_dataset_repo, + repo_type="dataset", + token=settings.hf_token, + ) + logger.info("Deleted file: %s", bucket_path) + except Exception as exc: + logger.warning("Failed to delete %s: %s", bucket_path, exc) diff --git a/backend/app/services/thumbnail.py b/backend/app/services/thumbnail.py new file mode 100644 index 0000000000000000000000000000000000000000..00c158836a6e9db7dd2da6526a3213903b662f8e --- /dev/null +++ b/backend/app/services/thumbnail.py @@ -0,0 +1,77 @@ +""" +KeyStone – Thumbnail Generation Service +Uses Pillow to generate resized JPEG thumbnails and previews. +""" + +import io +import logging +from typing import Optional, Tuple + +from PIL import Image, ImageOps + +from app.config import get_settings + +logger = logging.getLogger(__name__) +settings = get_settings() + + +def _open_image(file_bytes: bytes) -> Image.Image: + img = Image.open(io.BytesIO(file_bytes)) + # Auto-rotate based on EXIF orientation + img = ImageOps.exif_transpose(img) + # Convert to RGB (handles RGBA, palette, etc.) + if img.mode not in ("RGB", "L"): + img = img.convert("RGB") + return img + + +def generate_thumbnail( + file_bytes: bytes, + size: int = 0, + quality: int = 0, +) -> Tuple[bytes, int, int]: + """ + Generate a square-cropped thumbnail. + + Returns: + (jpeg_bytes, width, height) of the *original* image dimensions. + """ + size = size or settings.thumbnail_size + quality = quality or settings.thumbnail_quality + + img = _open_image(file_bytes) + orig_width, orig_height = img.size + + # Thumbnail (preserves aspect ratio, fits within size×size) + thumb = img.copy() + thumb.thumbnail((size, size), Image.LANCZOS) + + buf = io.BytesIO() + thumb.save(buf, format="JPEG", quality=quality, optimize=True) + return buf.getvalue(), orig_width, orig_height + + +def generate_preview( + file_bytes: bytes, + max_dimension: int = 1920, + quality: int = 80, +) -> bytes: + """ + Generate a full-resolution preview (max 1920px on the long edge, JPEG). + Used for the lightbox view in the gallery. + """ + img = _open_image(file_bytes) + img.thumbnail((max_dimension, max_dimension), Image.LANCZOS) + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=quality, optimize=True) + return buf.getvalue() + + +def get_image_dimensions(file_bytes: bytes) -> Optional[Tuple[int, int]]: + """Return (width, height) of image without fully decoding it.""" + try: + img = Image.open(io.BytesIO(file_bytes)) + return img.size + except Exception as exc: + logger.warning("Could not read image dimensions: %s", exc) + return None diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000000000000000000000000000000000000..78c5011f9d9ca225df04580db45e866a771e0c66 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +asyncio_mode = auto +testpaths = tests diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..7083296a791c714f261449968b215e328ceb5e98 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,37 @@ +# ── Web Framework ───────────────────────────────────────────────────────────── +fastapi==0.115.6 +uvicorn[standard]==0.32.1 +python-multipart==0.0.20 + +# ── Database ────────────────────────────────────────────────────────────────── +sqlalchemy[asyncio]==2.0.36 +asyncpg==0.30.0 +alembic==1.14.0 + +# ── Configuration ───────────────────────────────────────────────────────────── +pydantic==2.10.3 +pydantic-settings==2.7.0 +python-dotenv==1.0.1 + +# ── Authentication ──────────────────────────────────────────────────────────── +python-jose[cryptography]==3.3.0 +httpx==0.28.1 + +# ── Storage (S3-compatible / Hugging Face) ──────────────────────────────────── +boto3==1.35.88 +huggingface_hub==0.27.0 + +# ── Image Processing ────────────────────────────────────────────────────────── +Pillow==11.1.0 + +# ── Rate Limiting ───────────────────────────────────────────────────────────── +slowapi==0.1.9 + +# ── Utilities ───────────────────────────────────────────────────────────────── +python-magic==0.4.27 +aiofiles==24.1.0 + +# ── Testing ─────────────────────────────────────────────────────────────────── +pytest==8.3.4 +pytest-asyncio==0.25.2 +httpx==0.28.1 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..63791815567a20f3a687080393d6711cd068981f --- /dev/null +++ b/backend/tests/__init__.py @@ -0,0 +1 @@ +"""KeyStone – Tests Package""" diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py new file mode 100644 index 0000000000000000000000000000000000000000..2937d2a36c1af3060f3f429d7c4ca19e1c0fb9cf --- /dev/null +++ b/backend/tests/test_health.py @@ -0,0 +1,36 @@ +""" +KeyStone – Health Endpoint Tests +""" + +import pytest +from httpx import AsyncClient, ASGITransport + + +@pytest.mark.asyncio +async def test_health_endpoint(): + """Health endpoint should return 200 with status=ok.""" + from app.main import app + + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.get("/health") + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + assert "version" in data + assert "service" in data + + +@pytest.mark.asyncio +async def test_docs_available(): + """Swagger docs should be accessible.""" + from app.main import app + + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.get("/docs") + + assert response.status_code == 200 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..3d22acbec88b0f488107e208225cec95e8ada543 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +version: "3.9" + +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + ports: + - "7860:7860" + env_file: + - ./backend/.env + environment: + - ENVIRONMENT=development + - DEBUG=true + volumes: + - ./backend:/app + depends_on: + - db + restart: unless-stopped + + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: keystone + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + postgres_data: diff --git a/mobile/.env.example b/mobile/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..5e74db705a91868f96a2f7f4cccbb4124d031926 --- /dev/null +++ b/mobile/.env.example @@ -0,0 +1,10 @@ +# Mobile App Environment Variables +# Copy this file to .env and fill in your values + +# KeyStone Backend API URL +EXPO_PUBLIC_API_URL=https://dpv007-keystone.hf.space + +# Supabase +EXPO_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImtmZmFuaGtiZGJhYnhhbndubW1lIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODQyMTExNTYsImV4cCI6MjA5OTc4NzE1Nn0.W4LgjVQekUxlkz2ZPTg4BpckCBp_Z7QEKXN-XOH1CvE +EXPO_PUBLIC_SUPABASE_URL=https://kffanhkbdbabxanwnmme.supabase.co +EXPO_PUBLIC_SUPABASE_KEY=sb_publishable_pTshogCZaZas2Im0POcPDw_fkMdTqSe \ No newline at end of file diff --git a/mobile/app.json b/mobile/app.json new file mode 100644 index 0000000000000000000000000000000000000000..7b7ee4db48fd931329d13f8e23648853b5e5dd52 --- /dev/null +++ b/mobile/app.json @@ -0,0 +1,60 @@ +{ + "expo": { + "name": "KeyStone", + "slug": "keystone", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "scheme": "keystone", + "userInterfaceStyle": "automatic", + "splash": { + "image": "./assets/splash.png", + "resizeMode": "contain", + "backgroundColor": "#0f0f23" + }, + "assetBundlePatterns": ["**/*"], + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.dpv007.keystone", + "infoPlist": { + "NSPhotoLibraryUsageDescription": "KeyStone needs access to your photo library to back up your photos.", + "NSPhotoLibraryAddUsageDescription": "KeyStone needs access to save photos.", + "UIBackgroundModes": ["fetch", "processing"] + } + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#0f0f23" + }, + "package": "com.dpv007.keystone", + "permissions": [ + "android.permission.READ_MEDIA_IMAGES", + "android.permission.READ_MEDIA_VIDEO", + "android.permission.READ_EXTERNAL_STORAGE", + "android.permission.ACCESS_NETWORK_STATE", + "android.permission.RECEIVE_BOOT_COMPLETED", + "android.permission.FOREGROUND_SERVICE" + ] + }, + "web": { + "bundler": "metro", + "output": "static", + "favicon": "./assets/favicon.png" + }, + "plugins": [ + "expo-router", + [ + "expo-media-library", + { + "photosPermission": "Allow KeyStone to access your photos.", + "savePhotosPermission": "Allow KeyStone to save photos.", + "isAccessMediaLocationEnabled": true + } + ] + ], + "experiments": { + "typedRoutes": true + } + } +} diff --git a/mobile/app/(auth)/_layout.tsx b/mobile/app/(auth)/_layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..142087cda07f23b40077e08cc653b27a906a12c2 --- /dev/null +++ b/mobile/app/(auth)/_layout.tsx @@ -0,0 +1,12 @@ +/** + * KeyStone – Auth Layout + * Wraps all auth screens with a shared gradient background. + */ + +import { Stack } from 'expo-router'; + +export default function AuthLayout() { + return ( + + ); +} diff --git a/mobile/app/(auth)/forgot-password.tsx b/mobile/app/(auth)/forgot-password.tsx new file mode 100644 index 0000000000000000000000000000000000000000..027d5e5aacb95f4456e9ee5e1bf2d93f143cd199 --- /dev/null +++ b/mobile/app/(auth)/forgot-password.tsx @@ -0,0 +1,94 @@ +/** + * KeyStone – Forgot Password Screen + */ + +import { + View, Text, TextInput, TouchableOpacity, StyleSheet, + KeyboardAvoidingView, Platform, ScrollView, ActivityIndicator, +} from 'react-native'; +import { useState } from 'react'; +import { Link } from 'expo-router'; +import { LinearGradient } from 'expo-linear-gradient'; +import { useAuthStore } from '../../store/authStore'; + +export default function ForgotPasswordScreen() { + const [email, setEmail] = useState(''); + const [error, setError] = useState(''); + const [sent, setSent] = useState(false); + const { resetPassword, isLoading } = useAuthStore(); + + const handleReset = async () => { + setError(''); + if (!email) { setError('Please enter your email address.'); return; } + try { + await resetPassword(email); + setSent(true); + } catch (err: any) { + setError(err.message || 'Failed to send reset email.'); + } + }; + + return ( + + + + + + {sent ? '📨 Email sent!' : '🔑 Reset password'} + + {sent + ? `We sent a password reset link to ${email}.` + : "Enter your email and we'll send you a reset link."} + + + {!sent && ( + <> + {error ? {error} : null} + + Email + + + + + {isLoading ? : Send Reset Link} + + + + )} + + ← Back to Sign In + + + + + + ); +} + +const styles = StyleSheet.create({ + gradient: { flex: 1 }, + flex: { flex: 1 }, + scroll: { flexGrow: 1, justifyContent: 'center' }, + container: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 20 }, + card: { width: '100%', maxWidth: 420, backgroundColor: 'rgba(255,255,255,0.04)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.08)', borderRadius: 24, padding: 28 }, + cardTitle: { fontSize: 22, fontWeight: '700', color: '#fff', marginBottom: 8 }, + cardSubtitle: { fontSize: 14, color: '#8888aa', marginBottom: 24, lineHeight: 20 }, + errorBox: { backgroundColor: 'rgba(239,68,68,0.15)', borderWidth: 1, borderColor: 'rgba(239,68,68,0.3)', borderRadius: 10, padding: 12, marginBottom: 16 }, + errorText: { color: '#fca5a5', fontSize: 13 }, + inputGroup: { marginBottom: 16 }, + label: { fontSize: 13, fontWeight: '600', color: '#c4c4d4', marginBottom: 8 }, + input: { backgroundColor: 'rgba(255,255,255,0.06)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.1)', borderRadius: 12, paddingHorizontal: 16, paddingVertical: 13, color: '#fff', fontSize: 15 }, + btn: { borderRadius: 14, overflow: 'hidden', marginBottom: 20 }, + btnDisabled: { opacity: 0.6 }, + btnGradient: { paddingVertical: 15, alignItems: 'center', borderRadius: 14 }, + btnText: { color: '#fff', fontWeight: '700', fontSize: 16 }, + backLink: { color: '#7c3aed', fontSize: 14, textAlign: 'center', marginTop: 8 }, +}); diff --git a/mobile/app/(auth)/login.tsx b/mobile/app/(auth)/login.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e35341b7c4806cf0023b4721d9ec71f1ada6faf4 --- /dev/null +++ b/mobile/app/(auth)/login.tsx @@ -0,0 +1,207 @@ +/** + * KeyStone – Login Screen + * Works on Android, iOS, and Web (React Native Web). + */ + +import { + View, Text, TextInput, TouchableOpacity, StyleSheet, + KeyboardAvoidingView, Platform, ScrollView, ActivityIndicator, + Alert, Dimensions, +} from 'react-native'; +import { useState } from 'react'; +import { Link, useRouter } from 'expo-router'; +import { LinearGradient } from 'expo-linear-gradient'; +import { useAuthStore } from '../../store/authStore'; + +const { width } = Dimensions.get('window'); +const isWeb = Platform.OS === 'web'; + +export default function LoginScreen() { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const { signIn, isLoading } = useAuthStore(); + const router = useRouter(); + + const handleLogin = async () => { + setError(''); + if (!email || !password) { + setError('Please fill in all fields.'); + return; + } + try { + await signIn(email, password); + router.replace('/(tabs)/gallery'); + } catch (err: any) { + setError(err.message || 'Login failed. Please try again.'); + } + }; + + return ( + + + + + + {/* Logo / Brand */} + + + 🔷 + + KeyStone + Your private photo cloud + + + {/* Card */} + + Welcome back + Sign in to your account + + {error ? ( + + {error} + + ) : null} + + + Email + + + + + + Password + + Forgot password? + + + + + + + + {isLoading ? ( + + ) : ( + Sign In + )} + + + + + Don't have an account? + + Sign up + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + gradient: { flex: 1 }, + flex: { flex: 1 }, + scroll: { flexGrow: 1, justifyContent: 'center' }, + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 20, + paddingVertical: 40, + }, + logoArea: { alignItems: 'center', marginBottom: 40 }, + logoIcon: { + width: 72, height: 72, + borderRadius: 20, + backgroundColor: 'rgba(124,58,237,0.2)', + borderWidth: 1, + borderColor: 'rgba(124,58,237,0.5)', + alignItems: 'center', + justifyContent: 'center', + marginBottom: 12, + }, + logoEmoji: { fontSize: 36 }, + brandName: { fontSize: 32, fontWeight: '800', color: '#fff', letterSpacing: -0.5 }, + brandTagline: { fontSize: 14, color: '#8888aa', marginTop: 4 }, + + card: { + width: '100%', + maxWidth: 420, + backgroundColor: 'rgba(255,255,255,0.04)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.08)', + borderRadius: 24, + padding: 28, + }, + cardTitle: { fontSize: 24, fontWeight: '700', color: '#fff', marginBottom: 4 }, + cardSubtitle: { fontSize: 14, color: '#8888aa', marginBottom: 24 }, + + errorBox: { + backgroundColor: 'rgba(239,68,68,0.15)', + borderWidth: 1, + borderColor: 'rgba(239,68,68,0.3)', + borderRadius: 10, + padding: 12, + marginBottom: 16, + }, + errorText: { color: '#fca5a5', fontSize: 13 }, + + inputGroup: { marginBottom: 16 }, + labelRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }, + label: { fontSize: 13, fontWeight: '600', color: '#c4c4d4', marginBottom: 8 }, + forgotLink: { fontSize: 12, color: '#7c3aed' }, + input: { + backgroundColor: 'rgba(255,255,255,0.06)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.1)', + borderRadius: 12, + paddingHorizontal: 16, + paddingVertical: 13, + color: '#fff', + fontSize: 15, + }, + + btn: { borderRadius: 14, overflow: 'hidden', marginTop: 8 }, + btnDisabled: { opacity: 0.6 }, + btnGradient: { paddingVertical: 15, alignItems: 'center' }, + btnText: { color: '#fff', fontWeight: '700', fontSize: 16 }, + + footerRow: { flexDirection: 'row', justifyContent: 'center', marginTop: 20 }, + footerText: { color: '#8888aa', fontSize: 13 }, + footerLink: { color: '#7c3aed', fontWeight: '600', fontSize: 13 }, +}); diff --git a/mobile/app/(auth)/register.tsx b/mobile/app/(auth)/register.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6aa483033acd2c653568e0406b0a8c1033ff1ccc --- /dev/null +++ b/mobile/app/(auth)/register.tsx @@ -0,0 +1,176 @@ +/** + * KeyStone – Register Screen + */ + +import { + View, Text, TextInput, TouchableOpacity, StyleSheet, + KeyboardAvoidingView, Platform, ScrollView, ActivityIndicator, +} from 'react-native'; +import { useState } from 'react'; +import { Link, useRouter } from 'expo-router'; +import { LinearGradient } from 'expo-linear-gradient'; +import { useAuthStore } from '../../store/authStore'; + +export default function RegisterScreen() { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirm, setConfirm] = useState(''); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); + const { signUp, isLoading } = useAuthStore(); + const router = useRouter(); + + const handleRegister = async () => { + setError(''); + if (!email || !password || !confirm) { + setError('Please fill in all fields.'); + return; + } + if (password.length < 8) { + setError('Password must be at least 8 characters.'); + return; + } + if (password !== confirm) { + setError('Passwords do not match.'); + return; + } + try { + await signUp(email, password); + setSuccess(true); + } catch (err: any) { + setError(err.message || 'Registration failed. Please try again.'); + } + }; + + if (success) { + return ( + + + 📬 + Check your email! + + We've sent a confirmation link to {email}. Click it to activate your account. + + router.replace('/(auth)/login')}> + + Back to Sign In + + + + + ); + } + + return ( + + + + + + + 🔷 + + KeyStone + Create your private cloud + + + + Create account + Start backing up your photos today + + {error ? ( + + {error} + + ) : null} + + + Email + + + + + Password + + + + + Confirm Password + + + + + + {isLoading ? : Create Account} + + + + + Already have an account? + Sign in + + + + + + + ); +} + +const styles = StyleSheet.create({ + gradient: { flex: 1 }, + flex: { flex: 1 }, + scroll: { flexGrow: 1, justifyContent: 'center' }, + container: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 20, paddingVertical: 40 }, + logoArea: { alignItems: 'center', marginBottom: 32 }, + logoIcon: { width: 64, height: 64, borderRadius: 18, backgroundColor: 'rgba(124,58,237,0.2)', borderWidth: 1, borderColor: 'rgba(124,58,237,0.5)', alignItems: 'center', justifyContent: 'center', marginBottom: 10 }, + logoEmoji: { fontSize: 30 }, + brandName: { fontSize: 28, fontWeight: '800', color: '#fff' }, + brandTagline: { fontSize: 13, color: '#8888aa', marginTop: 4 }, + card: { width: '100%', maxWidth: 420, backgroundColor: 'rgba(255,255,255,0.04)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.08)', borderRadius: 24, padding: 28 }, + cardTitle: { fontSize: 22, fontWeight: '700', color: '#fff', marginBottom: 4 }, + cardSubtitle: { fontSize: 13, color: '#8888aa', marginBottom: 20 }, + errorBox: { backgroundColor: 'rgba(239,68,68,0.15)', borderWidth: 1, borderColor: 'rgba(239,68,68,0.3)', borderRadius: 10, padding: 12, marginBottom: 16 }, + errorText: { color: '#fca5a5', fontSize: 13 }, + inputGroup: { marginBottom: 14 }, + label: { fontSize: 13, fontWeight: '600', color: '#c4c4d4', marginBottom: 8 }, + input: { backgroundColor: 'rgba(255,255,255,0.06)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.1)', borderRadius: 12, paddingHorizontal: 16, paddingVertical: 13, color: '#fff', fontSize: 15 }, + btn: { borderRadius: 14, overflow: 'hidden', marginTop: 8 }, + btnDisabled: { opacity: 0.6 }, + btnGradient: { paddingVertical: 15, alignItems: 'center', borderRadius: 14 }, + btnText: { color: '#fff', fontWeight: '700', fontSize: 16 }, + footerRow: { flexDirection: 'row', justifyContent: 'center', marginTop: 20 }, + footerText: { color: '#8888aa', fontSize: 13 }, + footerLink: { color: '#7c3aed', fontWeight: '600', fontSize: 13 }, + successContainer: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 32 }, + successEmoji: { fontSize: 64, marginBottom: 20 }, + successTitle: { fontSize: 26, fontWeight: '800', color: '#fff', marginBottom: 12 }, + successText: { fontSize: 15, color: '#8888aa', textAlign: 'center', marginBottom: 32, lineHeight: 22 }, +}); diff --git a/mobile/app/(tabs)/_layout.tsx b/mobile/app/(tabs)/_layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9770ff0f342c89f9f120fc26e91a5aa14dbbf877 --- /dev/null +++ b/mobile/app/(tabs)/_layout.tsx @@ -0,0 +1,60 @@ +/** + * KeyStone – Tabs Layout + * Bottom tab navigator for Gallery, Albums, and Settings. + * On web, renders as a sidebar navigation instead. + */ + +import { Tabs } from 'expo-router'; +import { Platform, View, StyleSheet } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; + +const TAB_BAR_STYLE = { + backgroundColor: '#12122a', + borderTopColor: 'rgba(255,255,255,0.08)', + borderTopWidth: 1, + paddingBottom: Platform.OS === 'ios' ? 20 : 8, + paddingTop: 8, + height: Platform.OS === 'ios' ? 84 : 64, +}; + +export default function TabsLayout() { + return ( + + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + + ); +} diff --git a/mobile/app/(tabs)/albums.tsx b/mobile/app/(tabs)/albums.tsx new file mode 100644 index 0000000000000000000000000000000000000000..29ac7f4ff4d15e31eb923f0fe550d77084026d1b --- /dev/null +++ b/mobile/app/(tabs)/albums.tsx @@ -0,0 +1,182 @@ +/** + * KeyStone – Albums Screen + */ + +import { + View, Text, StyleSheet, TouchableOpacity, FlatList, + Modal, TextInput, ActivityIndicator, Alert, +} from 'react-native'; +import { useState } from 'react'; +import { Image } from 'expo-image'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Ionicons } from '@expo/vector-icons'; +import { LinearGradient } from 'expo-linear-gradient'; +import { useAlbums, useCreateAlbum } from '../../hooks/useAlbums'; +import { Album } from '../../services/api'; + +function AlbumCard({ album }: { album: Album }) { + return ( + + + {album.cover_photo_id ? ( + + ) : ( + + + + )} + + {album.photo_count} + + + {album.name} + {album.description ? ( + {album.description} + ) : null} + + ); +} + +function CreateAlbumModal({ + visible, + onClose, +}: { visible: boolean; onClose: () => void }) { + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const { mutate, isPending } = useCreateAlbum(); + + const handleCreate = () => { + if (!name.trim()) return; + mutate({ name: name.trim(), description: description.trim() || undefined }, { + onSuccess: () => { setName(''); setDescription(''); onClose(); }, + }); + }; + + return ( + + + + New Album + + + + + Cancel + + + + {isPending ? : Create} + + + + + + + ); +} + +export default function AlbumsScreen() { + const [showCreate, setShowCreate] = useState(false); + const { data: albums = [], isLoading, refetch, isRefetching } = useAlbums(); + + return ( + + + + + Albums + setShowCreate(true)}> + + + + + {isLoading ? ( + + + + ) : albums.length === 0 ? ( + + 🗂️ + No albums yet + Organize your photos into albums. + setShowCreate(true)}> + Create Album + + + ) : ( + } + keyExtractor={(item) => item.id} + numColumns={2} + columnWrapperStyle={styles.row} + contentContainerStyle={styles.grid} + showsVerticalScrollIndicator={false} + /> + )} + + setShowCreate(false)} /> + + + ); +} + +const styles = StyleSheet.create({ + safeArea: { flex: 1, backgroundColor: '#0f0f23' }, + container: { flex: 1, backgroundColor: '#0f0f23' }, + header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 14 }, + headerTitle: { fontSize: 24, fontWeight: '800', color: '#fff' }, + addBtn: { width: 36, height: 36, borderRadius: 10, backgroundColor: '#7c3aed', alignItems: 'center', justifyContent: 'center' }, + centered: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 32 }, + emptyEmoji: { fontSize: 56, marginBottom: 16 }, + emptyTitle: { fontSize: 20, fontWeight: '700', color: '#fff', marginBottom: 8 }, + emptyText: { fontSize: 14, color: '#8888aa', textAlign: 'center', marginBottom: 20 }, + emptyBtn: { backgroundColor: '#7c3aed', paddingHorizontal: 24, paddingVertical: 12, borderRadius: 12 }, + emptyBtnText: { color: '#fff', fontWeight: '700' }, + grid: { paddingHorizontal: 12, paddingBottom: 20 }, + row: { justifyContent: 'space-between', marginBottom: 16 }, + albumCard: { width: '48%', backgroundColor: 'rgba(255,255,255,0.04)', borderRadius: 16, overflow: 'hidden', borderWidth: 1, borderColor: 'rgba(255,255,255,0.06)' }, + albumCover: { width: '100%', aspectRatio: 1, backgroundColor: '#1a1a2e', position: 'relative' }, + albumImage: { width: '100%', height: '100%' }, + albumImagePlaceholder: { width: '100%', height: '100%', alignItems: 'center', justifyContent: 'center' }, + albumOverlay: { position: 'absolute', bottom: 6, right: 8 }, + albumCount: { color: 'rgba(255,255,255,0.7)', fontSize: 11, fontWeight: '700' }, + albumName: { color: '#fff', fontWeight: '700', fontSize: 14, padding: 10, paddingBottom: 2 }, + albumDesc: { color: '#8888aa', fontSize: 12, paddingHorizontal: 10, paddingBottom: 10 }, + modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.7)', justifyContent: 'flex-end' }, + modalCard: { backgroundColor: '#1a1a2e', borderTopLeftRadius: 24, borderTopRightRadius: 24, padding: 24, paddingBottom: 40 }, + modalTitle: { color: '#fff', fontSize: 20, fontWeight: '700', marginBottom: 20 }, + input: { backgroundColor: 'rgba(255,255,255,0.06)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.1)', borderRadius: 12, paddingHorizontal: 16, paddingVertical: 12, color: '#fff', fontSize: 15, marginBottom: 12 }, + inputDesc: { height: 80, textAlignVertical: 'top' }, + modalActions: { flexDirection: 'row', gap: 10, marginTop: 8 }, + cancelBtn: { flex: 1, paddingVertical: 14, borderRadius: 12, borderWidth: 1, borderColor: 'rgba(255,255,255,0.1)', alignItems: 'center' }, + cancelBtnText: { color: '#8888aa', fontWeight: '600' }, + createBtn: { flex: 1, borderRadius: 12, overflow: 'hidden' }, + createBtnGradient: { paddingVertical: 14, alignItems: 'center' }, + createBtnText: { color: '#fff', fontWeight: '700' }, +}); diff --git a/mobile/app/(tabs)/gallery.tsx b/mobile/app/(tabs)/gallery.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a5b000000609e79e5c53af9818f16360942f5afa --- /dev/null +++ b/mobile/app/(tabs)/gallery.tsx @@ -0,0 +1,270 @@ +/** + * KeyStone – Gallery Screen + * Infinite-scroll photo grid with timeline grouping. + * Works on mobile (native FlashList) and web (CSS grid fallback). + */ + +import { + View, Text, StyleSheet, TouchableOpacity, FlatList, + Dimensions, RefreshControl, Platform, TextInput, ActivityIndicator, +} from 'react-native'; +import { useState, useCallback, useRef } from 'react'; +import { Image } from 'expo-image'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Ionicons } from '@expo/vector-icons'; +import { useInfinitePhotos, useSearchPhotos } from '../../hooks/usePhotos'; +import { useSyncStore } from '../../store/syncStore'; +import { runSync } from '../../services/syncEngine'; +import { Photo } from '../../services/api'; + +const { width } = Dimensions.get('window'); +const isWeb = Platform.OS === 'web'; + +// Responsive column count +const getNumColumns = () => { + if (isWeb) { + const w = typeof window !== 'undefined' ? window.innerWidth : 1024; + if (w > 1200) return 6; + if (w > 768) return 4; + return 3; + } + return width > 600 ? 4 : 3; +}; + +const NUM_COLS = getNumColumns(); +const ITEM_SIZE = Math.floor((width - (NUM_COLS + 1) * 2) / NUM_COLS); + +function PhotoItem({ photo, onPress }: { photo: Photo; onPress: () => void }) { + return ( + + + {photo.is_favorite && ( + + + + )} + + ); +} + +function SyncBanner() { + const { isRunning, uploaded, totalPhotos } = useSyncStore(); + if (!isRunning) return null; + return ( + + + + Syncing… {uploaded}/{totalPhotos} photos + + + ); +} + +export default function GalleryScreen() { + const [searchQuery, setSearchQuery] = useState(''); + const [isSearching, setIsSearching] = useState(false); + + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + refetch, + isRefetching, + } = useInfinitePhotos(); + + const { data: searchData, isLoading: isSearchLoading } = useSearchPhotos( + isSearching ? searchQuery : '', + ); + + const photos: Photo[] = isSearching + ? (searchData?.items || []) + : (data?.pages?.flatMap(p => p.items) || []); + + const handleSyncNow = useCallback(async () => { + runSync(); + }, []); + + const renderItem = ({ item }: { item: Photo }) => ( + {}} /> + ); + + const renderFooter = () => { + if (!isFetchingNextPage) return null; + return ( + + + + ); + }; + + return ( + + + + {/* Header */} + + + KeyStone + + {photos.length > 0 ? `${data?.pages?.[0]?.total ?? 0} photos` : 'Your private cloud'} + + + + + + + + + + {/* Search Bar */} + + + + { setSearchQuery(t); setIsSearching(t.length > 0); }} + returnKeyType="search" + /> + {searchQuery.length > 0 && ( + { setSearchQuery(''); setIsSearching(false); }}> + + + )} + + + + {/* Sync Banner */} + + + {/* Photo Grid */} + {isLoading ? ( + + + Loading photos… + + ) : photos.length === 0 ? ( + + 📷 + No photos yet + Tap the sync button to back up your camera roll. + + Start Sync + + + ) : ( + item.id} + numColumns={NUM_COLS} + key={`cols-${NUM_COLS}`} + contentContainerStyle={styles.grid} + columnWrapperStyle={styles.row} + onEndReached={() => { if (hasNextPage && !isFetchingNextPage) fetchNextPage(); }} + onEndReachedThreshold={0.5} + ListFooterComponent={renderFooter} + refreshControl={ + + } + showsVerticalScrollIndicator={false} + /> + )} + + + ); +} + +const styles = StyleSheet.create({ + safeArea: { flex: 1, backgroundColor: '#0f0f23' }, + container: { flex: 1, backgroundColor: '#0f0f23' }, + + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + }, + headerTitle: { fontSize: 24, fontWeight: '800', color: '#fff' }, + headerSubtitle: { fontSize: 12, color: '#555577', marginTop: 2 }, + headerActions: { flexDirection: 'row', gap: 8 }, + iconBtn: { + width: 36, height: 36, + borderRadius: 10, + backgroundColor: 'rgba(255,255,255,0.06)', + alignItems: 'center', + justifyContent: 'center', + }, + + searchRow: { paddingHorizontal: 16, paddingBottom: 12 }, + searchBar: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(255,255,255,0.06)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.08)', + borderRadius: 12, + paddingHorizontal: 12, + paddingVertical: 10, + }, + searchInput: { flex: 1, color: '#fff', fontSize: 14 }, + + syncBanner: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(124,58,237,0.12)', + borderBottomWidth: 1, + borderBottomColor: 'rgba(124,58,237,0.2)', + paddingHorizontal: 16, + paddingVertical: 10, + gap: 8, + }, + syncText: { color: '#c4a8ff', fontSize: 13 }, + + grid: { paddingHorizontal: 2, paddingBottom: 20 }, + row: { gap: 2, marginBottom: 2 }, + photoItem: { + flex: isWeb ? 1 : undefined, + aspectRatio: 1, + backgroundColor: '#1a1a2e', + borderRadius: 4, + overflow: 'hidden', + }, + photoImage: { width: '100%', height: '100%' }, + favBadge: { + position: 'absolute', + top: 4, right: 4, + backgroundColor: 'rgba(239,68,68,0.85)', + borderRadius: 8, + padding: 3, + }, + + centered: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 32 }, + loadingText: { color: '#8888aa', marginTop: 12, fontSize: 14 }, + emptyEmoji: { fontSize: 64, marginBottom: 16 }, + emptyTitle: { fontSize: 22, fontWeight: '700', color: '#fff', marginBottom: 8 }, + emptyText: { fontSize: 14, color: '#8888aa', textAlign: 'center', lineHeight: 20, marginBottom: 24 }, + emptyBtn: { backgroundColor: '#7c3aed', paddingHorizontal: 28, paddingVertical: 13, borderRadius: 14 }, + emptyBtnText: { color: '#fff', fontWeight: '700', fontSize: 15 }, + + loaderFooter: { paddingVertical: 20, alignItems: 'center' }, +}); diff --git a/mobile/app/(tabs)/settings.tsx b/mobile/app/(tabs)/settings.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d4db88d3909d473d7acef4f440deba1b93c09831 --- /dev/null +++ b/mobile/app/(tabs)/settings.tsx @@ -0,0 +1,258 @@ +/** + * KeyStone – Settings Screen + */ + +import { + View, Text, StyleSheet, TouchableOpacity, Switch, + ScrollView, Alert, Platform, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Ionicons } from '@expo/vector-icons'; +import { useAuthStore } from '../../store/authStore'; +import { useSyncStore } from '../../store/syncStore'; +import { runSync, pauseSync, resumeSync } from '../../services/syncEngine'; +import { healthCheck } from '../../services/api'; +import { useState, useEffect } from 'react'; + +function SettingRow({ + icon, label, sublabel, children, onPress, dangerous, +}: { + icon: string; + label: string; + sublabel?: string; + children?: React.ReactNode; + onPress?: () => void; + dangerous?: boolean; +}) { + return ( + + + + + + {label} + {sublabel ? {sublabel} : null} + + {children} + {!children && onPress && ( + + )} + + ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( + + {title} + {children} + + ); +} + +export default function SettingsScreen() { + const { user, signOut } = useAuthStore(); + const { settings, updateSettings, isRunning, isPaused, uploaded, skipped, failed, totalPhotos } = useSyncStore(); + const [apiStatus, setApiStatus] = useState<'checking' | 'ok' | 'error'>('checking'); + + useEffect(() => { + healthCheck() + .then(() => setApiStatus('ok')) + .catch(() => setApiStatus('error')); + }, []); + + const handleSignOut = () => { + if (Platform.OS === 'web') { + signOut(); + } else { + Alert.alert('Sign Out', 'Are you sure you want to sign out?', [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Sign Out', style: 'destructive', onPress: signOut }, + ]); + } + }; + + const handleSyncToggle = () => { + if (isRunning) { + pauseSync(); + } else { + runSync(); + } + }; + + return ( + + + + {/* Header */} + + Settings + + + {/* Profile */} + + + + {user?.email?.[0]?.toUpperCase() ?? '?'} + + + + {user?.email ?? 'Unknown'} + + + + API {apiStatus === 'checking' ? 'connecting…' : apiStatus === 'ok' ? 'connected' : 'unreachable'} + + + + + + {/* Sync Stats */} + {(uploaded > 0 || skipped > 0 || failed > 0) && ( + + + {uploaded} + Uploaded + + + + {skipped} + Skipped + + + + 0 && { color: '#ef4444' }]}>{failed} + Failed + + + )} + + {/* Sync */} +
+ + updateSettings({ autoSync: v })} + trackColor={{ false: '#2a2a4a', true: '#7c3aed' }} + thumbColor="#fff" + /> + + + + updateSettings({ wifiOnly: v })} + trackColor={{ false: '#2a2a4a', true: '#7c3aed' }} + thumbColor="#fff" + /> + + + +
+ + {/* Quality */} +
+ {(['low', 'medium', 'high'] as const).map((q, i, arr) => ( + + updateSettings({ thumbnailQuality: q })} + > + {settings.thumbnailQuality === q && ( + + )} + + {i < arr.length - 1 && } + + ))} +
+ + {/* About */} +
+ + + +
+ + {/* Account */} +
+ +
+ + +
+
+ ); +} + +const styles = StyleSheet.create({ + safeArea: { flex: 1, backgroundColor: '#0f0f23' }, + container: { flex: 1, backgroundColor: '#0f0f23' }, + header: { paddingHorizontal: 16, paddingVertical: 14 }, + headerTitle: { fontSize: 24, fontWeight: '800', color: '#fff' }, + + profileCard: { + flexDirection: 'row', + alignItems: 'center', + marginHorizontal: 16, + marginBottom: 16, + backgroundColor: 'rgba(124,58,237,0.1)', + borderWidth: 1, + borderColor: 'rgba(124,58,237,0.2)', + borderRadius: 16, + padding: 16, + gap: 14, + }, + avatar: { width: 48, height: 48, borderRadius: 24, backgroundColor: '#7c3aed', alignItems: 'center', justifyContent: 'center' }, + avatarText: { color: '#fff', fontWeight: '800', fontSize: 20 }, + profileInfo: { flex: 1 }, + profileEmail: { color: '#fff', fontWeight: '600', fontSize: 15 }, + statusRow: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 4 }, + statusDot: { width: 7, height: 7, borderRadius: 4, backgroundColor: '#555577' }, + statusDotGreen: { backgroundColor: '#22c55e' }, + statusDotRed: { backgroundColor: '#ef4444' }, + statusText: { color: '#8888aa', fontSize: 12 }, + + statsRow: { + flexDirection: 'row', + marginHorizontal: 16, + marginBottom: 16, + backgroundColor: 'rgba(255,255,255,0.04)', + borderRadius: 14, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.06)', + padding: 14, + }, + statItem: { flex: 1, alignItems: 'center' }, + statNum: { color: '#7c3aed', fontSize: 22, fontWeight: '800' }, + statLabel: { color: '#8888aa', fontSize: 11, marginTop: 2 }, + statDivider: { width: 1, backgroundColor: 'rgba(255,255,255,0.06)' }, + + section: { marginBottom: 16 }, + sectionTitle: { color: '#555577', fontSize: 11, fontWeight: '700', letterSpacing: 1, textTransform: 'uppercase', marginHorizontal: 16, marginBottom: 8 }, + sectionCard: { marginHorizontal: 16, backgroundColor: 'rgba(255,255,255,0.04)', borderRadius: 16, borderWidth: 1, borderColor: 'rgba(255,255,255,0.06)', overflow: 'hidden' }, + settingRow: { flexDirection: 'row', alignItems: 'center', padding: 14, gap: 12 }, + settingIcon: { width: 34, height: 34, borderRadius: 9, backgroundColor: 'rgba(124,58,237,0.15)', alignItems: 'center', justifyContent: 'center' }, + settingIconDanger: { backgroundColor: 'rgba(239,68,68,0.12)' }, + settingInfo: { flex: 1 }, + settingLabel: { color: '#e0e0f0', fontWeight: '600', fontSize: 14 }, + settingSubLabel: { color: '#555577', fontSize: 12, marginTop: 2 }, + divider: { height: 1, backgroundColor: 'rgba(255,255,255,0.04)', marginLeft: 60 }, +}); diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..292e9b0afbc16f6de66fc8339e4fc1c7ba741833 --- /dev/null +++ b/mobile/app/_layout.tsx @@ -0,0 +1,60 @@ +/** + * KeyStone – Root Layout + * Sets up QueryClient, auth initialization, and route guarding. + */ + +import { useEffect } from 'react'; +import { Stack, useRouter, useSegments } from 'expo-router'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { StatusBar } from 'expo-status-bar'; +import { useAuthStore } from '../store/authStore'; +import { registerBackgroundSync } from '../services/backgroundSync'; +import { Platform } from 'react-native'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 2, // 2 minutes + retry: 2, + }, + }, +}); + +function AuthGuard({ children }: { children: React.ReactNode }) { + const { session, isInitialized, initialize } = useAuthStore(); + const segments = useSegments(); + const router = useRouter(); + + useEffect(() => { + initialize(); + }, []); + + useEffect(() => { + if (!isInitialized) return; + + const inAuthGroup = segments[0] === '(auth)'; + + if (!session && !inAuthGroup) { + router.replace('/(auth)/login'); + } else if (session && inAuthGroup) { + router.replace('/(tabs)/gallery'); + // Register background sync on native + if (Platform.OS !== 'web') { + registerBackgroundSync(); + } + } + }, [session, isInitialized, segments]); + + return <>{children}; +} + +export default function RootLayout() { + return ( + + + + + + + ); +} diff --git a/mobile/hooks/useAlbums.ts b/mobile/hooks/useAlbums.ts new file mode 100644 index 0000000000000000000000000000000000000000..c912dafa7c80201d1830b168231337fb6133ddcd --- /dev/null +++ b/mobile/hooks/useAlbums.ts @@ -0,0 +1,49 @@ +/** + * KeyStone – Albums React Query Hooks + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { listAlbums, createAlbum, getAlbum, addPhotosToAlbum, Album } from '../services/api'; + +export const albumKeys = { + all: ['albums'] as const, + lists: () => [...albumKeys.all, 'list'] as const, + detail: (id: string) => [...albumKeys.all, id] as const, +}; + +export function useAlbums() { + return useQuery({ + queryKey: albumKeys.lists(), + queryFn: listAlbums, + staleTime: 1000 * 60 * 2, + }); +} + +export function useAlbum(id: string) { + return useQuery({ + queryKey: albumKeys.detail(id), + queryFn: () => getAlbum(id), + enabled: !!id, + }); +} + +export function useCreateAlbum() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (data: { name: string; description?: string }) => createAlbum(data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: albumKeys.lists() }); + }, + }); +} + +export function useAddPhotosToAlbum() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ albumId, photoIds }: { albumId: string; photoIds: string[] }) => + addPhotosToAlbum(albumId, photoIds), + onSuccess: (_, { albumId }) => { + qc.invalidateQueries({ queryKey: albumKeys.detail(albumId) }); + }, + }); +} diff --git a/mobile/hooks/usePhotos.ts b/mobile/hooks/usePhotos.ts new file mode 100644 index 0000000000000000000000000000000000000000..add11ef57c034bb646a802b831c929798ee4a7a7 --- /dev/null +++ b/mobile/hooks/usePhotos.ts @@ -0,0 +1,70 @@ +/** + * KeyStone – Photos React Query Hooks + */ + +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { listPhotos, getPhoto, deletePhoto, updatePhoto, searchPhotos, Photo } from '../services/api'; + +// ── Keys ─────────────────────────────────────────────────────────────────────── +export const photoKeys = { + all: ['photos'] as const, + lists: () => [...photoKeys.all, 'list'] as const, + list: (filters?: object) => [...photoKeys.lists(), filters] as const, + detail: (id: string) => [...photoKeys.all, id] as const, + search: (q: string) => [...photoKeys.all, 'search', q] as const, +}; + +// ── Infinite Gallery ─────────────────────────────────────────────────────────── +export function useInfinitePhotos(favoritesOnly = false) { + return useInfiniteQuery({ + queryKey: photoKeys.list({ favoritesOnly }), + queryFn: ({ pageParam }) => + listPhotos({ limit: 60, cursor: pageParam as string | undefined, favorites_only: favoritesOnly }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => + lastPage.has_more ? lastPage.next_cursor ?? undefined : undefined, + staleTime: 1000 * 60, + }); +} + +// ── Single Photo ─────────────────────────────────────────────────────────────── +export function usePhoto(id: string) { + return useQuery({ + queryKey: photoKeys.detail(id), + queryFn: () => getPhoto(id), + enabled: !!id, + }); +} + +// ── Search ───────────────────────────────────────────────────────────────────── +export function useSearchPhotos(q: string) { + return useQuery({ + queryKey: photoKeys.search(q), + queryFn: () => searchPhotos(q), + enabled: q.length > 0, + staleTime: 1000 * 30, + }); +} + +// ── Delete Photo ─────────────────────────────────────────────────────────────── +export function useDeletePhoto() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => deletePhoto(id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: photoKeys.lists() }); + }, + }); +} + +// ── Toggle Favorite ──────────────────────────────────────────────────────────── +export function useToggleFavorite() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, is_favorite }: { id: string; is_favorite: boolean }) => + updatePhoto(id, { is_favorite }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: photoKeys.lists() }); + }, + }); +} diff --git a/mobile/hooks/useSync.ts b/mobile/hooks/useSync.ts new file mode 100644 index 0000000000000000000000000000000000000000..3ba02fc26c2b40f5335adfc37bb28c24d4f6bcbc --- /dev/null +++ b/mobile/hooks/useSync.ts @@ -0,0 +1,26 @@ +/** + * KeyStone – Sync Hook + */ + +import { useMutation } from '@tanstack/react-query'; +import { runSync, pauseSync, resumeSync } from '../services/syncEngine'; +import { useSyncStore } from '../store/syncStore'; + +export function useSync() { + const { isRunning, isPaused, uploaded, skipped, failed, totalPhotos } = useSyncStore(); + + const startMutation = useMutation({ mutationFn: runSync }); + + return { + isRunning, + isPaused, + uploaded, + skipped, + failed, + totalPhotos, + startSync: startMutation.mutate, + pauseSync, + resumeSync, + isStarting: startMutation.isPending, + }; +} diff --git a/mobile/package.json b/mobile/package.json new file mode 100644 index 0000000000000000000000000000000000000000..963cd1a2acfba73717e254bb261b08236ec35a17 --- /dev/null +++ b/mobile/package.json @@ -0,0 +1,44 @@ +{ + "name": "keystone", + "version": "1.0.0", + "main": "expo-router/entry", + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web", + "lint": "expo lint" + }, + "dependencies": { + "@expo/vector-icons": "^14.0.2", + "@react-native-async-storage/async-storage": "^1.23.1", + "@supabase/supabase-js": "^2.47.10", + "@tanstack/react-query": "^5.62.3", + "expo": "~52.0.20", + "expo-background-fetch": "~12.0.1", + "expo-blur": "~14.0.1", + "expo-constants": "~17.0.3", + "expo-crypto": "~14.0.1", + "expo-file-system": "~18.0.6", + "expo-image": "~2.0.3", + "expo-linear-gradient": "~14.0.1", + "expo-media-library": "~16.0.5", + "expo-network": "~7.0.1", + "expo-router": "~4.0.14", + "expo-splash-screen": "~0.29.18", + "expo-status-bar": "~2.0.0", + "expo-task-manager": "~12.0.3", + "react": "18.3.1", + "react-native": "0.76.5", + "react-native-gesture-handler": "~2.20.2", + "react-native-reanimated": "~3.16.1", + "react-native-safe-area-context": "4.12.0", + "react-native-screens": "~4.4.0", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@babel/core": "^7.25.2", + "@types/react": "~18.3.12", + "typescript": "^5.3.3" + } +} diff --git a/mobile/services/api.ts b/mobile/services/api.ts new file mode 100644 index 0000000000000000000000000000000000000000..f7b42ecfe2082687c247a76f1f24dd63b2cfb7aa --- /dev/null +++ b/mobile/services/api.ts @@ -0,0 +1,164 @@ +/** + * KeyStone – API Client + * Thin wrapper around fetch that attaches the Supabase JWT automatically. + */ + +import { supabase, API_URL } from './supabase'; + +class ApiError extends Error { + constructor(public status: number, message: string) { + super(message); + this.name = 'ApiError'; + } +} + +async function getAuthHeader(): Promise> { + const { data: { session } } = await supabase.auth.getSession(); + if (!session?.access_token) { + throw new ApiError(401, 'Not authenticated'); + } + return { Authorization: `Bearer ${session.access_token}` }; +} + +async function request( + path: string, + options: RequestInit = {}, +): Promise { + const authHeader = await getAuthHeader(); + const url = `${API_URL}${path}`; + + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...authHeader, + ...options.headers, + }, + }); + + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new ApiError(response.status, body.detail || `HTTP ${response.status}`); + } + + if (response.status === 204) return undefined as T; + return response.json(); +} + +// ── Auth ────────────────────────────────────────────────────────────────────── +export const verifyToken = () => request('/auth/verify', { method: 'POST' }); + +// ── Sync ────────────────────────────────────────────────────────────────────── +export const syncStart = (data: { device_name: string; platform: string; device_token?: string }) => + request('/sync/start', { method: 'POST', body: JSON.stringify(data) }); + +export const syncCheck = (hashes: string[]) => + request<{ missing_hashes: string[]; existing_count: number; missing_count: number }>( + '/sync/check', + { method: 'POST', body: JSON.stringify({ hashes }) }, + ); + +// ── Photos ──────────────────────────────────────────────────────────────────── +export const listPhotos = (params?: { limit?: number; cursor?: string; favorites_only?: boolean }) => { + const qs = new URLSearchParams(); + if (params?.limit) qs.set('limit', String(params.limit)); + if (params?.cursor) qs.set('cursor', params.cursor); + if (params?.favorites_only) qs.set('favorites_only', 'true'); + return request<{ items: Photo[]; total: number; next_cursor: string | null; has_more: boolean }>( + `/photos?${qs.toString()}`, + ); +}; + +export const getPhoto = (id: string) => request(`/photos/${id}`); + +export const deletePhoto = (id: string) => + request(`/photos/${id}`, { method: 'DELETE' }); + +export const updatePhoto = (id: string, data: Partial) => + request(`/photos/${id}`, { method: 'PATCH', body: JSON.stringify(data) }); + +// ── Upload ──────────────────────────────────────────────────────────────────── +export const uploadPhoto = async ( + fileUri: string, + filename: string, + mimeType: string, + sha256: string, + takenAt?: string, +) => { + const authHeader = await getAuthHeader(); + const formData = new FormData(); + formData.append('file', { uri: fileUri, name: filename, type: mimeType } as any); + formData.append('sha256', sha256); + if (takenAt) formData.append('taken_at', takenAt); + + const response = await fetch(`${API_URL}/upload`, { + method: 'POST', + headers: authHeader, + body: formData, + }); + + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new ApiError(response.status, body.detail || 'Upload failed'); + } + return response.json(); +}; + +// ── Albums ──────────────────────────────────────────────────────────────────── +export const listAlbums = () => + request('/albums'); + +export const createAlbum = (data: { name: string; description?: string }) => + request('/albums', { method: 'POST', body: JSON.stringify(data) }); + +export const getAlbum = (id: string) => + request(`/albums/${id}`); + +export const addPhotosToAlbum = (albumId: string, photoIds: string[]) => + request(`/albums/${albumId}/photos`, { + method: 'POST', + body: JSON.stringify({ photo_ids: photoIds }), + }); + +// ── Search ──────────────────────────────────────────────────────────────────── +export const searchPhotos = (q: string, limit = 50) => + request<{ items: Photo[]; total: number; has_more: boolean }>( + `/search?q=${encodeURIComponent(q)}&limit=${limit}`, + ); + +// ── Health ──────────────────────────────────────────────────────────────────── +export const healthCheck = () => + fetch(`${API_URL}/health`).then(r => r.json()); + +// ── Types ───────────────────────────────────────────────────────────────────── +export interface Photo { + id: string; + user_id: string; + sha256: string; + filename: string; + mime_type: string; + bucket_path: string; + thumbnail_path: string | null; + preview_path: string | null; + width: number | null; + height: number | null; + size: number; + taken_at: string | null; + created_at: string; + uploaded_at: string; + deleted: boolean; + is_favorite: boolean; + ai_description: string | null; + ai_tags: string | null; +} + +export interface Album { + id: string; + user_id: string; + name: string; + description: string | null; + cover_photo_id: string | null; + created_at: string; + updated_at: string; + photo_count: number; +} diff --git a/mobile/services/backgroundSync.ts b/mobile/services/backgroundSync.ts new file mode 100644 index 0000000000000000000000000000000000000000..7ffb98acb48f447e27d9823bb83960ca96f79d39 --- /dev/null +++ b/mobile/services/backgroundSync.ts @@ -0,0 +1,73 @@ +/** + * KeyStone – Background Sync + * Registers an Expo Background Fetch task that runs the sync engine + * periodically even when the app is in the background. + */ + +import * as BackgroundFetch from 'expo-background-fetch'; +import * as TaskManager from 'expo-task-manager'; +import * as Battery from 'expo-battery'; + +import { runSync } from './syncEngine'; +import { useSyncStore } from '../store/syncStore'; + +export const BACKGROUND_SYNC_TASK = 'KEYSTONE_BACKGROUND_SYNC'; + +// ── Task Definition ─────────────────────────────────────────────────────────── +TaskManager.defineTask(BACKGROUND_SYNC_TASK, async () => { + console.log('[BackgroundSync] Task fired'); + const { settings } = useSyncStore.getState(); + + if (!settings.autoSync) { + return BackgroundFetch.BackgroundFetchResult.NoData; + } + + try { + // Skip if battery is critically low (< 15%) + try { + const batteryLevel = await Battery.getBatteryLevelAsync(); + const batteryState = await Battery.getBatteryStateAsync(); + const isCharging = batteryState === Battery.BatteryState.CHARGING || + batteryState === Battery.BatteryState.FULL; + + if (batteryLevel < 0.15 && !isCharging) { + console.log('[BackgroundSync] Battery too low – skipping'); + return BackgroundFetch.BackgroundFetchResult.NoData; + } + } catch { + // expo-battery may not be available on all platforms + } + + await runSync(); + return BackgroundFetch.BackgroundFetchResult.NewData; + } catch (err) { + console.error('[BackgroundSync] Error:', err); + return BackgroundFetch.BackgroundFetchResult.Failed; + } +}); + +// ── Registration ────────────────────────────────────────────────────────────── +export async function registerBackgroundSync(): Promise { + const isRegistered = await TaskManager.isTaskRegisteredAsync(BACKGROUND_SYNC_TASK); + if (isRegistered) return; + + try { + await BackgroundFetch.registerTaskAsync(BACKGROUND_SYNC_TASK, { + minimumInterval: 15 * 60, // 15 minutes minimum (iOS may enforce longer) + stopOnTerminate: false, + startOnBoot: true, + }); + console.log('[BackgroundSync] Registered successfully'); + } catch (err) { + console.warn('[BackgroundSync] Registration failed:', err); + } +} + +export async function unregisterBackgroundSync(): Promise { + try { + await BackgroundFetch.unregisterTaskAsync(BACKGROUND_SYNC_TASK); + console.log('[BackgroundSync] Unregistered'); + } catch { + // Task may not be registered + } +} diff --git a/mobile/services/hashUtil.ts b/mobile/services/hashUtil.ts new file mode 100644 index 0000000000000000000000000000000000000000..0da5331dfb9f64029f59959ec817baaa4e22a997 --- /dev/null +++ b/mobile/services/hashUtil.ts @@ -0,0 +1,38 @@ +/** + * KeyStone – Hash Utility + * Computes SHA256 of a local file using expo-crypto. + */ + +import * as Crypto from 'expo-crypto'; +import * as FileSystem from 'expo-file-system'; + +/** + * Compute the SHA256 hash of a file at the given URI. + * Reads the file as base64, then hashes it. + */ +export async function computeFileSHA256(uri: string): Promise { + // Read file as base64 + const base64 = await FileSystem.readAsStringAsync(uri, { + encoding: FileSystem.EncodingType.Base64, + }); + + // Convert base64 to binary string and hash + const digest = await Crypto.digestStringAsync( + Crypto.CryptoDigestAlgorithm.SHA256, + base64, + { encoding: Crypto.CryptoEncoding.HEX }, + ); + + return digest; +} + +/** + * Compute SHA256 from a base64-encoded string directly. + */ +export async function computeBase64SHA256(base64: string): Promise { + return Crypto.digestStringAsync( + Crypto.CryptoDigestAlgorithm.SHA256, + base64, + { encoding: Crypto.CryptoEncoding.HEX }, + ); +} diff --git a/mobile/services/supabase.ts b/mobile/services/supabase.ts new file mode 100644 index 0000000000000000000000000000000000000000..15f98ca1c351adcd13025f32fc7d19a59c78ea74 --- /dev/null +++ b/mobile/services/supabase.ts @@ -0,0 +1,16 @@ +import { createClient } from '@supabase/supabase-js'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL!; +const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!; + +export const supabase = createClient(supabaseUrl, supabaseAnonKey, { + auth: { + storage: AsyncStorage, + autoRefreshToken: true, + persistSession: true, + detectSessionInUrl: false, + }, +}); + +export const API_URL = process.env.EXPO_PUBLIC_API_URL || 'https://dpv007-keystone.hf.space'; diff --git a/mobile/services/syncEngine.ts b/mobile/services/syncEngine.ts new file mode 100644 index 0000000000000000000000000000000000000000..c597ee50c9cfe7f5dd6a4e3009e8e9d274946aeb --- /dev/null +++ b/mobile/services/syncEngine.ts @@ -0,0 +1,157 @@ +/** + * KeyStone – Sync Engine + * Core upload loop: + * 1. Request media library permission + * 2. Fetch all photos from camera roll + * 3. Compute SHA256 for each + * 4. POST /sync/check to get only missing hashes + * 5. Upload missing photos one by one + */ + +import * as MediaLibrary from 'expo-media-library'; +import * as Network from 'expo-network'; +import { Platform } from 'react-native'; + +import { syncStart, syncCheck, uploadPhoto } from './api'; +import { computeFileSHA256 } from './hashUtil'; +import { useSyncStore } from '../store/syncStore'; +import { useAuthStore } from '../store/authStore'; + +const BATCH_SIZE = 50; // hashes sent per /sync/check call + +/** + * Request media library permissions. + * Returns true if granted. + */ +export async function requestMediaPermission(): Promise { + const { status } = await MediaLibrary.requestPermissionsAsync(); + return status === 'granted'; +} + +/** + * Main sync function. Call this to start a full sync session. + */ +export async function runSync(): Promise { + const syncStore = useSyncStore.getState(); + const { settings } = syncStore; + + // Guard: already running + if (syncStore.isRunning) return; + + // Guard: Wi-Fi only check + if (settings.wifiOnly) { + const netState = await Network.getNetworkStateAsync(); + if (netState.type !== Network.NetworkStateType.WIFI) { + console.log('[Sync] Wi-Fi only mode – skipping sync (not on Wi-Fi)'); + return; + } + } + + // Guard: permissions + const hasPermission = await requestMediaPermission(); + if (!hasPermission) { + console.warn('[Sync] Media library permission not granted'); + return; + } + + syncStore.setRunning(true); + syncStore.resetStats(); + + try { + // 1. Register device + const deviceName = `${Platform.OS}-device`; + const platform = Platform.OS; // android | ios | web + const { device_id } = await syncStart({ device_name: deviceName, platform }); + syncStore.setDeviceId(device_id); + + // 2. Fetch all media assets + let after: string | undefined; + let hasNextPage = true; + const allAssets: MediaLibrary.Asset[] = []; + + while (hasNextPage) { + const page = await MediaLibrary.getAssetsAsync({ + mediaType: [MediaLibrary.MediaType.photo], + first: 100, + after, + sortBy: MediaLibrary.SortBy.creationTime, + }); + allAssets.push(...page.assets); + hasNextPage = page.hasNextPage; + after = page.endCursor; + } + + syncStore.setTotalPhotos(allAssets.length); + console.log(`[Sync] Found ${allAssets.length} photos in camera roll`); + + // 3. Process in batches for hash check + for (let i = 0; i < allAssets.length; i += BATCH_SIZE) { + if (syncStore.isPaused) { + console.log('[Sync] Paused – waiting...'); + await new Promise(resolve => setTimeout(resolve, 2000)); + i -= BATCH_SIZE; // retry this batch + continue; + } + + const batch = allAssets.slice(i, i + BATCH_SIZE); + + // Compute SHA256 for each asset in batch + const hashMap: Record = {}; + for (const asset of batch) { + try { + const info = await MediaLibrary.getAssetInfoAsync(asset); + const uri = info.localUri || asset.uri; + const hash = await computeFileSHA256(uri); + hashMap[hash] = asset; + } catch (err) { + console.warn('[Sync] Failed to hash asset:', asset.filename, err); + } + } + + const batchHashes = Object.keys(hashMap); + if (batchHashes.length === 0) continue; + + // 4. Check which are missing on server + const { missing_hashes, existing_count } = await syncCheck(batchHashes); + syncStore.incrementSkipped(); // approximate + + console.log(`[Sync] Batch: ${existing_count} existing, ${missing_hashes.length} to upload`); + + // 5. Upload missing + for (const hash of missing_hashes) { + if (syncStore.isPaused) break; + + const asset = hashMap[hash]; + if (!asset) continue; + + try { + const info = await MediaLibrary.getAssetInfoAsync(asset); + const uri = info.localUri || asset.uri; + const mimeType = asset.mediaType === 'photo' ? 'image/jpeg' : 'image/jpeg'; + const takenAt = new Date(asset.creationTime).toISOString(); + + await uploadPhoto(uri, asset.filename, mimeType, hash, takenAt); + syncStore.incrementUploaded(); + console.log(`[Sync] Uploaded: ${asset.filename}`); + } catch (err: any) { + syncStore.incrementFailed(); + console.error(`[Sync] Upload failed for ${asset.filename}:`, err.message); + } + } + } + + console.log('[Sync] Complete ✓'); + } catch (err) { + console.error('[Sync] Fatal error:', err); + } finally { + syncStore.setRunning(false); + } +} + +export function pauseSync() { + useSyncStore.getState().setPaused(true); +} + +export function resumeSync() { + useSyncStore.getState().setPaused(false); +} diff --git a/mobile/store/authStore.ts b/mobile/store/authStore.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f259540cb569341b9c084ef54eff83c1cf03110 --- /dev/null +++ b/mobile/store/authStore.ts @@ -0,0 +1,84 @@ +/** + * KeyStone – Auth Store (Zustand) + * Manages Supabase session, user profile, and auth state machine. + */ + +import { create } from 'zustand'; +import { Session, User } from '@supabase/supabase-js'; +import { supabase } from '../services/supabase'; + +interface AuthState { + session: Session | null; + user: User | null; + isLoading: boolean; + isInitialized: boolean; + + // Actions + initialize: () => Promise; + signIn: (email: string, password: string) => Promise; + signUp: (email: string, password: string) => Promise; + signOut: () => Promise; + resetPassword: (email: string) => Promise; +} + +export const useAuthStore = create((set) => ({ + session: null, + user: null, + isLoading: false, + isInitialized: false, + + initialize: async () => { + // Restore session from AsyncStorage + const { data: { session } } = await supabase.auth.getSession(); + set({ session, user: session?.user ?? null, isInitialized: true }); + + // Listen for auth state changes + supabase.auth.onAuthStateChange((_event, session) => { + set({ session, user: session?.user ?? null }); + }); + }, + + signIn: async (email, password) => { + set({ isLoading: true }); + try { + const { data, error } = await supabase.auth.signInWithPassword({ email, password }); + if (error) throw error; + set({ session: data.session, user: data.user }); + } finally { + set({ isLoading: false }); + } + }, + + signUp: async (email, password) => { + set({ isLoading: true }); + try { + const { data, error } = await supabase.auth.signUp({ email, password }); + if (error) throw error; + set({ session: data.session, user: data.user ?? null }); + } finally { + set({ isLoading: false }); + } + }, + + signOut: async () => { + set({ isLoading: true }); + try { + await supabase.auth.signOut(); + set({ session: null, user: null }); + } finally { + set({ isLoading: false }); + } + }, + + resetPassword: async (email) => { + set({ isLoading: true }); + try { + const { error } = await supabase.auth.resetPasswordForEmail(email, { + redirectTo: 'keystone://reset-password', + }); + if (error) throw error; + } finally { + set({ isLoading: false }); + } + }, +})); diff --git a/mobile/store/syncStore.ts b/mobile/store/syncStore.ts new file mode 100644 index 0000000000000000000000000000000000000000..4e54b5592e6a54656526c3df6d5df9fae276335a --- /dev/null +++ b/mobile/store/syncStore.ts @@ -0,0 +1,92 @@ +/** + * KeyStone – Sync Store (Zustand) + * Tracks upload queue, progress, and sync settings. + */ + +import { create } from 'zustand'; + +export type UploadItem = { + id: string; // local asset ID + uri: string; + filename: string; + sha256: string; + status: 'pending' | 'uploading' | 'done' | 'failed' | 'duplicate'; + progress: number; // 0-100 + error?: string; +}; + +interface SyncSettings { + autoSync: boolean; + wifiOnly: boolean; + thumbnailQuality: 'low' | 'medium' | 'high'; +} + +interface SyncState { + // Queue + queue: UploadItem[]; + isRunning: boolean; + isPaused: boolean; + deviceId: string | null; + + // Stats + uploaded: number; + skipped: number; + failed: number; + totalPhotos: number; + + // Settings + settings: SyncSettings; + + // Actions + setQueue: (items: UploadItem[]) => void; + updateItem: (id: string, updates: Partial) => void; + setRunning: (running: boolean) => void; + setPaused: (paused: boolean) => void; + setDeviceId: (id: string) => void; + incrementUploaded: () => void; + incrementSkipped: () => void; + incrementFailed: () => void; + setTotalPhotos: (count: number) => void; + updateSettings: (settings: Partial) => void; + resetStats: () => void; +} + +export const useSyncStore = create((set) => ({ + queue: [], + isRunning: false, + isPaused: false, + deviceId: null, + uploaded: 0, + skipped: 0, + failed: 0, + totalPhotos: 0, + + settings: { + autoSync: true, + wifiOnly: true, + thumbnailQuality: 'medium', + }, + + setQueue: (items) => set({ queue: items }), + + updateItem: (id, updates) => + set((state) => ({ + queue: state.queue.map((item) => + item.id === id ? { ...item, ...updates } : item, + ), + })), + + setRunning: (running) => set({ isRunning: running }), + setPaused: (paused) => set({ isPaused: paused }), + setDeviceId: (id) => set({ deviceId: id }), + + incrementUploaded: () => set((s) => ({ uploaded: s.uploaded + 1 })), + incrementSkipped: () => set((s) => ({ skipped: s.skipped + 1 })), + incrementFailed: () => set((s) => ({ failed: s.failed + 1 })), + setTotalPhotos: (count) => set({ totalPhotos: count }), + + updateSettings: (settings) => + set((state) => ({ settings: { ...state.settings, ...settings } })), + + resetStats: () => set({ uploaded: 0, skipped: 0, failed: 0, totalPhotos: 0 }), +})); diff --git a/mobile/tsconfig.json b/mobile/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..140c044c42a8cecef00a8dc5289a42895b59a350 --- /dev/null +++ b/mobile/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "paths": { + "@/*": ["./*"] + } + } +}