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:
Till JS 2026-03-28 18:12:42 +01:00
parent 27b70e8197
commit 73181ab91d
34 changed files with 625 additions and 1239 deletions

View file

@ -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:

View file

@ -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
```

View file

@ -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"]

View file

@ -1,5 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src"
}

View file

@ -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"
}

View file

@ -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 {}

View file

@ -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',
];

View file

@ -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;
}

View file

@ -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();
}
}

View file

@ -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(),
};
}
}

View 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,
};

View file

@ -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();

View file

@ -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());
}
}

View file

@ -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');
}
}
}

View file

@ -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 {}

View file

@ -1,8 +0,0 @@
import { Module } from '@nestjs/common';
import { ExifService } from './exif.service';
@Module({
providers: [ExifService],
exports: [ExifService],
})
export class ExifModule {}

View file

@ -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;
}
}

View file

@ -1,8 +0,0 @@
import { Module } from '@nestjs/common';
import { MatrixService } from './matrix.service';
@Module({
providers: [MatrixService],
exports: [MatrixService],
})
export class MatrixModule {}

View file

@ -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;
}
}

View file

@ -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 {}

View file

@ -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}`
);
}
}

View file

@ -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 {}

View file

@ -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,
};
}
}

View file

@ -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 {}

View 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);
}
}

View 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;
}

View 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;
}
}
}

View 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;
}
}
}

View file

@ -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: {

View file

@ -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}`;
}
}

View file

@ -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`,

View file

@ -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"]
}

View file

@ -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"
}
}

View file

@ -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"]
}