// Gespeicherte Karten: Dienst (Regeln fürs Speichern, Aufräumen, Laden) und Anschluss an Appwrite. // Der Dienst kennt nur das schmale `CardsApi`-Interface und lässt sich dadurch ohne Netzwerk testen. import { AppwriteException, ID, Permission, Query, Role, type Models } from 'appwrite'; import { ID_PATTERN, parseConfig, serializeConfig, type CardConfig } from '../card/config'; import type { SavedCardRef } from '../editor/state'; import { msg, t } from '../i18n/i18n'; import { APPWRITE_ENDPOINT, IDS, client, storage, tables } from './appwrite'; import { currentUserId } from './auth'; import { errorType } from './errors'; export interface CardRowData { name: string; config: string; imageFileId: string; previewFileId: string; } export interface CardRow extends CardRowData { $id: string; $updatedAt: string; } export interface CardsApi { createFile(file: File, permissions: string[]): Promise<{ $id: string }>; deleteFile(fileId: string): Promise; fetchFile(fileId: string): Promise; createRow(data: CardRowData, permissions: string[]): Promise; updateRow(id: string, data: Partial): Promise; getRow(id: string): Promise; deleteRow(id: string): Promise; listRows(): Promise; } export interface SavedCard extends SavedCardRef { config: CardConfig; } export interface SaveInput { name: string; config: CardConfig; image: Blob; /** false: das Bild ist schon gespeichert und wird nicht erneut hochgeladen (nur bei `previous`). */ imageChanged: boolean; preview: Blob; previous: SavedCardRef | null; } export const DEFAULT_CARD_NAME = msg('Meine Karte'); const NAME_MAX = 80; /** Zeilen- und Datei-IDs von Appwrite (definiert in card/config.ts, dort braucht sie auch die Logo-ID der Konfiguration). */ export { ID_PATTERN }; /** Trimmt, fasst Leerraum zusammen und kürzt auf 80 Zeichen (Codepoints); leer ergibt „Meine Karte“ (in der Sprache der Seite). */ export function normalizeName(name: string): string { const collapsed = name.replace(/\s+/g, ' ').trim(); const cut = Array.from(collapsed).slice(0, NAME_MAX).join('').trim(); return cut || t(DEFAULT_CARD_NAME); } /** Nur der Besitzer darf die Zeile bzw. Datei lesen, ändern und löschen. */ export function ownerPermissions(userId: string): string[] { const owner = Role.user(userId); return [Permission.read(owner), Permission.update(owner), Permission.delete(owner)]; } const EXTENSIONS: Record = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp' }; /** Verpackt ein Bild als Datei für den Bucket; der Name folgt dem Typ (der Bucket prüft die Endung). */ export function toUploadFile(blob: Blob, base: 'image' | 'preview'): File { const ext = EXTENSIONS[blob.type]; if (!ext) throw new Error(`Bildtyp nicht erlaubt: ${blob.type || 'unbekannt'}`); return new File([blob], `${base}.${ext}`, { type: blob.type }); } function toSaved(row: CardRow): SavedCard { return { id: row.$id, name: row.name, imageFileId: row.imageFileId, previewFileId: row.previewFileId, updatedAt: row.$updatedAt, config: parseConfig(row.config), }; } /** Räumt auf, soweit es geht; ein Fehler (auch 404) ist hier nie ein Grund abzubrechen. */ async function deleteQuietly(api: CardsApi, fileIds: string[]): Promise { await Promise.allSettled(fileIds.map((id) => api.deleteFile(id))); } /** * Der Server hat eine Anfrage eindeutig abgelehnt (4xx außer Zeitüberschreitung 408 und Drosselung 429): es wurde nichts * geschrieben. Netzfehler, 5xx und alles ohne Statuscode lassen offen, ob die Zeile doch angekommen ist. */ function isDefinitiveFailure(err: unknown): boolean { const code = typeof err === 'object' && err !== null ? (err as { code?: unknown }).code : undefined; return typeof code === 'number' && code >= 400 && code < 500 && code !== 408 && code !== 429; } export function createCardsService(api: CardsApi, userId: () => string) { async function save(input: SaveInput): Promise { // Ohne Nutzer wirft das hier, bevor etwas hochgeladen wird. const permissions = ownerPermissions(userId()); const prev = input.previous; const name = normalizeName(input.name); const config = serializeConfig(input.config); const uploadImage = prev === null || input.imageChanged; // Typen vorab prüfen: ein unzulässiges Bild darf keine halben Uploads hinterlassen. const imageFile = uploadImage ? toUploadFile(input.image, 'image') : null; const previewFile = toUploadFile(input.preview, 'preview'); const uploaded: string[] = []; const upload = async (file: File): Promise => { const created = await api.createFile(file, permissions); uploaded.push(created.$id); return created.$id; }; let row: CardRow; // Die Zeile gab es nicht mehr (anderer Tab, anderes Konto): dann wurde eine neue Karte angelegt, die alten Dateien sind Müll. let replacedMissingCard = false; // Scheitert ein Upload, hängt noch keine Zeile an den Dateien. Beim Schreiben der Zeile ist der Ausgang bei // Netzfehler/5xx dagegen offen (sie kann angekommen sein): dann bleiben die Uploads lieber liegen, als dass // eine Zeile auf gelöschte Dateien zeigt. let writingRow = false; try { const newImageId = imageFile ? await upload(imageFile) : null; const previewFileId = await upload(previewFile); writingRow = true; if (!prev) { // Ohne vorherige Karte wird immer ein Bild hochgeladen (uploadImage). row = await api.createRow({ name, config, imageFileId: newImageId as string, previewFileId }, permissions); } else { // Ohne Bildwechsel steht kein imageFileId im Update: der Schnappschuss des Editors kann veraltet sein // (Bild in einem anderen Tab ersetzt) und würde die Zeile auf eine gelöschte Datei zurücksetzen. const update: Partial = { name, config, previewFileId }; if (newImageId) update.imageFileId = newImageId; try { row = await api.updateRow(prev.id, update); } catch (err) { if (errorType(err) !== 'row_not_found') throw err; replacedMissingCard = true; // Ohne Bildwechsel gibt es noch keine Bilddatei der neuen Karte; die der verschwundenen gehört ihr nicht. writingRow = false; const ownImageId = newImageId ?? (await upload(toUploadFile(input.image, 'image'))); writingRow = true; row = await api.createRow({ name, config, imageFileId: ownImageId, previewFileId }, permissions); } } } catch (err) { if (!writingRow || isDefinitiveFailure(err)) await deleteQuietly(api, uploaded); throw err; } if (prev) { // Die Karte zeigt schon auf die neuen Dateien; die alten sind nur noch Müll. const oldFiles = replacedMissingCard || uploadImage ? [prev.previewFileId, prev.imageFileId] : [prev.previewFileId]; await deleteQuietly(api, oldFiles); } return toSaved(row); } return { async list(): Promise { return (await api.listRows()).map(toSaved); }, async get(id: string): Promise { // Die ID landet im Pfad der Anfrage; was keine Appwrite-ID sein kann, gibt es nicht. if (!ID_PATTERN.test(id)) throw new AppwriteException('Row not found', 404, 'row_not_found', ''); return toSaved(await api.getRow(id)); }, save, async rename(id: string, name: string): Promise { return toSaved(await api.updateRow(id, { name: normalizeName(name) })); }, async remove(card: SavedCardRef): Promise { await api.deleteRow(card.id); await deleteQuietly(api, [card.imageFileId, card.previewFileId]); }, loadImage(card: SavedCardRef): Promise { return api.fetchFile(card.imageFileId); }, loadPreview(card: SavedCardRef): Promise { return api.fetchFile(card.previewFileId); }, }; } export type CardsService = ReturnType; /** Lädt eine Bilddatei aus einem Bucket mit Projekt-Header und Cookie (siehe `client.prepareRequest`). */ export async function fetchFile(bucketId: string, fileId: string): Promise { // Bewusst kein storage.getFileView(): das hängt das Projekt als Query-Parameter an die URL und schickt keinen // Header; so trifft die Anfrage die Traefik-Route für /v1 mit Header nicht. prepareRequest setzt Header und Cookie. const url = new URL( `${APPWRITE_ENDPOINT}/storage/buckets/${encodeURIComponent(bucketId)}/files/${encodeURIComponent(fileId)}/view`, ); const { uri, options } = client.prepareRequest('GET', url); const res = await fetch(uri, options); if (!res.ok) { const text = await res.text(); let body: { message?: unknown; type?: unknown } = {}; try { const parsed: unknown = JSON.parse(text); if (typeof parsed === 'object' && parsed !== null) body = parsed; } catch { // keine JSON-Antwort (z. B. Fehlerseite des Proxys): Statuszeile genügt } throw new AppwriteException( typeof body.message === 'string' ? body.message : res.statusText, res.status, typeof body.type === 'string' ? body.type : '', text, ); } const blob = await res.blob(); if (!blob.type.startsWith('image/')) throw new AppwriteException('Unerwartete Antwort', 502, 'general_unknown', ''); return blob; } type TableRow = Models.Row & CardRowData; export function appwriteCardsApi(): CardsApi { const target = { databaseId: IDS.database, tableId: IDS.table }; return { createFile: (file, permissions) => storage.createFile({ bucketId: IDS.bucket, fileId: ID.unique(), file, permissions }), deleteFile: async (fileId) => { await storage.deleteFile({ bucketId: IDS.bucket, fileId }); }, // Die privaten Karten liegen im Karten-Bucket; die Galerie holt ihre Bilder über `fetchFile` mit eigenem Bucket. fetchFile: (fileId) => fetchFile(IDS.bucket, fileId), createRow: (data, permissions) => tables.createRow({ ...target, rowId: ID.unique(), data, permissions }), // Beim Ändern bleiben die Rechte der Zeile unangetastet. updateRow: (id, data) => tables.updateRow({ ...target, rowId: id, data }), getRow: (id) => tables.getRow({ ...target, rowId: id }), deleteRow: async (id) => { await tables.deleteRow({ ...target, rowId: id }); }, listRows: async () => { const list = await tables.listRows({ ...target, queries: [Query.orderDesc('$updatedAt'), Query.limit(100)], }); return list.rows; }, }; } /** Die ID des angemeldeten Nutzers; ohne Nutzer ein Fehler, den `withLogin`/`createSaveAction` als 401 erkennen. */ export function requireUserId(): string { const id = currentUserId(); if (!id) throw Object.assign(new Error('unauthorized'), { code: 401 }); return id; } let shared: CardsService | null = null; /** Der Kartendienst der Seite (beim ersten Gebrauch erzeugt). */ export function cardsService(): CardsService { shared ??= createCardsService(appwriteCardsApi(), requireUserId); return shared; }