From fcc36eadcb6e2ad353d2d29e34a7db14154718f6 Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 8 May 2026 18:53:55 +0200 Subject: [PATCH] =?UTF-8?q?chore(cutover):=20remove=20services/mana-media/?= =?UTF-8?q?=20=E2=80=94=20moved=20to=20mana-platform?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live containers on the Mac Mini build out of `../mana/services/mana-media/` since the 8-Doppel-Cutover commit (774852ba2). Smoke test green 2026-05-08 — health endpoints, JWKS, login flow, Stripe-webhook all reachable from the new build path. Removing the now-stale duplicate. Was 17M in this repo, gone now. Active code lives in `Code/mana/services/mana-media/` (siehe ../mana/CLAUDE.md). Co-Authored-By: Claude Opus 4.7 (1M context) --- services/mana-media/.env.example | 22 - services/mana-media/CLAUDE.md | 222 ------- services/mana-media/apps/api/Dockerfile | 18 - .../mana-media/apps/api/drizzle.config.ts | 7 - services/mana-media/apps/api/package.json | 34 -- services/mana-media/apps/api/src/constants.ts | 17 - services/mana-media/apps/api/src/db.ts | 28 - .../mana-media/apps/api/src/db/migrate.ts | 46 -- .../migrations/0000_marvelous_micromacro.sql | 68 --- .../src/db/migrations/meta/0000_snapshot.json | 561 ------------------ .../api/src/db/migrations/meta/_journal.json | 13 - .../apps/api/src/db/schema/index.ts | 1 - .../apps/api/src/db/schema/media.schema.ts | 164 ----- services/mana-media/apps/api/src/index.ts | 170 ------ .../apps/api/src/routes/delivery.ts | 108 ---- .../mana-media/apps/api/src/routes/upload.ts | 169 ------ .../mana-media/apps/api/src/services/exif.ts | 56 -- .../apps/api/src/services/process.ts | 136 ----- .../mana-media/apps/api/src/services/sniff.ts | 78 --- .../apps/api/src/services/storage.ts | 91 --- .../apps/api/src/services/upload.ts | 392 ------------ services/mana-media/apps/api/test-image.png | 0 services/mana-media/apps/api/tsconfig.json | 19 - services/mana-media/docker-compose.yml | 63 -- services/mana-media/drizzle.config.ts | 3 - services/mana-media/nest-cli.json | 8 - services/mana-media/package.json | 9 - .../mana-media/packages/client/package.json | 15 - .../mana-media/packages/client/src/index.ts | 224 ------- .../mana-media/packages/client/tsconfig.json | 14 - services/mana-media/tsconfig.json | 15 - 31 files changed, 2771 deletions(-) delete mode 100644 services/mana-media/.env.example delete mode 100644 services/mana-media/CLAUDE.md delete mode 100644 services/mana-media/apps/api/Dockerfile delete mode 100644 services/mana-media/apps/api/drizzle.config.ts delete mode 100644 services/mana-media/apps/api/package.json delete mode 100644 services/mana-media/apps/api/src/constants.ts delete mode 100644 services/mana-media/apps/api/src/db.ts delete mode 100644 services/mana-media/apps/api/src/db/migrate.ts delete mode 100644 services/mana-media/apps/api/src/db/migrations/0000_marvelous_micromacro.sql delete mode 100644 services/mana-media/apps/api/src/db/migrations/meta/0000_snapshot.json delete mode 100644 services/mana-media/apps/api/src/db/migrations/meta/_journal.json delete mode 100644 services/mana-media/apps/api/src/db/schema/index.ts delete mode 100644 services/mana-media/apps/api/src/db/schema/media.schema.ts delete mode 100644 services/mana-media/apps/api/src/index.ts delete mode 100644 services/mana-media/apps/api/src/routes/delivery.ts delete mode 100644 services/mana-media/apps/api/src/routes/upload.ts delete mode 100644 services/mana-media/apps/api/src/services/exif.ts delete mode 100644 services/mana-media/apps/api/src/services/process.ts delete mode 100644 services/mana-media/apps/api/src/services/sniff.ts delete mode 100644 services/mana-media/apps/api/src/services/storage.ts delete mode 100644 services/mana-media/apps/api/src/services/upload.ts delete mode 100644 services/mana-media/apps/api/test-image.png delete mode 100644 services/mana-media/apps/api/tsconfig.json delete mode 100644 services/mana-media/docker-compose.yml delete mode 100644 services/mana-media/drizzle.config.ts delete mode 100644 services/mana-media/nest-cli.json delete mode 100644 services/mana-media/package.json delete mode 100644 services/mana-media/packages/client/package.json delete mode 100644 services/mana-media/packages/client/src/index.ts delete mode 100644 services/mana-media/packages/client/tsconfig.json delete mode 100644 services/mana-media/tsconfig.json diff --git a/services/mana-media/.env.example b/services/mana-media/.env.example deleted file mode 100644 index a63feeaf8..000000000 --- a/services/mana-media/.env.example +++ /dev/null @@ -1,22 +0,0 @@ -# Server -PORT=3050 -NODE_ENV=development - -# Redis (for job queue) -REDIS_HOST=localhost -REDIS_PORT=6379 - -# MinIO / S3 -S3_ENDPOINT=localhost -S3_PORT=9000 -S3_USE_SSL=false -S3_ACCESS_KEY=minioadmin -S3_SECRET_KEY=minioadmin -S3_BUCKET=mana-media -S3_PUBLIC_URL=http://localhost:9000/mana-media - -# Public URL for generated links -PUBLIC_URL=http://localhost:3050/api/v1 - -# CORS -CORS_ORIGINS=http://localhost:3000,http://localhost:5173 diff --git a/services/mana-media/CLAUDE.md b/services/mana-media/CLAUDE.md deleted file mode 100644 index e17f2cafe..000000000 --- a/services/mana-media/CLAUDE.md +++ /dev/null @@ -1,222 +0,0 @@ -# mana-media - Unified Media Platform - -Central media handling service for all Mana applications with content-addressable storage (CAS) and automatic deduplication. - -**Stack:** Hono + Bun (migrated from NestJS 2026-03-28) - -## Overview - -mana-media provides: -- **Content-Addressable Storage** - SHA-256 based deduplication across all apps -- **Upload API** - File uploads with automatic deduplication -- **Processing** - Thumbnails, WebP conversion, resizing (via BullMQ) -- **Delivery** - Optimized file serving, on-the-fly transforms - -**Port:** 3015 (production) - -## Quick Start - -```bash -# Start dependencies (Redis + MinIO + PostgreSQL) -pnpm docker:up - -# Create database -PGPASSWORD=devpassword psql -h localhost -U mana -d postgres -c "CREATE DATABASE mana_media;" - -# Install dependencies -cd services/mana-media/apps/api -pnpm install - -# Push schema -pnpm db:push - -# Start development server -pnpm dev -``` - -Service runs on `http://localhost:3015` - -## API Endpoints - -### Upload -```bash -# Upload file -curl -X POST http://localhost:3015/api/v1/media/upload \ - -F "file=@image.jpg" \ - -F "app=chat" \ - -F "userId=user123" - -# Response -{ - "id": "abc123", - "status": "processing", - "hash": "sha256...", - "urls": { - "original": "http://localhost:3015/api/v1/media/abc123/file", - "thumbnail": "http://localhost:3015/api/v1/media/abc123/file/thumb" - } -} -``` - -### Get Media -```bash -# Get metadata -GET /api/v1/media/:id - -# Get by content hash (check if file exists) -GET /api/v1/media/hash/:sha256hash - -# Get original file -GET /api/v1/media/:id/file - -# Get thumbnail -GET /api/v1/media/:id/file/thumb - -# Get medium variant -GET /api/v1/media/:id/file/medium - -# On-the-fly transform -GET /api/v1/media/:id/transform?w=400&h=300&fit=cover&format=webp -``` - -### List & Delete -```bash -# List media (filter by app/user) -GET /api/v1/media?app=chat&userId=user123&limit=50 - -# Delete -DELETE /api/v1/media/:id -``` - -## Client Library - -```typescript -import { MediaClient } from '@mana/media-client'; - -const media = new MediaClient('http://localhost:3050'); - -// Upload -const result = await media.upload(file, { app: 'chat' }); - -// Wait for processing -const ready = await media.waitForReady(result.id); - -// Get URLs -const thumbUrl = media.getThumbnailUrl(result.id); -const customUrl = media.getTransformUrl(result.id, { - width: 400, - format: 'webp' -}); -``` - -## Architecture - -``` -┌─────────────────────────────────────────────────────────────┐ -│ mana-media (Port 3015) │ -├─────────────────────────────────────────────────────────────┤ -│ Upload Module │ File uploads, dedup │ -│ Process Module │ Sharp thumbnail generation (BullMQ) │ -│ Storage Module │ MinIO S3 abstraction │ -│ Delivery Module │ File serving + on-the-fly transforms │ -│ Database Module │ PostgreSQL + Drizzle ORM │ -└─────────────────────────────────────────────────────────────┘ - │ │ │ - ▼ ▼ ▼ - ┌─────────┐ ┌─────────┐ ┌────────────┐ - │ Redis │ │ MinIO │ │ PostgreSQL │ - │ BullMQ │ │ Storage │ │ mana_media │ - └─────────┘ └─────────┘ └────────────┘ -``` - -## Database Schema - -### media (Content-Addressable Storage) -| Column | Type | Description | -|--------|------|-------------| -| id | UUID | Primary key | -| content_hash | TEXT | SHA-256 hash (unique) | -| original_name | TEXT | Original filename | -| mime_type | TEXT | MIME type | -| size | BIGINT | File size in bytes | -| original_key | TEXT | S3 storage key | -| status | TEXT | uploading/processing/ready/failed | -| thumbnail_key | TEXT | Thumbnail S3 key | -| width/height | INT | Image dimensions | - -### media_references (User ownership) -| Column | Type | Description | -|--------|------|-------------| -| id | UUID | Primary key | -| media_id | UUID | FK to media | -| user_id | UUID | Owner user ID | -| app | TEXT | Source app (food, contacts, etc.) | -| source_url | TEXT | Original source URL | - -## Processing Pipeline - -| File Type | Generated Variants | -|-----------|-------------------| -| Images | thumb (200x200), medium (800x800), large (1920x1920) | -| Videos | (planned) thumbnail, HLS streaming | -| Documents | (planned) thumbnail, text extraction | - -## Environment Variables - -| Variable | Default | Description | -|----------|---------|-------------| -| PORT | 3015 | API port | -| DATABASE_URL | - | PostgreSQL connection string | -| REDIS_HOST | localhost | Redis host | -| REDIS_PORT | 6379 | Redis port | -| REDIS_PASSWORD | - | Redis password (optional) | -| S3_ENDPOINT | localhost | MinIO/S3 endpoint | -| S3_PORT | 9000 | MinIO/S3 port | -| S3_USE_SSL | false | Use HTTPS for S3 | -| S3_ACCESS_KEY | minioadmin | S3 access key | -| S3_SECRET_KEY | minioadmin | S3 secret key | -| S3_BUCKET | mana-media | Storage bucket | -| S3_PUBLIC_URL | - | Public URL for media | -| PUBLIC_URL | http://localhost:3015/api/v1 | Public API URL | - -## Development - -```bash -# Run with watch mode (Bun) -pnpm dev - -# Type check -pnpm type-check - -# Database commands -cd apps/api -pnpm db:push # Push schema to database -pnpm db:studio # Open Drizzle Studio -``` - -## Storage Layout - -``` -mana-media bucket/ -├── originals/ # Original uploads -│ └── 2026/02/01/ -│ └── {id}.{ext} -├── processed/ # Generated variants -│ └── {id}/ -│ ├── thumb.webp -│ ├── medium.webp -│ └── large.webp -└── cache/ # Transform cache - └── {id}_{params}.webp -``` - -## Roadmap - -- [x] v0.1: Basic upload + thumbnails -- [x] v0.2: PostgreSQL persistence with Drizzle ORM -- [x] v0.3: Content-addressable storage with SHA-256 deduplication -- [ ] v0.5: Video thumbnails (FFmpeg) -- [ ] v0.6: Chunked upload for large files -- [ ] v0.7: OCR for documents -- [ ] v0.8: Vector search (Qdrant) -- [ ] v1.0: Full production ready diff --git a/services/mana-media/apps/api/Dockerfile b/services/mana-media/apps/api/Dockerfile deleted file mode 100644 index 20c027662..000000000 --- a/services/mana-media/apps/api/Dockerfile +++ /dev/null @@ -1,18 +0,0 @@ -FROM oven/bun:1 AS production - -WORKDIR /app - -COPY package.json bun.lock* ./ -COPY src ./src - -# Remove workspace devDependencies that can't resolve in Docker, then install -RUN sed -i '/"@mana\/shared-drizzle-config"/d' package.json && \ - bun install --production --frozen-lockfile 2>/dev/null || bun install --production - -EXPOSE 3015 - -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:3015/health || exit 1 - -USER bun -CMD ["bun", "run", "src/index.ts"] diff --git a/services/mana-media/apps/api/drizzle.config.ts b/services/mana-media/apps/api/drizzle.config.ts deleted file mode 100644 index 767146071..000000000 --- a/services/mana-media/apps/api/drizzle.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createDrizzleConfig } from '@mana/shared-drizzle-config'; - -export default createDrizzleConfig({ - dbName: 'mana_platform', - schemaPath: './src/db/schema/index.ts', - schemaFilter: ['media'], -}); diff --git a/services/mana-media/apps/api/package.json b/services/mana-media/apps/api/package.json deleted file mode 100644 index 9625daf81..000000000 --- a/services/mana-media/apps/api/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "@mana-media/api", - "version": "0.2.0", - "private": true, - "scripts": { - "dev": "bun run --hot src/index.ts", - "start": "bun run src/index.ts", - "type-check": "tsc --noEmit", - "db:push": "drizzle-kit push", - "db:generate": "drizzle-kit generate", - "db:migrate": "bun run src/db/migrate.ts", - "db:studio": "drizzle-kit studio" - }, - "dependencies": { - "bullmq": "^5.34.0", - "drizzle-orm": "^0.38.3", - "exifr": "^7.1.3", - "heic-convert": "^2.1.0", - "hono": "^4.7.0", - "mime-types": "^2.1.35", - "minio": "^8.0.0", - "postgres": "^3.4.5", - "prom-client": "^15.1.0", - "sharp": "^0.33.0" - }, - "devDependencies": { - "@mana/shared-drizzle-config": "workspace:*", - "@types/heic-convert": "^2.1.0", - "@types/mime-types": "^2.1.4", - "@types/node": "^22.0.0", - "drizzle-kit": "^0.30.1", - "typescript": "^5.7.0" - } -} diff --git a/services/mana-media/apps/api/src/constants.ts b/services/mana-media/apps/api/src/constants.ts deleted file mode 100644 index 34b16d87e..000000000 --- a/services/mana-media/apps/api/src/constants.ts +++ /dev/null @@ -1,17 +0,0 @@ -export const PROCESS_QUEUE = 'media-process'; - -export const IMAGE_VARIANTS = { - thumbnail: { width: 200, height: 200, fit: 'cover' as const }, - medium: { width: 800, height: 800, fit: 'inside' as const }, - large: { width: 1920, height: 1920, fit: 'inside' as const }, -}; - -export const SUPPORTED_IMAGE_TYPES = [ - 'image/jpeg', - 'image/png', - 'image/webp', - 'image/gif', - 'image/avif', - 'image/heic', - 'image/heif', -]; diff --git a/services/mana-media/apps/api/src/db.ts b/services/mana-media/apps/api/src/db.ts deleted file mode 100644 index 53a0d21cb..000000000 --- a/services/mana-media/apps/api/src/db.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { drizzle } from 'drizzle-orm/postgres-js'; -import postgres from 'postgres'; -import * as schema from './db/schema'; - -let connection: ReturnType | null = null; -let db: ReturnType | null = null; - -export function getDb(databaseUrl: string) { - if (!db) { - connection = postgres(databaseUrl, { - max: 10, - idle_timeout: 20, - connect_timeout: 10, - }); - db = drizzle(connection, { schema }); - } - return db; -} - -export async function closeConnection() { - if (connection) { - await connection.end(); - connection = null; - db = null; - } -} - -export type Database = ReturnType; diff --git a/services/mana-media/apps/api/src/db/migrate.ts b/services/mana-media/apps/api/src/db/migrate.ts deleted file mode 100644 index 698a57da8..000000000 --- a/services/mana-media/apps/api/src/db/migrate.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Drizzle migration runner. - * - * Run on every container startup (and as `pnpm db:migrate` for local use) - * so that fresh deployments end up with the `media` schema and tables - * without any manual SQL. Drizzle's migrator tracks applied migrations - * in `drizzle.__drizzle_migrations`, so re-runs are no-ops. - */ -import { drizzle } from 'drizzle-orm/postgres-js'; -import { migrate } from 'drizzle-orm/postgres-js/migrator'; -import postgres from 'postgres'; -import { fileURLToPath } from 'url'; -import { dirname, resolve } from 'path'; - -export async function runMigrations(databaseUrl: string): Promise { - // Migrations live next to this file at runtime, regardless of cwd. - const here = dirname(fileURLToPath(import.meta.url)); - const migrationsFolder = resolve(here, 'migrations'); - - // `max: 1` is required by drizzle's migrator. - const sql = postgres(databaseUrl, { max: 1 }); - try { - const db = drizzle(sql); - await migrate(db, { migrationsFolder }); - } finally { - await sql.end({ timeout: 5 }); - } -} - -// Allow `bun run src/db/migrate.ts` for manual / CI use. -if (import.meta.main) { - const databaseUrl = process.env.DATABASE_URL; - if (!databaseUrl) { - console.error('DATABASE_URL is required'); - process.exit(1); - } - runMigrations(databaseUrl) - .then(() => { - console.log('Migrations applied'); - process.exit(0); - }) - .catch((err) => { - console.error('Migration failed:', err); - process.exit(1); - }); -} diff --git a/services/mana-media/apps/api/src/db/migrations/0000_marvelous_micromacro.sql b/services/mana-media/apps/api/src/db/migrations/0000_marvelous_micromacro.sql deleted file mode 100644 index 036972da5..000000000 --- a/services/mana-media/apps/api/src/db/migrations/0000_marvelous_micromacro.sql +++ /dev/null @@ -1,68 +0,0 @@ -CREATE SCHEMA "media"; ---> statement-breakpoint -CREATE TABLE "media"."media" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "content_hash" text NOT NULL, - "original_name" text, - "mime_type" text NOT NULL, - "size" bigint NOT NULL, - "original_key" text NOT NULL, - "status" text DEFAULT 'uploading' NOT NULL, - "width" integer, - "height" integer, - "format" text, - "has_alpha" boolean, - "exif_data" jsonb, - "date_taken" timestamp with time zone, - "camera_make" text, - "camera_model" text, - "focal_length" text, - "aperture" text, - "iso" integer, - "exposure_time" text, - "gps_latitude" text, - "gps_longitude" text, - "thumbnail_key" text, - "medium_key" text, - "large_key" text, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "media_content_hash_unique" UNIQUE("content_hash") -); ---> statement-breakpoint -CREATE TABLE "media"."media_references" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "media_id" uuid NOT NULL, - "user_id" text NOT NULL, - "app" text NOT NULL, - "source_url" text, - "metadata" jsonb, - "created_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "media"."media_thumbnails" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "media_id" uuid NOT NULL, - "width" integer NOT NULL, - "height" integer NOT NULL, - "fit" text DEFAULT 'cover' NOT NULL, - "format" text DEFAULT 'webp' NOT NULL, - "quality" integer DEFAULT 80 NOT NULL, - "storage_key" text NOT NULL, - "size" integer NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -ALTER TABLE "media"."media_references" ADD CONSTRAINT "media_references_media_id_media_id_fk" FOREIGN KEY ("media_id") REFERENCES "media"."media"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "media"."media_thumbnails" ADD CONSTRAINT "media_thumbnails_media_id_media_id_fk" FOREIGN KEY ("media_id") REFERENCES "media"."media"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -CREATE INDEX "media_content_hash_idx" ON "media"."media" USING btree ("content_hash");--> statement-breakpoint -CREATE INDEX "media_status_idx" ON "media"."media" USING btree ("status");--> statement-breakpoint -CREATE INDEX "media_created_at_idx" ON "media"."media" USING btree ("created_at");--> statement-breakpoint -CREATE INDEX "media_date_taken_idx" ON "media"."media" USING btree ("date_taken");--> statement-breakpoint -CREATE INDEX "media_camera_idx" ON "media"."media" USING btree ("camera_make","camera_model");--> statement-breakpoint -CREATE INDEX "media_ref_media_id_idx" ON "media"."media_references" USING btree ("media_id");--> statement-breakpoint -CREATE INDEX "media_ref_user_id_idx" ON "media"."media_references" USING btree ("user_id");--> statement-breakpoint -CREATE INDEX "media_ref_app_idx" ON "media"."media_references" USING btree ("app");--> statement-breakpoint -CREATE INDEX "media_ref_user_app_idx" ON "media"."media_references" USING btree ("user_id","app");--> statement-breakpoint -CREATE INDEX "media_thumb_media_id_idx" ON "media"."media_thumbnails" USING btree ("media_id");--> statement-breakpoint -CREATE INDEX "media_thumb_params_idx" ON "media"."media_thumbnails" USING btree ("media_id","width","height","fit","format"); \ No newline at end of file diff --git a/services/mana-media/apps/api/src/db/migrations/meta/0000_snapshot.json b/services/mana-media/apps/api/src/db/migrations/meta/0000_snapshot.json deleted file mode 100644 index e93bedf30..000000000 --- a/services/mana-media/apps/api/src/db/migrations/meta/0000_snapshot.json +++ /dev/null @@ -1,561 +0,0 @@ -{ - "id": "270c9891-ddcf-4b1e-b987-d91f033e0767", - "prevId": "00000000-0000-0000-0000-000000000000", - "version": "7", - "dialect": "postgresql", - "tables": { - "media.media": { - "name": "media", - "schema": "media", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "content_hash": { - "name": "content_hash", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "original_name": { - "name": "original_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "mime_type": { - "name": "mime_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "size": { - "name": "size", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "original_key": { - "name": "original_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'uploading'" - }, - "width": { - "name": "width", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "height": { - "name": "height", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "format": { - "name": "format", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "has_alpha": { - "name": "has_alpha", - "type": "boolean", - "primaryKey": false, - "notNull": false - }, - "exif_data": { - "name": "exif_data", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "date_taken": { - "name": "date_taken", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "camera_make": { - "name": "camera_make", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "camera_model": { - "name": "camera_model", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "focal_length": { - "name": "focal_length", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "aperture": { - "name": "aperture", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "iso": { - "name": "iso", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "exposure_time": { - "name": "exposure_time", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "gps_latitude": { - "name": "gps_latitude", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "gps_longitude": { - "name": "gps_longitude", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "thumbnail_key": { - "name": "thumbnail_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "medium_key": { - "name": "medium_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "large_key": { - "name": "large_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "media_content_hash_idx": { - "name": "media_content_hash_idx", - "columns": [ - { - "expression": "content_hash", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "media_status_idx": { - "name": "media_status_idx", - "columns": [ - { - "expression": "status", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "media_created_at_idx": { - "name": "media_created_at_idx", - "columns": [ - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "media_date_taken_idx": { - "name": "media_date_taken_idx", - "columns": [ - { - "expression": "date_taken", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "media_camera_idx": { - "name": "media_camera_idx", - "columns": [ - { - "expression": "camera_make", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "camera_model", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "media_content_hash_unique": { - "name": "media_content_hash_unique", - "nullsNotDistinct": false, - "columns": ["content_hash"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "media.media_references": { - "name": "media_references", - "schema": "media", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "media_id": { - "name": "media_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "app": { - "name": "app", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_url": { - "name": "source_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "metadata": { - "name": "metadata", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "media_ref_media_id_idx": { - "name": "media_ref_media_id_idx", - "columns": [ - { - "expression": "media_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "media_ref_user_id_idx": { - "name": "media_ref_user_id_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "media_ref_app_idx": { - "name": "media_ref_app_idx", - "columns": [ - { - "expression": "app", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "media_ref_user_app_idx": { - "name": "media_ref_user_app_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "app", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "media_references_media_id_media_id_fk": { - "name": "media_references_media_id_media_id_fk", - "tableFrom": "media_references", - "tableTo": "media", - "schemaTo": "media", - "columnsFrom": ["media_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "media.media_thumbnails": { - "name": "media_thumbnails", - "schema": "media", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "media_id": { - "name": "media_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "width": { - "name": "width", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "height": { - "name": "height", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "fit": { - "name": "fit", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'cover'" - }, - "format": { - "name": "format", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'webp'" - }, - "quality": { - "name": "quality", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 80 - }, - "storage_key": { - "name": "storage_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "size": { - "name": "size", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "media_thumb_media_id_idx": { - "name": "media_thumb_media_id_idx", - "columns": [ - { - "expression": "media_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "media_thumb_params_idx": { - "name": "media_thumb_params_idx", - "columns": [ - { - "expression": "media_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "width", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "height", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "fit", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "format", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "media_thumbnails_media_id_media_id_fk": { - "name": "media_thumbnails_media_id_media_id_fk", - "tableFrom": "media_thumbnails", - "tableTo": "media", - "schemaTo": "media", - "columnsFrom": ["media_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": { - "media": "media" - }, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/services/mana-media/apps/api/src/db/migrations/meta/_journal.json b/services/mana-media/apps/api/src/db/migrations/meta/_journal.json deleted file mode 100644 index f5a84d7dd..000000000 --- a/services/mana-media/apps/api/src/db/migrations/meta/_journal.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "version": "7", - "dialect": "postgresql", - "entries": [ - { - "idx": 0, - "version": "7", - "when": 1775759324563, - "tag": "0000_marvelous_micromacro", - "breakpoints": true - } - ] -} diff --git a/services/mana-media/apps/api/src/db/schema/index.ts b/services/mana-media/apps/api/src/db/schema/index.ts deleted file mode 100644 index 213a5eba2..000000000 --- a/services/mana-media/apps/api/src/db/schema/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './media.schema'; diff --git a/services/mana-media/apps/api/src/db/schema/media.schema.ts b/services/mana-media/apps/api/src/db/schema/media.schema.ts deleted file mode 100644 index 6f5c0ca27..000000000 --- a/services/mana-media/apps/api/src/db/schema/media.schema.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { - pgSchema, - uuid, - text, - timestamp, - integer, - boolean, - index, - jsonb, - bigint, -} from 'drizzle-orm/pg-core'; -import { relations } from 'drizzle-orm'; - -export const mediaSchema = pgSchema('media'); - -/** - * Core media table - stores unique files by content hash (SHA-256) - * This is the Content-Addressable Storage (CAS) approach - */ -export const media = mediaSchema.table( - 'media', - { - id: uuid('id').primaryKey().defaultRandom(), - // Content-addressable: SHA-256 hash of the file content - contentHash: text('content_hash').notNull().unique(), - // Original filename (for display purposes) - originalName: text('original_name'), - // MIME type - mimeType: text('mime_type').notNull(), - // File size in bytes - size: bigint('size', { mode: 'number' }).notNull(), - // Storage keys - originalKey: text('original_key').notNull(), - // Processing status - status: text('status', { enum: ['uploading', 'processing', 'ready', 'failed'] }) - .default('uploading') - .notNull(), - // Image metadata - width: integer('width'), - height: integer('height'), - format: text('format'), - hasAlpha: boolean('has_alpha'), - // EXIF metadata - exifData: jsonb('exif_data'), - dateTaken: timestamp('date_taken', { withTimezone: true }), - cameraMake: text('camera_make'), - cameraModel: text('camera_model'), - focalLength: text('focal_length'), - aperture: text('aperture'), - iso: integer('iso'), - exposureTime: text('exposure_time'), - gpsLatitude: text('gps_latitude'), - gpsLongitude: text('gps_longitude'), - // Generated variants - thumbnailKey: text('thumbnail_key'), - mediumKey: text('medium_key'), - largeKey: text('large_key'), - // Timestamps - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), - }, - (table) => [ - index('media_content_hash_idx').on(table.contentHash), - index('media_status_idx').on(table.status), - index('media_created_at_idx').on(table.createdAt), - index('media_date_taken_idx').on(table.dateTaken), - index('media_camera_idx').on(table.cameraMake, table.cameraModel), - ] -); - -/** - * Media references - tracks which user/app owns a reference to a media item - * Multiple users can reference the same media (deduplication) - */ -export const mediaReferences = mediaSchema.table( - 'media_references', - { - id: uuid('id').primaryKey().defaultRandom(), - // The media being referenced - mediaId: uuid('media_id') - .references(() => media.id, { onDelete: 'cascade' }) - .notNull(), - // Owner user ID - userId: text('user_id').notNull(), - // Source app (food, contacts, chat, etc.) - app: text('app').notNull(), - // Optional: reference to the source URL - sourceUrl: text('source_url'), - // Custom metadata per reference - metadata: jsonb('metadata'), - // Timestamps - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - }, - (table) => [ - index('media_ref_media_id_idx').on(table.mediaId), - index('media_ref_user_id_idx').on(table.userId), - index('media_ref_app_idx').on(table.app), - index('media_ref_user_app_idx').on(table.userId, table.app), - ] -); - -/** - * Lazy-generated thumbnails cache - * Stores on-the-fly generated thumbnails with specific parameters - */ -export const mediaThumbnails = mediaSchema.table( - 'media_thumbnails', - { - id: uuid('id').primaryKey().defaultRandom(), - mediaId: uuid('media_id') - .references(() => media.id, { onDelete: 'cascade' }) - .notNull(), - // Parameters that define this thumbnail - width: integer('width').notNull(), - height: integer('height').notNull(), - fit: text('fit').default('cover').notNull(), - format: text('format').default('webp').notNull(), - quality: integer('quality').default(80).notNull(), - // Storage key for this specific thumbnail - storageKey: text('storage_key').notNull(), - // Size of the generated thumbnail - size: integer('size').notNull(), - // Timestamps - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - }, - (table) => [ - index('media_thumb_media_id_idx').on(table.mediaId), - index('media_thumb_params_idx').on( - table.mediaId, - table.width, - table.height, - table.fit, - table.format - ), - ] -); - -// Relations -export const mediaRelations = relations(media, ({ many }) => ({ - references: many(mediaReferences), - thumbnails: many(mediaThumbnails), -})); - -export const mediaReferencesRelations = relations(mediaReferences, ({ one }) => ({ - media: one(media, { - fields: [mediaReferences.mediaId], - references: [media.id], - }), -})); - -export const mediaThumbnailsRelations = relations(mediaThumbnails, ({ one }) => ({ - media: one(media, { - fields: [mediaThumbnails.mediaId], - references: [media.id], - }), -})); - -// Types -export type Media = typeof media.$inferSelect; -export type NewMedia = typeof media.$inferInsert; -export type MediaReference = typeof mediaReferences.$inferSelect; -export type NewMediaReference = typeof mediaReferences.$inferInsert; -export type MediaThumbnail = typeof mediaThumbnails.$inferSelect; -export type NewMediaThumbnail = typeof mediaThumbnails.$inferInsert; diff --git a/services/mana-media/apps/api/src/index.ts b/services/mana-media/apps/api/src/index.ts deleted file mode 100644 index 85168da12..000000000 --- a/services/mana-media/apps/api/src/index.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { Hono } from 'hono'; -import { cors } from 'hono/cors'; -import { Queue, Worker, Job } from 'bullmq'; -import { collectDefaultMetrics, Registry, Counter, Histogram } from 'prom-client'; -import { getDb, closeConnection } from './db'; -import { runMigrations } from './db/migrate'; -import { StorageService } from './services/storage'; -import { UploadService } from './services/upload'; -import { ProcessService } from './services/process'; -import { ExifService } from './services/exif'; -import { uploadRoutes } from './routes/upload'; -import { deliveryRoutes } from './routes/delivery'; -import { PROCESS_QUEUE, SUPPORTED_IMAGE_TYPES } from './constants'; - -const port = parseInt(process.env.PORT || '3015'); - -// Database -const databaseUrl = process.env.DATABASE_URL; -if (!databaseUrl) throw new Error('DATABASE_URL is required'); - -// Apply pending Drizzle migrations before opening the pool. Idempotent — -// drizzle tracks applied migrations in drizzle.__drizzle_migrations, so -// existing deployments are unaffected. -await runMigrations(databaseUrl); - -const db = getDb(databaseUrl); - -// Services -const storage = new StorageService(); -await storage.init(); - -const exifService = new ExifService(); -const processService = new ProcessService(storage, exifService); - -const processQueue = new Queue(PROCESS_QUEUE, { - connection: { - host: process.env.REDIS_HOST || 'localhost', - port: parseInt(process.env.REDIS_PORT || '6379'), - password: process.env.REDIS_PASSWORD || undefined, - }, -}); - -const uploadService = new UploadService(db, storage, processQueue); - -// BullMQ Worker -const worker = new Worker( - PROCESS_QUEUE, - async (job: Job<{ mediaId: string; mimeType: string; originalKey: string }>) => { - const { mediaId, mimeType, originalKey } = job.data; - console.log(`Processing media ${mediaId} (${mimeType})`); - - try { - if (SUPPORTED_IMAGE_TYPES.includes(mimeType)) { - const result = await processService.processImage(mediaId, originalKey, mimeType); - await uploadService.update(mediaId, { - status: 'ready', - thumbnailKey: result.thumbnail, - mediumKey: result.medium, - largeKey: result.large, - width: result.metadata?.width, - height: result.metadata?.height, - format: result.metadata?.format, - hasAlpha: result.metadata?.hasAlpha, - exifData: result.exif?.raw, - dateTaken: result.exif?.dateTaken, - cameraMake: result.exif?.cameraMake, - cameraModel: result.exif?.cameraModel, - focalLength: result.exif?.focalLength, - aperture: result.exif?.aperture, - iso: result.exif?.iso, - exposureTime: result.exif?.exposureTime, - gpsLatitude: result.exif?.gpsLatitude, - gpsLongitude: result.exif?.gpsLongitude, - }); - console.log( - `Processed image ${mediaId}: thumbnail=${!!result.thumbnail}, medium=${!!result.medium}, large=${!!result.large}, exif=${!!result.exif}` - ); - } else { - await uploadService.update(mediaId, { status: 'ready' }); - } - } catch (error) { - console.error(`Failed to process media ${mediaId}:`, error); - await uploadService.update(mediaId, { status: 'failed' }); - throw error; - } - }, - { - connection: { - host: process.env.REDIS_HOST || 'localhost', - port: parseInt(process.env.REDIS_PORT || '6379'), - password: process.env.REDIS_PASSWORD || undefined, - }, - } -); - -// Prometheus metrics -const register = new Registry(); -register.setDefaultLabels({ service: 'mana-media' }); -collectDefaultMetrics({ register, prefix: 'media_' }); - -const httpRequestsTotal = new Counter({ - name: 'media_http_requests_total', - help: 'Total HTTP requests', - labelNames: ['method', 'path', 'status'] as const, - registers: [register], -}); - -const httpRequestDuration = new Histogram({ - name: 'media_http_request_duration_seconds', - help: 'HTTP request duration in seconds', - labelNames: ['method', 'path', 'status'] as const, - buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], - registers: [register], -}); - -// Hono app -const app = new Hono(); - -// CORS -app.use( - '*', - cors({ - origin: process.env.CORS_ORIGINS?.split(',') || '*', - credentials: true, - }) -); - -// Metrics middleware -app.use('*', async (c, next) => { - const start = Date.now(); - await next(); - const duration = (Date.now() - start) / 1000; - const path = c.req.routePath || c.req.path; - httpRequestsTotal.inc({ method: c.req.method, path, status: c.res.status }); - httpRequestDuration.observe({ method: c.req.method, path, status: c.res.status }, duration); -}); - -// Health -app.get('/health', (c) => - c.json({ status: 'ok', service: 'mana-media', timestamp: new Date().toISOString() }) -); - -// Metrics -app.get('/metrics', async (c) => { - c.header('Content-Type', register.contentType); - return c.text(await register.metrics()); -}); - -// API routes -const api = new Hono(); -api.route('/media', uploadRoutes(uploadService)); -api.route('/media', deliveryRoutes(uploadService, processService, storage)); -app.route('/api/v1', api); - -// Graceful shutdown -process.on('SIGTERM', async () => { - console.log('Shutting down...'); - await worker.close(); - await processQueue.close(); - await closeConnection(); - process.exit(0); -}); - -console.log(`Mana Media service running on port ${port}`); -console.log(`Health check: http://localhost:${port}/health`); - -export default { - port, - fetch: app.fetch, -}; diff --git a/services/mana-media/apps/api/src/routes/delivery.ts b/services/mana-media/apps/api/src/routes/delivery.ts deleted file mode 100644 index 2d40bbb5d..000000000 --- a/services/mana-media/apps/api/src/routes/delivery.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { Hono } from 'hono'; -import { stream } from 'hono/streaming'; -import type { UploadService } from '../services/upload'; -import type { ProcessService } from '../services/process'; -import type { StorageService } from '../services/storage'; -import { sniffImageMimeType } from '../services/sniff'; - -type Variant = 'thumb' | 'medium' | 'large'; - -export function deliveryRoutes( - uploadService: UploadService, - processService: ProcessService, - storage: StorageService -) { - const app = new Hono(); - - // Get original file - app.get('/:id/file', async (c) => { - const record = await uploadService.get(c.req.param('id')); - if (!record) return c.json({ error: 'Media not found' }, 404); - - return streamFile(c, storage, record.keys.original, record.mimeType); - }); - - // Get variant - app.get('/:id/file/:variant', async (c) => { - const record = await uploadService.get(c.req.param('id')); - if (!record) return c.json({ error: 'Media not found' }, 404); - - const variant = c.req.param('variant') as Variant; - const variantMap: Record = { - thumb: record.keys.thumbnail, - medium: record.keys.medium, - large: record.keys.large, - }; - - const key = variantMap[variant]; - if (!key) { - return streamFile(c, storage, record.keys.original, record.mimeType); - } - - return streamFile(c, storage, key, 'image/webp'); - }); - - // On-the-fly transform - app.get('/:id/transform', async (c) => { - const record = await uploadService.get(c.req.param('id')); - if (!record) return c.json({ error: 'Media not found' }, 404); - - const originalBuffer = await storage.download(record.keys.original); - - // Trust the stored mime first; fall back to magic-byte sniffing - // for legacy rows uploaded before the upload sniffer landed - // (HEIC from Chrome, etc.) where the row says - // `application/octet-stream` but the bytes are actually an image. - // Refuse only when neither header nor bytes look like an image. - const looksLikeImage = - record.mimeType.startsWith('image/') || sniffImageMimeType(originalBuffer) !== null; - if (!looksLikeImage) { - return c.json({ error: 'Transform only supported for images' }, 400); - } - - const format = (c.req.query('format') as 'webp' | 'jpeg' | 'png' | 'avif') || 'webp'; - - const transformedBuffer = await processService.transformImage(originalBuffer, { - width: c.req.query('w') ? parseInt(c.req.query('w')!) : undefined, - height: c.req.query('h') ? parseInt(c.req.query('h')!) : undefined, - fit: (c.req.query('fit') as 'cover' | 'contain' | 'fill' | 'inside' | 'outside') || 'inside', - format, - quality: c.req.query('q') ? parseInt(c.req.query('q')!) : 85, - }); - - const mimeTypes: Record = { - webp: 'image/webp', - jpeg: 'image/jpeg', - png: 'image/png', - avif: 'image/avif', - }; - - c.header('Content-Type', mimeTypes[format]); - c.header('Cache-Control', 'public, max-age=31536000'); - // Hono 4.7 types `c.body()` as `Uint8Array` (strict, - // not ArrayBufferLike). Node's `Buffer` and - // `new Uint8Array(buffer, ...)` views both carry the loose - // ArrayBufferLike tag. `Uint8Array.from()` copies into a fresh - // ArrayBuffer which satisfies the strict type. - return c.body(Uint8Array.from(transformedBuffer)); - }); - - return app; -} - -async function streamFile(c: any, storage: StorageService, key: string, contentType: string) { - try { - const fileStream = await storage.getStream(key); - - c.header('Content-Type', contentType); - c.header('Cache-Control', 'public, max-age=31536000'); - - return stream(c, async (s) => { - for await (const chunk of fileStream) { - await s.write(chunk); - } - }); - } catch { - return c.json({ error: 'File not found' }, 404); - } -} diff --git a/services/mana-media/apps/api/src/routes/upload.ts b/services/mana-media/apps/api/src/routes/upload.ts deleted file mode 100644 index fc039a8ff..000000000 --- a/services/mana-media/apps/api/src/routes/upload.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { Hono } from 'hono'; -import type { UploadService, MediaRecord } from '../services/upload'; -import { sniffImageMimeType } from '../services/sniff'; - -function toResponse(record: MediaRecord) { - const baseUrl = process.env.PUBLIC_URL || 'http://localhost:3015/api/v1'; - return { - id: record.id, - status: record.status, - originalName: record.originalName, - mimeType: record.mimeType, - size: record.size, - hash: record.hash, - urls: { - original: `${baseUrl}/media/${record.id}/file`, - thumbnail: record.keys.thumbnail ? `${baseUrl}/media/${record.id}/file/thumb` : undefined, - medium: record.keys.medium ? `${baseUrl}/media/${record.id}/file/medium` : undefined, - large: record.keys.large ? `${baseUrl}/media/${record.id}/file/large` : undefined, - }, - metadata: record.metadata, - exif: record.exif, - createdAt: record.createdAt, - }; -} - -export function uploadRoutes(uploadService: UploadService) { - const app = new Hono(); - - // Upload file - app.post('/upload', async (c) => { - const body = await c.req.parseBody(); - const file = body['file']; - - if (!file || !(file instanceof File)) { - return c.json({ error: 'No file provided' }, 400); - } - - if (file.size > 100 * 1024 * 1024) { - return c.json({ error: 'File too large (max 100MB)' }, 400); - } - - let buffer = Buffer.from(await file.arrayBuffer()); - - // Magic-byte sniff first; trust the bytes over the browser's - // `file.type`. Chrome on macOS doesn't recognise HEIC and sends - // an empty type, which would otherwise land as - // `application/octet-stream` and break every downstream - // `mimeType.startsWith('image/')` check (transform endpoint, - // process pipeline, etc). A successful sniff returns an - // authoritative image MIME; anything we don't recognise falls - // back to whatever the browser claimed. - const sniffed = sniffImageMimeType(buffer); - let mimeType = sniffed ?? file.type ?? 'application/octet-stream'; - let storedName = file.name; - let storedSize = file.size; - - // HEIC/HEIF transcode. The sharp version we ship has the heif - // container format but no HEVC decoder plugin (libde265 is not - // bundled in sharp's prebuilt binaries due to patent licensing), - // so iPhone HEIC uploads would fail every downstream sharp - // transform with `No decoding plugin installed for this - // compression format`. Convert to JPEG once at upload time via - // `heic-convert` (pure-JS WASM, no system deps); the server then - // stores standard JPEG and every later step is mime-agnostic. - if (mimeType === 'image/heic' || mimeType === 'image/heif') { - try { - const heicConvert = (await import('heic-convert')).default; - const jpegArrayBuffer = await heicConvert({ - // `Buffer` extends `Uint8Array` and is what heic-convert - // actually accepts at runtime. `@types/heic-convert` - // over-tightens the param to `ArrayBufferLike` (which - // in TS ≥ 5.7 includes the `grow` property only on - // `SharedArrayBuffer`), so a normal Buffer doesn't - // match the declared type. Cast through `unknown` to - // avoid lying about a wider intersection. - buffer: buffer as unknown as ArrayBufferLike, - format: 'JPEG', - quality: 0.9, - }); - buffer = Buffer.from(jpegArrayBuffer); - mimeType = 'image/jpeg'; - storedName = file.name.replace(/\.(heic|heif)$/i, '.jpg'); - storedSize = buffer.length; - } catch (err) { - console.error('[upload] HEIC convert failed', err); - return c.json({ error: 'HEIC conversion failed', detail: (err as Error).message }, 500); - } - } - - const record = await uploadService.upload(buffer, storedName, mimeType, storedSize, { - app: body['app'] as string | undefined, - userId: body['userId'] as string | undefined, - skipProcessing: body['skipProcessing'] === 'true', - }); - - return c.json(toResponse(record), 201); - }); - - // Get by ID - app.get('/:id', async (c) => { - const id = c.req.param('id'); - - // Skip route conflicts with sub-paths - if (['hash', 'list', 'stats'].includes(id)) return; - - const record = await uploadService.get(id); - if (!record) return c.json({ error: 'Media not found' }, 404); - return c.json(toResponse(record)); - }); - - // Get by hash - app.get('/hash/:hash', async (c) => { - const record = await uploadService.getByHash(c.req.param('hash')); - if (!record) return c.json({ error: 'Media not found' }, 404); - return c.json(toResponse(record)); - }); - - // List - app.get('/', async (c) => { - const records = await uploadService.list({ - app: c.req.query('app'), - userId: c.req.query('userId'), - limit: c.req.query('limit') ? parseInt(c.req.query('limit')!) : 50, - }); - return c.json(records.map(toResponse)); - }); - - // List all with advanced filtering - app.get('/list/all', async (c) => { - const userId = c.req.query('userId'); - if (!userId) return c.json({ error: 'userId is required' }, 400); - - const apps = c.req.query('apps'); - const result = await uploadService.listAll({ - userId, - apps: apps ? apps.split(',').map((a) => a.trim()) : undefined, - mimeType: c.req.query('mimeType'), - dateFrom: c.req.query('dateFrom') ? new Date(c.req.query('dateFrom')!) : undefined, - dateTo: c.req.query('dateTo') ? new Date(c.req.query('dateTo')!) : undefined, - hasLocation: c.req.query('hasLocation') === 'true', - limit: c.req.query('limit') ? parseInt(c.req.query('limit')!) : 50, - offset: c.req.query('offset') ? parseInt(c.req.query('offset')!) : 0, - sortBy: (c.req.query('sortBy') as 'createdAt' | 'dateTaken' | 'size') || 'createdAt', - sortOrder: (c.req.query('sortOrder') as 'asc' | 'desc') || 'desc', - }); - - return c.json({ - items: result.items.map(toResponse), - total: result.total, - hasMore: result.hasMore, - }); - }); - - // Stats - app.get('/stats', async (c) => { - const userId = c.req.query('userId'); - if (!userId) return c.json({ error: 'userId is required' }, 400); - return c.json(await uploadService.getStats(userId)); - }); - - // Delete - app.delete('/:id', async (c) => { - const deleted = await uploadService.delete(c.req.param('id')); - if (!deleted) return c.json({ error: 'Media not found' }, 404); - return c.json({ success: true }); - }); - - return app; -} diff --git a/services/mana-media/apps/api/src/services/exif.ts b/services/mana-media/apps/api/src/services/exif.ts deleted file mode 100644 index 8f089099c..000000000 --- a/services/mana-media/apps/api/src/services/exif.ts +++ /dev/null @@ -1,56 +0,0 @@ -import exifr from 'exifr'; - -export interface ExifData { - cameraMake?: string; - cameraModel?: string; - focalLength?: string; - aperture?: string; - iso?: number; - exposureTime?: string; - dateTaken?: Date; - gpsLatitude?: string; - gpsLongitude?: string; - raw?: Record; -} - -export class ExifService { - async extract(buffer: Buffer): Promise { - try { - const exif = await exifr.parse(buffer, { - gps: true, - tiff: true, - exif: true, - }); - - if (!exif) return null; - - const result: ExifData = { raw: exif }; - - if (exif.Make) result.cameraMake = String(exif.Make).trim(); - if (exif.Model) result.cameraModel = String(exif.Model).trim(); - if (exif.FocalLength) result.focalLength = `${exif.FocalLength}mm`; - if (exif.FNumber) result.aperture = String(exif.FNumber); - if (exif.ISO) result.iso = Number(exif.ISO); - if (exif.ExposureTime) { - result.exposureTime = - exif.ExposureTime < 1 - ? `1/${Math.round(1 / exif.ExposureTime)}` - : `${exif.ExposureTime}s`; - } - if (exif.DateTimeOriginal) { - result.dateTaken = new Date(exif.DateTimeOriginal); - } else if (exif.CreateDate) { - result.dateTaken = new Date(exif.CreateDate); - } - if (exif.latitude !== undefined && exif.longitude !== undefined) { - result.gpsLatitude = String(exif.latitude); - result.gpsLongitude = String(exif.longitude); - } - - return result; - } catch (error) { - console.warn(`Failed to extract EXIF data: ${error}`); - return null; - } - } -} diff --git a/services/mana-media/apps/api/src/services/process.ts b/services/mana-media/apps/api/src/services/process.ts deleted file mode 100644 index aaea4dd64..000000000 --- a/services/mana-media/apps/api/src/services/process.ts +++ /dev/null @@ -1,136 +0,0 @@ -import sharp from 'sharp'; -import { StorageService } from './storage'; -import { ExifService, type ExifData } from './exif'; -import { IMAGE_VARIANTS, SUPPORTED_IMAGE_TYPES } from '../constants'; - -export interface ProcessResult { - thumbnail?: string; - medium?: string; - large?: string; - metadata?: { - width?: number; - height?: number; - format?: string; - hasAlpha?: boolean; - }; - exif?: ExifData; -} - -export class ProcessService { - constructor( - private storage: StorageService, - private exifService: ExifService - ) {} - - async processImage( - mediaId: string, - originalKey: string, - mimeType: string - ): Promise { - if (!SUPPORTED_IMAGE_TYPES.includes(mimeType)) return {}; - - const originalBuffer = await this.storage.download(originalKey); - const image = sharp(originalBuffer); - const metadata = await image.metadata(); - const exifData = await this.exifService.extract(originalBuffer); - - const result: ProcessResult = { - metadata: { - width: metadata.width, - height: metadata.height, - format: metadata.format, - hasAlpha: metadata.hasAlpha, - }, - exif: exifData || undefined, - }; - - const basePath = originalKey.replace(/^originals\//, 'processed/').replace(/\.[^.]+$/, ''); - - // Thumbnail - const thumbKey = `${basePath}/thumb.webp`; - const thumbBuffer = await sharp(originalBuffer) - .resize(IMAGE_VARIANTS.thumbnail.width, IMAGE_VARIANTS.thumbnail.height, { - fit: IMAGE_VARIANTS.thumbnail.fit, - }) - .webp({ quality: 80 }) - .toBuffer(); - await this.storage.upload(thumbKey, thumbBuffer, 'image/webp'); - result.thumbnail = thumbKey; - - // Medium - if ( - (metadata.width || 0) > IMAGE_VARIANTS.medium.width || - (metadata.height || 0) > IMAGE_VARIANTS.medium.height - ) { - const mediumKey = `${basePath}/medium.webp`; - const mediumBuffer = await sharp(originalBuffer) - .resize(IMAGE_VARIANTS.medium.width, IMAGE_VARIANTS.medium.height, { - fit: IMAGE_VARIANTS.medium.fit, - withoutEnlargement: true, - }) - .webp({ quality: 85 }) - .toBuffer(); - await this.storage.upload(mediumKey, mediumBuffer, 'image/webp'); - result.medium = mediumKey; - } - - // Large - if ( - (metadata.width || 0) > IMAGE_VARIANTS.large.width || - (metadata.height || 0) > IMAGE_VARIANTS.large.height - ) { - const largeKey = `${basePath}/large.webp`; - const largeBuffer = await sharp(originalBuffer) - .resize(IMAGE_VARIANTS.large.width, IMAGE_VARIANTS.large.height, { - fit: IMAGE_VARIANTS.large.fit, - withoutEnlargement: true, - }) - .webp({ quality: 90 }) - .toBuffer(); - await this.storage.upload(largeKey, largeBuffer, 'image/webp'); - result.large = largeKey; - } - - return result; - } - - async transformImage( - buffer: Buffer, - options: { - width?: number; - height?: number; - fit?: 'cover' | 'contain' | 'fill' | 'inside' | 'outside'; - format?: 'webp' | 'jpeg' | 'png' | 'avif'; - quality?: number; - } - ): Promise { - let pipeline = sharp(buffer); - - if (options.width || options.height) { - pipeline = pipeline.resize(options.width, options.height, { - fit: options.fit || 'inside', - withoutEnlargement: true, - }); - } - - const format = options.format || 'webp'; - const quality = options.quality || 85; - - switch (format) { - case 'webp': - pipeline = pipeline.webp({ quality }); - break; - case 'jpeg': - pipeline = pipeline.jpeg({ quality }); - break; - case 'png': - pipeline = pipeline.png(); - break; - case 'avif': - pipeline = pipeline.avif({ quality }); - break; - } - - return pipeline.toBuffer(); - } -} diff --git a/services/mana-media/apps/api/src/services/sniff.ts b/services/mana-media/apps/api/src/services/sniff.ts deleted file mode 100644 index 692960d84..000000000 --- a/services/mana-media/apps/api/src/services/sniff.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Magic-byte sniffer for image MIME types. - * - * Why this exists: - * Browsers don't all recognise the same set of image formats. Chrome - * on macOS, for example, hands a HEIC file to the upload endpoint - * with `file.type === ''`, which the server then stores as - * `application/octet-stream`. The transform endpoint subsequently - * refuses to touch the row because `mimeType.startsWith('image/')` - * is false — even though the bytes on disk are a perfectly valid - * image. Sniffing the buffer's magic bytes at upload time fixes - * this at the source. - * - * The sniffer reads only the first ~16 bytes — cheap, synchronous, - * runs once per upload. Only image formats are detected; any other - * file type returns null so the caller can fall back to whatever the - * browser reported. - */ - -const ASCII = (s: string): number[] => Array.from(s, (c) => c.charCodeAt(0)); - -function bytesEqual(buf: Buffer, offset: number, expected: number[]): boolean { - if (offset + expected.length > buf.length) return false; - for (let i = 0; i < expected.length; i++) { - if (buf[offset + i] !== expected[i]) return false; - } - return true; -} - -// HEIF/HEIC family — major brand at offset 8, after the 4-byte size + -// `ftyp` marker at offset 4. List from ISO/IEC 23008-12 + 14496-12. -// AVIF shares the same container with a different brand. -const HEIC_BRANDS = ['heic', 'heix', 'heim', 'heis', 'hevc', 'hevx', 'mif1', 'msf1']; -const AVIF_BRANDS = ['avif', 'avis']; - -/** - * Inspect the first bytes of `buf` and return a canonical image MIME - * type if recognized, or null when nothing matches. Trustworthy - * substitute for `file.type` when the browser left it empty or - * defaulted it to `application/octet-stream`. - */ -export function sniffImageMimeType(buf: Buffer): string | null { - // JPEG — FF D8 FF - if (bytesEqual(buf, 0, [0xff, 0xd8, 0xff])) return 'image/jpeg'; - - // PNG — 89 50 4E 47 0D 0A 1A 0A - if (bytesEqual(buf, 0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) { - return 'image/png'; - } - - // GIF87a / GIF89a — 47 49 46 38 ... - if (bytesEqual(buf, 0, ASCII('GIF8'))) return 'image/gif'; - - // WebP — RIFF....WEBP at offset 8 - if (bytesEqual(buf, 0, ASCII('RIFF')) && bytesEqual(buf, 8, ASCII('WEBP'))) { - return 'image/webp'; - } - - // BMP — 42 4D - if (bytesEqual(buf, 0, [0x42, 0x4d])) return 'image/bmp'; - - // TIFF — 49 49 2A 00 (LE) or 4D 4D 00 2A (BE) - if ( - bytesEqual(buf, 0, [0x49, 0x49, 0x2a, 0x00]) || - bytesEqual(buf, 0, [0x4d, 0x4d, 0x00, 0x2a]) - ) { - return 'image/tiff'; - } - - // HEIC / HEIF / AVIF — `ftyp` at offset 4, brand at offset 8. - if (bytesEqual(buf, 4, ASCII('ftyp'))) { - const brand = buf.slice(8, 12).toString('ascii'); - if (HEIC_BRANDS.includes(brand)) return 'image/heic'; - if (AVIF_BRANDS.includes(brand)) return 'image/avif'; - } - - return null; -} diff --git a/services/mana-media/apps/api/src/services/storage.ts b/services/mana-media/apps/api/src/services/storage.ts deleted file mode 100644 index c535ff09e..000000000 --- a/services/mana-media/apps/api/src/services/storage.ts +++ /dev/null @@ -1,91 +0,0 @@ -import * as Minio from 'minio'; -import { Readable } from 'stream'; - -export interface StorageObject { - key: string; - bucket: string; - size: number; - contentType: string; - etag: string; -} - -export class StorageService { - private client: Minio.Client; - private bucket: string; - - constructor() { - this.client = new Minio.Client({ - endPoint: process.env.S3_ENDPOINT || 'localhost', - port: parseInt(process.env.S3_PORT || '9000'), - useSSL: process.env.S3_USE_SSL === 'true', - accessKey: process.env.S3_ACCESS_KEY || 'minioadmin', - secretKey: process.env.S3_SECRET_KEY || 'minioadmin', - }); - this.bucket = process.env.S3_BUCKET || 'mana-media'; - } - - async init() { - const exists = await this.client.bucketExists(this.bucket); - if (!exists) { - await this.client.makeBucket(this.bucket); - console.log(`Created bucket: ${this.bucket}`); - } - } - - async upload( - key: string, - data: Buffer | Readable, - contentType: string, - metadata?: Record - ): Promise { - const size = Buffer.isBuffer(data) ? data.length : undefined; - - await this.client.putObject(this.bucket, key, data, size, { - 'Content-Type': contentType, - ...metadata, - }); - - const stat = await this.client.statObject(this.bucket, key); - - return { - key, - bucket: this.bucket, - size: stat.size, - contentType, - etag: stat.etag, - }; - } - - async download(key: string): Promise { - const stream = await this.client.getObject(this.bucket, key); - const chunks: Buffer[] = []; - - return new Promise((resolve, reject) => { - stream.on('data', (chunk) => chunks.push(chunk)); - stream.on('end', () => resolve(Buffer.concat(chunks))); - stream.on('error', reject); - }); - } - - async getStream(key: string): Promise { - return this.client.getObject(this.bucket, key); - } - - async delete(key: string): Promise { - await this.client.removeObject(this.bucket, key); - } - - async exists(key: string): Promise { - try { - await this.client.statObject(this.bucket, key); - return true; - } catch { - return false; - } - } - - getPublicUrl(key: string): string { - const endpoint = process.env.S3_PUBLIC_URL || `http://localhost:9000/${this.bucket}`; - return `${endpoint}/${key}`; - } -} diff --git a/services/mana-media/apps/api/src/services/upload.ts b/services/mana-media/apps/api/src/services/upload.ts deleted file mode 100644 index c3e011831..000000000 --- a/services/mana-media/apps/api/src/services/upload.ts +++ /dev/null @@ -1,392 +0,0 @@ -import { Queue } from 'bullmq'; -import * as mime from 'mime-types'; -import * as crypto from 'crypto'; -import { eq, and, gte, lte, like, isNotNull, sql, desc, asc, inArray } from 'drizzle-orm'; -import type { Database } from '../db'; -import { StorageService } from './storage'; -import { PROCESS_QUEUE } from '../constants'; -import { - media, - mediaReferences, - type Media, - type NewMedia, - type NewMediaReference, -} from '../db/schema'; - -export interface MediaRecord { - id: string; - originalName: string | null; - mimeType: string; - size: number; - hash: string; - status: 'uploading' | 'processing' | 'ready' | 'failed'; - keys: { - original: string; - thumbnail?: string; - medium?: string; - large?: string; - }; - metadata?: { - width?: number; - height?: number; - format?: string; - hasAlpha?: boolean; - }; - exif?: { - cameraMake?: string; - cameraModel?: string; - dateTaken?: Date; - focalLength?: string; - aperture?: string; - iso?: number; - exposureTime?: string; - gpsLatitude?: string; - gpsLongitude?: string; - }; - createdAt: Date; - updatedAt: Date; -} - -export interface ListAllOptions { - userId: string; - apps?: string[]; - mimeType?: string; - dateFrom?: Date; - dateTo?: Date; - hasLocation?: boolean; - limit?: number; - offset?: number; - sortBy?: 'createdAt' | 'dateTaken' | 'size'; - sortOrder?: 'asc' | 'desc'; -} - -export interface ListAllResult { - items: MediaRecord[]; - total: number; - hasMore: boolean; -} - -export interface StatsResult { - totalCount: number; - totalSize: number; - byApp: Record; - byYear: Record; -} - -export class UploadService { - constructor( - private db: Database, - private storage: StorageService, - private processQueue: Queue - ) {} - - async upload( - buffer: Buffer, - originalname: string, - mimetype: string, - fileSize: number, - options?: { app?: string; userId?: string; skipProcessing?: boolean } - ): Promise { - const hash = this.computeHash(buffer); - - const existing = await this.findByHash(hash); - if (existing) { - if (options?.userId && options?.app) { - await this.createReference(existing.id, options.userId, options.app); - } - return this.toMediaRecord(existing); - } - - const ext = mime.extension(mimetype) || 'bin'; - const date = new Date(); - const datePath = `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}`; - const id = crypto.randomUUID(); - const originalKey = `originals/${datePath}/${id}.${ext}`; - - await this.storage.upload(originalKey, buffer, mimetype, { - 'x-amz-meta-original-name': originalname, - 'x-amz-meta-media-id': id, - }); - - const [inserted] = await this.db - .insert(media) - .values({ - id, - contentHash: hash, - originalName: originalname, - mimeType: mimetype, - size: fileSize, - originalKey, - status: options?.skipProcessing ? 'ready' : 'processing', - } satisfies NewMedia) - .returning(); - - if (options?.userId && options?.app) { - await this.createReference(inserted.id, options.userId, options.app); - } - - if (!options?.skipProcessing) { - await this.processQueue.add('process-media', { - mediaId: inserted.id, - mimeType: mimetype, - originalKey, - }); - } - - return this.toMediaRecord(inserted); - } - - async get(id: string): Promise { - const [result] = await this.db.select().from(media).where(eq(media.id, id)).limit(1); - return result ? this.toMediaRecord(result) : null; - } - - async getByHash(hash: string): Promise { - const result = await this.findByHash(hash); - return result ? this.toMediaRecord(result) : null; - } - - async update( - id: string, - updates: Partial< - Pick< - Media, - | 'status' - | 'thumbnailKey' - | 'mediumKey' - | 'largeKey' - | 'width' - | 'height' - | 'format' - | 'hasAlpha' - | 'exifData' - | 'dateTaken' - | 'cameraMake' - | 'cameraModel' - | 'focalLength' - | 'aperture' - | 'iso' - | 'exposureTime' - | 'gpsLatitude' - | 'gpsLongitude' - > - > - ): Promise { - const [updated] = await this.db - .update(media) - .set({ ...updates, updatedAt: new Date() }) - .where(eq(media.id, id)) - .returning(); - return updated ? this.toMediaRecord(updated) : null; - } - - async delete(id: string): Promise { - const [record] = await this.db.select().from(media).where(eq(media.id, id)).limit(1); - if (!record) return false; - - const keys = [ - record.originalKey, - record.thumbnailKey, - record.mediumKey, - record.largeKey, - ].filter(Boolean) as string[]; - for (const key of keys) { - await this.storage.delete(key).catch(() => {}); - } - - await this.db.delete(media).where(eq(media.id, id)); - return true; - } - - async list(options?: { app?: string; userId?: string; limit?: number }): Promise { - if (options?.userId || options?.app) { - const conditions = []; - if (options.userId) conditions.push(eq(mediaReferences.userId, options.userId)); - if (options.app) conditions.push(eq(mediaReferences.app, options.app)); - - const results = await this.db - .select({ media: media }) - .from(media) - .innerJoin(mediaReferences, eq(media.id, mediaReferences.mediaId)) - .where(conditions.length === 1 ? conditions[0] : and(...conditions)) - .limit(options.limit || 50); - - return results.map((r) => this.toMediaRecord(r.media)); - } - - const results = await this.db - .select() - .from(media) - .orderBy(media.createdAt) - .limit(options?.limit || 50); - return results.map((r) => this.toMediaRecord(r)); - } - - async listAll(options: ListAllOptions): Promise { - const conditions = [eq(mediaReferences.userId, options.userId)]; - - if (options.apps && options.apps.length > 0) { - conditions.push(inArray(mediaReferences.app, options.apps)); - } - if (options.mimeType) { - if (options.mimeType.endsWith('/*')) { - conditions.push(like(media.mimeType, `${options.mimeType.slice(0, -1)}%`)); - } else { - conditions.push(eq(media.mimeType, options.mimeType)); - } - } - if (options.dateFrom) conditions.push(gte(media.createdAt, options.dateFrom)); - if (options.dateTo) conditions.push(lte(media.createdAt, options.dateTo)); - if (options.hasLocation) { - conditions.push(isNotNull(media.gpsLatitude)); - conditions.push(isNotNull(media.gpsLongitude)); - } - conditions.push(eq(media.status, 'ready')); - - const orderColumn = - options.sortBy === 'dateTaken' - ? media.dateTaken - : options.sortBy === 'size' - ? media.size - : media.createdAt; - const orderFn = options.sortOrder === 'asc' ? asc : desc; - - const countResult = await this.db - .select({ count: sql`count(distinct ${media.id})` }) - .from(media) - .innerJoin(mediaReferences, eq(media.id, mediaReferences.mediaId)) - .where(and(...conditions)); - const total = Number(countResult[0]?.count || 0); - - const limit = options.limit || 50; - const offset = options.offset || 0; - - const results = await this.db - .selectDistinct({ media: media }) - .from(media) - .innerJoin(mediaReferences, eq(media.id, mediaReferences.mediaId)) - .where(and(...conditions)) - .orderBy(orderFn(orderColumn)) - .limit(limit) - .offset(offset); - - return { - items: results.map((r) => this.toMediaRecord(r.media)), - total, - hasMore: offset + results.length < total, - }; - } - - async getStats(userId: string): Promise { - const totalResult = await this.db - .select({ - count: sql`count(distinct ${media.id})`, - size: sql`sum(${media.size})`, - }) - .from(media) - .innerJoin(mediaReferences, eq(media.id, mediaReferences.mediaId)) - .where(eq(mediaReferences.userId, userId)); - - const byAppResult = await this.db - .select({ - app: mediaReferences.app, - count: sql`count(distinct ${media.id})`, - size: sql`sum(${media.size})`, - }) - .from(media) - .innerJoin(mediaReferences, eq(media.id, mediaReferences.mediaId)) - .where(eq(mediaReferences.userId, userId)) - .groupBy(mediaReferences.app); - - const byYearResult = await this.db - .select({ - year: sql`extract(year from ${media.createdAt})::text`, - count: sql`count(distinct ${media.id})`, - }) - .from(media) - .innerJoin(mediaReferences, eq(media.id, mediaReferences.mediaId)) - .where(eq(mediaReferences.userId, userId)) - .groupBy(sql`extract(year from ${media.createdAt})`); - - const byApp: Record = {}; - for (const row of byAppResult) { - byApp[row.app] = { count: Number(row.count), size: Number(row.size) }; - } - - const byYear: Record = {}; - for (const row of byYearResult) { - byYear[row.year] = Number(row.count); - } - - return { - totalCount: Number(totalResult[0]?.count || 0), - totalSize: Number(totalResult[0]?.size || 0), - byApp, - byYear, - }; - } - - private async findByHash(hash: string): Promise { - const [result] = await this.db.select().from(media).where(eq(media.contentHash, hash)).limit(1); - return result || null; - } - - private async createReference( - mediaId: string, - userId: string, - app: string, - sourceUrl?: string - ): Promise { - await this.db.insert(mediaReferences).values({ - mediaId, - userId, - app, - sourceUrl: sourceUrl || null, - } satisfies NewMediaReference); - } - - private computeHash(buffer: Buffer): string { - return crypto.createHash('sha256').update(buffer).digest('hex'); - } - - private toMediaRecord(m: Media): MediaRecord { - return { - id: m.id, - originalName: m.originalName, - mimeType: m.mimeType, - size: Number(m.size), - hash: m.contentHash, - status: m.status as MediaRecord['status'], - keys: { - original: m.originalKey, - thumbnail: m.thumbnailKey || undefined, - medium: m.mediumKey || undefined, - large: m.largeKey || undefined, - }, - metadata: m.width - ? { - width: m.width || undefined, - height: m.height || undefined, - format: m.format || undefined, - hasAlpha: m.hasAlpha || undefined, - } - : undefined, - exif: - m.cameraMake || m.dateTaken || m.gpsLatitude - ? { - cameraMake: m.cameraMake || undefined, - cameraModel: m.cameraModel || undefined, - dateTaken: m.dateTaken || undefined, - focalLength: m.focalLength || undefined, - aperture: m.aperture || undefined, - iso: m.iso || undefined, - exposureTime: m.exposureTime || undefined, - gpsLatitude: m.gpsLatitude || undefined, - gpsLongitude: m.gpsLongitude || undefined, - } - : undefined, - createdAt: m.createdAt, - updatedAt: m.updatedAt, - }; - } -} diff --git a/services/mana-media/apps/api/test-image.png b/services/mana-media/apps/api/test-image.png deleted file mode 100644 index e69de29bb..000000000 diff --git a/services/mana-media/apps/api/tsconfig.json b/services/mana-media/apps/api/tsconfig.json deleted file mode 100644 index 321d0b537..000000000 --- a/services/mana-media/apps/api/tsconfig.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "bundler", - "types": ["bun-types"], - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "strict": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "outDir": "./dist", - "declaration": true, - "noEmit": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/services/mana-media/docker-compose.yml b/services/mana-media/docker-compose.yml deleted file mode 100644 index 74265e8f2..000000000 --- a/services/mana-media/docker-compose.yml +++ /dev/null @@ -1,63 +0,0 @@ -services: - api: - build: - context: . - dockerfile: apps/api/Dockerfile - container_name: mana-media-api - restart: unless-stopped - ports: - - "3050:3050" - environment: - - NODE_ENV=production - - PORT=3050 - - REDIS_HOST=redis - - REDIS_PORT=6379 - - S3_ENDPOINT=minio - - S3_PORT=9000 - - S3_USE_SSL=false - - S3_ACCESS_KEY=${S3_ACCESS_KEY:-minioadmin} - - S3_SECRET_KEY=${S3_SECRET_KEY:-minioadmin} - - S3_BUCKET=mana-media - depends_on: - - redis - - minio - healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3050/api/v1/health"] - interval: 30s - timeout: 10s - retries: 3 - - redis: - image: redis:7-alpine - container_name: mana-media-redis - restart: unless-stopped - volumes: - - redis_data:/data - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 10s - timeout: 5s - retries: 5 - - minio: - image: minio/minio:latest - container_name: mana-media-minio - restart: unless-stopped - command: server /data --console-address ":9001" - environment: - - MINIO_ROOT_USER=${S3_ACCESS_KEY:-minioadmin} - - MINIO_ROOT_PASSWORD=${S3_SECRET_KEY:-minioadmin} - volumes: - - minio_data:/data - ports: - - "9010:9000" - - "9011:9001" - healthcheck: - test: ["CMD", "mc", "ready", "local"] - interval: 30s - timeout: 20s - retries: 3 - -volumes: - redis_data: - minio_data: diff --git a/services/mana-media/drizzle.config.ts b/services/mana-media/drizzle.config.ts deleted file mode 100644 index bec134e34..000000000 --- a/services/mana-media/drizzle.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { createDrizzleConfig } from '@mana/shared-drizzle-config'; - -export default createDrizzleConfig('./src/db/schema'); diff --git a/services/mana-media/nest-cli.json b/services/mana-media/nest-cli.json deleted file mode 100644 index 95538fb90..000000000 --- a/services/mana-media/nest-cli.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/nest-cli", - "collection": "@nestjs/schematics", - "sourceRoot": "src", - "compilerOptions": { - "deleteOutDir": true - } -} diff --git a/services/mana-media/package.json b/services/mana-media/package.json deleted file mode 100644 index 74a700680..000000000 --- a/services/mana-media/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "@mana-media/root", - "private": true, - "scripts": { - "dev": "pnpm --filter @mana-media/api dev", - "start": "pnpm --filter @mana-media/api start", - "type-check": "tsc --noEmit -p apps/api/tsconfig.json && tsc --noEmit -p packages/client/tsconfig.json" - } -} diff --git a/services/mana-media/packages/client/package.json b/services/mana-media/packages/client/package.json deleted file mode 100644 index 7c5277fe0..000000000 --- a/services/mana-media/packages/client/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "@mana/media-client", - "version": "0.1.0", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "scripts": { - "build": "tsc", - "type-check": "tsc --noEmit" - }, - "dependencies": {}, - "devDependencies": { - "typescript": "^5.7.0", - "@types/node": "^22.0.0" - } -} diff --git a/services/mana-media/packages/client/src/index.ts b/services/mana-media/packages/client/src/index.ts deleted file mode 100644 index 56a32f238..000000000 --- a/services/mana-media/packages/client/src/index.ts +++ /dev/null @@ -1,224 +0,0 @@ -export interface MediaResult { - id: string; - status: 'uploading' | 'processing' | 'ready' | 'failed'; - originalName: string | null; - mimeType: string; - size: number; - hash: string; - urls: { - original: string; - thumbnail?: string; - medium?: string; - large?: string; - }; - createdAt: Date; -} - -export interface UploadOptions { - app?: string; - userId?: string; - skipProcessing?: boolean; -} - -export interface SearchOptions { - type?: 'image' | 'video' | 'audio' | 'document'; - app?: string; - limit?: number; -} - -export interface TransformOptions { - width?: number; - height?: number; - fit?: 'cover' | 'contain' | 'fill' | 'inside' | 'outside'; - format?: 'webp' | 'jpeg' | 'png' | 'avif'; - quality?: number; -} - -export class MediaClient { - private baseUrl: string; - private apiKey?: string; - - constructor(baseUrl: string, apiKey?: string) { - this.baseUrl = baseUrl.replace(/\/$/, ''); - this.apiKey = apiKey; - } - - /** - * Upload a file to the media service - */ - async upload( - file: File | Blob | ArrayBuffer, - options?: UploadOptions & { filename?: string } - ): Promise { - const formData = new FormData(); - - if (file instanceof ArrayBuffer) { - // ArrayBuffer (works in both Node.js and browser) - const blob = new Blob([file]); - formData.append('file', blob, options?.filename || 'file'); - } else { - // Browser File/Blob - formData.append('file', file, options?.filename); - } - - if (options?.app) formData.append('app', options.app); - if (options?.userId) formData.append('userId', options.userId); - if (options?.skipProcessing) formData.append('skipProcessing', 'true'); - - const response = await fetch(`${this.baseUrl}/api/v1/media/upload`, { - method: 'POST', - headers: this.getHeaders(), - body: formData, - }); - - if (!response.ok) { - throw new Error(`Upload failed: ${response.statusText}`); - } - - return response.json(); - } - - /** - * Get media by content hash (SHA-256) - * Useful for checking if a file already exists before uploading - */ - async getByHash(hash: string): Promise { - const response = await fetch(`${this.baseUrl}/api/v1/media/hash/${hash}`, { - headers: this.getHeaders(), - }); - - if (response.status === 404) { - return null; - } - - if (!response.ok) { - throw new Error(`Get by hash failed: ${response.statusText}`); - } - - return response.json(); - } - - /** - * Get media by ID - */ - async get(id: string): Promise { - const response = await fetch(`${this.baseUrl}/api/v1/media/${id}`, { - headers: this.getHeaders(), - }); - - if (!response.ok) { - throw new Error(`Get failed: ${response.statusText}`); - } - - return response.json(); - } - - /** - * List media - */ - async list(options?: { app?: string; userId?: string; limit?: number }): Promise { - const params = new URLSearchParams(); - if (options?.app) params.append('app', options.app); - if (options?.userId) params.append('userId', options.userId); - if (options?.limit) params.append('limit', options.limit.toString()); - - const response = await fetch(`${this.baseUrl}/api/v1/media?${params}`, { - headers: this.getHeaders(), - }); - - if (!response.ok) { - throw new Error(`List failed: ${response.statusText}`); - } - - return response.json(); - } - - /** - * Delete media - */ - async delete(id: string): Promise { - const response = await fetch(`${this.baseUrl}/api/v1/media/${id}`, { - method: 'DELETE', - headers: this.getHeaders(), - }); - - return response.ok; - } - - /** - * Get URL for original file - */ - getOriginalUrl(id: string): string { - return `${this.baseUrl}/api/v1/media/${id}/file`; - } - - /** - * Get URL for thumbnail - */ - getThumbnailUrl(id: string): string { - return `${this.baseUrl}/api/v1/media/${id}/file/thumb`; - } - - /** - * Get URL for medium variant - */ - getMediumUrl(id: string): string { - return `${this.baseUrl}/api/v1/media/${id}/file/medium`; - } - - /** - * Get URL for large variant - */ - getLargeUrl(id: string): string { - return `${this.baseUrl}/api/v1/media/${id}/file/large`; - } - - /** - * Get URL for custom transform - */ - getTransformUrl(id: string, options: TransformOptions): string { - const params = new URLSearchParams(); - if (options.width) params.append('w', options.width.toString()); - if (options.height) params.append('h', options.height.toString()); - if (options.fit) params.append('fit', options.fit); - if (options.format) params.append('format', options.format); - if (options.quality) params.append('q', options.quality.toString()); - - return `${this.baseUrl}/api/v1/media/${id}/transform?${params}`; - } - - /** - * Wait for media processing to complete - */ - async waitForReady(id: string, timeoutMs = 30000, pollIntervalMs = 1000): Promise { - const startTime = Date.now(); - - while (Date.now() - startTime < timeoutMs) { - const result = await this.get(id); - - if (result.status === 'ready') { - return result; - } - - if (result.status === 'failed') { - throw new Error('Media processing failed'); - } - - await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); - } - - throw new Error('Timeout waiting for media to be ready'); - } - - private getHeaders(): Record { - const headers: Record = {}; - - if (this.apiKey) { - headers['Authorization'] = `Bearer ${this.apiKey}`; - } - - return headers; - } -} - -export default MediaClient; diff --git a/services/mana-media/packages/client/tsconfig.json b/services/mana-media/packages/client/tsconfig.json deleted file mode 100644 index 0c2f45e96..000000000 --- a/services/mana-media/packages/client/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "commonjs", - "declaration": true, - "outDir": "./dist", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/services/mana-media/tsconfig.json b/services/mana-media/tsconfig.json deleted file mode 100644 index 64b3ce4c1..000000000 --- a/services/mana-media/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "bundler", - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "strict": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true - }, - "include": ["apps/**/*", "packages/**/*"], - "exclude": ["node_modules", "dist"] -}