feat(mana-media): add centralized media storage with NutriPhi integration

- Implement mana-media service with PostgreSQL/Drizzle ORM persistence
- Add content-addressable storage (SHA-256) for automatic deduplication
- Add Matrix MXC URL import endpoint to copy images from Matrix
- Create @manacore/media-client package for service consumption
- Integrate mana-media into NutriPhi bot for persistent image storage
- Update pnpm-workspace.yaml to include nested service packages
- Add mana-media to docker-compose with port 3015

Images sent to NutriPhi bot are now stored in mana-media after analysis,
providing persistent storage with deduplication across all apps.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-02-02 17:30:14 +01:00
parent 171cf7a854
commit d4663b5643
31 changed files with 2114 additions and 4419 deletions

View file

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

View file

@ -0,0 +1,160 @@
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,4 +1,5 @@
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';
@ -12,6 +13,8 @@ interface ProcessJobData {
@Processor(PROCESS_QUEUE)
export class ProcessWorker extends WorkerHost {
private readonly logger = new Logger(ProcessWorker.name);
constructor(
private processService: ProcessService,
private uploadService: UploadService
@ -22,7 +25,7 @@ export class ProcessWorker extends WorkerHost {
async process(job: Job<ProcessJobData>): Promise<void> {
const { mediaId, mimeType, originalKey } = job.data;
console.log(`Processing media ${mediaId} (${mimeType})`);
this.logger.log(`Processing media ${mediaId} (${mimeType})`);
try {
if (SUPPORTED_IMAGE_TYPES.includes(mimeType)) {
@ -32,7 +35,7 @@ export class ProcessWorker extends WorkerHost {
await this.uploadService.update(mediaId, { status: 'ready' });
}
} catch (error) {
console.error(`Failed to process media ${mediaId}:`, error);
this.logger.error(`Failed to process media ${mediaId}:`, error);
await this.uploadService.update(mediaId, { status: 'failed' });
throw error;
}
@ -47,16 +50,16 @@ export class ProcessWorker extends WorkerHost {
await this.uploadService.update(mediaId, {
status: 'ready',
keys: {
original: originalKey,
thumbnail: result.thumbnail,
medium: result.medium,
large: result.large,
},
metadata: result.metadata,
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,
});
console.log(
this.logger.log(
`Processed image ${mediaId}: thumbnail=${!!result.thumbnail}, medium=${!!result.medium}, large=${!!result.large}`
);
}

View file

@ -17,9 +17,10 @@ import { UploadService, MediaRecord } from './upload.service';
interface UploadResponse {
id: string;
status: MediaRecord['status'];
originalName: string;
originalName: string | null;
mimeType: string;
size: number;
hash: string;
urls: {
original: string;
thumbnail?: string;
@ -29,6 +30,13 @@ interface UploadResponse {
createdAt: Date;
}
interface ImportFromMatrixDto {
mxcUrl: string;
app: string;
userId: string;
skipProcessing?: boolean;
}
@Controller('media')
export class UploadController {
constructor(private uploadService: UploadService) {}
@ -60,6 +68,37 @@ export class UploadController {
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);
@ -69,6 +108,19 @@ export class UploadController {
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,
@ -93,7 +145,7 @@ export class UploadController {
}
private toResponse(record: MediaRecord): UploadResponse {
const baseUrl = process.env.PUBLIC_URL || 'http://localhost:3050/api/v1';
const baseUrl = process.env.PUBLIC_URL || 'http://localhost:3015/api/v1';
return {
id: record.id,
@ -101,6 +153,7 @@ export class UploadController {
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,

View file

@ -2,6 +2,7 @@ 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({
@ -9,6 +10,7 @@ import { PROCESS_QUEUE } from '../process/process.constants';
BullModule.registerQueue({
name: PROCESS_QUEUE,
}),
MatrixModule,
],
controllers: [UploadController],
providers: [UploadService],

View file

@ -1,15 +1,25 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Inject } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { v4 as uuid } from 'uuid';
import * as mime from 'mime-types';
import * as crypto from 'crypto';
import { eq } 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 {
media,
mediaReferences,
type Media,
type NewMedia,
type NewMediaReference,
} from '../../db/schema';
export interface MediaRecord {
id: string;
originalName: string;
originalName: string | null;
mimeType: string;
size: number;
hash: string;
@ -22,19 +32,23 @@ export interface MediaRecord {
medium?: string;
large?: string;
};
metadata?: Record<string, unknown>;
metadata?: {
width?: number;
height?: number;
format?: string;
hasAlpha?: boolean;
};
createdAt: Date;
updatedAt: Date;
}
// In-memory store for MVP (replace with DB later)
const mediaStore = new Map<string, MediaRecord>();
@Injectable()
export class UploadService {
constructor(
private storage: StorageService,
@InjectQueue(PROCESS_QUEUE) private processQueue: Queue
private matrixService: MatrixService,
@InjectQueue(PROCESS_QUEUE) private processQueue: Queue,
@Inject(DATABASE_CONNECTION) private db: Database
) {}
async upload(
@ -45,113 +59,271 @@ export class UploadService {
skipProcessing?: boolean;
}
): Promise<MediaRecord> {
const id = uuid();
const ext = mime.extension(file.mimetype) || 'bin';
const hash = this.computeHash(file.buffer);
// Check for duplicate
const existing = this.findByHash(hash);
// Check for existing media with same content hash
const existing = await this.findByHash(hash);
if (existing) {
return 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 keys
// Generate storage key
const ext = mime.extension(file.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 original
// Upload to storage
await this.storage.upload(originalKey, file.buffer, file.mimetype, {
'x-amz-meta-original-name': file.originalname,
'x-amz-meta-media-id': id,
});
// Create record
const record: MediaRecord = {
id,
originalName: file.originalname,
mimeType: file.mimetype,
size: file.size,
hash,
status: options?.skipProcessing ? 'ready' : 'processing',
app: options?.app,
userId: options?.userId,
keys: {
original: originalKey,
},
createdAt: date,
updatedAt: date,
};
// Insert into database
const [inserted] = await this.db
.insert(media)
.values({
id,
contentHash: hash,
originalName: file.originalname,
mimeType: file.mimetype,
size: file.size,
originalKey,
status: options?.skipProcessing ? 'ready' : 'processing',
} satisfies NewMedia)
.returning();
mediaStore.set(id, record);
// 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: id,
mediaId: inserted.id,
mimeType: file.mimetype,
originalKey,
});
}
return record;
return this.toMediaRecord(inserted);
}
/**
* Import media from a Matrix MXC URL
*/
async importFromMatrix(
mxcUrl: string,
options: {
app: string;
userId: string;
skipProcessing?: boolean;
}
): Promise<MediaRecord | null> {
// Download from Matrix
const matrixMedia = await this.matrixService.downloadFromMxc(mxcUrl);
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({
id,
contentHash: hash,
originalName: matrixMedia.filename || null,
mimeType: matrixMedia.mimeType,
size: matrixMedia.size,
originalKey,
status: options?.skipProcessing ? 'ready' : 'processing',
} 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,
mimeType: matrixMedia.mimeType,
originalKey,
});
}
return this.toMediaRecord(inserted);
}
async get(id: string): Promise<MediaRecord | null> {
return mediaStore.get(id) || null;
const [result] = await this.db.select().from(media).where(eq(media.id, id)).limit(1);
return result ? this.toMediaRecord(result) : null;
}
async update(id: string, updates: Partial<MediaRecord>): Promise<MediaRecord | null> {
const record = mediaStore.get(id);
if (!record) return null;
async getByHash(hash: string): Promise<MediaRecord | null> {
const result = await this.findByHash(hash);
return result ? this.toMediaRecord(result) : null;
}
const updated = {
...record,
...updates,
updatedAt: new Date(),
};
mediaStore.set(id, updated);
return updated;
async update(
id: string,
updates: Partial<
Pick<
Media,
| 'status'
| 'thumbnailKey'
| 'mediumKey'
| 'largeKey'
| 'width'
| 'height'
| 'format'
| 'hasAlpha'
>
>
): Promise<MediaRecord | null> {
const [updated] = await this.db
.update(media)
.set({
...updates,
updatedAt: new Date(),
})
.where(eq(media.id, id))
.returning();
return updated ? this.toMediaRecord(updated) : null;
}
async delete(id: string): Promise<boolean> {
const record = mediaStore.get(id);
const [record] = await this.db.select().from(media).where(eq(media.id, id)).limit(1);
if (!record) return false;
// Delete all associated files
const keys = Object.values(record.keys).filter(Boolean) as string[];
// Delete all associated storage files
const keys = [
record.originalKey,
record.thumbnailKey,
record.mediumKey,
record.largeKey,
].filter(Boolean) as string[];
for (const key of keys) {
await this.storage.delete(key).catch(() => {});
}
mediaStore.delete(id);
// 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[]> {
let records = Array.from(mediaStore.values());
// If filtering by user/app, we need to join with references
if (options?.userId || options?.app) {
const query = this.db
.select({ media: media })
.from(media)
.innerJoin(mediaReferences, eq(media.id, mediaReferences.mediaId));
if (options?.app) {
records = records.filter((r) => r.app === options.app);
}
if (options?.userId) {
records = records.filter((r) => r.userId === options.userId);
// 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)
.limit(options.limit || 50);
return results.map((r) => this.toMediaRecord(r.media));
}
records.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
// Simple list without filtering
const results = await this.db
.select()
.from(media)
.orderBy(media.createdAt)
.limit(options?.limit || 50);
if (options?.limit) {
records = records.slice(0, options.limit);
}
return results.map((r) => this.toMediaRecord(r));
}
return records;
private async findByHash(hash: string): Promise<Media | null> {
const [result] = await this.db.select().from(media).where(eq(media.contentHash, hash)).limit(1);
return result || null;
}
private async createReference(
mediaId: string,
userId: string,
app: string,
sourceUrl?: string
): Promise<void> {
await this.db.insert(mediaReferences).values({
mediaId,
userId,
app,
sourceUrl: sourceUrl || null,
} satisfies NewMediaReference);
}
private computeHash(buffer: Buffer): string {
return crypto.createHash('sha256').update(buffer).digest('hex');
}
private findByHash(hash: string): MediaRecord | undefined {
return Array.from(mediaStore.values()).find((r) => r.hash === hash);
private toMediaRecord(m: Media): MediaRecord {
return {
id: m.id,
originalName: m.originalName,
mimeType: m.mimeType,
size: Number(m.size),
hash: m.contentHash,
status: m.status as MediaRecord['status'],
keys: {
original: m.originalKey,
thumbnail: m.thumbnailKey || undefined,
medium: m.mediumKey || undefined,
large: m.largeKey || undefined,
},
metadata: m.width
? {
width: m.width || undefined,
height: m.height || undefined,
format: m.format || undefined,
hasAlpha: m.hasAlpha || undefined,
}
: undefined,
createdAt: m.createdAt,
updatedAt: m.updatedAt,
};
}
}