diff --git a/docker-compose.macmini.yml b/docker-compose.macmini.yml index a6e7a1c7d..cdbdaf4df 100644 --- a/docker-compose.macmini.yml +++ b/docker-compose.macmini.yml @@ -569,8 +569,7 @@ services: mana-media: build: - context: . - dockerfile: services/mana-media/apps/api/Dockerfile + context: services/mana-media/apps/api image: mana-media:local container_name: mana-core-media restart: always @@ -601,11 +600,11 @@ services: ports: - "3011:3011" healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3011/api/v1/health"] + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3011/health"] interval: 120s timeout: 10s retries: 3 - start_period: 40s + start_period: 10s mana-landing-builder: build: diff --git a/services/mana-media/CLAUDE.md b/services/mana-media/CLAUDE.md index 9ceacf41a..8b908914d 100644 --- a/services/mana-media/CLAUDE.md +++ b/services/mana-media/CLAUDE.md @@ -2,6 +2,8 @@ Central media handling service for all ManaCore applications with content-addressable storage (CAS) and automatic deduplication. +**Stack:** Hono + Bun (migrated from NestJS 2026-03-28) + ## Overview mana-media provides: @@ -195,16 +197,14 @@ const customUrl = media.getTransformUrl(result.id, { ## Development ```bash -# Run with watch mode +# Run with watch mode (Bun) pnpm dev # Type check pnpm type-check -# Build -pnpm build - # Database commands +cd apps/api pnpm db:push # Push schema to database pnpm db:studio # Open Drizzle Studio ``` diff --git a/services/mana-media/apps/api/Dockerfile b/services/mana-media/apps/api/Dockerfile index 37fc36b86..09a8c9ef4 100644 --- a/services/mana-media/apps/api/Dockerfile +++ b/services/mana-media/apps/api/Dockerfile @@ -1,58 +1,16 @@ -# syntax=docker/dockerfile:1 -# ================================ -# Build Stage -# ================================ -FROM node:20-alpine AS builder -RUN corepack enable && corepack prepare pnpm@9.15.0 --activate +FROM oven/bun:1 AS production + WORKDIR /app -# Copy all necessary files -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -COPY patches ./patches -COPY packages/shared-drizzle-config ./packages/shared-drizzle-config -COPY services/mana-media ./services/mana-media +COPY package.json bun.lock* ./ +COPY src ./src -# Install all dependencies -RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile +RUN bun install --production --frozen-lockfile -# Build the API -WORKDIR /app/services/mana-media/apps/api -RUN pnpm build - -# ================================ -# Production Stage -# ================================ -FROM node:20-alpine AS runner -RUN apk add --no-cache wget -ENV NODE_ENV=production - -# Create non-root user -RUN addgroup --system --gid 1001 nodejs && \ - adduser --system --uid 1001 nestjs - -# Keep same directory structure so pnpm symlinks work -WORKDIR /app/services/mana-media/apps/api - -# Copy the pnpm store that symlinks point to -COPY --from=builder --chown=nestjs:nodejs /app/node_modules/.pnpm /app/node_modules/.pnpm - -# Copy the app's node_modules (contains symlinks to pnpm store) -COPY --from=builder --chown=nestjs:nodejs /app/services/mana-media/apps/api/node_modules ./node_modules - -# Copy shared packages that are symlinked -COPY --from=builder --chown=nestjs:nodejs /app/packages/shared-drizzle-config /app/packages/shared-drizzle-config -# Copy built application -COPY --from=builder --chown=nestjs:nodejs /app/services/mana-media/apps/api/dist ./dist -COPY --from=builder --chown=nestjs:nodejs /app/services/mana-media/apps/api/package.json ./ - -USER nestjs - -# Expose port EXPOSE 3015 -# Health check HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:3015/api/v1/health || exit 1 + CMD wget --no-verbose --tries=1 --spider http://localhost:3015/health || exit 1 -# Start the application -CMD ["node", "dist/src/main"] +USER bun +CMD ["bun", "run", "src/index.ts"] diff --git a/services/mana-media/apps/api/nest-cli.json b/services/mana-media/apps/api/nest-cli.json deleted file mode 100644 index 68d1974c4..000000000 --- a/services/mana-media/apps/api/nest-cli.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/nest-cli", - "collection": "@nestjs/schematics", - "sourceRoot": "src" -} diff --git a/services/mana-media/apps/api/package.json b/services/mana-media/apps/api/package.json index 8cb8f1c94..471f634db 100644 --- a/services/mana-media/apps/api/package.json +++ b/services/mana-media/apps/api/package.json @@ -1,44 +1,29 @@ { "name": "@mana-media/api", - "version": "0.1.0", + "version": "0.2.0", "private": true, "scripts": { - "dev": "nest start --watch", - "build": "nest build", - "start": "nest start", - "start:prod": "node dist/main", + "dev": "bun run --watch src/index.ts", + "start": "bun run src/index.ts", "type-check": "tsc --noEmit", - "lint": "eslint 'src/**/*.ts'", "db:push": "drizzle-kit push", "db:studio": "drizzle-kit studio" }, "dependencies": { - "@nestjs/bullmq": "^11.0.0", - "@nestjs/common": "^11.0.0", - "@nestjs/config": "^3.3.0", - "@nestjs/core": "^11.0.0", - "@nestjs/platform-express": "^11.0.0", "bullmq": "^5.34.0", "drizzle-orm": "^0.38.3", - "express": "^4.21.0", + "exifr": "^7.1.3", + "hono": "^4.7.0", "mime-types": "^2.1.35", "minio": "^8.0.0", "postgres": "^3.4.5", - "reflect-metadata": "^0.2.0", "prom-client": "^15.1.0", - "rxjs": "^7.8.0", - "sharp": "^0.33.0", - "uuid": "^11.0.0", - "exifr": "^7.1.3" + "sharp": "^0.33.0" }, "devDependencies": { "@manacore/shared-drizzle-config": "workspace:*", - "@nestjs/cli": "^11.0.0", - "@types/express": "^5.0.0", "@types/mime-types": "^2.1.4", - "@types/multer": "^2.0.0", "@types/node": "^22.0.0", - "@types/uuid": "^10.0.0", "drizzle-kit": "^0.30.1", "typescript": "^5.7.0" } diff --git a/services/mana-media/apps/api/src/app.module.ts b/services/mana-media/apps/api/src/app.module.ts deleted file mode 100644 index 9ebe171a2..000000000 --- a/services/mana-media/apps/api/src/app.module.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { BullModule } from '@nestjs/bullmq'; -import { DatabaseModule } from './db/database.module'; -import { UploadModule } from './modules/upload/upload.module'; -import { StorageModule } from './modules/storage/storage.module'; -import { ProcessModule } from './modules/process/process.module'; -import { DeliveryModule } from './modules/delivery/delivery.module'; -import { MatrixModule } from './modules/matrix/matrix.module'; -import { HealthController } from './health.controller'; -import { MetricsController } from './metrics.controller'; - -@Module({ - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - }), - BullModule.forRoot({ - connection: { - host: process.env.REDIS_HOST || 'localhost', - port: parseInt(process.env.REDIS_PORT || '6379'), - password: process.env.REDIS_PASSWORD || undefined, - }, - }), - DatabaseModule, - StorageModule, - UploadModule, - ProcessModule, - DeliveryModule, - MatrixModule, - ], - controllers: [HealthController, MetricsController], -}) -export class AppModule {} diff --git a/services/mana-media/apps/api/src/modules/process/process.constants.ts b/services/mana-media/apps/api/src/constants.ts similarity index 53% rename from services/mana-media/apps/api/src/modules/process/process.constants.ts rename to services/mana-media/apps/api/src/constants.ts index 6d17327c7..34b16d87e 100644 --- a/services/mana-media/apps/api/src/modules/process/process.constants.ts +++ b/services/mana-media/apps/api/src/constants.ts @@ -15,13 +15,3 @@ export const SUPPORTED_IMAGE_TYPES = [ 'image/heic', 'image/heif', ]; - -export const SUPPORTED_VIDEO_TYPES = ['video/mp4', 'video/quicktime', 'video/webm', 'video/mpeg']; - -export const SUPPORTED_AUDIO_TYPES = ['audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/webm']; - -export const SUPPORTED_DOCUMENT_TYPES = [ - 'application/pdf', - 'application/msword', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', -]; diff --git a/services/mana-media/apps/api/src/db/connection.ts b/services/mana-media/apps/api/src/db.ts similarity index 61% rename from services/mana-media/apps/api/src/db/connection.ts rename to services/mana-media/apps/api/src/db.ts index caa7eaf45..53a0d21cb 100644 --- a/services/mana-media/apps/api/src/db/connection.ts +++ b/services/mana-media/apps/api/src/db.ts @@ -1,27 +1,18 @@ import { drizzle } from 'drizzle-orm/postgres-js'; -import * as schema from './schema'; - -// Use require for postgres to avoid ESM/CommonJS interop issues -const postgres = require('postgres'); +import postgres from 'postgres'; +import * as schema from './db/schema'; let connection: ReturnType | null = null; let db: ReturnType | null = null; -export function getConnection(databaseUrl: string) { - if (!connection) { +export function getDb(databaseUrl: string) { + if (!db) { connection = postgres(databaseUrl, { max: 10, idle_timeout: 20, connect_timeout: 10, }); - } - return connection; -} - -export function getDb(databaseUrl: string) { - if (!db) { - const conn = getConnection(databaseUrl); - db = drizzle(conn, { schema }); + db = drizzle(connection, { schema }); } return db; } diff --git a/services/mana-media/apps/api/src/db/database.module.ts b/services/mana-media/apps/api/src/db/database.module.ts deleted file mode 100644 index 394b230c6..000000000 --- a/services/mana-media/apps/api/src/db/database.module.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Module, Global } from '@nestjs/common'; -import type { OnModuleDestroy } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { getDb, closeConnection } from './connection'; -import type { Database } from './connection'; - -export const DATABASE_CONNECTION = 'DATABASE_CONNECTION'; - -@Global() -@Module({ - providers: [ - { - provide: DATABASE_CONNECTION, - useFactory: (configService: ConfigService): Database => { - const databaseUrl = configService.get('DATABASE_URL'); - if (!databaseUrl) { - throw new Error('DATABASE_URL environment variable is not set'); - } - return getDb(databaseUrl); - }, - inject: [ConfigService], - }, - ], - exports: [DATABASE_CONNECTION], -}) -export class DatabaseModule implements OnModuleDestroy { - async onModuleDestroy() { - await closeConnection(); - } -} diff --git a/services/mana-media/apps/api/src/health.controller.ts b/services/mana-media/apps/api/src/health.controller.ts deleted file mode 100644 index 2bc143749..000000000 --- a/services/mana-media/apps/api/src/health.controller.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Controller, Get } from '@nestjs/common'; - -@Controller() -export class HealthController { - @Get('health') - health() { - return { - status: 'ok', - service: 'mana-media', - timestamp: new Date().toISOString(), - }; - } -} diff --git a/services/mana-media/apps/api/src/index.ts b/services/mana-media/apps/api/src/index.ts new file mode 100644 index 000000000..3983a726f --- /dev/null +++ b/services/mana-media/apps/api/src/index.ts @@ -0,0 +1,165 @@ +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 { StorageService } from './services/storage'; +import { UploadService } from './services/upload'; +import { ProcessService } from './services/process'; +import { ExifService } from './services/exif'; +import { MatrixService } from './services/matrix'; +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'); +const db = getDb(databaseUrl); + +// Services +const storage = new StorageService(); +await storage.init(); + +const exifService = new ExifService(); +const matrixService = new MatrixService(); +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, matrixService, 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/main.ts b/services/mana-media/apps/api/src/main.ts deleted file mode 100644 index 7a4ccd331..000000000 --- a/services/mana-media/apps/api/src/main.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { NestFactory } from '@nestjs/core'; -import { ValidationPipe, Logger } from '@nestjs/common'; -import { json, urlencoded } from 'express'; -import { AppModule } from './app.module'; - -async function bootstrap() { - const logger = new Logger('Bootstrap'); - const app = await NestFactory.create(AppModule); - - app.setGlobalPrefix('api/v1'); - - // Increase body size limit for large file uploads - app.use(json({ limit: '100mb' })); - app.use(urlencoded({ extended: true, limit: '100mb' })); - - app.useGlobalPipes( - new ValidationPipe({ - whitelist: true, - transform: true, - forbidNonWhitelisted: true, - }) - ); - - app.enableCors({ - origin: process.env.CORS_ORIGINS?.split(',') || '*', - credentials: true, - }); - - const port = process.env.PORT || 3015; - await app.listen(port); - - logger.log(`Mana Media service running on port ${port}`); - logger.log(`Health check: http://localhost:${port}/health`); -} - -bootstrap(); diff --git a/services/mana-media/apps/api/src/metrics.controller.ts b/services/mana-media/apps/api/src/metrics.controller.ts deleted file mode 100644 index 8f94977fa..000000000 --- a/services/mana-media/apps/api/src/metrics.controller.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Controller, Get, Res } from '@nestjs/common'; -import { Response } from 'express'; -import { collectDefaultMetrics, Registry, Counter, Histogram } from 'prom-client'; - -const register = new Registry(); -register.setDefaultLabels({ service: 'mana-media' }); -collectDefaultMetrics({ register, prefix: 'media_' }); - -export const httpRequestsTotal = new Counter({ - name: 'media_http_requests_total', - help: 'Total HTTP requests', - labelNames: ['method', 'path', 'status'], - registers: [register], -}); - -export const httpRequestDuration = new Histogram({ - name: 'media_http_request_duration_seconds', - help: 'HTTP request duration in seconds', - labelNames: ['method', 'path', 'status'], - buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], - registers: [register], -}); - -@Controller('metrics') -export class MetricsController { - @Get() - async getMetrics(@Res() res: Response) { - res.set('Content-Type', register.contentType); - res.end(await register.metrics()); - } -} diff --git a/services/mana-media/apps/api/src/modules/delivery/delivery.controller.ts b/services/mana-media/apps/api/src/modules/delivery/delivery.controller.ts deleted file mode 100644 index 57877d1a2..000000000 --- a/services/mana-media/apps/api/src/modules/delivery/delivery.controller.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { - Controller, - Get, - Param, - Query, - Res, - NotFoundException, - BadRequestException, -} from '@nestjs/common'; -import { Response } from 'express'; -import { UploadService } from '../upload/upload.service'; -import { ProcessService } from '../process/process.service'; -import { StorageService } from '../storage/storage.service'; - -type Variant = 'thumb' | 'medium' | 'large'; - -@Controller('media') -export class DeliveryController { - constructor( - private uploadService: UploadService, - private processService: ProcessService, - private storage: StorageService - ) {} - - @Get(':id/file') - async getOriginal(@Param('id') id: string, @Res() res: Response): Promise { - const record = await this.uploadService.get(id); - if (!record) { - throw new NotFoundException('Media not found'); - } - - await this.streamFile(res, record.keys.original, record.mimeType); - } - - @Get(':id/file/:variant') - async getVariant( - @Param('id') id: string, - @Param('variant') variant: Variant, - @Res() res: Response - ): Promise { - const record = await this.uploadService.get(id); - if (!record) { - throw new NotFoundException('Media not found'); - } - - const variantMap: Record = { - thumb: record.keys.thumbnail, - medium: record.keys.medium, - large: record.keys.large, - }; - - const key = variantMap[variant]; - if (!key) { - // Fallback to original if variant doesn't exist - await this.streamFile(res, record.keys.original, record.mimeType); - return; - } - - await this.streamFile(res, key, 'image/webp'); - } - - @Get(':id/transform') - async transform( - @Param('id') id: string, - @Query('w') width?: string, - @Query('h') height?: string, - @Query('fit') fit?: string, - @Query('format') format?: string, - @Query('q') quality?: string, - @Res() res?: Response - ): Promise { - if (!res) return; - - const record = await this.uploadService.get(id); - if (!record) { - throw new NotFoundException('Media not found'); - } - - if (!record.mimeType.startsWith('image/')) { - throw new BadRequestException('Transform only supported for images'); - } - - // Download original - const originalBuffer = await this.storage.download(record.keys.original); - - // Transform - const transformedBuffer = await this.processService.transformImage(originalBuffer, { - width: width ? parseInt(width) : undefined, - height: height ? parseInt(height) : undefined, - fit: (fit as 'cover' | 'contain' | 'fill' | 'inside' | 'outside') || 'inside', - format: (format as 'webp' | 'jpeg' | 'png' | 'avif') || 'webp', - quality: quality ? parseInt(quality) : 85, - }); - - const mimeTypes: Record = { - webp: 'image/webp', - jpeg: 'image/jpeg', - png: 'image/png', - avif: 'image/avif', - }; - - res.set('Content-Type', mimeTypes[format || 'webp']); - res.set('Cache-Control', 'public, max-age=31536000'); - res.send(transformedBuffer); - } - - private async streamFile(res: Response, key: string, contentType: string): Promise { - try { - const stream = await this.storage.getStream(key); - - res.set('Content-Type', contentType); - res.set('Cache-Control', 'public, max-age=31536000'); - - stream.pipe(res); - } catch (error) { - throw new NotFoundException('File not found'); - } - } -} diff --git a/services/mana-media/apps/api/src/modules/delivery/delivery.module.ts b/services/mana-media/apps/api/src/modules/delivery/delivery.module.ts deleted file mode 100644 index dfa4593a9..000000000 --- a/services/mana-media/apps/api/src/modules/delivery/delivery.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { DeliveryController } from './delivery.controller'; -import { UploadModule } from '../upload/upload.module'; -import { ProcessModule } from '../process/process.module'; - -@Module({ - imports: [UploadModule, ProcessModule], - controllers: [DeliveryController], -}) -export class DeliveryModule {} diff --git a/services/mana-media/apps/api/src/modules/exif/exif.module.ts b/services/mana-media/apps/api/src/modules/exif/exif.module.ts deleted file mode 100644 index 1a42294ca..000000000 --- a/services/mana-media/apps/api/src/modules/exif/exif.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ExifService } from './exif.service'; - -@Module({ - providers: [ExifService], - exports: [ExifService], -}) -export class ExifModule {} diff --git a/services/mana-media/apps/api/src/modules/exif/exif.service.ts b/services/mana-media/apps/api/src/modules/exif/exif.service.ts deleted file mode 100644 index 2a3879216..000000000 --- a/services/mana-media/apps/api/src/modules/exif/exif.service.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import exifr from 'exifr'; - -export interface ExifData { - // Camera info - cameraMake?: string; - cameraModel?: string; - // Lens info - focalLength?: string; - aperture?: string; - // Exposure - iso?: number; - exposureTime?: string; - // Date/time - dateTaken?: Date; - // GPS - gpsLatitude?: string; - gpsLongitude?: string; - // Full raw EXIF data - raw?: Record; -} - -@Injectable() -export class ExifService { - private readonly logger = new Logger(ExifService.name); - - /** - * Extract EXIF data from an image buffer - */ - async extract(buffer: Buffer): Promise { - try { - const exif = await exifr.parse(buffer, { - // Include GPS data - gps: true, - // Parse all EXIF data - tiff: true, - exif: true, - }); - - if (!exif) { - return null; - } - - const result: ExifData = { - raw: exif, - }; - - // Camera info - if (exif.Make) { - result.cameraMake = String(exif.Make).trim(); - } - if (exif.Model) { - result.cameraModel = String(exif.Model).trim(); - } - - // Lens/exposure settings - 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) { - // Format as fraction (e.g., "1/125") - if (exif.ExposureTime < 1) { - result.exposureTime = `1/${Math.round(1 / exif.ExposureTime)}`; - } else { - result.exposureTime = `${exif.ExposureTime}s`; - } - } - - // Date taken - if (exif.DateTimeOriginal) { - result.dateTaken = new Date(exif.DateTimeOriginal); - } else if (exif.CreateDate) { - result.dateTaken = new Date(exif.CreateDate); - } - - // GPS coordinates - if (exif.latitude !== undefined && exif.longitude !== undefined) { - result.gpsLatitude = String(exif.latitude); - result.gpsLongitude = String(exif.longitude); - } - - this.logger.debug( - `Extracted EXIF: camera=${result.cameraMake} ${result.cameraModel}, date=${result.dateTaken}` - ); - - return result; - } catch (error) { - this.logger.warn(`Failed to extract EXIF data: ${error}`); - return null; - } - } - - /** - * Check if the buffer likely contains EXIF data (quick check) - */ - hasExif(buffer: Buffer): boolean { - // JPEG files with EXIF start with FFD8 and contain "Exif" marker - if (buffer[0] === 0xff && buffer[1] === 0xd8) { - const exifMarker = buffer.indexOf('Exif'); - return exifMarker !== -1 && exifMarker < 100; - } - return false; - } -} diff --git a/services/mana-media/apps/api/src/modules/matrix/matrix.module.ts b/services/mana-media/apps/api/src/modules/matrix/matrix.module.ts deleted file mode 100644 index 21d58665f..000000000 --- a/services/mana-media/apps/api/src/modules/matrix/matrix.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { MatrixService } from './matrix.service'; - -@Module({ - providers: [MatrixService], - exports: [MatrixService], -}) -export class MatrixModule {} diff --git a/services/mana-media/apps/api/src/modules/matrix/matrix.service.ts b/services/mana-media/apps/api/src/modules/matrix/matrix.service.ts deleted file mode 100644 index 8065e4300..000000000 --- a/services/mana-media/apps/api/src/modules/matrix/matrix.service.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; - -export interface MatrixMediaInfo { - buffer: Buffer; - mimeType: string; - size: number; - filename?: string; -} - -/** - * Service for downloading media from Matrix homeservers - * Handles MXC URLs like mxc://matrix.mana.how/abc123 - */ -@Injectable() -export class MatrixService { - private readonly logger = new Logger(MatrixService.name); - private readonly homeserverUrl: string; - - constructor(private config: ConfigService) { - this.homeserverUrl = this.config.get('MATRIX_HOMESERVER_URL', 'https://matrix.mana.how'); - } - - /** - * Parse an MXC URL into server and media ID - * @param mxcUrl - URL in format mxc://server/media_id - */ - parseMxcUrl(mxcUrl: string): { server: string; mediaId: string } | null { - const match = mxcUrl.match(/^mxc:\/\/([^/]+)\/(.+)$/); - if (!match) { - return null; - } - return { server: match[1], mediaId: match[2] }; - } - - /** - * Convert MXC URL to HTTP download URL - */ - getDownloadUrl(mxcUrl: string): string | null { - const parsed = this.parseMxcUrl(mxcUrl); - if (!parsed) { - return null; - } - - // Use the Matrix Content Repository API - // Format: /_matrix/media/v3/download/{serverName}/{mediaId} - return `${this.homeserverUrl}/_matrix/media/v3/download/${parsed.server}/${parsed.mediaId}`; - } - - /** - * Download media from a Matrix MXC URL - */ - async downloadFromMxc(mxcUrl: string): Promise { - const downloadUrl = this.getDownloadUrl(mxcUrl); - if (!downloadUrl) { - this.logger.error(`Invalid MXC URL: ${mxcUrl}`); - return null; - } - - try { - this.logger.debug(`Downloading from Matrix: ${downloadUrl}`); - - const response = await fetch(downloadUrl); - - if (!response.ok) { - this.logger.error( - `Failed to download from Matrix: ${response.status} ${response.statusText}` - ); - return null; - } - - const contentType = response.headers.get('content-type') || 'application/octet-stream'; - const contentDisposition = response.headers.get('content-disposition'); - - // Extract filename from Content-Disposition if available - let filename: string | undefined; - if (contentDisposition) { - const match = contentDisposition.match( - /filename[*]?=['"]?(?:UTF-\d['"]*)?([^;\r\n"']*)['"]?/i - ); - if (match) { - filename = decodeURIComponent(match[1]); - } - } - - const arrayBuffer = await response.arrayBuffer(); - const buffer = Buffer.from(arrayBuffer); - - return { - buffer, - mimeType: contentType, - size: buffer.length, - filename, - }; - } catch (error) { - this.logger.error(`Error downloading from Matrix: ${error}`); - return null; - } - } - - /** - * Download a thumbnail from Matrix - * Matrix can generate thumbnails on-the-fly with specified dimensions - */ - async downloadThumbnailFromMxc( - mxcUrl: string, - options?: { - width?: number; - height?: number; - method?: 'crop' | 'scale'; - } - ): Promise { - const parsed = this.parseMxcUrl(mxcUrl); - if (!parsed) { - this.logger.error(`Invalid MXC URL: ${mxcUrl}`); - return null; - } - - const width = options?.width || 320; - const height = options?.height || 240; - const method = options?.method || 'scale'; - - // Use the Matrix thumbnail API - // Format: /_matrix/media/v3/thumbnail/{serverName}/{mediaId}?width=X&height=Y&method=crop|scale - const thumbnailUrl = `${this.homeserverUrl}/_matrix/media/v3/thumbnail/${parsed.server}/${parsed.mediaId}?width=${width}&height=${height}&method=${method}`; - - try { - this.logger.debug(`Downloading thumbnail from Matrix: ${thumbnailUrl}`); - - const response = await fetch(thumbnailUrl); - - if (!response.ok) { - this.logger.warn( - `Failed to get thumbnail from Matrix: ${response.status}, falling back to full download` - ); - return this.downloadFromMxc(mxcUrl); - } - - const contentType = response.headers.get('content-type') || 'image/png'; - const arrayBuffer = await response.arrayBuffer(); - const buffer = Buffer.from(arrayBuffer); - - return { - buffer, - mimeType: contentType, - size: buffer.length, - }; - } catch (error) { - this.logger.error(`Error downloading thumbnail from Matrix: ${error}`); - return null; - } - } - - /** - * Check if a URL is a valid MXC URL - */ - isValidMxcUrl(url: string): boolean { - return this.parseMxcUrl(url) !== null; - } -} diff --git a/services/mana-media/apps/api/src/modules/process/process.module.ts b/services/mana-media/apps/api/src/modules/process/process.module.ts deleted file mode 100644 index e93e68cdf..000000000 --- a/services/mana-media/apps/api/src/modules/process/process.module.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Module, forwardRef } from '@nestjs/common'; -import { BullModule } from '@nestjs/bullmq'; -import { ProcessService } from './process.service'; -import { ProcessWorker } from './process.worker'; -import { PROCESS_QUEUE } from './process.constants'; -import { UploadModule } from '../upload/upload.module'; -import { ExifModule } from '../exif/exif.module'; -import { StorageModule } from '../storage/storage.module'; - -@Module({ - imports: [ - BullModule.registerQueue({ - name: PROCESS_QUEUE, - }), - forwardRef(() => UploadModule), - ExifModule, - StorageModule, - ], - providers: [ProcessService, ProcessWorker], - exports: [ProcessService], -}) -export class ProcessModule {} diff --git a/services/mana-media/apps/api/src/modules/process/process.worker.ts b/services/mana-media/apps/api/src/modules/process/process.worker.ts deleted file mode 100644 index 0898d8839..000000000 --- a/services/mana-media/apps/api/src/modules/process/process.worker.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Processor, WorkerHost } from '@nestjs/bullmq'; -import { Logger } from '@nestjs/common'; -import { Job } from 'bullmq'; -import { ProcessService } from './process.service'; -import { UploadService } from '../upload/upload.service'; -import { PROCESS_QUEUE, SUPPORTED_IMAGE_TYPES } from './process.constants'; - -interface ProcessJobData { - mediaId: string; - mimeType: string; - originalKey: string; -} - -@Processor(PROCESS_QUEUE) -export class ProcessWorker extends WorkerHost { - private readonly logger = new Logger(ProcessWorker.name); - - constructor( - private processService: ProcessService, - private uploadService: UploadService - ) { - super(); - } - - async process(job: Job): Promise { - const { mediaId, mimeType, originalKey } = job.data; - - this.logger.log(`Processing media ${mediaId} (${mimeType})`); - - try { - if (SUPPORTED_IMAGE_TYPES.includes(mimeType)) { - await this.processImage(mediaId, originalKey, mimeType); - } else { - // For unsupported types, just mark as ready - await this.uploadService.update(mediaId, { status: 'ready' }); - } - } catch (error) { - this.logger.error(`Failed to process media ${mediaId}:`, error); - await this.uploadService.update(mediaId, { status: 'failed' }); - throw error; - } - } - - private async processImage( - mediaId: string, - originalKey: string, - mimeType: string - ): Promise { - const result = await this.processService.processImage(mediaId, originalKey, mimeType); - - await this.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, - // EXIF data - 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, - }); - - this.logger.log( - `Processed image ${mediaId}: thumbnail=${!!result.thumbnail}, medium=${!!result.medium}, large=${!!result.large}, exif=${!!result.exif}` - ); - } -} diff --git a/services/mana-media/apps/api/src/modules/storage/storage.module.ts b/services/mana-media/apps/api/src/modules/storage/storage.module.ts deleted file mode 100644 index a811af5ed..000000000 --- a/services/mana-media/apps/api/src/modules/storage/storage.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Module, Global } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { StorageService } from './storage.service'; - -@Global() -@Module({ - imports: [ConfigModule], - providers: [StorageService], - exports: [StorageService], -}) -export class StorageModule {} diff --git a/services/mana-media/apps/api/src/modules/upload/upload.controller.ts b/services/mana-media/apps/api/src/modules/upload/upload.controller.ts deleted file mode 100644 index d187a0400..000000000 --- a/services/mana-media/apps/api/src/modules/upload/upload.controller.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { - Controller, - Post, - Get, - Delete, - Param, - Query, - Body, - UploadedFile, - UseInterceptors, - BadRequestException, - NotFoundException, -} from '@nestjs/common'; -import { FileInterceptor } from '@nestjs/platform-express'; -import { UploadService, MediaRecord } from './upload.service'; - -interface UploadResponse { - id: string; - status: MediaRecord['status']; - originalName: string | null; - mimeType: string; - size: number; - hash: string; - urls: { - original: string; - thumbnail?: string; - medium?: string; - large?: string; - }; - metadata?: { - width?: number; - height?: number; - format?: string; - }; - exif?: { - cameraMake?: string; - cameraModel?: string; - dateTaken?: Date; - focalLength?: string; - aperture?: string; - iso?: number; - exposureTime?: string; - gpsLatitude?: string; - gpsLongitude?: string; - }; - createdAt: Date; -} - -interface ListAllResponse { - items: UploadResponse[]; - total: number; - hasMore: boolean; -} - -interface StatsResponse { - totalCount: number; - totalSize: number; - byApp: Record; - byYear: Record; -} - -interface ImportFromMatrixDto { - mxcUrl: string; - app: string; - userId: string; - skipProcessing?: boolean; -} - -@Controller('media') -export class UploadController { - constructor(private uploadService: UploadService) {} - - @Post('upload') - @UseInterceptors( - FileInterceptor('file', { - limits: { - fileSize: 100 * 1024 * 1024, // 100 MB - }, - }) - ) - async upload( - @UploadedFile() file: Express.Multer.File, - @Body('app') app?: string, - @Body('userId') userId?: string, - @Body('skipProcessing') skipProcessing?: string - ): Promise { - if (!file) { - throw new BadRequestException('No file provided'); - } - - const record = await this.uploadService.upload(file, { - app, - userId, - skipProcessing: skipProcessing === 'true', - }); - - return this.toResponse(record); - } - - /** - * Import media from a Matrix MXC URL - * Copies the file from Matrix to our storage with deduplication - */ - @Post('import/matrix') - async importFromMatrix(@Body() dto: ImportFromMatrixDto): Promise { - if (!dto.mxcUrl) { - throw new BadRequestException('mxcUrl is required'); - } - if (!dto.app) { - throw new BadRequestException('app is required'); - } - if (!dto.userId) { - throw new BadRequestException('userId is required'); - } - - const record = await this.uploadService.importFromMatrix(dto.mxcUrl, { - app: dto.app, - userId: dto.userId, - skipProcessing: dto.skipProcessing, - }); - - if (!record) { - throw new BadRequestException( - 'Failed to import from Matrix. Invalid MXC URL or download failed.' - ); - } - - return this.toResponse(record); - } - - @Get(':id') - async get(@Param('id') id: string): Promise { - const record = await this.uploadService.get(id); - if (!record) { - throw new NotFoundException('Media not found'); - } - return this.toResponse(record); - } - - /** - * Get media by content hash (SHA-256) - * Useful for checking if a file already exists before uploading - */ - @Get('hash/:hash') - async getByHash(@Param('hash') hash: string): Promise { - const record = await this.uploadService.getByHash(hash); - if (!record) { - throw new NotFoundException('Media not found'); - } - return this.toResponse(record); - } - - @Get() - async list( - @Query('app') app?: string, - @Query('userId') userId?: string, - @Query('limit') limit?: string - ): Promise { - const records = await this.uploadService.list({ - app, - userId, - limit: limit ? parseInt(limit) : 50, - }); - return records.map((r) => this.toResponse(r)); - } - - /** - * List media across all apps for a user with advanced filtering - * Supports filtering by multiple apps, date range, MIME type, etc. - */ - @Get('list/all') - async listAll( - @Query('userId') userId: string, - @Query('apps') apps?: string, - @Query('mimeType') mimeType?: string, - @Query('dateFrom') dateFrom?: string, - @Query('dateTo') dateTo?: string, - @Query('hasLocation') hasLocation?: string, - @Query('limit') limit?: string, - @Query('offset') offset?: string, - @Query('sortBy') sortBy?: 'createdAt' | 'dateTaken' | 'size', - @Query('sortOrder') sortOrder?: 'asc' | 'desc' - ): Promise { - if (!userId) { - throw new BadRequestException('userId is required'); - } - - const result = await this.uploadService.listAll({ - userId, - apps: apps ? apps.split(',').map((a) => a.trim()) : undefined, - mimeType, - dateFrom: dateFrom ? new Date(dateFrom) : undefined, - dateTo: dateTo ? new Date(dateTo) : undefined, - hasLocation: hasLocation === 'true', - limit: limit ? parseInt(limit) : 50, - offset: offset ? parseInt(offset) : 0, - sortBy: sortBy || 'createdAt', - sortOrder: sortOrder || 'desc', - }); - - return { - items: result.items.map((r) => this.toResponse(r)), - total: result.total, - hasMore: result.hasMore, - }; - } - - /** - * Get media statistics for a user - */ - @Get('stats') - async stats(@Query('userId') userId: string): Promise { - if (!userId) { - throw new BadRequestException('userId is required'); - } - return this.uploadService.getStats(userId); - } - - @Delete(':id') - async delete(@Param('id') id: string): Promise<{ success: boolean }> { - const deleted = await this.uploadService.delete(id); - if (!deleted) { - throw new NotFoundException('Media not found'); - } - return { success: true }; - } - - private toResponse(record: MediaRecord): UploadResponse { - 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, - }; - } -} diff --git a/services/mana-media/apps/api/src/modules/upload/upload.module.ts b/services/mana-media/apps/api/src/modules/upload/upload.module.ts deleted file mode 100644 index d99f5f090..000000000 --- a/services/mana-media/apps/api/src/modules/upload/upload.module.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Module } from '@nestjs/common'; -import { BullModule } from '@nestjs/bullmq'; -import { UploadController } from './upload.controller'; -import { UploadService } from './upload.service'; -import { MatrixModule } from '../matrix/matrix.module'; -import { PROCESS_QUEUE } from '../process/process.constants'; - -@Module({ - imports: [ - BullModule.registerQueue({ - name: PROCESS_QUEUE, - }), - MatrixModule, - ], - controllers: [UploadController], - providers: [UploadService], - exports: [UploadService], -}) -export class UploadModule {} diff --git a/services/mana-media/apps/api/src/routes/delivery.ts b/services/mana-media/apps/api/src/routes/delivery.ts new file mode 100644 index 000000000..a1d564049 --- /dev/null +++ b/services/mana-media/apps/api/src/routes/delivery.ts @@ -0,0 +1,94 @@ +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'; + +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); + + if (!record.mimeType.startsWith('image/')) { + return c.json({ error: 'Transform only supported for images' }, 400); + } + + const originalBuffer = await storage.download(record.keys.original); + 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'); + return c.body(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 new file mode 100644 index 000000000..cbbda7663 --- /dev/null +++ b/services/mana-media/apps/api/src/routes/upload.ts @@ -0,0 +1,151 @@ +import { Hono } from 'hono'; +import type { UploadService, MediaRecord } from '../services/upload'; + +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); + } + + const buffer = Buffer.from(await file.arrayBuffer()); + const record = await uploadService.upload( + buffer, + file.name, + file.type || 'application/octet-stream', + file.size, + { + app: body['app'] as string | undefined, + userId: body['userId'] as string | undefined, + skipProcessing: body['skipProcessing'] === 'true', + } + ); + + return c.json(toResponse(record), 201); + }); + + // Import from Matrix + app.post('/import/matrix', async (c) => { + const { mxcUrl, app: appName, userId, skipProcessing } = await c.req.json(); + + if (!mxcUrl) return c.json({ error: 'mxcUrl is required' }, 400); + if (!appName) return c.json({ error: 'app is required' }, 400); + if (!userId) return c.json({ error: 'userId is required' }, 400); + + const record = await uploadService.importFromMatrix(mxcUrl, { + app: appName, + userId, + skipProcessing, + }); + + if (!record) { + return c.json( + { error: 'Failed to import from Matrix. Invalid MXC URL or download failed.' }, + 400 + ); + } + + 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 new file mode 100644 index 000000000..8f089099c --- /dev/null +++ b/services/mana-media/apps/api/src/services/exif.ts @@ -0,0 +1,56 @@ +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/matrix.ts b/services/mana-media/apps/api/src/services/matrix.ts new file mode 100644 index 000000000..278157aec --- /dev/null +++ b/services/mana-media/apps/api/src/services/matrix.ts @@ -0,0 +1,61 @@ +export interface MatrixMediaInfo { + buffer: Buffer; + mimeType: string; + size: number; + filename?: string; +} + +export class MatrixService { + private readonly homeserverUrl: string; + + constructor() { + this.homeserverUrl = process.env.MATRIX_HOMESERVER_URL || 'https://matrix.mana.how'; + } + + parseMxcUrl(mxcUrl: string): { server: string; mediaId: string } | null { + const match = mxcUrl.match(/^mxc:\/\/([^/]+)\/(.+)$/); + if (!match) return null; + return { server: match[1], mediaId: match[2] }; + } + + getDownloadUrl(mxcUrl: string): string | null { + const parsed = this.parseMxcUrl(mxcUrl); + if (!parsed) return null; + return `${this.homeserverUrl}/_matrix/media/v3/download/${parsed.server}/${parsed.mediaId}`; + } + + async downloadFromMxc(mxcUrl: string): Promise { + const downloadUrl = this.getDownloadUrl(mxcUrl); + if (!downloadUrl) { + console.error(`Invalid MXC URL: ${mxcUrl}`); + return null; + } + + try { + const response = await fetch(downloadUrl); + if (!response.ok) { + console.error(`Failed to download from Matrix: ${response.status} ${response.statusText}`); + return null; + } + + const contentType = response.headers.get('content-type') || 'application/octet-stream'; + const contentDisposition = response.headers.get('content-disposition'); + + let filename: string | undefined; + if (contentDisposition) { + const match = contentDisposition.match( + /filename[*]?=['"]?(?:UTF-\d['"]*)?([^;\r\n"']*)['"]?/i + ); + if (match) filename = decodeURIComponent(match[1]); + } + + const arrayBuffer = await response.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + return { buffer, mimeType: contentType, size: buffer.length, filename }; + } catch (error) { + console.error(`Error downloading from Matrix: ${error}`); + return null; + } + } +} diff --git a/services/mana-media/apps/api/src/modules/process/process.service.ts b/services/mana-media/apps/api/src/services/process.ts similarity index 79% rename from services/mana-media/apps/api/src/modules/process/process.service.ts rename to services/mana-media/apps/api/src/services/process.ts index 04b40a347..aaea4dd64 100644 --- a/services/mana-media/apps/api/src/modules/process/process.service.ts +++ b/services/mana-media/apps/api/src/services/process.ts @@ -1,8 +1,7 @@ -import { Injectable } from '@nestjs/common'; import sharp from 'sharp'; -import { StorageService } from '../storage/storage.service'; -import { ExifService, type ExifData } from '../exif/exif.service'; -import { IMAGE_VARIANTS, SUPPORTED_IMAGE_TYPES } from './process.constants'; +import { StorageService } from './storage'; +import { ExifService, type ExifData } from './exif'; +import { IMAGE_VARIANTS, SUPPORTED_IMAGE_TYPES } from '../constants'; export interface ProcessResult { thumbnail?: string; @@ -17,7 +16,6 @@ export interface ProcessResult { exif?: ExifData; } -@Injectable() export class ProcessService { constructor( private storage: StorageService, @@ -29,18 +27,11 @@ export class ProcessService { originalKey: string, mimeType: string ): Promise { - if (!SUPPORTED_IMAGE_TYPES.includes(mimeType)) { - return {}; - } + if (!SUPPORTED_IMAGE_TYPES.includes(mimeType)) return {}; - // Download original const originalBuffer = await this.storage.download(originalKey); - - // Get metadata const image = sharp(originalBuffer); const metadata = await image.metadata(); - - // Extract EXIF data const exifData = await this.exifService.extract(originalBuffer); const result: ProcessResult = { @@ -53,7 +44,6 @@ export class ProcessService { exif: exifData || undefined, }; - // Generate variants const basePath = originalKey.replace(/^originals\//, 'processed/').replace(/\.[^.]+$/, ''); // Thumbnail @@ -64,11 +54,10 @@ export class ProcessService { }) .webp({ quality: 80 }) .toBuffer(); - await this.storage.upload(thumbKey, thumbBuffer, 'image/webp'); result.thumbnail = thumbKey; - // Medium (only if original is larger) + // Medium if ( (metadata.width || 0) > IMAGE_VARIANTS.medium.width || (metadata.height || 0) > IMAGE_VARIANTS.medium.height @@ -81,12 +70,11 @@ export class ProcessService { }) .webp({ quality: 85 }) .toBuffer(); - await this.storage.upload(mediumKey, mediumBuffer, 'image/webp'); result.medium = mediumKey; } - // Large (only if original is larger) + // Large if ( (metadata.width || 0) > IMAGE_VARIANTS.large.width || (metadata.height || 0) > IMAGE_VARIANTS.large.height @@ -99,7 +87,6 @@ export class ProcessService { }) .webp({ quality: 90 }) .toBuffer(); - await this.storage.upload(largeKey, largeBuffer, 'image/webp'); result.large = largeKey; } @@ -107,16 +94,6 @@ export class ProcessService { return result; } - async generateThumbnail( - buffer: Buffer, - options?: { width?: number; height?: number } - ): Promise { - const width = options?.width || IMAGE_VARIANTS.thumbnail.width; - const height = options?.height || IMAGE_VARIANTS.thumbnail.height; - - return sharp(buffer).resize(width, height, { fit: 'cover' }).webp({ quality: 80 }).toBuffer(); - } - async transformImage( buffer: Buffer, options: { diff --git a/services/mana-media/apps/api/src/modules/storage/storage.service.ts b/services/mana-media/apps/api/src/services/storage.ts similarity index 63% rename from services/mana-media/apps/api/src/modules/storage/storage.service.ts rename to services/mana-media/apps/api/src/services/storage.ts index 8f475e991..c535ff09e 100644 --- a/services/mana-media/apps/api/src/modules/storage/storage.service.ts +++ b/services/mana-media/apps/api/src/services/storage.ts @@ -1,5 +1,3 @@ -import { Injectable, OnModuleInit } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import * as Minio from 'minio'; import { Readable } from 'stream'; @@ -11,23 +9,22 @@ export interface StorageObject { etag: string; } -@Injectable() -export class StorageService implements OnModuleInit { +export class StorageService { private client: Minio.Client; private bucket: string; - constructor(private config: ConfigService) { + constructor() { this.client = new Minio.Client({ - endPoint: this.config.get('S3_ENDPOINT', 'localhost'), - port: parseInt(this.config.get('S3_PORT', '9000')), - useSSL: this.config.get('S3_USE_SSL', 'false') === 'true', - accessKey: this.config.get('S3_ACCESS_KEY', 'minioadmin'), - secretKey: this.config.get('S3_SECRET_KEY', 'minioadmin'), + 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 = this.config.get('S3_BUCKET', 'mana-media'); + this.bucket = process.env.S3_BUCKET || 'mana-media'; } - async onModuleInit() { + async init() { const exists = await this.client.bucketExists(this.bucket); if (!exists) { await this.client.makeBucket(this.bucket); @@ -87,16 +84,8 @@ export class StorageService implements OnModuleInit { } } - async getPresignedUrl(key: string, expiresIn = 3600): Promise { - return this.client.presignedGetObject(this.bucket, key, expiresIn); - } - - async getUploadUrl(key: string, expiresIn = 3600): Promise { - return this.client.presignedPutObject(this.bucket, key, expiresIn); - } - getPublicUrl(key: string): string { - const endpoint = this.config.get('S3_PUBLIC_URL', `http://localhost:9000/${this.bucket}`); + const endpoint = process.env.S3_PUBLIC_URL || `http://localhost:9000/${this.bucket}`; return `${endpoint}/${key}`; } } diff --git a/services/mana-media/apps/api/src/modules/upload/upload.service.ts b/services/mana-media/apps/api/src/services/upload.ts similarity index 77% rename from services/mana-media/apps/api/src/modules/upload/upload.service.ts rename to services/mana-media/apps/api/src/services/upload.ts index b49aefcf2..413909274 100644 --- a/services/mana-media/apps/api/src/modules/upload/upload.service.ts +++ b/services/mana-media/apps/api/src/services/upload.ts @@ -1,21 +1,18 @@ -import { Injectable, Inject } from '@nestjs/common'; -import { InjectQueue } from '@nestjs/bullmq'; import { Queue } from 'bullmq'; import * as mime from 'mime-types'; import * as crypto from 'crypto'; -import { eq, and, or, gte, lte, like, isNotNull, sql, desc, asc, inArray } from 'drizzle-orm'; -import { StorageService } from '../storage/storage.service'; -import { MatrixService } from '../matrix/matrix.service'; -import { PROCESS_QUEUE } from '../process/process.constants'; -import { DATABASE_CONNECTION } from '../../db/database.module'; -import type { Database } from '../../db/connection'; +import { eq, and, gte, lte, like, isNotNull, sql, desc, asc, inArray } from 'drizzle-orm'; +import type { Database } from '../db'; +import { StorageService } from './storage'; +import { MatrixService } from './matrix'; +import { PROCESS_QUEUE } from '../constants'; import { media, mediaReferences, type Media, type NewMedia, type NewMediaReference, -} from '../../db/schema'; +} from '../db/schema'; export interface MediaRecord { id: string; @@ -24,8 +21,6 @@ export interface MediaRecord { size: number; hash: string; status: 'uploading' | 'processing' | 'ready' | 'failed'; - app?: string; - userId?: string; keys: { original: string; thumbnail?: string; @@ -79,72 +74,63 @@ export interface StatsResult { byYear: Record; } -@Injectable() export class UploadService { constructor( + private db: Database, private storage: StorageService, private matrixService: MatrixService, - @InjectQueue(PROCESS_QUEUE) private processQueue: Queue, - @Inject(DATABASE_CONNECTION) private db: Database + private processQueue: Queue ) {} async upload( - file: Express.Multer.File, - options?: { - app?: string; - userId?: string; - skipProcessing?: boolean; - } + buffer: Buffer, + originalname: string, + mimetype: string, + fileSize: number, + options?: { app?: string; userId?: string; skipProcessing?: boolean } ): Promise { - const hash = this.computeHash(file.buffer); + const hash = this.computeHash(buffer); - // Check for existing media with same content hash const existing = await this.findByHash(hash); if (existing) { - // If userId and app provided, create a reference if (options?.userId && options?.app) { await this.createReference(existing.id, options.userId, options.app); } return this.toMediaRecord(existing); } - // Generate storage key - const ext = mime.extension(file.mimetype) || 'bin'; + 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}`; - // Upload to storage - await this.storage.upload(originalKey, file.buffer, file.mimetype, { - 'x-amz-meta-original-name': file.originalname, + await this.storage.upload(originalKey, buffer, mimetype, { + 'x-amz-meta-original-name': originalname, 'x-amz-meta-media-id': id, }); - // Insert into database const [inserted] = await this.db .insert(media) .values({ id, contentHash: hash, - originalName: file.originalname, - mimeType: file.mimetype, - size: file.size, + originalName: originalname, + mimeType: mimetype, + size: fileSize, originalKey, status: options?.skipProcessing ? 'ready' : 'processing', } satisfies NewMedia) .returning(); - // Create reference if user provided if (options?.userId && options?.app) { await this.createReference(inserted.id, options.userId, options.app); } - // Queue processing job if (!options?.skipProcessing) { await this.processQueue.add('process-media', { mediaId: inserted.id, - mimeType: file.mimetype, + mimeType: mimetype, originalKey, }); } @@ -152,48 +138,33 @@ export class UploadService { return this.toMediaRecord(inserted); } - /** - * Import media from a Matrix MXC URL - */ async importFromMatrix( mxcUrl: string, - options: { - app: string; - userId: string; - skipProcessing?: boolean; - } + options: { app: string; userId: string; skipProcessing?: boolean } ): Promise { - // Download from Matrix const matrixMedia = await this.matrixService.downloadFromMxc(mxcUrl); - if (!matrixMedia) { - return null; - } + if (!matrixMedia) return null; const hash = this.computeHash(matrixMedia.buffer); - // Check for existing media const existing = await this.findByHash(hash); if (existing) { - // Create reference with source URL await this.createReference(existing.id, options.userId, options.app, mxcUrl); return this.toMediaRecord(existing); } - // Generate storage key const ext = mime.extension(matrixMedia.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}`; - // Upload to storage await this.storage.upload(originalKey, matrixMedia.buffer, matrixMedia.mimeType, { 'x-amz-meta-source': 'matrix', 'x-amz-meta-source-url': mxcUrl, 'x-amz-meta-media-id': id, }); - // Insert into database const [inserted] = await this.db .insert(media) .values({ @@ -207,10 +178,8 @@ export class UploadService { } satisfies NewMedia) .returning(); - // Create reference with source URL await this.createReference(inserted.id, options.userId, options.app, mxcUrl); - // Queue processing job if (!options?.skipProcessing) { await this.processQueue.add('process-media', { mediaId: inserted.id, @@ -260,13 +229,9 @@ export class UploadService { ): Promise { const [updated] = await this.db .update(media) - .set({ - ...updates, - updatedAt: new Date(), - }) + .set({ ...updates, updatedAt: new Date() }) .where(eq(media.id, id)) .returning(); - return updated ? this.toMediaRecord(updated) : null; } @@ -274,7 +239,6 @@ export class UploadService { const [record] = await this.db.select().from(media).where(eq(media.id, id)).limit(1); if (!record) return false; - // Delete all associated storage files const keys = [ record.originalKey, record.thumbnailKey, @@ -285,84 +249,55 @@ export class UploadService { await this.storage.delete(key).catch(() => {}); } - // Delete from database (references will cascade) await this.db.delete(media).where(eq(media.id, id)); return true; } async list(options?: { app?: string; userId?: string; limit?: number }): Promise { - // If filtering by user/app, we need to join with references if (options?.userId || options?.app) { - const query = this.db + 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)); - - // Build conditions - 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 query - .where(conditions.length === 1 ? conditions[0] : undefined) + .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)); } - // Simple list without filtering const results = await this.db .select() .from(media) .orderBy(media.createdAt) .limit(options?.limit || 50); - return results.map((r) => this.toMediaRecord(r)); } - /** - * List media across all apps for a user with advanced filtering - */ async listAll(options: ListAllOptions): Promise { const conditions = [eq(mediaReferences.userId, options.userId)]; - // Filter by multiple apps if (options.apps && options.apps.length > 0) { conditions.push(inArray(mediaReferences.app, options.apps)); } - - // Filter by MIME type (supports wildcards like "image/*") if (options.mimeType) { if (options.mimeType.endsWith('/*')) { - const prefix = options.mimeType.slice(0, -1); - conditions.push(like(media.mimeType, `${prefix}%`)); + conditions.push(like(media.mimeType, `${options.mimeType.slice(0, -1)}%`)); } else { conditions.push(eq(media.mimeType, options.mimeType)); } } - - // Filter by date range - if (options.dateFrom) { - conditions.push(gte(media.createdAt, options.dateFrom)); - } - if (options.dateTo) { - conditions.push(lte(media.createdAt, options.dateTo)); - } - - // Filter by location + 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)); } - - // Only show ready media conditions.push(eq(media.status, 'ready')); - // Build order by const orderColumn = options.sortBy === 'dateTaken' ? media.dateTaken @@ -371,7 +306,6 @@ export class UploadService { : media.createdAt; const orderFn = options.sortOrder === 'asc' ? asc : desc; - // Get total count const countResult = await this.db .select({ count: sql`count(distinct ${media.id})` }) .from(media) @@ -379,7 +313,6 @@ export class UploadService { .where(and(...conditions)); const total = Number(countResult[0]?.count || 0); - // Get paginated results const limit = options.limit || 50; const offset = options.offset || 0; @@ -399,11 +332,7 @@ export class UploadService { }; } - /** - * Get media statistics for a user - */ async getStats(userId: string): Promise { - // Total count and size const totalResult = await this.db .select({ count: sql`count(distinct ${media.id})`, @@ -413,7 +342,6 @@ export class UploadService { .innerJoin(mediaReferences, eq(media.id, mediaReferences.mediaId)) .where(eq(mediaReferences.userId, userId)); - // By app const byAppResult = await this.db .select({ app: mediaReferences.app, @@ -425,7 +353,6 @@ export class UploadService { .where(eq(mediaReferences.userId, userId)) .groupBy(mediaReferences.app); - // By year const byYearResult = await this.db .select({ year: sql`extract(year from ${media.createdAt})::text`, diff --git a/services/mana-media/apps/api/tsconfig.json b/services/mana-media/apps/api/tsconfig.json index b439390d0..321d0b537 100644 --- a/services/mana-media/apps/api/tsconfig.json +++ b/services/mana-media/apps/api/tsconfig.json @@ -1,22 +1,19 @@ { "compilerOptions": { - "module": "commonjs", - "declaration": true, - "removeComments": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "allowSyntheticDefaultImports": true, "target": "ES2022", - "sourceMap": true, - "outDir": "./dist", - "baseUrl": "./", - "incremental": true, + "module": "ES2022", + "moduleResolution": "bundler", + "types": ["bun-types"], + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, "skipLibCheck": true, - "strictNullChecks": true, - "noImplicitAny": true, - "strictBindCallApply": true, "forceConsistentCasingInFileNames": true, - "noFallthroughCasesInSwitch": true, - "esModuleInterop": true - } + "resolveJsonModule": true, + "outDir": "./dist", + "declaration": true, + "noEmit": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] } diff --git a/services/mana-media/package.json b/services/mana-media/package.json index 6d1e515d0..74a700680 100644 --- a/services/mana-media/package.json +++ b/services/mana-media/package.json @@ -3,9 +3,7 @@ "private": true, "scripts": { "dev": "pnpm --filter @mana-media/api dev", - "build": "pnpm --filter @mana-media/api build", - "start": "pnpm --filter @mana-media/api start:prod", - "type-check": "tsc --noEmit -p apps/api/tsconfig.json && tsc --noEmit -p packages/client/tsconfig.json", - "lint": "eslint 'apps/api/src/**/*.ts'" + "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/tsconfig.json b/services/mana-media/tsconfig.json index f02c2417e..64b3ce4c1 100644 --- a/services/mana-media/tsconfig.json +++ b/services/mana-media/tsconfig.json @@ -1,25 +1,15 @@ { "compilerOptions": { - "module": "commonjs", - "declaration": true, - "removeComments": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "allowSyntheticDefaultImports": true, "target": "ES2022", - "sourceMap": true, - "outDir": "./dist", - "baseUrl": "./", - "incremental": true, - "skipLibCheck": true, - "strictNullChecks": true, - "noImplicitAny": true, - "strictBindCallApply": true, - "forceConsistentCasingInFileNames": true, - "noFallthroughCasesInSwitch": true, + "module": "ES2022", + "moduleResolution": "bundler", "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, "resolveJsonModule": true }, - "include": ["src/**/*"], + "include": ["apps/**/*", "packages/**/*"], "exclude": ["node_modules", "dist"] }