diff --git a/web/src/account/appwrite.ts b/web/src/account/appwrite.ts index bb35a29..d99d971 100644 --- a/web/src/account/appwrite.ts +++ b/web/src/account/appwrite.ts @@ -5,8 +5,18 @@ import { Account, Client, Storage, TablesDB } from 'appwrite'; export const APPWRITE_ENDPOINT: string = import.meta.env.VITE_APPWRITE_ENDPOINT || `${location.origin}/v1`; export const APPWRITE_PROJECT: string = import.meta.env.VITE_APPWRITE_PROJECT_ID || '6ac2130a00289343d6d5'; -/** Datenbank, Tabelle und Bucket des Projekts (siehe web/scripts/appwrite-setup.mjs). */ -export const IDS = { database: 'aircard', table: 'cards', bucket: 'card-images' } as const; +/** + * Datenbank, Tabellen und Buckets des Projekts (siehe web/scripts/appwrite-setup.mjs). + * `table`/`bucket` sind die privaten Karten; `gallery`/`reports`/`galleryBucket` gehören zur Community-Galerie. + */ +export const IDS = { + database: 'aircard', + table: 'cards', + bucket: 'card-images', + gallery: 'gallery', + reports: 'reports', + galleryBucket: 'gallery-images', +} as const; export const client: Client = new Client().setEndpoint(APPWRITE_ENDPOINT).setProject(APPWRITE_PROJECT); export const account: Account = new Account(client); diff --git a/web/src/account/cards.test.ts b/web/src/account/cards.test.ts index 641dc5a..f943a65 100644 --- a/web/src/account/cards.test.ts +++ b/web/src/account/cards.test.ts @@ -32,6 +32,7 @@ vi.mock('./appwrite', () => ({ import { appwriteCardsApi, createCardsService, + fetchFile, normalizeName, ownerPermissions, toUploadFile, @@ -711,3 +712,52 @@ describe('appwriteCardsApi', () => { }); }); }); + +describe('fetchFile (eigenständig, beliebiger Bucket)', () => { + beforeEach(() => { + for (const fn of Object.values(mocks)) fn.mockReset(); + vi.unstubAllGlobals(); + }); + + const prepare = () => + mocks.prepareRequest.mockImplementation((method: string, url: URL) => ({ + uri: url.toString(), + options: { method, headers: { 'X-Appwrite-Project': 'proj' }, credentials: 'include' }, + })); + + it('holt die Datei aus dem übergebenen Bucket, mit Projekt-Header und Cookie', async () => { + prepare(); + const fetchMock = vi.fn(async () => new Response(png(), { status: 200, headers: { 'content-type': 'image/png' } })); + vi.stubGlobal('fetch', fetchMock); + const result = await fetchFile('gallery-images', 'f1'); + expect(result.type).toBe('image/png'); + expect(fetchMock).toHaveBeenCalledWith('https://aircard.test/v1/storage/buckets/gallery-images/files/f1/view', { + method: 'GET', + headers: { 'X-Appwrite-Project': 'proj' }, + credentials: 'include', + }); + }); + + it('maskiert Bucket- und Datei-ID im Pfad', async () => { + prepare(); + vi.stubGlobal('fetch', vi.fn(async () => new Response(png(), { headers: { 'content-type': 'image/png' } }))); + await fetchFile('a/b', '../x?y'); + expect((mocks.prepareRequest.mock.calls[0][1] as URL).pathname).toBe('/v1/storage/buckets/a%2Fb/files/..%2Fx%3Fy/view'); + }); + + it('Fehlerantwort: AppwriteException mit Status und Typ des Servers', async () => { + prepare(); + const body = JSON.stringify({ message: 'File not found', type: 'storage_file_not_found', code: 404 }); + vi.stubGlobal('fetch', vi.fn(async () => new Response(body, { status: 404, statusText: 'Not Found' }))); + const err = await fetchFile('gallery-images', 'f1').catch((e: unknown) => e); + expect(err).toBeInstanceOf(AppwriteException); + expect(err).toMatchObject({ code: 404, type: 'storage_file_not_found' }); + }); + + it('CardsApi.fetchFile bleibt die Hülle über den Karten-Bucket', async () => { + prepare(); + vi.stubGlobal('fetch', vi.fn(async () => new Response(png(), { headers: { 'content-type': 'image/png' } }))); + await appwriteCardsApi().fetchFile('f1'); + expect((mocks.prepareRequest.mock.calls[0][1] as URL).pathname).toBe('/v1/storage/buckets/bucket/files/f1/view'); + }); +}); diff --git a/web/src/account/cards.ts b/web/src/account/cards.ts index 5dfbb64..6e32312 100644 --- a/web/src/account/cards.ts +++ b/web/src/account/cards.ts @@ -47,7 +47,7 @@ export interface SaveInput { export const DEFAULT_CARD_NAME = 'Meine Karte'; const NAME_MAX = 80; /** Zeilen- und Datei-IDs von Appwrite: höchstens 36 Zeichen, kein Sonderzeichen am Anfang. */ -const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/; +export const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/; /** Trimmt, fasst Leerraum zusammen und kürzt auf 80 Zeichen (Codepoints); leer ergibt „Meine Karte“. */ export function normalizeName(name: string): string { @@ -193,11 +193,13 @@ export function createCardsService(api: CardsApi, userId: () => string) { export type CardsService = ReturnType; -/** Lädt eine Bilddatei mit Projekt-Header und Cookie (siehe `client.prepareRequest`). */ -async function fetchFile(fileId: string): Promise { +/** 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/${IDS.bucket}/files/${encodeURIComponent(fileId)}/view`); + 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) { @@ -231,7 +233,8 @@ export function appwriteCardsApi(): CardsApi { deleteFile: async (fileId) => { await storage.deleteFile({ bucketId: IDS.bucket, fileId }); }, - fetchFile, + // 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. diff --git a/web/src/community/api.test.ts b/web/src/community/api.test.ts new file mode 100644 index 0000000..a55d167 --- /dev/null +++ b/web/src/community/api.test.ts @@ -0,0 +1,418 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + createJWT: vi.fn(), + userId: null as string | null, + authListeners: [] as Array<(user: unknown) => void>, + logoutListeners: [] as Array<() => void>, +})); + +vi.mock('../account/appwrite', () => ({ account: { createJWT: mocks.createJWT } })); +vi.mock('../account/auth', () => ({ + currentUserId: () => mocks.userId, + onAuthChange: (fn: (user: unknown) => void) => { + mocks.authListeners.push(fn); + return () => undefined; + }, + onLogout: (fn: () => void) => { + mocks.logoutListeners.push(fn); + return () => undefined; + }, +})); + +type Api = typeof import('./api'); + +const UNAVAILABLE = 'Der Dienst ist gerade nicht erreichbar.'; +const MINUTE = 60_000; + +// Der JWT-Cache lebt im Modul; jeder Test bekommt eine frische Instanz. +async function freshApi(): Promise { + vi.resetModules(); + return import('./api'); +} + +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + +const fetchMock = vi.fn<(url: string, init: RequestInit) => Promise>(); + +function sentHeaders(call = 0): Record { + return fetchMock.mock.calls[call][1].headers as Record; +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-10-04T10:00:00Z')); + mocks.createJWT.mockReset(); + mocks.userId = 'u1'; + mocks.authListeners.length = 0; + mocks.logoutListeners.length = 0; + let n = 0; + mocks.createJWT.mockImplementation(async () => ({ jwt: `jwt${++n}` })); + fetchMock.mockReset(); + fetchMock.mockImplementation(async () => json(200, { ok: true })); + vi.stubGlobal('fetch', fetchMock); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe('apiPost', () => { + it('sendet JSON per POST an den Pfad und liefert die JSON-Antwort', async () => { + const api = await freshApi(); + fetchMock.mockResolvedValue(json(200, { schonGemeldet: true })); + await expect(api.apiPost('/api/melden', { id: 'c1', reason: 'spam' }, { auth: false })).resolves.toEqual({ + schonGemeldet: true, + }); + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('/api/melden'); + expect(init.method).toBe('POST'); + expect(sentHeaders()['Content-Type']).toBe('application/json'); + expect(init.body).toBe(JSON.stringify({ id: 'c1', reason: 'spam' })); + }); + + it('ohne auth: kein JWT, kein createJWT', async () => { + const api = await freshApi(); + await api.apiPost('/api/melden', {}, { auth: false }); + expect(mocks.createJWT).not.toHaveBeenCalled(); + expect(sentHeaders()['X-Aircard-Jwt']).toBeUndefined(); + }); + + it('mit auth: sendet den Header X-Aircard-Jwt mit dem JWT von createJWT (ohne duration)', async () => { + const api = await freshApi(); + await api.apiPost('/api/zurueckziehen', { id: 'c1' }, { auth: true }); + expect(mocks.createJWT).toHaveBeenCalledOnce(); + expect(mocks.createJWT.mock.calls[0]).toEqual([]); + expect(sentHeaders()['X-Aircard-Jwt']).toBe('jwt1'); + }); + + describe('JWT-Cache', () => { + it('der zweite Aufruf innerhalb von 10 Minuten holt kein neues JWT', async () => { + const api = await freshApi(); + await api.apiPost('/api/x', {}, { auth: true }); + vi.setSystemTime(Date.now() + 9 * MINUTE + 59_000); + await api.apiPost('/api/x', {}, { auth: true }); + expect(mocks.createJWT).toHaveBeenCalledOnce(); + expect(sentHeaders(1)['X-Aircard-Jwt']).toBe('jwt1'); + }); + + it('nach 10 Minuten wird ein neues JWT geholt', async () => { + const api = await freshApi(); + await api.apiPost('/api/x', {}, { auth: true }); + vi.setSystemTime(Date.now() + 10 * MINUTE); + await api.apiPost('/api/x', {}, { auth: true }); + expect(mocks.createJWT).toHaveBeenCalledTimes(2); + expect(sentHeaders(1)['X-Aircard-Jwt']).toBe('jwt2'); + }); + + it('gilt je Nutzer-ID: ein anderer Nutzer bekommt nie das JWT des vorigen', async () => { + const api = await freshApi(); + await api.apiPost('/api/x', {}, { auth: true }); + mocks.userId = 'u2'; + await api.apiPost('/api/x', {}, { auth: true }); + expect(mocks.createJWT).toHaveBeenCalledTimes(2); + expect(sentHeaders(1)['X-Aircard-Jwt']).toBe('jwt2'); + }); + + it('wird bei einer Änderung des Nutzers (onAuthChange) geleert', async () => { + const api = await freshApi(); + await api.apiPost('/api/x', {}, { auth: true }); + expect(mocks.authListeners).toHaveLength(1); + mocks.authListeners[0](null); + await api.apiPost('/api/x', {}, { auth: true }); + expect(mocks.createJWT).toHaveBeenCalledTimes(2); + }); + + it('wird beim Abmelden (onLogout) geleert', async () => { + const api = await freshApi(); + await api.apiPost('/api/x', {}, { auth: true }); + expect(mocks.logoutListeners).toHaveLength(1); + mocks.logoutListeners[0](); + await api.apiPost('/api/x', {}, { auth: true }); + expect(mocks.createJWT).toHaveBeenCalledTimes(2); + }); + + it('ein JWT, das erst nach einem Nutzerwechsel eintrifft, wird nicht gemerkt', async () => { + const api = await freshApi(); + let release: (value: { jwt: string }) => void = () => undefined; + mocks.createJWT.mockImplementationOnce(() => new Promise((resolve) => (release = resolve))); + const first = api.apiPost('/api/x', {}, { auth: true }); + await vi.waitFor(() => expect(mocks.createJWT).toHaveBeenCalledOnce()); + mocks.authListeners[0](null); // Abmelden, während createJWT noch läuft + release({ jwt: 'late' }); + await first; + await api.apiPost('/api/x', {}, { auth: true }); + expect(mocks.createJWT).toHaveBeenCalledTimes(2); + expect(sentHeaders(1)['X-Aircard-Jwt']).toBe('jwt1'); + }); + + it('ohne bekannte Nutzer-ID wird nichts gemerkt', async () => { + const api = await freshApi(); + mocks.userId = null; + await api.apiPost('/api/x', {}, { auth: true }); + await api.apiPost('/api/x', {}, { auth: true }); + expect(mocks.createJWT).toHaveBeenCalledTimes(2); + }); + }); + + describe('401 von der API', () => { + it('holt ein neues JWT und wiederholt genau einmal', async () => { + const api = await freshApi(); + fetchMock.mockResolvedValueOnce(json(401, { error: 'Bitte melde dich an.' })); + fetchMock.mockResolvedValueOnce(json(200, { ok: true })); + await expect(api.apiPost('/api/x', { a: 1 }, { auth: true })).resolves.toEqual({ ok: true }); + expect(mocks.createJWT).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(sentHeaders(0)['X-Aircard-Jwt']).toBe('jwt1'); + expect(sentHeaders(1)['X-Aircard-Jwt']).toBe('jwt2'); + expect(fetchMock.mock.calls[1][1].body).toBe(JSON.stringify({ a: 1 })); + }); + + it('das neue JWT wird gemerkt, das alte nicht mehr', async () => { + const api = await freshApi(); + fetchMock.mockResolvedValueOnce(json(401, { error: 'Bitte melde dich an.' })); + await api.apiPost('/api/x', {}, { auth: true }); + await api.apiPost('/api/x', {}, { auth: true }); + expect(mocks.createJWT).toHaveBeenCalledTimes(2); + expect(sentHeaders(2)['X-Aircard-Jwt']).toBe('jwt2'); + }); + + it('bei einem zweiten 401 ist Schluss: ApiError mit code 401, keine dritte Anfrage', async () => { + const api = await freshApi(); + fetchMock.mockImplementation(async () => json(401, { error: 'Bitte melde dich an.' })); + const err = await api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(api.ApiError); + expect(err).toMatchObject({ code: 401, message: 'Bitte melde dich an.' }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(mocks.createJWT).toHaveBeenCalledTimes(2); + }); + + it('ohne auth wird nicht wiederholt', async () => { + const api = await freshApi(); + fetchMock.mockResolvedValue(json(401, { error: 'Nein.' })); + await expect(api.apiPost('/api/x', {}, { auth: false })).rejects.toMatchObject({ code: 401 }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it('andere Fehler (403) lösen keinen Wiederholungsversuch aus', async () => { + const api = await freshApi(); + fetchMock.mockResolvedValue(json(403, { error: 'Nur für Admins.' })); + await expect(api.apiPost('/api/x', {}, { auth: true })).rejects.toMatchObject({ code: 403 }); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(mocks.createJWT).toHaveBeenCalledOnce(); + }); + }); + + describe('Fehler', () => { + it('ApiError trägt Statuscode (code = status) und den deutschen Servertext aus `message`', async () => { + const api = await freshApi(); + fetchMock.mockResolvedValue(json(400, { message: 'Bitte gib einen Titel an.' })); + const err = await api.apiPost('/api/x', {}, { auth: false }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(api.ApiError); + expect(err).toBeInstanceOf(Error); + expect(err).toMatchObject({ code: 400, status: 400, message: 'Bitte gib einen Titel an.' }); + }); + + it('liest den Text auch aus `error` (so antwortet aircard-api tatsächlich)', async () => { + const api = await freshApi(); + fetchMock.mockResolvedValue(json(429, { error: 'Zu viele Meldungen – bitte später nochmal.' })); + await expect(api.apiPost('/api/melden', {}, { auth: false })).rejects.toMatchObject({ + code: 429, + message: 'Zu viele Meldungen – bitte später nochmal.', + }); + }); + + it('JSON ohne Text: allgemeine Meldung mit dem Statuscode', async () => { + const api = await freshApi(); + fetchMock.mockResolvedValue(json(500, {})); + await expect(api.apiPost('/api/x', {}, { auth: false })).rejects.toMatchObject({ + code: 500, + message: 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.', + }); + }); + + it('der Statuscode lässt isUnauthorized aus errors.ts greifen', async () => { + const api = await freshApi(); + const { isUnauthorized } = await import('../account/errors'); + fetchMock.mockResolvedValue(json(401, { error: 'x' })); + const err = await api.apiPost('/api/x', {}, { auth: false }).catch((e: unknown) => e); + expect(isUnauthorized(err)).toBe(true); + }); + + it('Netzfehler: code 503 und „Der Dienst ist gerade nicht erreichbar.“', async () => { + const api = await freshApi(); + fetchMock.mockRejectedValue(new TypeError('Failed to fetch')); + const err = await api.apiPost('/api/x', {}, { auth: false }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(api.ApiError); + expect(err).toMatchObject({ code: 503, message: UNAVAILABLE }); + }); + + it.each([502, 503, 504])('Status %i (auch mit JSON) gilt als nicht erreichbar: code 503', async (status) => { + const api = await freshApi(); + fetchMock.mockResolvedValue(json(status, { error: 'irgendwas' })); + await expect(api.apiPost('/api/x', {}, { auth: false })).rejects.toMatchObject({ code: 503, message: UNAVAILABLE }); + }); + + it('eine Fehlerseite ohne JSON (z. B. Proxy) gilt als nicht erreichbar', async () => { + const api = await freshApi(); + fetchMock.mockResolvedValue(new Response('404 page not found', { status: 404 })); + await expect(api.apiPost('/api/x', {}, { auth: false })).rejects.toMatchObject({ code: 503, message: UNAVAILABLE }); + }); + + it('eine HTML-Antwort mit Status 200 (Seitenfallback ohne API-Route) gilt als nicht erreichbar', async () => { + const api = await freshApi(); + fetchMock.mockResolvedValue(new Response('AirCard', { status: 200 })); + await expect(api.apiPost('/api/x', {}, { auth: false })).rejects.toMatchObject({ code: 503, message: UNAVAILABLE }); + }); + + it('JSON, das kein Objekt ist, gilt als nicht erreichbar', async () => { + const api = await freshApi(); + fetchMock.mockResolvedValue(new Response('"ok"', { status: 200 })); + await expect(api.apiPost('/api/x', {}, { auth: false })).rejects.toMatchObject({ code: 503 }); + }); + + it('createJWT mit user_auth_method_unsupported: Hinweis an den Betreiber, keine Anfrage', async () => { + const api = await freshApi(); + mocks.createJWT.mockRejectedValue( + Object.assign(new Error('JWT unsupported'), { code: 501, type: 'user_auth_method_unsupported' }), + ); + const err = await api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(api.ApiError); + expect(err).toMatchObject({ + code: 501, + message: 'Anmeldung per Token ist auf dem Server ausgeschaltet – bitte den Betreiber informieren.', + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('createJWT mit 401 (keine Sitzung) geht unverändert durch, damit withLogin greift', async () => { + const api = await freshApi(); + const failure = Object.assign(new Error('missing scope'), { code: 401, type: 'general_unauthorized_scope' }); + mocks.createJWT.mockRejectedValue(failure); + await expect(api.apiPost('/api/x', {}, { auth: true })).rejects.toBe(failure); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); +}); + +describe('publishCard', () => { + const input = { + title: 'Meine Karte', + issuer: 'Beispielbank', + tags: ['visa', 'gold'], + displayName: 'Kenso', + imageFileId: 'img1', + previewFileId: 'pre1', + sourceCardId: 'card1', + }; + + it('POST /api/veroeffentlichen mit JWT, Tags als Liste; liefert { id }', async () => { + const api = await freshApi(); + fetchMock.mockResolvedValue(json(201, { id: 'g1' })); + await expect(api.publishCard(input)).resolves.toEqual({ id: 'g1' }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('/api/veroeffentlichen'); + expect(JSON.parse(init.body as string)).toEqual(input); + expect(sentHeaders()['X-Aircard-Jwt']).toBe('jwt1'); + }); + + it('409: „Diese Karte ist schon veröffentlicht.“', async () => { + const api = await freshApi(); + fetchMock.mockResolvedValue(json(409, { error: 'Diese Karte ist schon veröffentlicht.' })); + const err = await api.publishCard(input).catch((e: unknown) => e); + expect(err).toMatchObject({ code: 409, message: 'Diese Karte ist schon veröffentlicht.' }); + expect(api.isAlreadyPublished(err)).toBe(true); + }); + + it('403 „bereits veröffentlicht oder gesperrt“ (Dateien belegt) gilt ebenfalls als schon veröffentlicht', async () => { + const api = await freshApi(); + fetchMock.mockResolvedValue(json(403, { error: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.' })); + const err = await api.publishCard(input).catch((e: unknown) => e); + expect(err).toBeInstanceOf(api.ApiError); + expect(err).toMatchObject({ code: 409, message: 'Diese Karte ist schon veröffentlicht.' }); + expect(api.isAlreadyPublished(err)).toBe(true); + }); + + it('ein anderes 403 bleibt ein 403 mit dem Servertext', async () => { + const api = await freshApi(); + fetchMock.mockResolvedValue(json(403, { error: 'Diese Datei gehört dir nicht.' })); + const err = await api.publishCard(input).catch((e: unknown) => e); + expect(err).toMatchObject({ code: 403, message: 'Diese Datei gehört dir nicht.' }); + expect(api.isAlreadyPublished(err)).toBe(false); + }); + + it('400 (Validierung) behält den Servertext', async () => { + const api = await freshApi(); + fetchMock.mockResolvedValue(json(400, { error: 'Bitte gib höchstens 5 Tags an.' })); + await expect(api.publishCard(input)).rejects.toMatchObject({ code: 400, message: 'Bitte gib höchstens 5 Tags an.' }); + }); + + it('eine Antwort ohne id ist ein Fehler', async () => { + const api = await freshApi(); + fetchMock.mockResolvedValue(json(201, {})); + await expect(api.publishCard(input)).rejects.toMatchObject({ code: 503, message: UNAVAILABLE }); + }); + + it('isAlreadyPublished: nur ApiError mit code 409', async () => { + const api = await freshApi(); + expect(api.isAlreadyPublished(new api.ApiError(409, 'x'))).toBe(true); + expect(api.isAlreadyPublished(new api.ApiError(400, 'x'))).toBe(false); + expect(api.isAlreadyPublished(Object.assign(new Error('x'), { code: 409 }))).toBe(false); + expect(api.isAlreadyPublished(null)).toBe(false); + }); +}); + +describe('withdrawCard, reportCard, setVisibility', () => { + it('withdrawCard: POST /api/zurueckziehen { id } mit JWT', async () => { + const api = await freshApi(); + await expect(api.withdrawCard('g1')).resolves.toBeUndefined(); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('/api/zurueckziehen'); + expect(JSON.parse(init.body as string)).toEqual({ id: 'g1' }); + expect(sentHeaders()['X-Aircard-Jwt']).toBe('jwt1'); + }); + + it('reportCard: POST /api/melden { id, reason } ohne JWT; { schonGemeldet: false } bei neuer Meldung', async () => { + const api = await freshApi(); + fetchMock.mockResolvedValue(json(200, { ok: true })); + await expect(api.reportCard('g1', 'spam')).resolves.toEqual({ schonGemeldet: false }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('/api/melden'); + expect(JSON.parse(init.body as string)).toEqual({ id: 'g1', reason: 'spam' }); + expect(mocks.createJWT).not.toHaveBeenCalled(); + }); + + it('reportCard: { schonGemeldet: true } bei doppelter Meldung', async () => { + const api = await freshApi(); + fetchMock.mockResolvedValue(json(200, { schonGemeldet: true })); + await expect(api.reportCard('g1', 'urheberrecht')).resolves.toEqual({ schonGemeldet: true }); + }); + + it('reportCard: 429 mit dem Servertext', async () => { + const api = await freshApi(); + fetchMock.mockResolvedValue(json(429, { error: 'Zu viele Meldungen – bitte später nochmal.' })); + await expect(api.reportCard('g1', 'spam')).rejects.toMatchObject({ + code: 429, + message: 'Zu viele Meldungen – bitte später nochmal.', + }); + }); + + it('setVisibility: POST /api/admin/sichtbarkeit { id, sichtbar } mit JWT', async () => { + const api = await freshApi(); + await expect(api.setVisibility('g1', false)).resolves.toBeUndefined(); + await api.setVisibility('g1', true); + expect(fetchMock.mock.calls[0][0]).toBe('/api/admin/sichtbarkeit'); + expect(JSON.parse(fetchMock.mock.calls[0][1].body as string)).toEqual({ id: 'g1', sichtbar: false }); + expect(JSON.parse(fetchMock.mock.calls[1][1].body as string)).toEqual({ id: 'g1', sichtbar: true }); + expect(sentHeaders()['X-Aircard-Jwt']).toBe('jwt1'); + }); + + it('setVisibility: 403 für Nicht-Admins mit dem Servertext', async () => { + const api = await freshApi(); + fetchMock.mockResolvedValue(json(403, { error: 'Nur für Admins.' })); + await expect(api.setVisibility('g1', true)).rejects.toMatchObject({ code: 403, message: 'Nur für Admins.' }); + }); +}); diff --git a/web/src/community/api.ts b/web/src/community/api.ts new file mode 100644 index 0000000..330b4a3 --- /dev/null +++ b/web/src/community/api.ts @@ -0,0 +1,181 @@ +// Schreibzugriffe der Community-Galerie: Aufrufe des Dienstes `aircard-api` (deploy/api) mit dem Nutzer-JWT. +// Lesen läuft direkt über Appwrite (gallery.ts). Fehler sind `ApiError` mit HTTP-Status als `code`, damit +// `isUnauthorized`/`withLogin`/`errorMessage` aus account/ sie wie Appwrite-Fehler behandeln. +import { account } from '../account/appwrite'; +import { currentUserId, onAuthChange, onLogout } from '../account/auth'; +import { errorType } from '../account/errors'; + +export type ReportReason = 'unangemessen' | 'kartendaten' | 'urheberrecht' | 'spam' | 'sonstiges'; + +export interface PublishInput { + title: string; + issuer: string; + tags: string[]; + displayName: string; + imageFileId: string; + previewFileId: string; + sourceCardId: string; +} + +const UNAVAILABLE = 'Der Dienst ist gerade nicht erreichbar.'; +const GENERIC = 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.'; +const JWT_UNSUPPORTED = 'Anmeldung per Token ist auf dem Server ausgeschaltet – bitte den Betreiber informieren.'; +const ALREADY_PUBLISHED = 'Diese Karte ist schon veröffentlicht.'; +/** Wortlaut der API (deploy/api/app.js, MSG.filesTaken): die hochgeladenen Dateien sind schon belegt oder gesperrt. */ +const FILES_TAKEN = 'Diese Bilder sind bereits veröffentlicht oder gesperrt.'; +/** Appwrite-JWTs gelten 15 Minuten; wir verwenden eines höchstens 10 Minuten. */ +const JWT_TTL_MS = 10 * 60 * 1000; + +/** Fehler des API-Dienstes. `code` ist der HTTP-Status (`status` dasselbe), `message` der deutsche Servertext. */ +export class ApiError extends Error { + readonly code: number; + + constructor(code: number, message: string) { + super(message); + this.name = 'ApiError'; + this.code = code; + } + + get status(): number { + return this.code; + } +} + +// ---- JWT (je Nutzer-ID gemerkt) ---------------------------------------------------------------------- + +let cached: { userId: string; jwt: string; expires: number } | null = null; +/** Zählt Leerungen: ein JWT, das erst nach einem Nutzerwechsel eintrifft, gehört dem alten Nutzer und wird nicht gemerkt. */ +let generation = 0; + +function clearJwt(): void { + cached = null; + generation++; +} + +let subscribed = false; + +/** Erst beim ersten JWT: so lässt sich das Modul importieren, ohne den Anmeldedienst zu berühren. */ +function subscribe(): void { + if (subscribed) return; + subscribed = true; + onAuthChange(clearJwt); + onLogout(clearJwt); +} + +async function getJwt(): Promise { + subscribe(); + const userId = currentUserId(); + if (userId && cached && cached.userId === userId && Date.now() < cached.expires) return cached.jwt; + const started = generation; + let jwt: string; + try { + // Ohne `duration`: die Standarddauer (15 Minuten) genügt. + jwt = (await account.createJWT()).jwt; + } catch (err) { + if (errorType(err) === 'user_auth_method_unsupported') { + const code = (err as { code?: unknown }).code; + throw new ApiError(typeof code === 'number' ? code : 501, JWT_UNSUPPORTED); + } + throw err; // z. B. 401 ohne Sitzung: withLogin erkennt es am Statuscode + } + if (userId && started === generation) cached = { userId, jwt, expires: Date.now() + JWT_TTL_MS }; + return jwt; +} + +// ---- Anfrage ------------------------------------------------------------------------------------------ + +type Json = Record; + +function isRecord(value: unknown): value is Json { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Der deutsche Text einer Fehlerantwort: aircard-api sendet `{error}`, `{message}` wird ebenso akzeptiert. */ +function serverMessage(body: Json): string | null { + for (const key of ['message', 'error']) { + const value = body[key]; + if (typeof value === 'string' && value.trim() !== '') return value; + } + return null; +} + +async function send(path: string, body: unknown, jwt: string | null): Promise { + const headers: Record = { 'Content-Type': 'application/json' }; + if (jwt) headers['X-Aircard-Jwt'] = jwt; + + let res: Response; + let text: string; + try { + res = await fetch(path, { method: 'POST', headers, body: JSON.stringify(body), credentials: 'omit' }); + text = await res.text(); + } catch { + throw new ApiError(503, UNAVAILABLE); + } + // Der Proxy antwortet, wenn der Dienst fehlt oder steht (502/503/504, Fehlerseite, Seitenfallback als HTML-200). + if (res.status === 502 || res.status === 503 || res.status === 504) throw new ApiError(503, UNAVAILABLE); + let data: unknown; + try { + data = JSON.parse(text); + } catch { + throw new ApiError(503, UNAVAILABLE); + } + if (!isRecord(data)) throw new ApiError(503, UNAVAILABLE); + if (!res.ok) throw new ApiError(res.status, serverMessage(data) ?? GENERIC); + return data; +} + +/** + * POST mit JSON-Body an den API-Dienst. Mit `auth` geht das Nutzer-JWT im Header `X-Aircard-Jwt` mit; + * antwortet der Dienst mit 401, wird genau einmal mit einem frischen JWT wiederholt. + */ +export async function apiPost(path: string, body: unknown, opts: { auth: boolean }): Promise { + if (!opts.auth) return send(path, body, null); + try { + return await send(path, body, await getJwt()); + } catch (err) { + if (!(err instanceof ApiError) || err.code !== 401) throw err; + clearJwt(); + return send(path, body, await getJwt()); + } +} + +// ---- Endpunkte ---------------------------------------------------------------------------------------- + +/** Wahr, wenn die Karte schon veröffentlicht ist (`publishCard` meldet das immer als 409). */ +export function isAlreadyPublished(err: unknown): boolean { + return err instanceof ApiError && err.code === 409; +} + +/** + * Veröffentlicht eine Karte. Doppelt veröffentlicht (409) und „Dateien schon belegt oder gesperrt“ (403) kommen + * beide als ApiError 409 „Diese Karte ist schon veröffentlicht.“ an; alle anderen Fehler unverändert. + */ +export async function publishCard(input: PublishInput): Promise<{ id: string }> { + let res: Json; + try { + res = await apiPost('/api/veroeffentlichen', input, { auth: true }); + } catch (err) { + if (err instanceof ApiError && err.code === 403 && err.message === FILES_TAKEN) { + throw new ApiError(409, ALREADY_PUBLISHED); + } + throw err; + } + if (typeof res.id !== 'string' || res.id === '') throw new ApiError(503, UNAVAILABLE); + return { id: res.id }; +} + +/** Zieht eine Veröffentlichung zurück (Autor oder Admin): löscht Zeile, Dateien und Meldungen. */ +export async function withdrawCard(id: string): Promise { + await apiPost('/api/zurueckziehen', { id }, { auth: true }); +} + +/** Meldet eine Karte (ohne Anmeldung). `schonGemeldet`: dieser Melder hatte sie schon gemeldet. */ +export async function reportCard(id: string, reason: ReportReason): Promise<{ schonGemeldet: boolean }> { + const res = await apiPost('/api/melden', { id, reason }, { auth: false }); + return { schonGemeldet: res.schonGemeldet === true }; +} + +/** Admin: blendet eine Karte ein oder aus. */ +export async function setVisibility(id: string, visible: boolean): Promise { + await apiPost('/api/admin/sichtbarkeit', { id, sichtbar: visible }, { auth: true }); +} diff --git a/web/src/community/gallery.test.ts b/web/src/community/gallery.test.ts new file mode 100644 index 0000000..b4d6297 --- /dev/null +++ b/web/src/community/gallery.test.ts @@ -0,0 +1,380 @@ +import { Query } from 'appwrite'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + listRows: vi.fn(), + getRow: vi.fn(), + fetchFile: vi.fn(), +})); + +vi.mock('../account/appwrite', () => ({ + IDS: { + database: 'db', + table: 'cards-table', + bucket: 'cards-bucket', + gallery: 'gallery-table', + reports: 'reports-table', + galleryBucket: 'gallery-bucket', + }, + account: {}, + client: {}, + storage: {}, + APPWRITE_ENDPOINT: 'https://aircard.test/v1', + tables: { listRows: mocks.listRows, getRow: mocks.getRow }, +})); + +// ID_PATTERN & Co. bleiben echt; nur das Laden der Datei geht nicht ins Netz. +vi.mock('../account/cards', async (importOriginal) => ({ + ...(await importOriginal()), + fetchFile: mocks.fetchFile, +})); + +import { + PAGE_SIZE, + galleryQueries, + getGalleryCard, + listGallery, + listReports, + loadGalleryImage, + myPublications, + toGalleryCard, + type GalleryCard, +} from './gallery'; + +const row = (n: number, patch: Record = {}) => ({ + $id: `g${n}`, + $createdAt: `2026-10-0${(n % 9) + 1}T10:00:00.000Z`, + title: `Karte ${n}`, + issuer: 'Beispielbank', + tags: 'visa,gold', + displayName: 'Kenso', + imageFileId: `img${n}`, + previewFileId: `pre${n}`, + sourceCardId: `src${n}`, + ownerId: 'u1', + hidden: false, + reportCount: 0, + searchText: 'karte beispielbank visa gold', + ...patch, +}); + +const notFound = (type = 'row_not_found') => Object.assign(new Error('Row not found'), { code: 404, type }); + +beforeEach(() => { + mocks.listRows.mockReset(); + mocks.getRow.mockReset(); + mocks.fetchFile.mockReset(); +}); + +describe('galleryQueries', () => { + it('öffentlich: nur sichtbare Karten, neueste zuerst, 24 pro Seite', () => { + expect(galleryQueries({})).toEqual([ + Query.equal('hidden', false), + Query.orderDesc('$createdAt'), + Query.limit(24), + ]); + }); + + it('die Abfragen sind genau die Appwrite-JSON-Strings', () => { + expect(galleryQueries({})).toEqual([ + JSON.stringify({ method: 'equal', attribute: 'hidden', values: [false] }), + JSON.stringify({ method: 'orderDesc', attribute: '$createdAt' }), + JSON.stringify({ method: 'limit', values: [24] }), + ]); + expect(PAGE_SIZE).toBe(24); + }); + + it('mit Cursor: cursorAfter(letzte ID)', () => { + expect(galleryQueries({ cursor: 'g24' })).toEqual([ + Query.equal('hidden', false), + Query.orderDesc('$createdAt'), + Query.limit(24), + Query.cursorAfter('g24'), + ]); + }); + + it('mit Suche: Volltext auf searchText, Leerraum zusammengefasst', () => { + expect(galleryQueries({ q: ' Visa Gold ' })).toEqual([ + Query.equal('hidden', false), + Query.orderDesc('$createdAt'), + Query.limit(24), + Query.search('searchText', 'Visa Gold'), + ]); + }); + + it('Suche und Cursor zusammen', () => { + expect(galleryQueries({ q: 'visa', cursor: 'g24' })).toEqual([ + Query.equal('hidden', false), + Query.orderDesc('$createdAt'), + Query.limit(24), + Query.cursorAfter('g24'), + Query.search('searchText', 'visa'), + ]); + }); + + it('eine leere oder nur aus Leerraum bestehende Suche wird ignoriert', () => { + expect(galleryQueries({ q: '' })).toEqual(galleryQueries({})); + expect(galleryQueries({ q: ' \t ' })).toEqual(galleryQueries({})); + }); + + it("Admin-Filter 'all' ist die öffentliche Ansicht", () => { + expect(galleryQueries({ filter: 'all' })).toEqual(galleryQueries({})); + }); + + it("Admin-Filter 'reported': reportCount > 0, ohne hidden-Bedingung (gemeldet und schon ausgeblendet)", () => { + const queries = galleryQueries({ filter: 'reported' }); + expect(queries).toEqual([Query.greaterThan('reportCount', 0), Query.orderDesc('$createdAt'), Query.limit(24)]); + expect(queries.join('')).not.toContain('hidden'); + }); + + it("Admin-Filter 'hidden': nur ausgeblendete Karten", () => { + expect(galleryQueries({ filter: 'hidden' })).toEqual([ + Query.equal('hidden', true), + Query.orderDesc('$createdAt'), + Query.limit(24), + ]); + }); + + it('Admin-Filter lassen sich mit Suche und Cursor kombinieren', () => { + expect(galleryQueries({ filter: 'reported', q: 'visa', cursor: 'g5' })).toEqual([ + Query.greaterThan('reportCount', 0), + Query.orderDesc('$createdAt'), + Query.limit(24), + Query.cursorAfter('g5'), + Query.search('searchText', 'visa'), + ]); + }); +}); + +describe('toGalleryCard', () => { + it('übernimmt alle Felder; Tags werden zur Liste', () => { + expect(toGalleryCard(row(1, { hidden: true, reportCount: 3 }))).toEqual({ + id: 'g1', + title: 'Karte 1', + issuer: 'Beispielbank', + tags: ['visa', 'gold'], + displayName: 'Kenso', + imageFileId: 'img1', + previewFileId: 'pre1', + sourceCardId: 'src1', + ownerId: 'u1', + hidden: true, + reportCount: 3, + createdAt: row(1).$createdAt, + } satisfies GalleryCard); + }); + + it('fehlende Felder bekommen leere Standardwerte', () => { + expect(toGalleryCard({ $id: 'g9' })).toEqual({ + id: 'g9', + title: '', + issuer: '', + tags: [], + displayName: '', + imageFileId: '', + previewFileId: '', + sourceCardId: '', + ownerId: '', + hidden: false, + reportCount: 0, + createdAt: '', + }); + }); + + it('Felder mit falschem Typ gelten als fehlend', () => { + const card = toGalleryCard({ + $id: 'g9', + title: 42, + tags: ['x'], + hidden: 'ja', + reportCount: 'viele', + displayName: null, + $createdAt: 5, + }); + expect(card).toMatchObject({ title: '', tags: [], hidden: false, reportCount: 0, displayName: '', createdAt: '' }); + }); + + it('Tags: getrimmt, Leere raus', () => { + expect(toGalleryCard(row(1, { tags: ' visa , ,gold,, ' })).tags).toEqual(['visa', 'gold']); + expect(toGalleryCard(row(1, { tags: '' })).tags).toEqual([]); + }); + + it('reportCount: nur endliche, nicht negative Zahlen', () => { + expect(toGalleryCard(row(1, { reportCount: Number.NaN })).reportCount).toBe(0); + expect(toGalleryCard(row(1, { reportCount: -2 })).reportCount).toBe(0); + expect(toGalleryCard(row(1, { reportCount: 2 })).reportCount).toBe(2); + }); +}); + +describe('listGallery', () => { + it('fragt die Galerie-Tabelle mit den Abfragen ab und liefert Karten', async () => { + mocks.listRows.mockResolvedValue({ total: 2, rows: [row(1), row(2)] }); + const page = await listGallery({ q: 'visa' }); + expect(mocks.listRows).toHaveBeenCalledOnce(); + expect(mocks.listRows.mock.calls[0][0]).toEqual({ + databaseId: 'db', + tableId: 'gallery-table', + queries: galleryQueries({ q: 'visa' }), + }); + expect(page.cards.map((c) => c.id)).toEqual(['g1', 'g2']); + expect(page.cards[0].tags).toEqual(['visa', 'gold']); + }); + + it('weniger als 24 Zeilen: keine weitere Seite (nextCursor null)', async () => { + mocks.listRows.mockResolvedValue({ total: 23, rows: Array.from({ length: 23 }, (_, i) => row(i + 1)) }); + expect((await listGallery({})).nextCursor).toBeNull(); + }); + + it('keine Zeilen: leere Liste, nextCursor null', async () => { + mocks.listRows.mockResolvedValue({ total: 0, rows: [] }); + await expect(listGallery({})).resolves.toEqual({ cards: [], nextCursor: null }); + }); + + it('genau 24 Zeilen: nextCursor ist die ID der letzten Karte', async () => { + mocks.listRows.mockResolvedValue({ total: 60, rows: Array.from({ length: 24 }, (_, i) => row(i + 1)) }); + const page = await listGallery({}); + expect(page.cards).toHaveLength(24); + expect(page.nextCursor).toBe('g24'); + }); + + it('die nächste Seite schickt den Cursor mit', async () => { + mocks.listRows.mockResolvedValue({ total: 0, rows: [] }); + await listGallery({ cursor: 'g24', filter: 'hidden' }); + expect(mocks.listRows.mock.calls[0][0].queries).toEqual(galleryQueries({ cursor: 'g24', filter: 'hidden' })); + }); + + it('Fehler gehen durch', async () => { + const failure = Object.assign(new Error('down'), { code: 503 }); + mocks.listRows.mockRejectedValue(failure); + await expect(listGallery({})).rejects.toBe(failure); + }); +}); + +describe('getGalleryCard', () => { + it('liefert die Karte', async () => { + mocks.getRow.mockResolvedValue(row(7)); + const card = await getGalleryCard('g7'); + expect(card?.id).toBe('g7'); + expect(mocks.getRow).toHaveBeenCalledWith({ databaseId: 'db', tableId: 'gallery-table', rowId: 'g7' }); + }); + + it('404 (nicht vorhanden oder ausgeblendet für mich) ergibt null', async () => { + mocks.getRow.mockRejectedValue(notFound()); + await expect(getGalleryCard('g7')).resolves.toBeNull(); + }); + + it('404 ohne Typ (z. B. Proxy) ergibt ebenfalls null', async () => { + mocks.getRow.mockRejectedValue(Object.assign(new Error('not found'), { code: 404 })); + await expect(getGalleryCard('g7')).resolves.toBeNull(); + }); + + it('eine fehlende Tabelle (404 table_not_found) ist ein Konfigurationsfehler, kein „Karte weg“', async () => { + const failure = notFound('table_not_found'); + mocks.getRow.mockRejectedValue(failure); + await expect(getGalleryCard('g7')).rejects.toBe(failure); + }); + + it.each([500, 503, 401, 429])('Fehler %i geht durch', async (code) => { + const failure = Object.assign(new Error('x'), { code, type: 'general_server_error' }); + mocks.getRow.mockRejectedValue(failure); + await expect(getGalleryCard('g7')).rejects.toBe(failure); + }); + + it('Netzfehler gehen durch', async () => { + const failure = new TypeError('Failed to fetch'); + mocks.getRow.mockRejectedValue(failure); + await expect(getGalleryCard('g7')).rejects.toBe(failure); + }); + + it.each(['', '../x', 'a/b', 'a?b', '.hidden', '-x', 'x'.repeat(37), 'ä'])( + 'ungültige ID %j: null, ohne Anfrage', + async (id) => { + await expect(getGalleryCard(id)).resolves.toBeNull(); + expect(mocks.getRow).not.toHaveBeenCalled(); + }, + ); +}); + +describe('myPublications', () => { + it('liest die Zeilen des Nutzers (höchstens 100) und ordnet sie nach sourceCardId', async () => { + mocks.listRows.mockResolvedValue({ + total: 2, + rows: [row(1, { sourceCardId: 'cardA' }), row(2, { sourceCardId: 'cardB', hidden: true, reportCount: 3 })], + }); + const map = await myPublications('u1'); + expect(mocks.listRows).toHaveBeenCalledOnce(); + expect(mocks.listRows.mock.calls[0][0]).toEqual({ + databaseId: 'db', + tableId: 'gallery-table', + queries: [Query.equal('ownerId', 'u1'), Query.limit(100)], + }); + expect(map).toBeInstanceOf(Map); + expect([...map.keys()]).toEqual(['cardA', 'cardB']); + expect(map.get('cardA')?.id).toBe('g1'); + expect(map.get('cardB')).toMatchObject({ id: 'g2', hidden: true, reportCount: 3 }); + }); + + it('ohne Veröffentlichungen: leere Map', async () => { + mocks.listRows.mockResolvedValue({ total: 0, rows: [] }); + expect((await myPublications('u1')).size).toBe(0); + }); + + it('Zeilen ohne sourceCardId (Altdaten) werden übersprungen', async () => { + mocks.listRows.mockResolvedValue({ total: 2, rows: [row(1, { sourceCardId: '' }), row(2, { sourceCardId: 'cardB' })] }); + expect([...(await myPublications('u1')).keys()]).toEqual(['cardB']); + }); + + it('ohne Nutzer-ID: leere Map, ohne Anfrage', async () => { + expect((await myPublications('')).size).toBe(0); + expect(mocks.listRows).not.toHaveBeenCalled(); + }); +}); + +describe('listReports', () => { + it('liest die Meldungen einer Karte (höchstens 100), neueste zuerst', async () => { + mocks.listRows.mockResolvedValue({ + total: 2, + rows: [ + { $id: 'r2', $createdAt: '2026-10-04T11:00:00.000Z', galleryId: 'g1', reason: 'spam', reporterHash: 'abc' }, + { $id: 'r1', $createdAt: '2026-10-04T10:00:00.000Z', galleryId: 'g1', reason: 'urheberrecht', reporterHash: 'def' }, + ], + }); + const reports = await listReports('g1'); + expect(mocks.listRows.mock.calls[0][0]).toEqual({ + databaseId: 'db', + tableId: 'reports-table', + queries: [Query.equal('galleryId', 'g1'), Query.orderDesc('$createdAt'), Query.limit(100)], + }); + expect(reports).toEqual([ + { id: 'r2', galleryId: 'g1', reason: 'spam', createdAt: '2026-10-04T11:00:00.000Z' }, + { id: 'r1', galleryId: 'g1', reason: 'urheberrecht', createdAt: '2026-10-04T10:00:00.000Z' }, + ]); + }); + + it('der Melder-Hash wird nicht weitergereicht', async () => { + mocks.listRows.mockResolvedValue({ total: 1, rows: [{ $id: 'r1', galleryId: 'g1', reason: 'spam', reporterHash: 'abc' }] }); + expect(JSON.stringify(await listReports('g1'))).not.toContain('abc'); + }); + + it('ungültige ID: leere Liste, ohne Anfrage', async () => { + await expect(listReports('../x')).resolves.toEqual([]); + expect(mocks.listRows).not.toHaveBeenCalled(); + }); +}); + +describe('loadGalleryImage', () => { + const card = toGalleryCard(row(3)); + const blob = new Blob(['x'], { type: 'image/png' }); + + it("'full' lädt das Bild aus dem Galerie-Bucket", async () => { + mocks.fetchFile.mockResolvedValue(blob); + await expect(loadGalleryImage(card, 'full')).resolves.toBe(blob); + expect(mocks.fetchFile).toHaveBeenCalledWith('gallery-bucket', 'img3'); + }); + + it("'preview' lädt die Vorschau aus dem Galerie-Bucket", async () => { + mocks.fetchFile.mockResolvedValue(blob); + await loadGalleryImage(card, 'preview'); + expect(mocks.fetchFile).toHaveBeenCalledWith('gallery-bucket', 'pre3'); + }); +}); diff --git a/web/src/community/gallery.ts b/web/src/community/gallery.ts new file mode 100644 index 0000000..4e5dc98 --- /dev/null +++ b/web/src/community/gallery.ts @@ -0,0 +1,157 @@ +// Die öffentliche Galerie lesen: Tabelle `gallery` direkt aus Appwrite (Gäste dürfen sichtbare Zeilen lesen). +// Schreiben (Veröffentlichen, Zurückziehen, Melden, Ausblenden) läuft über den API-Dienst, siehe api.ts. +import { Query, type Models } from 'appwrite'; +import { ID_PATTERN, fetchFile } from '../account/cards'; +import { IDS, tables } from '../account/appwrite'; + +export interface GalleryCard { + id: string; + title: string; + issuer: string; + tags: string[]; + displayName: string; + imageFileId: string; + previewFileId: string; + sourceCardId: string; + ownerId: string; + hidden: boolean; + reportCount: number; + createdAt: string; +} + +export interface GalleryReport { + id: string; + galleryId: string; + reason: string; + createdAt: string; +} + +/** Ansicht der Galerie: öffentlich `all`; `reported`/`hidden` zeigen nur Admins etwas (die Rechte prüft der Server). */ +export type GalleryFilter = 'all' | 'reported' | 'hidden'; + +export interface GalleryOptions { + q?: string; + cursor?: string; + filter?: GalleryFilter; +} + +export const PAGE_SIZE = 24; +/** Obergrenze für eigene Veröffentlichungen und Meldungen einer Karte (eine Seite genügt). */ +const LIST_MAX = 100; + +type Row = Record; +/** Eine Zeile, wie Appwrite sie liefert: Systemfelder (`$id`, …) plus die Spalten der Tabelle. */ +type TableRow = Models.Row & Row; + +function text(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +/** Eine Zeile der Tabelle `gallery` als Karte; fehlende oder falsch typisierte Felder werden leer. */ +export function toGalleryCard(row: Row): GalleryCard { + const count = row.reportCount; + return { + id: text(row.$id), + title: text(row.title), + issuer: text(row.issuer), + tags: text(row.tags) + .split(',') + .map((tag) => tag.trim()) + .filter((tag) => tag !== ''), + displayName: text(row.displayName), + imageFileId: text(row.imageFileId), + previewFileId: text(row.previewFileId), + sourceCardId: text(row.sourceCardId), + ownerId: text(row.ownerId), + hidden: row.hidden === true, + reportCount: typeof count === 'number' && Number.isFinite(count) && count > 0 ? count : 0, + createdAt: text(row.$createdAt), + }; +} + +/** + * Abfragen für eine Seite der Galerie, neueste zuerst. Öffentlich immer nur sichtbare Karten; + * Admin-Filter: „Gemeldet“ = reportCount > 0 (auch schon ausgeblendete), „Ausgeblendet“ = hidden. + */ +export function galleryQueries(opts: GalleryOptions): string[] { + const base = + opts.filter === 'reported' + ? Query.greaterThan('reportCount', 0) + : opts.filter === 'hidden' + ? Query.equal('hidden', true) + : Query.equal('hidden', false); + const queries = [base, Query.orderDesc('$createdAt'), Query.limit(PAGE_SIZE)]; + if (opts.cursor) queries.push(Query.cursorAfter(opts.cursor)); + const q = (opts.q ?? '').replace(/\s+/g, ' ').trim(); + if (q) queries.push(Query.search('searchText', q)); + return queries; +} + +/** Eine Seite der Galerie. `nextCursor` ist die ID der letzten Karte, solange die Seite voll war, sonst null. */ +export async function listGallery(opts: GalleryOptions): Promise<{ cards: GalleryCard[]; nextCursor: string | null }> { + const list = await tables.listRows({ + databaseId: IDS.database, + tableId: IDS.gallery, + queries: galleryQueries(opts), + }); + const cards = list.rows.map((row) => toGalleryCard(row)); + return { cards, nextCursor: cards.length === PAGE_SIZE ? cards[cards.length - 1].id : null }; +} + +/** „Die Zeile gibt es nicht (oder nicht für mich)“; fehlende Tabelle oder Datenbank ist dagegen ein echter Fehler. */ +function isRowMissing(err: unknown): boolean { + if (typeof err !== 'object' || err === null) return false; + const { code, type } = err as { code?: unknown; type?: unknown }; + return code === 404 && (type === 'row_not_found' || type === undefined || type === ''); +} + +/** + * Eine Karte per ID. Gibt es sie nicht oder ist sie für mich ausgeblendet (Appwrite antwortet beides mit 404), ist das + * Ergebnis null. Eine ID, die keine Appwrite-ID sein kann, ergibt null ohne Anfrage. + */ +export async function getGalleryCard(id: string): Promise { + if (!ID_PATTERN.test(id)) return null; + try { + return toGalleryCard(await tables.getRow({ databaseId: IDS.database, tableId: IDS.gallery, rowId: id })); + } catch (err) { + if (isRowMissing(err)) return null; + throw err; + } +} + +/** Die eigenen Veröffentlichungen (auch ausgeblendete), nach privater Karte (`sourceCardId`) geordnet. */ +export async function myPublications(userId: string): Promise> { + const map = new Map(); + if (!userId) return map; + const list = await tables.listRows({ + databaseId: IDS.database, + tableId: IDS.gallery, + queries: [Query.equal('ownerId', userId), Query.limit(LIST_MAX)], + }); + for (const row of list.rows) { + const card = toGalleryCard(row); + if (card.sourceCardId) map.set(card.sourceCardId, card); + } + return map; +} + +/** Admin: die Meldungen einer Karte, neueste zuerst. Der Melder-Hash bleibt außen vor. */ +export async function listReports(galleryId: string): Promise { + if (!ID_PATTERN.test(galleryId)) return []; + const list = await tables.listRows({ + databaseId: IDS.database, + tableId: IDS.reports, + queries: [Query.equal('galleryId', galleryId), Query.orderDesc('$createdAt'), Query.limit(LIST_MAX)], + }); + return list.rows.map((row) => ({ + id: text(row.$id), + galleryId: text(row.galleryId), + reason: text(row.reason), + createdAt: text(row.$createdAt), + })); +} + +/** Lädt das Kartenbild (PNG 1536 px) oder die Vorschau (WebP 480 px) aus dem Galerie-Bucket. */ +export function loadGalleryImage(card: GalleryCard, size: 'full' | 'preview'): Promise { + return fetchFile(IDS.galleryBucket, size === 'full' ? card.imageFileId : card.previewFileId); +} diff --git a/web/src/community/publish.test.ts b/web/src/community/publish.test.ts new file mode 100644 index 0000000..981af42 --- /dev/null +++ b/web/src/community/publish.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it, vi } from 'vitest'; +import { DEFAULT_CONFIG } from '../card/config'; +import type { SavedCard } from '../account/cards'; + +// Die Standard-Abhängigkeiten (Appwrite, Canvas) kommen in diesen Tests nie zum Einsatz. +vi.mock('../account/appwrite', () => ({ + IDS: { galleryBucket: 'gallery-bucket' }, + account: {}, + client: {}, + tables: {}, + storage: {}, + APPWRITE_ENDPOINT: 'https://aircard.test/v1', +})); + +import { ApiError } from './api'; +import { PNG_MAX_BYTES, normalizeTags, publishSavedCard, type PublishDeps } from './publish'; + +const card: SavedCard = { + id: 'card1', + name: 'Meine Karte', + imageFileId: 'privImage', + previewFileId: 'privPreview', + updatedAt: '2026-10-04T10:00:00.000Z', + config: DEFAULT_CONFIG, +}; + +const form = { title: 'Meine Karte', issuer: 'Beispielbank', tags: 'Visa, Gold', displayName: 'Kenso' }; + +const png = () => new Blob(['png'], { type: 'image/png' }); +const webp = () => new Blob(['webp'], { type: 'image/webp' }); + +type Call = { name: string; args: unknown[] }; + +/** Fake-Abhängigkeiten, die jeden Aufruf in Reihenfolge aufzeichnen; Dateien bekommen die IDs f1, f2, … */ +function fakeDeps(overrides: Partial = {}) { + const calls: Call[] = []; + let files = 0; + const log = (name: string, args: unknown[], result: T): T => { + calls.push({ name, args }); + return result; + }; + const deps: PublishDeps = { + userId: () => log('userId', [], 'u1'), + loadImage: async (c) => log('loadImage', [c], new Blob(['src'], { type: 'image/jpeg' })), + render: async (c, image) => log('render', [c, image], { png: png(), preview: webp() }), + createFile: async (file, permissions) => log('createFile', [file, permissions], { $id: `f${++files}` }), + deleteFile: async (id) => log('deleteFile', [id], undefined), + publishCard: async (input) => log('publishCard', [input], { id: 'g1' }), + ...overrides, + }; + return { + deps, + calls, + names: () => calls.map((c) => c.name), + of: (name: string) => calls.filter((c) => c.name === name), + }; +} + +describe('publishSavedCard', () => { + it('Reihenfolge: Nutzer, Bild laden, rendern, Upload, Upload, API; liefert { id }', async () => { + const fake = fakeDeps(); + await expect(publishSavedCard(card, form, fake.deps)).resolves.toEqual({ id: 'g1' }); + expect(fake.names()).toEqual(['userId', 'loadImage', 'render', 'createFile', 'createFile', 'publishCard']); + expect(fake.of('loadImage')[0].args[0]).toBe(card); + expect(fake.of('render')[0].args[0]).toBe(card); + }); + + it('lädt zuerst das PNG, dann die Vorschau hoch; beide mit read(any) und delete(user:)', async () => { + const fake = fakeDeps(); + await publishSavedCard(card, form, fake.deps); + const [first, second] = fake.of('createFile'); + expect((first.args[0] as File).name).toBe('image.png'); + expect((first.args[0] as File).type).toBe('image/png'); + expect((second.args[0] as File).name).toBe('preview.webp'); + expect((second.args[0] as File).type).toBe('image/webp'); + for (const call of [first, second]) { + expect(call.args[1]).toEqual(['read("any")', 'delete("user:u1")']); + } + }); + + it('benennt die Vorschau nach dem echten Blob-Typ (Browser ohne WebP-Export liefern PNG)', async () => { + const fake = fakeDeps({ render: async () => ({ png: png(), preview: png() }) }); + await publishSavedCard(card, form, fake.deps); + const preview = fake.of('createFile')[1].args[0] as File; + expect(preview.name).toBe('preview.png'); + expect(preview.type).toBe('image/png'); + }); + + it('schickt der API beide neuen Datei-IDs, die Karten-ID und die bereinigten Felder (Tags als Liste)', async () => { + const fake = fakeDeps(); + await publishSavedCard(card, { title: ' Meine Karte ', issuer: ' Bank ', tags: 'Visa, Gold , visa,,', displayName: ' K ' }, fake.deps); + expect(fake.of('publishCard')[0].args[0]).toEqual({ + title: 'Meine Karte', + issuer: 'Bank', + tags: ['visa', 'gold'], + displayName: 'K', + imageFileId: 'f1', + previewFileId: 'f2', + sourceCardId: 'card1', + }); + }); + + it('API-Fehler: beide hochgeladenen Dateien werden gelöscht, der Fehler kommt an', async () => { + const failure = new ApiError(400, 'Bitte gib einen Titel an.'); + const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) }); + await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure); + expect(fake.of('deleteFile').map((c) => c.args[0]).sort()).toEqual(['f1', 'f2']); + }); + + it('409 (schon veröffentlicht): Aufräumen wie bei jedem Fehler', async () => { + const failure = new ApiError(409, 'Diese Karte ist schon veröffentlicht.'); + const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) }); + await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure); + expect(fake.of('deleteFile').map((c) => c.args[0]).sort()).toEqual(['f1', 'f2']); + }); + + it('Netzfehler der API: Aufräumen, der Fehler kommt an', async () => { + const failure = new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'); + const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) }); + await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure); + expect(fake.of('deleteFile')).toHaveLength(2); + }); + + it('zweiter Upload scheitert: nur der erste wird gelöscht, die API wird nicht gerufen', async () => { + const failure = new Error('upload kaputt'); + let uploads = 0; + const fake = fakeDeps({ + createFile: async (file, permissions) => { + fake.calls.push({ name: 'createFile', args: [file, permissions] }); + if (++uploads === 2) throw failure; + return { $id: 'f1' }; + }, + }); + await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure); + expect(fake.of('deleteFile').map((c) => c.args[0])).toEqual(['f1']); + expect(fake.of('publishCard')).toHaveLength(0); + }); + + it('erster Upload scheitert: nichts zu löschen, kein zweiter Upload', async () => { + const failure = new Error('upload kaputt'); + const fake = fakeDeps({ createFile: async () => Promise.reject(failure) }); + await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure); + expect(fake.of('deleteFile')).toHaveLength(0); + expect(fake.of('publishCard')).toHaveLength(0); + }); + + it('löscht nie etwas anderes als die zwei Dateien dieses Versuchs (nicht die privaten Dateien der Karte)', async () => { + const fake = fakeDeps({ publishCard: async () => Promise.reject(new ApiError(403, 'Dazu hast du keine Berechtigung.')) }); + await publishSavedCard(card, form, fake.deps).catch(() => undefined); + const deleted = fake.of('deleteFile').map((c) => c.args[0]); + expect(deleted).not.toContain('privImage'); + expect(deleted).not.toContain('privPreview'); + expect(deleted.every((id) => id === 'f1' || id === 'f2')).toBe(true); + }); + + it('ein Fehler beim Aufräumen überdeckt den ursprünglichen Fehler nicht und stoppt das Aufräumen nicht', async () => { + const failure = new ApiError(500, 'Interner Fehler.'); + const attempted: string[] = []; + const fake = fakeDeps({ + publishCard: async () => Promise.reject(failure), + deleteFile: async (id) => { + attempted.push(id); + throw new Error('delete kaputt'); + }, + }); + await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure); + expect(attempted.sort()).toEqual(['f1', 'f2']); + }); + + it('bei Erfolg wird nichts gelöscht', async () => { + const fake = fakeDeps(); + await publishSavedCard(card, form, fake.deps); + expect(fake.of('deleteFile')).toHaveLength(0); + }); + + describe('vor dem Hochladen', () => { + it('ein PNG über 5 MiB wird nicht hochgeladen: „Das Kartenbild ist zu groß für die Galerie (max. 5 MB).“', async () => { + const big = new Blob([new Uint8Array(PNG_MAX_BYTES + 1)], { type: 'image/png' }); + const fake = fakeDeps({ render: async () => ({ png: big, preview: webp() }) }); + await expect(publishSavedCard(card, form, fake.deps)).rejects.toThrow( + 'Das Kartenbild ist zu groß für die Galerie (max. 5 MB).', + ); + expect(fake.of('createFile')).toHaveLength(0); + expect(fake.of('deleteFile')).toHaveLength(0); + expect(fake.of('publishCard')).toHaveLength(0); + }); + + it('genau 5 MiB sind erlaubt', async () => { + const exact = new Blob([new Uint8Array(PNG_MAX_BYTES)], { type: 'image/png' }); + const fake = fakeDeps({ render: async () => ({ png: exact, preview: webp() }) }); + await expect(publishSavedCard(card, form, fake.deps)).resolves.toEqual({ id: 'g1' }); + }); + + it('ein Bild mit unzulässigem Typ wird nicht hochgeladen', async () => { + const fake = fakeDeps({ render: async () => ({ png: new Blob(['x'], { type: 'image/jpeg' }), preview: webp() }) }); + await expect(publishSavedCard(card, form, fake.deps)).rejects.toThrow('Das Kartenbild konnte nicht erzeugt werden.'); + expect(fake.of('createFile')).toHaveLength(0); + }); + + it('scheitert das Rendern, wird nichts hochgeladen oder gelöscht', async () => { + const failure = new Error('Das Bild konnte nicht gelesen werden.'); + const fake = fakeDeps({ render: async () => Promise.reject(failure) }); + await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure); + expect(fake.names()).toEqual(['userId', 'loadImage']); + }); + + it('scheitert das Laden des Bildes, passiert nichts weiter', async () => { + const failure = Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' }); + const fake = fakeDeps({ loadImage: async () => Promise.reject(failure) }); + await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure); + expect(fake.names()).toEqual(['userId']); + }); + + it('ohne angemeldeten Nutzer wird weder geladen noch gerendert', async () => { + const failure = Object.assign(new Error('unauthorized'), { code: 401 }); + const fake = fakeDeps({ + userId: () => { + throw failure; + }, + }); + await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure); + expect(fake.names()).toEqual([]); + }); + }); +}); + +describe('normalizeTags', () => { + it('trennt an Kommas, trimmt, schreibt klein und entfernt Leere und Doppelte', () => { + expect(normalizeTags(' Visa, Gold ,, visa ,GOLD, Karte ')).toEqual(['visa', 'gold', 'karte']); + }); + + it('leere Eingabe ergibt eine leere Liste', () => { + expect(normalizeTags('')).toEqual([]); + expect(normalizeTags(' , , ')).toEqual([]); + }); + + it('behält die Reihenfolge der ersten Nennung', () => { + expect(normalizeTags('b, a, b, c, a')).toEqual(['b', 'a', 'c']); + }); + + it('kürzt nichts: Grenzen (5 Tags, 20 Zeichen) prüft der Server mit deutscher Meldung', () => { + const long = 'x'.repeat(30); + expect(normalizeTags(`a,b,c,d,e,f,${long}`)).toEqual(['a', 'b', 'c', 'd', 'e', 'f', long]); + }); + + it('Umlaute werden mit klein geschrieben', () => { + expect(normalizeTags('ÄÖÜ, Straße')).toEqual(['äöü', 'straße']); + }); +}); diff --git a/web/src/community/publish.ts b/web/src/community/publish.ts new file mode 100644 index 0000000..c0f2561 --- /dev/null +++ b/web/src/community/publish.ts @@ -0,0 +1,121 @@ +// Eine gespeicherte Karte in der Community-Galerie veröffentlichen: rendern, zwei Dateien hochladen, API rufen. +// Scheitert danach irgendetwas, löscht der Client genau die zwei Dateien dieses Versuchs wieder. +import { ID, Permission, Role } from 'appwrite'; +import { IDS, storage } from '../account/appwrite'; +import { cardsService, requireUserId, toUploadFile, type SavedCard } from '../account/cards'; +import { CARD_W } from '../card/geometry'; +import { clampCrop } from '../card/crop'; +import { exportCard, loadCardAssets } from '../card/render'; +import { decodeImage } from '../editor/upload'; +import { publishCard, type PublishInput } from './api'; + +/** Der Galerie-Bucket nimmt Dateien bis 5 MiB an (wie der Server prüft). */ +export const PNG_MAX_BYTES = 5_242_880; +const PREVIEW_WIDTH = 480; +const PREVIEW_QUALITY = 0.85; +const TOO_LARGE = 'Das Kartenbild ist zu groß für die Galerie (max. 5 MB).'; +const NOT_RENDERED = 'Das Kartenbild konnte nicht erzeugt werden.'; +const GALLERY_TYPES = ['image/png', 'image/webp']; + +export interface PublishForm { + title: string; + issuer: string; + /** Rohe Eingabe, Komma-getrennt; wird mit `normalizeTags` zur Liste. */ + tags: string; + displayName: string; +} + +/** Alles, was `publishSavedCard` von außen braucht (im Test ersetzbar, ohne Netz und Canvas). */ +export interface PublishDeps { + /** Die ID des angemeldeten Nutzers; ohne Nutzer ein Fehler mit `code` 401. */ + userId(): string; + /** Das Originalbild der privaten Karte. */ + loadImage(card: SavedCard): Promise; + /** Rendert Kartenbild (PNG, 1536 px) und Vorschau (WebP, 480 px). */ + render(card: SavedCard, image: Blob): Promise<{ png: Blob; preview: Blob }>; + createFile(file: File, permissions: string[]): Promise<{ $id: string }>; + deleteFile(fileId: string): Promise; + publishCard(input: PublishInput): Promise<{ id: string }>; +} + +/** Wie der Server: an Kommas trennen, trimmen, klein schreiben, Leere und Doppelte entfernen. Grenzen prüft der Server. */ +export function normalizeTags(input: string): string[] { + const tags: string[] = []; + for (const part of input.split(',')) { + const tag = part.trim().toLowerCase(); + if (tag !== '' && !tags.includes(tag)) tags.push(tag); + } + return tags; +} + +async function renderForGallery(card: SavedCard, imageBlob: Blob): Promise<{ png: Blob; preview: Blob }> { + const image = await decodeImage(imageBlob); + const config = { ...card.config, crop: clampCrop(image, card.config.crop) }; + const assets = await loadCardAssets(config, image); + const png = await exportCard(config, assets, { width: CARD_W, type: 'image/png' }); + const preview = await exportCard(config, assets, { width: PREVIEW_WIDTH, type: 'image/webp', quality: PREVIEW_QUALITY }); + return { png, preview }; +} + +function defaultDeps(): PublishDeps { + const service = cardsService(); + return { + userId: requireUserId, + loadImage: (card) => service.loadImage(card), + render: renderForGallery, + // Immer eine frische ID: aufgeräumt wird nur, was dieser Versuch selbst angelegt hat. + createFile: (file, permissions) => + storage.createFile({ bucketId: IDS.galleryBucket, fileId: ID.unique(), file, permissions }), + deleteFile: async (fileId) => { + await storage.deleteFile({ bucketId: IDS.galleryBucket, fileId }); + }, + publishCard, + }; +} + +/** Räumt auf, soweit es geht; ein Fehler (auch 404) darf den eigentlichen Fehler nie überdecken. */ +async function deleteQuietly(deps: PublishDeps, fileIds: string[]): Promise { + await Promise.allSettled(fileIds.map((id) => deps.deleteFile(id))); +} + +/** + * Veröffentlicht die Karte: Bild laden, rendern, PNG und Vorschau in den Galerie-Bucket laden, API rufen. + * Schlägt nach dem ersten Upload etwas fehl (zweiter Upload, API, auch 409), werden die in diesem Versuch + * hochgeladenen Dateien (und nur diese) gelöscht und der Fehler weitergeworfen. + */ +export async function publishSavedCard( + card: SavedCard, + form: PublishForm, + deps: PublishDeps = defaultDeps(), +): Promise<{ id: string }> { + // Ohne Nutzer wirft das hier, bevor etwas geladen oder gerendert wird. + const userId = deps.userId(); + const permissions = [Permission.read(Role.any()), Permission.delete(Role.user(userId))]; + + const source = await deps.loadImage(card); + const { png, preview } = await deps.render(card, source); + // Alles vorab prüfen: ein unzulässiges oder zu großes Bild darf keine halben Uploads hinterlassen. + if (png.type !== 'image/png' || !GALLERY_TYPES.includes(preview.type)) throw new Error(NOT_RENDERED); + if (png.size > PNG_MAX_BYTES || preview.size > PNG_MAX_BYTES) throw new Error(TOO_LARGE); + const imageFile = toUploadFile(png, 'image'); + // Der Name folgt dem echten Typ: Browser ohne WebP-Export liefern für „webp“ ein PNG. + const previewFile = toUploadFile(preview, 'preview'); + + const uploaded: string[] = []; + try { + uploaded.push((await deps.createFile(imageFile, permissions)).$id); + uploaded.push((await deps.createFile(previewFile, permissions)).$id); + return await deps.publishCard({ + title: form.title.trim(), + issuer: form.issuer.trim(), + tags: normalizeTags(form.tags), + displayName: form.displayName.trim(), + imageFileId: uploaded[0], + previewFileId: uploaded[1], + sourceCardId: card.id, + }); + } catch (err) { + await deleteQuietly(deps, uploaded); + throw err; + } +}