mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:01:09 +02:00
chore(cutover): remove services/mana-media/ — moved to mana-platform
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) <noreply@anthropic.com>
This commit is contained in:
parent
af8ef60fe4
commit
fcc36eadcb
31 changed files with 0 additions and 2771 deletions
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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'],
|
||||
});
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import * as schema from './db/schema';
|
||||
|
||||
let connection: ReturnType<typeof postgres> | null = null;
|
||||
let db: ReturnType<typeof drizzle> | 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<typeof getDb>;
|
||||
|
|
@ -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<void> {
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1775759324563,
|
||||
"tag": "0000_marvelous_micromacro",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export * from './media.schema';
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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<Variant, string | undefined> = {
|
||||
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<string, string> = {
|
||||
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<ArrayBuffer>` (strict,
|
||||
// not ArrayBufferLike). Node's `Buffer<ArrayBufferLike>` 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
export class ExifService {
|
||||
async extract(buffer: Buffer): Promise<ExifData | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ProcessResult> {
|
||||
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<Buffer> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<string, string>
|
||||
): Promise<StorageObject> {
|
||||
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<Buffer> {
|
||||
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<Readable> {
|
||||
return this.client.getObject(this.bucket, key);
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
await this.client.removeObject(this.bucket, key);
|
||||
}
|
||||
|
||||
async exists(key: string): Promise<boolean> {
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, { count: number; size: number }>;
|
||||
byYear: Record<string, number>;
|
||||
}
|
||||
|
||||
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<MediaRecord> {
|
||||
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<MediaRecord | null> {
|
||||
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<MediaRecord | null> {
|
||||
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<MediaRecord | null> {
|
||||
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<boolean> {
|
||||
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<MediaRecord[]> {
|
||||
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<ListAllResult> {
|
||||
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<number>`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<StatsResult> {
|
||||
const totalResult = await this.db
|
||||
.select({
|
||||
count: sql<number>`count(distinct ${media.id})`,
|
||||
size: sql<number>`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<number>`count(distinct ${media.id})`,
|
||||
size: sql<number>`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<string>`extract(year from ${media.createdAt})::text`,
|
||||
count: sql<number>`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<string, { count: number; size: number }> = {};
|
||||
for (const row of byAppResult) {
|
||||
byApp[row.app] = { count: Number(row.count), size: Number(row.size) };
|
||||
}
|
||||
|
||||
const byYear: Record<string, number> = {};
|
||||
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<Media | null> {
|
||||
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<void> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import { createDrizzleConfig } from '@mana/shared-drizzle-config';
|
||||
|
||||
export default createDrizzleConfig('./src/db/schema');
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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<MediaResult> {
|
||||
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<MediaResult | null> {
|
||||
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<MediaResult> {
|
||||
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<MediaResult[]> {
|
||||
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<boolean> {
|
||||
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<MediaResult> {
|
||||
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<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (this.apiKey) {
|
||||
headers['Authorization'] = `Bearer ${this.apiKey}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
|
||||
export default MediaClient;
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue