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

@ -99,6 +99,7 @@ services:
BASE_URL: https://auth.mana.how BASE_URL: https://auth.mana.how
# Cross-domain SSO: share session cookies across all *.mana.how subdomains # Cross-domain SSO: share session cookies across all *.mana.how subdomains
COOKIE_DOMAIN: .mana.how COOKIE_DOMAIN: .mana.how
MANA_CORE_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY}
SMTP_HOST: smtp-relay.brevo.com SMTP_HOST: smtp-relay.brevo.com
SMTP_PORT: 587 SMTP_PORT: 587
SMTP_USER: ${SMTP_USER:-94cde5002@smtp-brevo.com} SMTP_USER: ${SMTP_USER:-94cde5002@smtp-brevo.com}
@ -746,15 +747,21 @@ services:
depends_on: depends_on:
synapse: synapse:
condition: service_healthy condition: service_healthy
mana-media:
condition: service_healthy
environment: environment:
NODE_ENV: production NODE_ENV: production
PORT: 4016 PORT: 4016
TZ: Europe/Berlin TZ: Europe/Berlin
REDIS_HOST: redis
REDIS_PASSWORD: ${REDIS_PASSWORD:-redis123}
MANA_CORE_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY}
MANA_CORE_AUTH_URL: http://mana-auth:3001
MATRIX_HOMESERVER_URL: http://synapse:8008 MATRIX_HOMESERVER_URL: http://synapse:8008
MATRIX_ACCESS_TOKEN: ${MATRIX_NUTRIPHI_BOT_TOKEN} MATRIX_ACCESS_TOKEN: ${MATRIX_NUTRIPHI_BOT_TOKEN}
MATRIX_ALLOWED_ROOMS: ${MATRIX_NUTRIPHI_BOT_ROOMS:-} MATRIX_ALLOWED_ROOMS: ${MATRIX_NUTRIPHI_BOT_ROOMS:-}
NUTRIPHI_BACKEND_URL: http://nutriphi-backend:3037 NUTRIPHI_BACKEND_URL: http://nutriphi-backend:3037
MANA_CORE_AUTH_URL: http://mana-auth:3001 MANA_MEDIA_URL: http://mana-media:3015
volumes: volumes:
- matrix_bots_data:/app/data - matrix_bots_data:/app/data
ports: ports:

1670
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -20,6 +20,12 @@ packages:
# Standalone microservices # Standalone microservices
- 'services/*' - 'services/*'
# Sub-apps within services (for services with nested structure like mana-media)
- 'services/*/apps/*'
# Service-specific packages
- 'services/*/packages/*'
# Monorepo-wide shared packages # Monorepo-wide shared packages
- 'packages/*' - 'packages/*'

View file

@ -1,36 +1,46 @@
# mana-media - Unified Media Platform # mana-media - Unified Media Platform
Central media handling service for all ManaCore applications. Central media handling service for all ManaCore applications with content-addressable storage (CAS) and automatic deduplication.
## Overview ## Overview
mana-media provides: mana-media provides:
- **Upload API** - Chunked uploads, deduplication - **Content-Addressable Storage** - SHA-256 based deduplication across all apps
- **Processing** - Thumbnails, WebP conversion, resizing - **Upload API** - File uploads with automatic deduplication
- **Matrix Import** - Copy images from Matrix MXC URLs to persistent storage
- **Processing** - Thumbnails, WebP conversion, resizing (via BullMQ)
- **Delivery** - Optimized file serving, on-the-fly transforms - **Delivery** - Optimized file serving, on-the-fly transforms
- **Search** (planned) - Vector-based semantic search
**Port:** 3015 (production)
## Quick Start ## Quick Start
```bash ```bash
# Start dependencies (Redis + MinIO) # Start dependencies (Redis + MinIO + PostgreSQL)
docker compose up redis minio -d pnpm docker:up
# Create database
PGPASSWORD=devpassword psql -h localhost -U manacore -d postgres -c "CREATE DATABASE mana_media;"
# Install dependencies # Install dependencies
cd services/mana-media/apps/api
pnpm install pnpm install
# Push schema
pnpm db:push
# Start development server # Start development server
pnpm dev pnpm dev
``` ```
Service runs on `http://localhost:3050` Service runs on `http://localhost:3015`
## API Endpoints ## API Endpoints
### Upload ### Upload
```bash ```bash
# Upload file # Upload file
curl -X POST http://localhost:3050/api/v1/media/upload \ curl -X POST http://localhost:3015/api/v1/media/upload \
-F "file=@image.jpg" \ -F "file=@image.jpg" \
-F "app=chat" \ -F "app=chat" \
-F "userId=user123" -F "userId=user123"
@ -39,18 +49,34 @@ curl -X POST http://localhost:3050/api/v1/media/upload \
{ {
"id": "abc123", "id": "abc123",
"status": "processing", "status": "processing",
"hash": "sha256...",
"urls": { "urls": {
"original": "http://localhost:3050/api/v1/media/abc123/file", "original": "http://localhost:3015/api/v1/media/abc123/file",
"thumbnail": "http://localhost:3050/api/v1/media/abc123/file/thumb" "thumbnail": "http://localhost:3015/api/v1/media/abc123/file/thumb"
} }
} }
``` ```
### Import from Matrix
```bash
# Import media from Matrix MXC URL
curl -X POST http://localhost:3015/api/v1/media/import/matrix \
-H "Content-Type: application/json" \
-d '{
"mxcUrl": "mxc://matrix.mana.how/abc123",
"app": "nutriphi",
"userId": "user-uuid"
}'
```
### Get Media ### Get Media
```bash ```bash
# Get metadata # Get metadata
GET /api/v1/media/:id GET /api/v1/media/:id
# Get by content hash (check if file exists)
GET /api/v1/media/hash/:sha256hash
# Get original file # Get original file
GET /api/v1/media/:id/file GET /api/v1/media/:id/file
@ -66,8 +92,8 @@ GET /api/v1/media/:id/transform?w=400&h=300&fit=cover&format=webp
### List & Delete ### List & Delete
```bash ```bash
# List media # List media (filter by app/user)
GET /api/v1/media?app=chat&limit=50 GET /api/v1/media?app=chat&userId=user123&limit=50
# Delete # Delete
DELETE /api/v1/media/:id DELETE /api/v1/media/:id
@ -97,22 +123,48 @@ const customUrl = media.getTransformUrl(result.id, {
## Architecture ## Architecture
``` ```
┌─────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────┐
│ mana-media │ │ mana-media (Port 3015) │
├─────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────────────┤
│ Upload Module │ Handles file uploads │ │ Upload Module │ File uploads, Matrix import, dedup │
│ Process Module │ Sharp/FFmpeg processing │ │ Matrix Module │ Download from Matrix MXC URLs │
│ Storage Module │ MinIO abstraction │ │ Process Module │ Sharp thumbnail generation (BullMQ) │
│ Delivery Module │ File serving + transforms │ │ Storage Module │ MinIO S3 abstraction │
└─────────────────────────────────────────────────┘ │ Delivery Module │ File serving + on-the-fly transforms │
│ │ │ Database Module │ PostgreSQL + Drizzle ORM │
▼ ▼ └─────────────────────────────────────────────────────────────┘
┌─────────┐ ┌─────────┐ │ │ │
│ Redis │ │ MinIO │ ▼ ▼ ▼
│ Queue │ │ Storage │ ┌─────────┐ ┌─────────┐ ┌────────────┐
└─────────┘ └─────────┘ │ Redis │ │ MinIO │ │ PostgreSQL │
│ BullMQ │ │ Storage │ │ mana_media │
└─────────┘ └─────────┘ └────────────┘
``` ```
## Database Schema
### media (Content-Addressable Storage)
| Column | Type | Description |
|--------|------|-------------|
| id | UUID | Primary key |
| content_hash | TEXT | SHA-256 hash (unique) |
| original_name | TEXT | Original filename |
| mime_type | TEXT | MIME type |
| size | BIGINT | File size in bytes |
| original_key | TEXT | S3 storage key |
| status | TEXT | uploading/processing/ready/failed |
| thumbnail_key | TEXT | Thumbnail S3 key |
| width/height | INT | Image dimensions |
### media_references (User ownership)
| Column | Type | Description |
|--------|------|-------------|
| id | UUID | Primary key |
| media_id | UUID | FK to media |
| user_id | UUID | Owner user ID |
| app | TEXT | Source app (nutriphi, contacts, etc.) |
| source_url | TEXT | Original source (e.g., mxc:// URL) |
## Processing Pipeline ## Processing Pipeline
| File Type | Generated Variants | | File Type | Generated Variants |
@ -125,14 +177,20 @@ const customUrl = media.getTransformUrl(result.id, {
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| PORT | 3050 | API port | | PORT | 3015 | API port |
| DATABASE_URL | - | PostgreSQL connection string |
| REDIS_HOST | localhost | Redis host | | REDIS_HOST | localhost | Redis host |
| REDIS_PORT | 6379 | Redis port | | REDIS_PORT | 6379 | Redis port |
| REDIS_PASSWORD | - | Redis password (optional) |
| S3_ENDPOINT | localhost | MinIO/S3 endpoint | | S3_ENDPOINT | localhost | MinIO/S3 endpoint |
| S3_PORT | 9000 | MinIO/S3 port | | S3_PORT | 9000 | MinIO/S3 port |
| S3_USE_SSL | false | Use HTTPS for S3 |
| S3_ACCESS_KEY | minioadmin | S3 access key | | S3_ACCESS_KEY | minioadmin | S3 access key |
| S3_SECRET_KEY | minioadmin | S3 secret key | | S3_SECRET_KEY | minioadmin | S3 secret key |
| S3_BUCKET | mana-media | Storage bucket | | S3_BUCKET | mana-media | Storage bucket |
| S3_PUBLIC_URL | - | Public URL for media |
| MATRIX_HOMESERVER_URL | https://matrix.mana.how | Matrix homeserver |
| PUBLIC_URL | http://localhost:3015/api/v1 | Public API URL |
## Development ## Development
@ -145,6 +203,10 @@ pnpm type-check
# Build # Build
pnpm build pnpm build
# Database commands
pnpm db:push # Push schema to database
pnpm db:studio # Open Drizzle Studio
``` ```
## Storage Layout ## Storage Layout
@ -166,8 +228,29 @@ mana-media bucket/
## Roadmap ## Roadmap
- [x] v0.1: Basic upload + thumbnails - [x] v0.1: Basic upload + thumbnails
- [ ] v0.2: Video thumbnails (FFmpeg) - [x] v0.2: PostgreSQL persistence with Drizzle ORM
- [ ] v0.3: Chunked upload for large files - [x] v0.3: Content-addressable storage with SHA-256 deduplication
- [ ] v0.4: OCR for documents - [x] v0.4: Matrix MXC URL import
- [ ] v0.5: Vector search (Qdrant) - [ ] v0.5: Video thumbnails (FFmpeg)
- [ ] v0.6: Chunked upload for large files
- [ ] v0.7: OCR for documents
- [ ] v0.8: Vector search (Qdrant)
- [ ] v1.0: Full production ready - [ ] v1.0: Full production ready
## Integration Example (NutriPhi Bot)
```typescript
// In matrix-nutriphi-bot
const response = await fetch('http://mana-media:3015/api/v1/media/import/matrix', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mxcUrl: 'mxc://matrix.mana.how/abc123',
app: 'nutriphi',
userId: userUuid,
}),
});
const { id, hash, urls } = await response.json();
// Store id or hash in meal record for reference
```

View file

@ -1,42 +1,64 @@
FROM node:22-alpine AS builder # ================================
# Build Stage (Monorepo-aware)
# ================================
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
WORKDIR /app WORKDIR /app
# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# Copy package files
COPY package.json pnpm-lock.yaml* ./
# Install dependencies # Install dependencies
FROM base AS deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/shared-drizzle-config/package.json ./packages/shared-drizzle-config/
COPY services/mana-media/package.json ./services/mana-media/
COPY services/mana-media/apps/api/package.json ./services/mana-media/apps/api/
COPY services/mana-media/packages/client/package.json ./services/mana-media/packages/client/
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
# Copy source # Build shared packages
COPY . . FROM base AS shared-builder
COPY --from=deps /app/node_modules ./node_modules
# Build COPY --from=deps /app/packages/shared-drizzle-config/node_modules ./packages/shared-drizzle-config/node_modules
COPY packages/shared-drizzle-config ./packages/shared-drizzle-config
WORKDIR /app/packages/shared-drizzle-config
RUN pnpm build RUN pnpm build
# Production stage # Build the application
FROM node:22-alpine AS runner FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/services/mana-media/node_modules ./services/mana-media/node_modules
COPY --from=deps /app/services/mana-media/apps/api/node_modules ./services/mana-media/apps/api/node_modules
COPY --from=shared-builder /app/packages/shared-drizzle-config ./packages/shared-drizzle-config
COPY services/mana-media ./services/mana-media
WORKDIR /app/services/mana-media/apps/api
RUN pnpm build
# ================================
# Production Stage
# ================================
FROM node:20-alpine AS runner
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate \
&& apk add --no-cache postgresql-client wget
ENV NODE_ENV=production
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nestjs
USER nestjs
WORKDIR /app WORKDIR /app
# Install pnpm # Copy built application
RUN corepack enable && corepack prepare pnpm@latest --activate 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/node_modules ./node_modules
COPY --from=builder --chown=nestjs:nodejs /app/services/mana-media/apps/api/package.json ./
COPY --from=builder --chown=nestjs:nodejs /app/packages/shared-drizzle-config ./packages/shared-drizzle-config
# Copy package files and install production deps only # Expose port
COPY package.json pnpm-lock.yaml* ./ EXPOSE 3015
RUN pnpm install --frozen-lockfile --prod
# Copy built files # Health check
COPY --from=builder /app/dist ./dist HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3015/health || exit 1
# Create non-root user
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nestjs
USER nestjs
EXPOSE 3050
# Start the application
CMD ["node", "dist/main"] CMD ["node", "dist/main"]

View file

@ -0,0 +1,6 @@
import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
export default createDrizzleConfig({
dbName: 'mana_media',
schemaPath: './src/db/schema/index.ts',
});

View file

@ -8,7 +8,9 @@
"start": "nest start", "start": "nest start",
"start:prod": "node dist/main", "start:prod": "node dist/main",
"type-check": "tsc --noEmit", "type-check": "tsc --noEmit",
"lint": "eslint 'src/**/*.ts'" "lint": "eslint 'src/**/*.ts'",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio"
}, },
"dependencies": { "dependencies": {
"@nestjs/bullmq": "^11.0.0", "@nestjs/bullmq": "^11.0.0",
@ -17,20 +19,25 @@
"@nestjs/core": "^11.0.0", "@nestjs/core": "^11.0.0",
"@nestjs/platform-express": "^11.0.0", "@nestjs/platform-express": "^11.0.0",
"bullmq": "^5.34.0", "bullmq": "^5.34.0",
"drizzle-orm": "^0.38.3",
"express": "^4.21.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"minio": "^8.0.0", "minio": "^8.0.0",
"postgres": "^3.4.5",
"reflect-metadata": "^0.2.0", "reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0", "rxjs": "^7.8.0",
"sharp": "^0.33.0", "sharp": "^0.33.0",
"uuid": "^11.0.0" "uuid": "^11.0.0"
}, },
"devDependencies": { "devDependencies": {
"@manacore/shared-drizzle-config": "workspace:*",
"@nestjs/cli": "^11.0.0", "@nestjs/cli": "^11.0.0",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/mime-types": "^2.1.4", "@types/mime-types": "^2.1.4",
"@types/multer": "^2.0.0", "@types/multer": "^2.0.0",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"drizzle-kit": "^0.30.1",
"typescript": "^5.7.0" "typescript": "^5.7.0"
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { BullModule } from '@nestjs/bullmq'; import { BullModule } from '@nestjs/bullmq';
import { DatabaseModule } from './db/database.module';
import { UploadModule } from './modules/upload/upload.module'; import { UploadModule } from './modules/upload/upload.module';
import { StorageModule } from './modules/storage/storage.module'; import { StorageModule } from './modules/storage/storage.module';
import { ProcessModule } from './modules/process/process.module'; import { ProcessModule } from './modules/process/process.module';
import { DeliveryModule } from './modules/delivery/delivery.module'; import { DeliveryModule } from './modules/delivery/delivery.module';
import { MatrixModule } from './modules/matrix/matrix.module';
import { HealthController } from './health.controller'; import { HealthController } from './health.controller';
@Module({ @Module({
@ -16,12 +18,15 @@ import { HealthController } from './health.controller';
connection: { connection: {
host: process.env.REDIS_HOST || 'localhost', host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379'), port: parseInt(process.env.REDIS_PORT || '6379'),
password: process.env.REDIS_PASSWORD || undefined,
}, },
}), }),
DatabaseModule,
StorageModule, StorageModule,
UploadModule, UploadModule,
ProcessModule, ProcessModule,
DeliveryModule, DeliveryModule,
MatrixModule,
], ],
controllers: [HealthController], controllers: [HealthController],
}) })

View file

@ -0,0 +1,37 @@
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');
let connection: ReturnType<typeof postgres> | null = null;
let db: ReturnType<typeof drizzle> | null = null;
export function getConnection(databaseUrl: string) {
if (!connection) {
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 });
}
return db;
}
export async function closeConnection() {
if (connection) {
await connection.end();
connection = null;
db = null;
}
}
export type Database = ReturnType<typeof getDb>;

View file

@ -0,0 +1,30 @@
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

@ -0,0 +1 @@
export * from './media.schema';

View file

@ -0,0 +1,149 @@
import {
pgTable,
uuid,
text,
timestamp,
integer,
boolean,
index,
jsonb,
bigint,
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
/**
* Core media table - stores unique files by content hash (SHA-256)
* This is the Content-Addressable Storage (CAS) approach
*/
export const media = pgTable(
'media',
{
id: uuid('id').primaryKey().defaultRandom(),
// Content-addressable: SHA-256 hash of the file content
contentHash: text('content_hash').notNull().unique(),
// Original filename (for display purposes)
originalName: text('original_name'),
// MIME type
mimeType: text('mime_type').notNull(),
// File size in bytes
size: bigint('size', { mode: 'number' }).notNull(),
// Storage keys
originalKey: text('original_key').notNull(),
// Processing status
status: text('status', { enum: ['uploading', 'processing', 'ready', 'failed'] })
.default('uploading')
.notNull(),
// Image metadata
width: integer('width'),
height: integer('height'),
format: text('format'),
hasAlpha: boolean('has_alpha'),
// Generated variants
thumbnailKey: text('thumbnail_key'),
mediumKey: text('medium_key'),
largeKey: text('large_key'),
// Timestamps
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index('media_content_hash_idx').on(table.contentHash),
index('media_status_idx').on(table.status),
index('media_created_at_idx').on(table.createdAt),
]
);
/**
* Media references - tracks which user/app owns a reference to a media item
* Multiple users can reference the same media (deduplication)
*/
export const mediaReferences = pgTable(
'media_references',
{
id: uuid('id').primaryKey().defaultRandom(),
// The media being referenced
mediaId: uuid('media_id')
.references(() => media.id, { onDelete: 'cascade' })
.notNull(),
// Owner info
userId: uuid('user_id').notNull(),
// Source app (nutriphi, contacts, chat, etc.)
app: text('app').notNull(),
// Optional: reference to the source (e.g., mxc:// URL from Matrix)
sourceUrl: text('source_url'),
// Custom metadata per reference
metadata: jsonb('metadata'),
// Timestamps
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index('media_ref_media_id_idx').on(table.mediaId),
index('media_ref_user_id_idx').on(table.userId),
index('media_ref_app_idx').on(table.app),
index('media_ref_user_app_idx').on(table.userId, table.app),
]
);
/**
* Lazy-generated thumbnails cache
* Stores on-the-fly generated thumbnails with specific parameters
*/
export const mediaThumbnails = pgTable(
'media_thumbnails',
{
id: uuid('id').primaryKey().defaultRandom(),
mediaId: uuid('media_id')
.references(() => media.id, { onDelete: 'cascade' })
.notNull(),
// Parameters that define this thumbnail
width: integer('width').notNull(),
height: integer('height').notNull(),
fit: text('fit').default('cover').notNull(),
format: text('format').default('webp').notNull(),
quality: integer('quality').default(80).notNull(),
// Storage key for this specific thumbnail
storageKey: text('storage_key').notNull(),
// Size of the generated thumbnail
size: integer('size').notNull(),
// Timestamps
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index('media_thumb_media_id_idx').on(table.mediaId),
index('media_thumb_params_idx').on(
table.mediaId,
table.width,
table.height,
table.fit,
table.format
),
]
);
// Relations
export const mediaRelations = relations(media, ({ many }) => ({
references: many(mediaReferences),
thumbnails: many(mediaThumbnails),
}));
export const mediaReferencesRelations = relations(mediaReferences, ({ one }) => ({
media: one(media, {
fields: [mediaReferences.mediaId],
references: [media.id],
}),
}));
export const mediaThumbnailsRelations = relations(mediaThumbnails, ({ one }) => ({
media: one(media, {
fields: [mediaThumbnails.mediaId],
references: [media.id],
}),
}));
// Types
export type Media = typeof media.$inferSelect;
export type NewMedia = typeof media.$inferInsert;
export type MediaReference = typeof mediaReferences.$inferSelect;
export type NewMediaReference = typeof mediaReferences.$inferInsert;
export type MediaThumbnail = typeof mediaThumbnails.$inferSelect;
export type NewMediaThumbnail = typeof mediaThumbnails.$inferInsert;

View file

@ -1,16 +1,23 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe, Logger } from '@nestjs/common';
import { json, urlencoded } from 'express';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
async function bootstrap() { async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api/v1'); 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( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({
whitelist: true, whitelist: true,
transform: true, transform: true,
forbidNonWhitelisted: true,
}) })
); );
@ -19,10 +26,11 @@ async function bootstrap() {
credentials: true, credentials: true,
}); });
const port = process.env.PORT || 3050; const port = process.env.PORT || 3015;
await app.listen(port); await app.listen(port);
console.log(`mana-media service running on port ${port}`); logger.log(`Mana Media service running on port ${port}`);
logger.log(`Health check: http://localhost:${port}/health`);
} }
bootstrap(); bootstrap();

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 { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job } from 'bullmq'; import { Job } from 'bullmq';
import { ProcessService } from './process.service'; import { ProcessService } from './process.service';
import { UploadService } from '../upload/upload.service'; import { UploadService } from '../upload/upload.service';
@ -12,6 +13,8 @@ interface ProcessJobData {
@Processor(PROCESS_QUEUE) @Processor(PROCESS_QUEUE)
export class ProcessWorker extends WorkerHost { export class ProcessWorker extends WorkerHost {
private readonly logger = new Logger(ProcessWorker.name);
constructor( constructor(
private processService: ProcessService, private processService: ProcessService,
private uploadService: UploadService private uploadService: UploadService
@ -22,7 +25,7 @@ export class ProcessWorker extends WorkerHost {
async process(job: Job<ProcessJobData>): Promise<void> { async process(job: Job<ProcessJobData>): Promise<void> {
const { mediaId, mimeType, originalKey } = job.data; const { mediaId, mimeType, originalKey } = job.data;
console.log(`Processing media ${mediaId} (${mimeType})`); this.logger.log(`Processing media ${mediaId} (${mimeType})`);
try { try {
if (SUPPORTED_IMAGE_TYPES.includes(mimeType)) { if (SUPPORTED_IMAGE_TYPES.includes(mimeType)) {
@ -32,7 +35,7 @@ export class ProcessWorker extends WorkerHost {
await this.uploadService.update(mediaId, { status: 'ready' }); await this.uploadService.update(mediaId, { status: 'ready' });
} }
} catch (error) { } 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' }); await this.uploadService.update(mediaId, { status: 'failed' });
throw error; throw error;
} }
@ -47,16 +50,16 @@ export class ProcessWorker extends WorkerHost {
await this.uploadService.update(mediaId, { await this.uploadService.update(mediaId, {
status: 'ready', status: 'ready',
keys: { thumbnailKey: result.thumbnail,
original: originalKey, mediumKey: result.medium,
thumbnail: result.thumbnail, largeKey: result.large,
medium: result.medium, width: result.metadata?.width,
large: result.large, height: result.metadata?.height,
}, format: result.metadata?.format,
metadata: result.metadata, hasAlpha: result.metadata?.hasAlpha,
}); });
console.log( this.logger.log(
`Processed image ${mediaId}: thumbnail=${!!result.thumbnail}, medium=${!!result.medium}, large=${!!result.large}` `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 { interface UploadResponse {
id: string; id: string;
status: MediaRecord['status']; status: MediaRecord['status'];
originalName: string; originalName: string | null;
mimeType: string; mimeType: string;
size: number; size: number;
hash: string;
urls: { urls: {
original: string; original: string;
thumbnail?: string; thumbnail?: string;
@ -29,6 +30,13 @@ interface UploadResponse {
createdAt: Date; createdAt: Date;
} }
interface ImportFromMatrixDto {
mxcUrl: string;
app: string;
userId: string;
skipProcessing?: boolean;
}
@Controller('media') @Controller('media')
export class UploadController { export class UploadController {
constructor(private uploadService: UploadService) {} constructor(private uploadService: UploadService) {}
@ -60,6 +68,37 @@ export class UploadController {
return this.toResponse(record); 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') @Get(':id')
async get(@Param('id') id: string): Promise<UploadResponse> { async get(@Param('id') id: string): Promise<UploadResponse> {
const record = await this.uploadService.get(id); const record = await this.uploadService.get(id);
@ -69,6 +108,19 @@ export class UploadController {
return this.toResponse(record); 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() @Get()
async list( async list(
@Query('app') app?: string, @Query('app') app?: string,
@ -93,7 +145,7 @@ export class UploadController {
} }
private toResponse(record: MediaRecord): UploadResponse { 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 { return {
id: record.id, id: record.id,
@ -101,6 +153,7 @@ export class UploadController {
originalName: record.originalName, originalName: record.originalName,
mimeType: record.mimeType, mimeType: record.mimeType,
size: record.size, size: record.size,
hash: record.hash,
urls: { urls: {
original: `${baseUrl}/media/${record.id}/file`, original: `${baseUrl}/media/${record.id}/file`,
thumbnail: record.keys.thumbnail ? `${baseUrl}/media/${record.id}/file/thumb` : undefined, 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 { BullModule } from '@nestjs/bullmq';
import { UploadController } from './upload.controller'; import { UploadController } from './upload.controller';
import { UploadService } from './upload.service'; import { UploadService } from './upload.service';
import { MatrixModule } from '../matrix/matrix.module';
import { PROCESS_QUEUE } from '../process/process.constants'; import { PROCESS_QUEUE } from '../process/process.constants';
@Module({ @Module({
@ -9,6 +10,7 @@ import { PROCESS_QUEUE } from '../process/process.constants';
BullModule.registerQueue({ BullModule.registerQueue({
name: PROCESS_QUEUE, name: PROCESS_QUEUE,
}), }),
MatrixModule,
], ],
controllers: [UploadController], controllers: [UploadController],
providers: [UploadService], 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 { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
import { v4 as uuid } from 'uuid';
import * as mime from 'mime-types'; import * as mime from 'mime-types';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import { eq } from 'drizzle-orm';
import { StorageService } from '../storage/storage.service'; import { StorageService } from '../storage/storage.service';
import { MatrixService } from '../matrix/matrix.service';
import { PROCESS_QUEUE } from '../process/process.constants'; 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 { export interface MediaRecord {
id: string; id: string;
originalName: string; originalName: string | null;
mimeType: string; mimeType: string;
size: number; size: number;
hash: string; hash: string;
@ -22,19 +32,23 @@ export interface MediaRecord {
medium?: string; medium?: string;
large?: string; large?: string;
}; };
metadata?: Record<string, unknown>; metadata?: {
width?: number;
height?: number;
format?: string;
hasAlpha?: boolean;
};
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
// In-memory store for MVP (replace with DB later)
const mediaStore = new Map<string, MediaRecord>();
@Injectable() @Injectable()
export class UploadService { export class UploadService {
constructor( constructor(
private storage: StorageService, 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( async upload(
@ -45,113 +59,271 @@ export class UploadService {
skipProcessing?: boolean; skipProcessing?: boolean;
} }
): Promise<MediaRecord> { ): Promise<MediaRecord> {
const id = uuid();
const ext = mime.extension(file.mimetype) || 'bin';
const hash = this.computeHash(file.buffer); const hash = this.computeHash(file.buffer);
// Check for duplicate // Check for existing media with same content hash
const existing = this.findByHash(hash); const existing = await this.findByHash(hash);
if (existing) { 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 date = new Date();
const datePath = `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}`; 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}`; const originalKey = `originals/${datePath}/${id}.${ext}`;
// Upload original // Upload to storage
await this.storage.upload(originalKey, file.buffer, file.mimetype, { await this.storage.upload(originalKey, file.buffer, file.mimetype, {
'x-amz-meta-original-name': file.originalname, 'x-amz-meta-original-name': file.originalname,
'x-amz-meta-media-id': id, 'x-amz-meta-media-id': id,
}); });
// Create record // Insert into database
const record: MediaRecord = { const [inserted] = await this.db
id, .insert(media)
originalName: file.originalname, .values({
mimeType: file.mimetype, id,
size: file.size, contentHash: hash,
hash, originalName: file.originalname,
status: options?.skipProcessing ? 'ready' : 'processing', mimeType: file.mimetype,
app: options?.app, size: file.size,
userId: options?.userId, originalKey,
keys: { status: options?.skipProcessing ? 'ready' : 'processing',
original: originalKey, } satisfies NewMedia)
}, .returning();
createdAt: date,
updatedAt: date,
};
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 // Queue processing job
if (!options?.skipProcessing) { if (!options?.skipProcessing) {
await this.processQueue.add('process-media', { await this.processQueue.add('process-media', {
mediaId: id, mediaId: inserted.id,
mimeType: file.mimetype, mimeType: file.mimetype,
originalKey, 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> { 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> { async getByHash(hash: string): Promise<MediaRecord | null> {
const record = mediaStore.get(id); const result = await this.findByHash(hash);
if (!record) return null; return result ? this.toMediaRecord(result) : null;
}
const updated = { async update(
...record, id: string,
...updates, updates: Partial<
updatedAt: new Date(), Pick<
}; Media,
mediaStore.set(id, updated); | 'status'
return updated; | '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> { 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; if (!record) return false;
// Delete all associated files // Delete all associated storage files
const keys = Object.values(record.keys).filter(Boolean) as string[]; const keys = [
record.originalKey,
record.thumbnailKey,
record.mediumKey,
record.largeKey,
].filter(Boolean) as string[];
for (const key of keys) { for (const key of keys) {
await this.storage.delete(key).catch(() => {}); 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; return true;
} }
async list(options?: { app?: string; userId?: string; limit?: number }): Promise<MediaRecord[]> { 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) { // Build conditions
records = records.filter((r) => r.app === options.app); const conditions = [];
} if (options.userId) {
if (options?.userId) { conditions.push(eq(mediaReferences.userId, options.userId));
records = records.filter((r) => r.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) { return results.map((r) => this.toMediaRecord(r));
records = records.slice(0, options.limit); }
}
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 { private computeHash(buffer: Buffer): string {
return crypto.createHash('sha256').update(buffer).digest('hex'); return crypto.createHash('sha256').update(buffer).digest('hex');
} }
private findByHash(hash: string): MediaRecord | undefined { private toMediaRecord(m: Media): MediaRecord {
return Array.from(mediaStore.values()).find((r) => r.hash === hash); 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,
};
} }
} }

View file

@ -0,0 +1,3 @@
import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
export default createDrizzleConfig('./src/db/schema');

View file

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

View file

@ -1,9 +1,10 @@
export interface MediaResult { export interface MediaResult {
id: string; id: string;
status: 'uploading' | 'processing' | 'ready' | 'failed'; status: 'uploading' | 'processing' | 'ready' | 'failed';
originalName: string; originalName: string | null;
mimeType: string; mimeType: string;
size: number; size: number;
hash: string;
urls: { urls: {
original: string; original: string;
thumbnail?: string; thumbnail?: string;
@ -13,6 +14,13 @@ export interface MediaResult {
createdAt: Date; createdAt: Date;
} }
export interface ImportFromMatrixOptions {
mxcUrl: string;
app: string;
userId: string;
skipProcessing?: boolean;
}
export interface UploadOptions { export interface UploadOptions {
app?: string; app?: string;
userId?: string; userId?: string;
@ -77,6 +85,53 @@ export class MediaClient {
return response.json(); return response.json();
} }
/**
* Import media from a Matrix MXC URL
* Copies the file from Matrix to mana-media storage with deduplication
*/
async importFromMatrix(options: ImportFromMatrixOptions): Promise<MediaResult> {
const response = await fetch(`${this.baseUrl}/api/v1/media/import/matrix`, {
method: 'POST',
headers: {
...this.getHeaders(),
'Content-Type': 'application/json',
},
body: JSON.stringify({
mxcUrl: options.mxcUrl,
app: options.app,
userId: options.userId,
skipProcessing: options.skipProcessing,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Import from Matrix failed: ${response.statusText} - ${error}`);
}
return response.json();
}
/**
* Get media by content hash (SHA-256)
* Useful for checking if a file already exists before uploading
*/
async getByHash(hash: string): Promise<MediaResult | null> {
const response = await fetch(`${this.baseUrl}/api/v1/media/hash/${hash}`, {
headers: this.getHeaders(),
});
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(`Get by hash failed: ${response.statusText}`);
}
return response.json();
}
/** /**
* Get media by ID * Get media by ID
*/ */

View file

@ -0,0 +1,25 @@
{
"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,
"esModuleInterop": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -10,6 +10,7 @@ Matrix NutriPhi Bot is a Matrix chat bot for AI-powered nutrition tracking. It i
- **Matrix**: matrix-bot-sdk - **Matrix**: matrix-bot-sdk
- **Backend**: NutriPhi API (port 3023) - **Backend**: NutriPhi API (port 3023)
- **Auth**: Mana Core Auth (JWT) - **Auth**: Mana Core Auth (JWT)
- **Media Storage**: mana-media (port 3015)
## Commands ## Commands
@ -41,9 +42,9 @@ services/matrix-nutriphi-bot/
│ ├── nutriphi/ │ ├── nutriphi/
│ │ ├── nutriphi.module.ts │ │ ├── nutriphi.module.ts
│ │ └── nutriphi.service.ts # NutriPhi API client │ │ └── nutriphi.service.ts # NutriPhi API client
│ └── session/ │ └── media/
│ ├── session.module.ts │ ├── media.module.ts
│ └── session.service.ts # User session & auth management │ └── media.service.ts # mana-media client for persistent storage
├── Dockerfile ├── Dockerfile
└── package.json └── package.json
``` ```
@ -67,10 +68,10 @@ services/matrix-nutriphi-bot/
## Image Analysis Flow ## Image Analysis Flow
1. User sends image to room 1. User sends image to room
2. Bot stores image URL: "Bild empfangen!" 2. Bot acknowledges: "Bild empfangen! Analysiere..."
3. User sends `!analyze` or `!analyze Beschreibung` 3. Bot downloads image, sends to NutriPhi API for analysis
4. Bot downloads image, sends to NutriPhi API 4. Bot displays nutrition results
5. Bot displays nutrition results 5. (Background) Image is stored in mana-media for persistent storage with deduplication
## Environment Variables ## Environment Variables
@ -91,6 +92,9 @@ NUTRIPHI_API_PREFIX=/api/v1
# Mana Core Auth # Mana Core Auth
MANA_CORE_AUTH_URL=http://localhost:3001 MANA_CORE_AUTH_URL=http://localhost:3001
# Mana Media (optional - for persistent image storage)
MANA_MEDIA_URL=http://localhost:3015
# Development bypass (optional) # Development bypass (optional)
DEV_BYPASS_AUTH=false DEV_BYPASS_AUTH=false
DEV_USER_ID= DEV_USER_ID=
@ -108,6 +112,7 @@ docker run -p 3315:3315 \
-e MATRIX_ACCESS_TOKEN=syt_xxx \ -e MATRIX_ACCESS_TOKEN=syt_xxx \
-e NUTRIPHI_BACKEND_URL=http://nutriphi-backend:3023 \ -e NUTRIPHI_BACKEND_URL=http://nutriphi-backend:3023 \
-e MANA_CORE_AUTH_URL=http://mana-core-auth:3001 \ -e MANA_CORE_AUTH_URL=http://mana-core-auth:3001 \
-e MANA_MEDIA_URL=http://mana-media:3015 \
-v matrix-nutriphi-bot-data:/app/data \ -v matrix-nutriphi-bot-data:/app/data \
matrix-nutriphi-bot matrix-nutriphi-bot
``` ```

View file

@ -29,6 +29,7 @@
"dependencies": { "dependencies": {
"@manacore/bot-services": "workspace:*", "@manacore/bot-services": "workspace:*",
"@manacore/matrix-bot-common": "workspace:*", "@manacore/matrix-bot-common": "workspace:*",
"@manacore/media-client": "workspace:*",
"@nestjs/common": "^10.4.15", "@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0", "@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15", "@nestjs/core": "^10.4.15",

View file

@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { MatrixService } from './matrix.service'; import { MatrixService } from './matrix.service';
import { NutriPhiModule } from '../nutriphi/nutriphi.module'; import { NutriPhiModule } from '../nutriphi/nutriphi.module';
import { SessionModule, TranscriptionModule, CreditModule } from '@manacore/bot-services'; import { SessionModule, TranscriptionModule, CreditModule } from '@manacore/bot-services';
import { MediaModule } from '../media/media.module';
@Module({ @Module({
imports: [ imports: [
@ -9,6 +10,7 @@ import { SessionModule, TranscriptionModule, CreditModule } from '@manacore/bot-
SessionModule.forRoot({ storageMode: 'redis' }), SessionModule.forRoot({ storageMode: 'redis' }),
TranscriptionModule.forRoot(), TranscriptionModule.forRoot(),
CreditModule.forRoot(), CreditModule.forRoot(),
MediaModule,
], ],
providers: [MatrixService], providers: [MatrixService],
exports: [MatrixService], exports: [MatrixService],

View file

@ -14,6 +14,7 @@ import {
WeeklyStats, WeeklyStats,
} from '../nutriphi/nutriphi.service'; } from '../nutriphi/nutriphi.service';
import { SessionService, TranscriptionService, CreditService } from '@manacore/bot-services'; import { SessionService, TranscriptionService, CreditService } from '@manacore/bot-services';
import { MediaService } from '../media/media.service';
import { HELP_MESSAGE, MEAL_TYPE_LABELS } from '../config/configuration'; import { HELP_MESSAGE, MEAL_TYPE_LABELS } from '../config/configuration';
const PHOTO_ANALYSIS_CREDITS = 3; const PHOTO_ANALYSIS_CREDITS = 3;
@ -36,7 +37,8 @@ export class MatrixService extends BaseMatrixService {
private nutriphiService: NutriPhiService, private nutriphiService: NutriPhiService,
private sessionService: SessionService, private sessionService: SessionService,
private transcriptionService: TranscriptionService, private transcriptionService: TranscriptionService,
private creditService: CreditService private creditService: CreditService,
private mediaService: MediaService
) { ) {
super(configService); super(configService);
} }
@ -114,6 +116,19 @@ Sag "hilfe" fur alle Befehle!`;
const response = this.formatAnalysisResult(result); const response = this.formatAnalysisResult(result);
await this.sendMessage(roomId, response); await this.sendMessage(roomId, response);
// Store image in mana-media for persistent storage (non-blocking)
// Use Matrix sender ID as user identifier
this.mediaService
.storeFromMatrix(mxcUrl, sender)
.then((mediaResult) => {
if (mediaResult) {
this.logger.log(`Image stored in mana-media: ${mediaResult.id}`);
}
})
.catch((error) => {
this.logger.warn(`Failed to store image in mana-media: ${error}`);
});
} catch (error) { } catch (error) {
await this.client.setTyping(roomId, false); await this.client.setTyping(roomId, false);
const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler';

View file

@ -18,6 +18,9 @@ export default () => ({
stt: { stt: {
url: process.env.STT_URL || 'http://localhost:3020', url: process.env.STT_URL || 'http://localhost:3020',
}, },
media: {
url: process.env.MANA_MEDIA_URL || 'http://localhost:3015',
},
}); });
export const HELP_MESSAGE = `**NutriPhi Bot - KI-Ernahrungsassistent** export const HELP_MESSAGE = `**NutriPhi Bot - KI-Ernahrungsassistent**

View file

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

View file

@ -0,0 +1,83 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MediaClient, MediaResult } from '@manacore/media-client';
@Injectable()
export class MediaService implements OnModuleInit {
private readonly logger = new Logger(MediaService.name);
private client: MediaClient | null = null;
constructor(private configService: ConfigService) {}
onModuleInit() {
const mediaUrl = this.configService.get<string>('media.url');
if (mediaUrl) {
this.client = new MediaClient(mediaUrl);
this.logger.log(`MediaClient initialized with URL: ${mediaUrl}`);
} else {
this.logger.warn('MANA_MEDIA_URL not configured, media storage disabled');
}
}
/**
* Store an image from a Matrix MXC URL in mana-media.
* Returns the media record if successful, null if disabled or failed.
*/
async storeFromMatrix(mxcUrl: string, userId: string): Promise<MediaResult | null> {
if (!this.client) {
this.logger.debug('Media storage disabled, skipping storage');
return null;
}
try {
const result = await this.client.importFromMatrix({
mxcUrl,
app: 'nutriphi',
userId,
});
this.logger.log(`Stored media from Matrix: ${result.id} (hash: ${result.hash})`);
return result;
} catch (error) {
this.logger.error(`Failed to store media from Matrix: ${error}`);
return null;
}
}
/**
* Check if a file already exists by hash
*/
async checkExists(hash: string): Promise<MediaResult | null> {
if (!this.client) {
return null;
}
try {
return await this.client.getByHash(hash);
} catch {
return null;
}
}
/**
* Get media by ID
*/
async get(id: string): Promise<MediaResult | null> {
if (!this.client) {
return null;
}
try {
return await this.client.get(id);
} catch {
return null;
}
}
/**
* Check if media service is available
*/
isEnabled(): boolean {
return this.client !== null;
}
}