feat(api): route all image uploads through mana-media for CAS, thumbnails & Photos gallery

Picture, Contacts, Planta, Storage, and NutriPhi image uploads now go
through mana-media instead of directly to S3. This enables SHA-256
deduplication, automatic thumbnail generation, EXIF extraction, and
makes all images visible in the Photos gallery. Non-image files (PDFs,
audio, docs) continue to use shared-storage directly. SVG avatars in
Contacts also stay on shared-storage since Sharp can't process SVGs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-04 10:38:30 +02:00
parent 46dae20fa3
commit 502813f49c
10 changed files with 238 additions and 213 deletions

View file

@ -10,6 +10,7 @@
"type-check": "tsc --noEmit"
},
"dependencies": {
"@manacore/media-client": "workspace:*",
"@manacore/shared-hono": "workspace:*",
"@manacore/shared-storage": "workspace:*",
"@mozilla/readability": "^0.5.0",

40
apps/api/src/lib/media.ts Normal file
View file

@ -0,0 +1,40 @@
/**
* Shared media helper routes image uploads through mana-media
* for CAS deduplication, thumbnail generation, and Photos gallery visibility.
*/
import { MediaClient, type MediaResult } from '@manacore/media-client';
const MEDIA_URL = process.env.MANA_MEDIA_URL || 'http://localhost:3015';
let client: MediaClient | null = null;
function getMediaClient(): MediaClient {
if (!client) client = new MediaClient(MEDIA_URL);
return client;
}
export async function uploadImageToMedia(
buffer: ArrayBuffer,
filename: string,
options: { app: string; userId: string }
): Promise<MediaResult> {
return getMediaClient().upload(buffer, {
app: options.app,
userId: options.userId,
filename,
});
}
export function getMediaUrls(mediaId: string) {
const c = getMediaClient();
return {
original: c.getOriginalUrl(mediaId),
thumbnail: c.getThumbnailUrl(mediaId),
medium: c.getMediumUrl(mediaId),
large: c.getLargeUrl(mediaId),
};
}
export function isImageMimeType(mimeType: string): boolean {
return mimeType.startsWith('image/') && mimeType !== 'image/svg+xml';
}

View file

@ -29,22 +29,37 @@ routes.post('/:id/avatar', async (c) => {
}
try {
const { createContactsStorage, generateUserFileKey, getContentType } = await import(
'@manacore/shared-storage'
);
const storage = createContactsStorage();
const key = generateUserFileKey(
userId,
`avatar-${c.req.param('id')}.${file.name.split('.').pop()}`
);
const buffer = Buffer.from(await file.arrayBuffer());
const buffer = await file.arrayBuffer();
const result = await storage.upload(key, buffer, {
contentType: getContentType(file.name),
public: true,
});
if (file.type === 'image/svg+xml') {
// SVGs stay on shared-storage (Sharp can't process SVG)
const { createContactsStorage, generateUserFileKey } = await import(
'@manacore/shared-storage'
);
const storage = createContactsStorage();
const key = generateUserFileKey(userId, `avatar-${c.req.param('id')}.svg`);
const result = await storage.upload(key, Buffer.from(buffer), {
contentType: 'image/svg+xml',
public: true,
});
return c.json({ avatarUrl: result.url }, 201);
}
return c.json({ avatarUrl: result.url }, 201);
// Raster images -> mana-media for dedup, thumbnails & Photos gallery
const { uploadImageToMedia } = await import('../../lib/media');
const result = await uploadImageToMedia(
buffer,
`avatar-${c.req.param('id')}.${file.name.split('.').pop()}`,
{ app: 'contacts', userId }
);
return c.json(
{
avatarUrl: result.urls.thumbnail || result.urls.original,
mediaId: result.id,
},
201
);
} catch {
return c.json({ error: 'Upload failed' }, 500);
}

View file

@ -25,11 +25,15 @@ const routes = new Hono();
// ─── Photo Analysis (server-only: Gemini Vision) ────────────
routes.post('/analysis/photo', async (c) => {
const userId = c.get('userId');
const { imageBase64, mimeType } = await c.req.json();
if (!imageBase64) return c.json({ error: 'imageBase64 required' }, 400);
const mime = mimeType || 'image/jpeg';
try {
const res = await fetch(`${LLM_URL}/api/v1/chat/completions`, {
// Run AI analysis and mana-media upload in parallel
const analysisPromise = fetch(`${LLM_URL}/api/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@ -41,7 +45,7 @@ routes.post('/analysis/photo', async (c) => {
{ type: 'text', text: 'Analysiere diese Mahlzeit.' },
{
type: 'image_url',
image_url: { url: `data:${mimeType || 'image/jpeg'};base64,${imageBase64}` },
image_url: { url: `data:${mime};base64,${imageBase64}` },
},
],
},
@ -52,12 +56,29 @@ routes.post('/analysis/photo', async (c) => {
}),
});
// Store meal photo in mana-media for Photos gallery & persistence
const ext = mime.split('/')[1] || 'jpg';
const { uploadImageToMedia } = await import('../../lib/media');
const buffer = Uint8Array.from(atob(imageBase64), (ch) => ch.charCodeAt(0));
const mediaPromise = uploadImageToMedia(buffer.buffer, `meal-${Date.now()}.${ext}`, {
app: 'nutriphi',
userId,
}).catch(() => null); // Don't fail analysis if media upload fails
const [res, mediaResult] = await Promise.all([analysisPromise, mediaPromise]);
if (!res.ok) return c.json({ error: 'AI analysis failed' }, 502);
const data = await res.json();
const content = data.choices?.[0]?.message?.content;
const analysis = typeof content === 'string' ? JSON.parse(content) : content;
// Attach media info so the frontend can store photoMediaId on the meal
if (mediaResult) {
analysis.mediaId = mediaResult.id;
analysis.photoUrl = mediaResult.urls.thumbnail || mediaResult.urls.original;
}
return c.json(analysis);
} catch (err) {
console.error('Photo analysis failed:', err);

View file

@ -98,7 +98,27 @@ routes.post('/generate', async (c) => {
await consumeCredits(userId, 'AI_IMAGE_GENERATION', cost, `Image: ${prompt.slice(0, 50)}`);
return c.json({ imageUrl, prompt, model: model || 'flux-schnell' });
// Store generated image in mana-media for dedup, thumbnails & Photos gallery
try {
const { uploadImageToMedia } = await import('../../lib/media');
const imgRes = await fetch(imageUrl);
const imgBuffer = await imgRes.arrayBuffer();
const media = await uploadImageToMedia(imgBuffer, `generated-${Date.now()}.png`, {
app: 'picture',
userId,
});
return c.json({
imageUrl: media.urls.original,
mediaId: media.id,
thumbnailUrl: media.urls.thumbnail,
prompt,
model: model || 'flux-schnell',
});
} catch {
// Fallback: return raw imageUrl if mana-media is unavailable
return c.json({ imageUrl, prompt, model: model || 'flux-schnell' });
}
} catch (_err) {
return c.json({ error: 'Generation failed' }, 500);
}
@ -115,19 +135,19 @@ routes.post('/upload', async (c) => {
if (file.size > 10 * 1024 * 1024) return c.json({ error: 'Max 10MB' }, 400);
try {
const { createPictureStorage, generateUserFileKey, getContentType } = await import(
'@manacore/shared-storage'
const { uploadImageToMedia } = await import('../../lib/media');
const buffer = await file.arrayBuffer();
const result = await uploadImageToMedia(buffer, file.name, { app: 'picture', userId });
return c.json(
{
storagePath: result.id,
publicUrl: result.urls.original,
mediaId: result.id,
thumbnailUrl: result.urls.thumbnail,
},
201
);
const storage = createPictureStorage();
const key = generateUserFileKey(userId, file.name);
const buffer = Buffer.from(await file.arrayBuffer());
const result = await storage.upload(key, buffer, {
contentType: getContentType(file.name),
public: true,
});
return c.json({ storagePath: key, publicUrl: result.url }, 201);
} catch (_err) {
return c.json({ error: 'Upload failed' }, 500);
}

View file

@ -24,19 +24,19 @@ routes.post('/photos/upload', async (c) => {
if (file.size > 10 * 1024 * 1024) return c.json({ error: 'File too large (max 10MB)' }, 400);
try {
const { createPlantaStorage, generateUserFileKey, getContentType } = await import(
'@manacore/shared-storage'
const { uploadImageToMedia } = await import('../../lib/media');
const buffer = await file.arrayBuffer();
const result = await uploadImageToMedia(buffer, file.name, { app: 'planta', userId });
return c.json(
{
storagePath: result.id,
publicUrl: result.urls.original,
mediaId: result.id,
plantId,
},
201
);
const storage = createPlantaStorage();
const key = generateUserFileKey(userId, file.name);
const buffer = Buffer.from(await file.arrayBuffer());
const result = await storage.upload(key, buffer, {
contentType: getContentType(file.name),
public: true,
});
return c.json({ storagePath: key, publicUrl: result.url, plantId }, 201);
} catch (err) {
console.error('Upload failed:', err);
return c.json({ error: 'Upload failed' }, 500);

View file

@ -22,14 +22,37 @@ routes.post('/files/upload', async (c) => {
if (file.size > 100 * 1024 * 1024) return c.json({ error: 'Max 100MB' }, 400);
try {
const buffer = await file.arrayBuffer();
const { isImageMimeType } = await import('../../lib/media');
// Images -> mana-media for dedup, thumbnails & Photos gallery
if (isImageMimeType(file.type)) {
const { uploadImageToMedia } = await import('../../lib/media');
const result = await uploadImageToMedia(buffer, file.name, { app: 'storage', userId });
return c.json(
{
id: crypto.randomUUID(),
name: file.name,
storagePath: result.id,
storageKey: result.id,
mimeType: file.type,
size: file.size,
parentFolderId: folderId,
mediaId: result.id,
},
201
);
}
// Non-images -> shared-storage as before
const { createStorageStorage, generateUserFileKey, getContentType } = await import(
'@manacore/shared-storage'
);
const storage = createStorageStorage();
const key = generateUserFileKey(userId, file.name);
const buffer = Buffer.from(await file.arrayBuffer());
await storage.upload(key, buffer, {
await storage.upload(key, Buffer.from(buffer), {
contentType: getContentType(file.name),
public: false,
});