managarten/apps/api/src/lib/media.ts
Till JS 2b08e2f3a2
Some checks are pending
CD Mac Mini / Detect Changes (push) Waiting to run
CD Mac Mini / Deploy (push) Blocked by required conditions
CI / Detect Changes (push) Waiting to run
CI / Validate (push) Waiting to run
CI / Build mana-search (push) Blocked by required conditions
CI / Build mana-sync (push) Blocked by required conditions
CI / Build mana-api-gateway (push) Blocked by required conditions
CI / Build mana-crawler (push) Blocked by required conditions
Docker Validate / Validate Dockerfiles (push) Waiting to run
Docker Validate / Build calendar-web (push) Blocked by required conditions
Docker Validate / Build quotes-web (push) Blocked by required conditions
Docker Validate / Build todo-backend (push) Blocked by required conditions
Docker Validate / Build todo-web (push) Blocked by required conditions
Docker Validate / Build mana-auth (push) Blocked by required conditions
Docker Validate / Build mana-sync (push) Blocked by required conditions
Docker Validate / Build mana-media (push) Blocked by required conditions
Mirror to Forgejo / Push to Forgejo (push) Waiting to run
chore(mana): comic aus unified-App entfernen
Comic-Surface ist nach Comicello (comicello.mana.how + comicello.com)
umgezogen. Comicello hat seit Phase ω-2 (2026-05-18) Feature-Parität
+ Cross-Module-Hooks via mana-share:
- Character-Variant-Render mit pinned-Variant-Anker
- Panel-Render via mana-image-edits
- Panel-Edit/Delete/Reorder
- POST /api/v1/share/receive für externe Apps (Journal/Notes/Calendar/
  Library/Writing können Text-Snippet rüberschicken, Story wird als
  Draft angelegt)
- /comics/new?text=…&sourceModule=… URL-Prefill als Alternative zum
  Server-Roundtrip

Gelöscht / abgebaut:
- Module: apps/mana/.../modules/comic + Routen + Locales
- Backend: apps/api/src/modules/comic, picture/routes
  verifyMediaOwnership-Allowlist auf nur `['me']` reduziert (comic-
  Tag war hier Identity-Anchor-Quelle für panel renders, jetzt
  Comicello-intern)
- shared-branding: APP_ICONS.comic + MANA_APPS comic-Entry
- shared-ai/tools/schemas: ganzer Comic-Block (list_comic_stories,
  create_comic_story, generate_comic_panel, list_comic_characters,
  create_comic_character, generate_character_variant,
  pin_character_variant)
- shared-ai/agents/templates: comic-author.ts + index.ts Eintrag
- mana-tool-registry: modules/comic.ts + types ModuleId 'comic' raus
- Cross-Module: website/embeds resolveComicStories +
  LocalComicStory-Import + 'comic.stories' EmbedSource + Inspector-
  Option; crypto-registry comicStories+comicCharacters; exposed-records
  comic-Eintrag
- picture/types: comicStoryId, comicPanelIndex, comicCharacterId
  Back-Ref-Felder (sowohl LocalImage als auch Image-Public-DTO);
  picture/queries to-Public-Mapping
- Registries: app-registry/apps.ts (Comic registerApp + FilmStrip-
  Icon + Header), categories, help-content, module-registry,
  data/tools/init
- i18n: comic in apps/{de,en,es,fr,it}.json

Was BLEIBT:
- cloudflared `comicello.mana.how` + `comicello-api.mana.how` —
  Standalone-Routes
- docker-compose mana-auth CORS_ORIGINS comicello.com + .mana.how —
  SSO für Standalone

Dexie v66:
- droppt comicStories + comicCharacters
- Upgrade-Callback strippt comicStoryId/comicPanelIndex/comicCharacterId
  aus existierenden Image-Rows (waren nie indiziert, nur Properties)

mana-web svelte-check 0/0 (7281 files), snapshot test 10/10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:54:11 +02:00

124 lines
4.1 KiB
TypeScript

/**
* Shared media helper — routes image uploads through mana-media
* for CAS deduplication, thumbnail generation, and Photos gallery visibility.
*/
import { MediaClient, type MediaResult } from '@mana/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';
}
/**
* Download a media file by id. The mana-media `/file` route is CDN-style
* public — no auth on the URL itself — so this is a plain fetch. Callers
* that need to gate on ownership MUST call `verifyMediaOwnership` first.
*/
export async function getMediaBuffer(
mediaId: string
): Promise<{ buffer: ArrayBuffer; mimeType: string }> {
const url = getMediaClient().getOriginalUrl(mediaId);
const res = await fetch(url);
if (!res.ok) {
throw new Error(`mana-media fetch failed for ${mediaId}: HTTP ${res.status}`);
}
const mimeType = res.headers.get('content-type') ?? 'application/octet-stream';
const buffer = await res.arrayBuffer();
return { buffer, mimeType };
}
/**
* Download a media file normalized to plain RGB PNG, max `longestSide`
* pixels on its longer edge (default 1024). Uses mana-media's `/transform`
* endpoint, which pipes the original through `sharp` server-side — that
* handles HEIC from iPhones, palette-mode PNGs, CMYK JPEGs, weird color
* profiles, and other formats OpenAI's gpt-image-1 rejects with
* `invalid_image_file` or `Invalid image file or mode`.
*
* `fit=inside` preserves aspect ratio (no distortion on portrait/landscape
* refs) and only caps the longer side, which keeps payloads comfortably
* under OpenAI's 4 MB/image limit without losing reference fidelity.
*/
export async function getMediaBufferAsPng(
mediaId: string,
longestSide = 1024
): Promise<{ buffer: ArrayBuffer; mimeType: 'image/png' }> {
const base = getMediaClient()
.getOriginalUrl(mediaId)
.replace(/\/file$/, '/transform');
const url = `${base}?format=png&w=${longestSide}&h=${longestSide}&fit=inside`;
const res = await fetch(url);
if (!res.ok) {
throw new Error(`mana-media transform failed for ${mediaId}: HTTP ${res.status}`);
}
const buffer = await res.arrayBuffer();
return { buffer, mimeType: 'image/png' };
}
/**
* Verify that every id in `mediaIds` is owned by `userId` under one of
* the given app scopes. Throws `{ status: 404, missing }` when any id
* doesn't land in the owned set — the caller turns that into an HTTP
* response.
*
* Accepts a single app string or an array. `['me']` covers the
* portrait flow; future apps may extend the list with their own
* upload tags.
*
* One `list()` round-trip per app. For N apps this is N calls, each
* capped at 500 rows — far beyond the product's intended per-app shape
* but the cap is the sanity fence.
*/
export async function verifyMediaOwnership(
userId: string,
mediaIds: readonly string[],
apps: string | readonly string[]
): Promise<void> {
if (mediaIds.length === 0) return;
const appList = typeof apps === 'string' ? [apps] : apps;
const ownedSet = new Set<string>();
for (const app of appList) {
const list = await getMediaClient().list({ userId, app, limit: 500 });
for (const m of list) ownedSet.add(m.id);
}
const missing = mediaIds.filter((id) => !ownedSet.has(id));
if (missing.length > 0) {
const err = new Error(`Reference media not owned: ${missing.join(', ')}`) as Error & {
status?: number;
missing?: string[];
};
err.status = 404;
err.missing = missing;
throw err;
}
}