mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
refactor(mana-media): migrate from NestJS to Hono/Bun
Replace NestJS framework with Hono + Bun, eliminating the last NestJS service from the stack. All business logic preserved: - CAS upload with SHA-256 dedup - BullMQ image processing (Sharp thumbnails/variants) - Matrix MXC URL import - EXIF extraction - File streaming/transforms - Prometheus metrics 23 NestJS files → 12 Hono files. Zero NestJS in the monorepo. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
27b70e8197
commit
73181ab91d
34 changed files with 625 additions and 1239 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
|
@ -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<typeof postgres> | null = null;
|
||||
let db: ReturnType<typeof drizzle> | 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;
|
||||
}
|
||||
|
|
@ -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<string>('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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
165
services/mana-media/apps/api/src/index.ts
Normal file
165
services/mana-media/apps/api/src/index.ts
Normal file
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
const record = await this.uploadService.get(id);
|
||||
if (!record) {
|
||||
throw new NotFoundException('Media not found');
|
||||
}
|
||||
|
||||
const variantMap: Record<Variant, string | undefined> = {
|
||||
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<void> {
|
||||
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<string, string> = {
|
||||
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<void> {
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ExifService } from './exif.service';
|
||||
|
||||
@Module({
|
||||
providers: [ExifService],
|
||||
exports: [ExifService],
|
||||
})
|
||||
export class ExifModule {}
|
||||
|
|
@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ExifService {
|
||||
private readonly logger = new Logger(ExifService.name);
|
||||
|
||||
/**
|
||||
* Extract EXIF data from an image buffer
|
||||
*/
|
||||
async extract(buffer: Buffer): Promise<ExifData | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { MatrixService } from './matrix.service';
|
||||
|
||||
@Module({
|
||||
providers: [MatrixService],
|
||||
exports: [MatrixService],
|
||||
})
|
||||
export class MatrixModule {}
|
||||
|
|
@ -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<MatrixMediaInfo | null> {
|
||||
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<MatrixMediaInfo | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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<ProcessJobData>): Promise<void> {
|
||||
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<void> {
|
||||
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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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<string, { count: number; size: number }>;
|
||||
byYear: Record<string, number>;
|
||||
}
|
||||
|
||||
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<UploadResponse> {
|
||||
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<UploadResponse> {
|
||||
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<UploadResponse> {
|
||||
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<UploadResponse> {
|
||||
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<UploadResponse[]> {
|
||||
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<ListAllResponse> {
|
||||
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<StatsResponse> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
94
services/mana-media/apps/api/src/routes/delivery.ts
Normal file
94
services/mana-media/apps/api/src/routes/delivery.ts
Normal file
|
|
@ -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<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);
|
||||
|
||||
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<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');
|
||||
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);
|
||||
}
|
||||
}
|
||||
151
services/mana-media/apps/api/src/routes/upload.ts
Normal file
151
services/mana-media/apps/api/src/routes/upload.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
56
services/mana-media/apps/api/src/services/exif.ts
Normal file
56
services/mana-media/apps/api/src/services/exif.ts
Normal file
|
|
@ -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<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
61
services/mana-media/apps/api/src/services/matrix.ts
Normal file
61
services/mana-media/apps/api/src/services/matrix.ts
Normal file
|
|
@ -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<MatrixMediaInfo | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ProcessResult> {
|
||||
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<Buffer> {
|
||||
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: {
|
||||
|
|
@ -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<string> {
|
||||
return this.client.presignedGetObject(this.bucket, key, expiresIn);
|
||||
}
|
||||
|
||||
async getUploadUrl(key: string, expiresIn = 3600): Promise<string> {
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, number>;
|
||||
}
|
||||
|
||||
@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<MediaRecord> {
|
||||
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<MediaRecord | null> {
|
||||
// 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<MediaRecord | null> {
|
||||
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<MediaRecord[]> {
|
||||
// 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<ListAllResult> {
|
||||
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<number>`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<StatsResult> {
|
||||
// Total count and size
|
||||
const totalResult = await this.db
|
||||
.select({
|
||||
count: sql<number>`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<string>`extract(year from ${media.createdAt})::text`,
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue