diff --git a/web/src/community/api.test.ts b/web/src/community/api.test.ts index 7fa1620..fde2359 100644 --- a/web/src/community/api.test.ts +++ b/web/src/community/api.test.ts @@ -513,6 +513,26 @@ describe('publishCard', () => { expect(api.isDefinitiveFailure(await api.publishCard(input).catch((e: unknown) => e))).toBe(true); }); + it('429 „schon 20 Karten veröffentlicht“ (Limit des Dienstes) behält den Text und ist eindeutig: es wurde nichts angelegt', async () => { + const api = await freshApi(); + const text = 'Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.'; + fetchMock.mockResolvedValue(json(429, { error: text })); + const err = await api.publishCard(input).catch((e: unknown) => e); + expect(err).toBeInstanceOf(api.ApiError); + expect(err).toMatchObject({ code: 429, message: text }); + expect(api.isAlreadyPublished(err)).toBe(false); + // damit räumt der Client die zwei hochgeladenen Dateien auf (ein anderes 429, z. B. vom Proxy, bleibt offen) + expect(api.isDefinitiveFailure(err)).toBe(true); + }); + + it('ein anderes 429 beim Veröffentlichen bleibt ein Fehler mit offenem Ausgang', async () => { + const api = await freshApi(); + fetchMock.mockResolvedValue(json(429, { error: 'Zu viele Anfragen.' })); + const err = await api.publishCard(input).catch((e: unknown) => e); + expect(err).toMatchObject({ code: 429, message: 'Zu viele Anfragen.' }); + expect(api.isDefinitiveFailure(err)).toBe(false); + }); + it('isAlreadyPublished: nur ApiError mit code 409', async () => { const api = await freshApi(); expect(api.isAlreadyPublished(new api.ApiError(409, 'x'))).toBe(true); diff --git a/web/src/community/api.ts b/web/src/community/api.ts index 5a9371e..d67a5b0 100644 --- a/web/src/community/api.ts +++ b/web/src/community/api.ts @@ -23,6 +23,8 @@ const JWT_UNSUPPORTED = 'Anmeldung per Token ist auf dem Server ausgeschaltet 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.'; +/** Wortlaut der API (MSG.tooManyPublications): das Limit von 20 Veröffentlichungen je Konto ist erreicht. */ +const TOO_MANY_PUBLICATIONS = 'Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.'; /** Appwrite-JWTs gelten 15 Minuten; wir verwenden eines höchstens 10 Minuten. */ const JWT_TTL_MS = 10 * 60 * 1000; @@ -189,7 +191,8 @@ export function isAlreadyPublished(err: unknown): boolean { /** * 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. + * beide als ApiError 409 „Diese Karte ist schon veröffentlicht.“ an. Das Limit des Dienstes (429 mit dem Limit-Text) ist + * eine eindeutige Ablehnung (es wurde nichts angelegt, die Dateien dürfen aufgeräumt werden); alle anderen Fehler unverändert. */ export async function publishCard(input: PublishInput): Promise<{ id: string }> { let res: Json; @@ -199,6 +202,9 @@ export async function publishCard(input: PublishInput): Promise<{ id: string }> if (err instanceof ApiError && err.code === 403 && err.message === FILES_TAKEN) { throw new ApiError(409, ALREADY_PUBLISHED); } + if (err instanceof ApiError && err.code === 429 && err.message === TOO_MANY_PUBLICATIONS) { + throw new ApiError(429, TOO_MANY_PUBLICATIONS, true); + } throw err; } if (typeof res.id !== 'string' || res.id === '') throw new ApiError(503, UNAVAILABLE); diff --git a/web/src/community/gallery-grid.test.ts b/web/src/community/gallery-grid.test.ts index 44a2ccc..32533be 100644 --- a/web/src/community/gallery-grid.test.ts +++ b/web/src/community/gallery-grid.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { GalleryCard } from './gallery'; +import type { CheckedImage, GalleryCard } from './gallery'; import { mountGalleryGrid, type GalleryGrid } from './gallery-grid'; const card = (n: number, patch: Partial = {}): GalleryCard => ({ @@ -18,14 +18,15 @@ const card = (n: number, patch: Partial = {}): GalleryCard => ({ ...patch, }); -const blob = () => new Blob(['x'], { type: 'image/webp' }); +let counter: number; +/** Ein geprüftes Bild, wie es `loadGalleryImageChecked` liefert: Blob plus fertige Object-URL. */ +const image = (): CheckedImage => ({ blob: new Blob(['x'], { type: 'image/webp' }), url: `blob:t-${++counter}` }); const flush = async () => { for (let i = 0; i < 5; i++) await Promise.resolve(); }; let host: HTMLElement; let grid: GalleryGrid | null; -let counter: number; const revoked: string[] = []; beforeEach(() => { @@ -34,7 +35,6 @@ beforeEach(() => { grid = null; counter = 0; revoked.length = 0; - URL.createObjectURL = vi.fn(() => `blob:t-${++counter}`); URL.revokeObjectURL = vi.fn((url: string) => { revoked.push(url); }); @@ -117,7 +117,7 @@ describe('mountGalleryGrid: Kacheln', () => { describe('mountGalleryGrid: Vorschaubilder', () => { it('lädt die Vorschau jeder Karte und zeigt sie per Object-URL', async () => { - const load = vi.fn(async () => blob()); + const load = vi.fn(async () => image()); grid = mountGalleryGrid(host, { load, onTag: () => {} }); const cards = [card(1), card(2)]; grid.append(cards); @@ -129,7 +129,7 @@ describe('mountGalleryGrid: Vorschaubilder', () => { }); it('gibt alle Object-URLs beim Aufräumen frei', async () => { - grid = mountGalleryGrid(host, { load: async () => blob(), onTag: () => {} }); + grid = mountGalleryGrid(host, { load: async () => image(), onTag: () => {} }); grid.append([card(1), card(2), card(3)]); await flush(); expect(counter).toBe(3); @@ -138,28 +138,29 @@ describe('mountGalleryGrid: Vorschaubilder', () => { expect(revoked.sort()).toEqual(['blob:t-1', 'blob:t-2', 'blob:t-3']); }); - it('legt nach dem Aufräumen keine URL mehr an (späte Antwort) und stößt keine neuen Ladungen an', async () => { - const resolvers: Array<(b: Blob) => void> = []; - const load = vi.fn(() => new Promise((r) => resolvers.push(r))); + it('eine nach dem Aufräumen eintreffende URL wird sofort freigegeben (späte Antwort), neue Ladungen starten nicht mehr', async () => { + const resolvers: Array<(i: CheckedImage) => void> = []; + const load = vi.fn(() => new Promise((r) => resolvers.push(r))); grid = mountGalleryGrid(host, { load, onTag: () => {} }); grid.append([card(1), card(2)]); expect(load).toHaveBeenCalledTimes(2); grid.dispose(); - for (const resolve of resolvers) resolve(blob()); + for (const resolve of resolvers) resolve(image()); await flush(); - expect(counter).toBe(0); + expect(revoked.sort()).toEqual(['blob:t-1', 'blob:t-2']); + expect(host.querySelector('img')).toBeNull(); grid.append([card(3)]); expect(load).toHaveBeenCalledTimes(2); expect(tiles()).toHaveLength(2); // die Kachel nach dem Aufräumen kommt nicht mehr dazu }); it('lädt höchstens sechs Vorschauen gleichzeitig', async () => { - const pending: Array<(b: Blob) => void> = []; - const load = vi.fn(() => new Promise((r) => pending.push(r))); + const pending: Array<(i: CheckedImage) => void> = []; + const load = vi.fn(() => new Promise((r) => pending.push(r))); grid = mountGalleryGrid(host, { load, onTag: () => {} }); grid.append(Array.from({ length: 10 }, (_, i) => card(i + 1))); expect(load).toHaveBeenCalledTimes(6); - pending[0](blob()); + pending[0](image()); await flush(); expect(load).toHaveBeenCalledTimes(7); }); @@ -167,12 +168,36 @@ describe('mountGalleryGrid: Vorschaubilder', () => { it('eine fehlgeschlagene Vorschau zeigt „Keine Vorschau“ und hält die anderen nicht auf', async () => { const load = vi.fn(async (c: GalleryCard) => { if (c.id === 'g1') throw new Error('404'); - return blob(); + return image(); }); grid = mountGalleryGrid(host, { load, onTag: () => {} }); grid.append([card(1), card(2)]); await flush(); expect(tiles()[0].querySelector('.gallery-thumb')?.textContent).toBe('Keine Vorschau'); expect(tiles()[1].querySelector('.gallery-thumb img')).not.toBeNull(); + expect(tiles()[0].querySelector('.gallery-thumb')?.hasAttribute('aria-busy')).toBe(false); + }); + + it('eine abgelehnte Vorschau (zu viele Pixel, nicht dekodierbar) zeigt „Keine Vorschau“, nie ein defektes Bild', async () => { + const load = vi.fn(async () => { + throw new Error('Das Bild konnte nicht geladen werden.'); // so lehnt checkGalleryImage ab + }); + grid = mountGalleryGrid(host, { load, onTag: () => {} }); + grid.append([card(1)]); + await flush(); + expect(host.querySelector('.gallery-thumb img')).toBeNull(); + expect(host.querySelector('.gallery-thumb')?.textContent).toBe('Keine Vorschau'); + }); + + it('zeigt das Bild nur, wenn es im Browser wirklich lädt: Ladefehler des ersetzt es durch „Keine Vorschau“ und gibt die URL frei', async () => { + grid = mountGalleryGrid(host, { load: async () => image(), onTag: () => {} }); + grid.append([card(1)]); + await flush(); + host.querySelector('.gallery-thumb img')?.dispatchEvent(new Event('error')); + expect(host.querySelector('.gallery-thumb img')).toBeNull(); + expect(host.querySelector('.gallery-thumb')?.textContent).toBe('Keine Vorschau'); + expect(revoked).toEqual(['blob:t-1']); + grid.dispose(); + expect(revoked).toEqual(['blob:t-1']); // nicht doppelt freigeben }); }); diff --git a/web/src/community/gallery-grid.ts b/web/src/community/gallery-grid.ts index ab94707..26bda2b 100644 --- a/web/src/community/gallery-grid.ts +++ b/web/src/community/gallery-grid.ts @@ -1,12 +1,12 @@ // Kachelraster der Community-Galerie: Vorschau, Titel, Herausgeber, bis zu drei Tags. -// Die Vorschaubilder kommen über `load` (WebP 480 px aus dem Galerie-Bucket) und werden als Object-URL angezeigt; -// `dispose()` gibt alle URLs wieder frei. Alle Texte gehen als Text in den DOM, nie als HTML. +// Die Vorschaubilder kommen über `load` (WebP 480 px aus dem Galerie-Bucket, mit Pixelgrenze geprüft: loadGalleryImageChecked) +// als fertige Object-URL; `dispose()` gibt alle URLs wieder frei. Alle Texte gehen als Text in den DOM, nie als HTML. import { el } from '../editor/dom'; -import type { GalleryCard } from './gallery'; +import type { CheckedImage, GalleryCard } from './gallery'; export interface GalleryGridOptions { - /** Lädt die Vorschau einer Karte. */ - load: (card: GalleryCard) => Promise; + /** Lädt und prüft die Vorschau einer Karte; wirft, wenn sie sich nicht anzeigen lässt („Keine Vorschau“). */ + load: (card: GalleryCard) => Promise; /** Ein Tag-Knopf wurde angeklickt. */ onTag: (tag: string) => void; /** Admin-Ansicht: „Ausgeblendet“ und die Zahl der Meldungen an der Kachel zeigen (für alle anderen bleibt das verborgen). */ @@ -39,16 +39,28 @@ export function mountGalleryGrid(container: HTMLElement, opts: GalleryGridOption const queue: Array<{ card: GalleryCard; thumb: HTMLElement }> = []; let active = 0; + const showEmpty = (thumb: HTMLElement) => + thumb.replaceChildren(el('span', { class: 'gallery-thumb-empty' }, 'Keine Vorschau')); + async function loadThumb(card: GalleryCard, thumb: HTMLElement): Promise { try { - const blob = await opts.load(card); - if (disposed) return; // zu spät: keine URL anlegen, die niemand mehr freigibt - const url = URL.createObjectURL(blob); + const { url } = await opts.load(card); + if (disposed) { + URL.revokeObjectURL(url); // zu spät: niemand gibt diese URL mehr frei + return; + } urls.add(url); - thumb.replaceChildren(el('img', { src: url, alt: '', width: THUMB_W, height: THUMB_H, decoding: 'async' })); + const img = el('img', { src: url, alt: '', width: THUMB_W, height: THUMB_H, decoding: 'async' }); + // Die Datei kam an und wurde geprüft, lässt sich aber nicht darstellen: lieber der Hinweis als ein defektes Bild. + img.addEventListener('error', () => { + if (disposed) return; + urls.delete(url); + URL.revokeObjectURL(url); + showEmpty(thumb); + }); + thumb.replaceChildren(img); } catch { - if (disposed) return; - thumb.replaceChildren(el('span', { class: 'gallery-thumb-empty' }, 'Keine Vorschau')); + if (!disposed) showEmpty(thumb); } finally { thumb.removeAttribute('aria-busy'); } diff --git a/web/src/community/gallery.test.ts b/web/src/community/gallery.test.ts index 9da0563..ddd0afd 100644 --- a/web/src/community/gallery.test.ts +++ b/web/src/community/gallery.test.ts @@ -1,5 +1,5 @@ import { AppwriteException, Query } from 'appwrite'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ listRows: vi.fn(), @@ -30,12 +30,15 @@ vi.mock('../account/cards', async (importOriginal) => ({ })); import { + MAX_GALLERY_PIXELS, PAGE_SIZE, + checkGalleryImage, galleryQueries, getGalleryCard, listGallery, listReports, loadGalleryImage, + loadGalleryImageChecked, myPublications, toGalleryCard, type GalleryCard, @@ -119,14 +122,24 @@ describe('galleryQueries', () => { expect(galleryQueries({ q: 'ä ö' })).toContain(Query.search('searchText', 'ä ö')); }); - it('Tag: contains auf tags, getrimmt und klein; die Galerie bleibt auf sichtbare Karten beschränkt', () => { + it('Tag: exakter Treffer auf ",tag," in tags (der Server speichert ",a,b,"), getrimmt und klein; nur sichtbare Karten', () => { expect(galleryQueries({ tag: ' Visa ' })).toEqual([ Query.equal('hidden', false), Query.orderDesc('$createdAt'), Query.limit(24), - Query.contains('tags', 'visa'), + Query.contains('tags', ',visa,'), ]); - expect(JSON.stringify(Query.contains('tags', 'visa'))).toContain('contains'); + expect(JSON.stringify(Query.contains('tags', ',visa,'))).toContain('contains'); + }); + + it('Tag: "gold" trifft nicht "marigold" (Trennzeichen an beiden Seiten)', () => { + const [, , , tagQuery] = galleryQueries({ tag: 'gold' }); + expect(JSON.parse(tagQuery)).toEqual({ method: 'contains', attribute: 'tags', values: [',gold,'] }); + const stored = (tags: string) => tags.includes(JSON.parse(tagQuery).values[0] as string); + expect(stored(',visa,gold,')).toBe(true); + expect(stored(',gold,')).toBe(true); + expect(stored(',marigold,')).toBe(false); + expect(stored(',goldig,')).toBe(false); }); it('ein leerer oder nur aus Leerraum bestehender Tag wird ignoriert', () => { @@ -140,7 +153,7 @@ describe('galleryQueries', () => { Query.orderDesc('$createdAt'), Query.limit(24), Query.cursorAfter('g24'), - Query.contains('tags', 'gold'), + Query.contains('tags', ',gold,'), Query.search('searchText', 'visa'), ]); }); @@ -183,7 +196,7 @@ describe('galleryQueries', () => { Query.equal('hidden', true), Query.orderDesc('$createdAt'), Query.limit(24), - Query.contains('tags', 'visa'), + Query.contains('tags', ',visa,'), Query.contains('searchText', 'x'), ]); }); @@ -252,6 +265,12 @@ describe('toGalleryCard', () => { expect(toGalleryCard(row(1, { tags: '' })).tags).toEqual([]); }); + it('Tags im Speicherformat ",a,b," (Komma an beiden Enden) werden zur sauberen Liste', () => { + expect(toGalleryCard(row(1, { tags: ',visa,gold,' })).tags).toEqual(['visa', 'gold']); + expect(toGalleryCard(row(1, { tags: ',blau,' })).tags).toEqual(['blau']); + 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); @@ -297,7 +316,7 @@ describe('listGallery', () => { Query.equal('hidden', false), Query.orderDesc('$createdAt'), Query.limit(24), - Query.contains('tags', 'visa'), + Query.contains('tags', ',visa,'), Query.contains('searchText', 'ab'), ]); }); @@ -457,3 +476,128 @@ describe('loadGalleryImage', () => { }, ); }); + +describe('checkGalleryImage (Pixelgrenze beim Anzeigen)', () => { + interface Behaviour { + load: 'ok' | 'fail'; + width: number; + height: number; + } + let behaviour: Behaviour; + let created: FakeImage[]; + let urlCounter: number; + let revoked: string[]; + + class FakeImage { + naturalWidth = 0; + naturalHeight = 0; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + #src = ''; + + constructor() { + created.push(this); + } + + set src(value: string) { + this.#src = value; + queueMicrotask(() => { + if (behaviour.load === 'ok') { + this.naturalWidth = behaviour.width; + this.naturalHeight = behaviour.height; + this.onload?.(); + } else { + this.onerror?.(); + } + }); + } + + get src(): string { + return this.#src; + } + } + + const blob = new Blob(['x'], { type: 'image/png' }); + + beforeEach(() => { + behaviour = { load: 'ok', width: 1536, height: 969 }; + created = []; + urlCounter = 0; + revoked = []; + vi.stubGlobal('Image', FakeImage); + vi.stubGlobal( + 'URL', + Object.assign(URL, { + createObjectURL: vi.fn(() => `blob:check-${++urlCounter}`), + revokeObjectURL: vi.fn((url: string) => { + revoked.push(url); + }), + }), + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('Grenze: 4 Megapixel (die Karte hat 1536 x 969, rund 1,5)', () => { + expect(MAX_GALLERY_PIXELS).toBe(4_000_000); + }); + + it('eine Karte in Normalgröße: liefert Blob und fertige Object-URL, die nicht freigegeben wird (der Aufrufer macht das)', async () => { + const image = await checkGalleryImage(blob); + expect(image).toEqual({ blob, url: 'blob:check-1' }); + expect(created).toHaveLength(1); + expect(created[0].src).toBe('blob:check-1'); + expect(revoked).toEqual([]); + }); + + it('genau 4 000 000 Pixel gehen noch durch', async () => { + behaviour = { ...behaviour, width: 2000, height: 2000 }; + await expect(checkGalleryImage(blob)).resolves.toMatchObject({ url: 'blob:check-1' }); + }); + + it('mehr als 4 000 000 Pixel: abgelehnt, die URL wird freigegeben (Dekompressionsbombe)', async () => { + behaviour = { ...behaviour, width: 2000, height: 2001 }; + await expect(checkGalleryImage(blob)).rejects.toThrow(); + expect(revoked).toEqual(['blob:check-1']); + }); + + it('riesige Maße (60 000 x 60 000): abgelehnt, ohne dass das Bild je angezeigt würde', async () => { + behaviour = { ...behaviour, width: 60_000, height: 60_000 }; + await expect(checkGalleryImage(blob)).rejects.toThrow(); + expect(revoked).toEqual(['blob:check-1']); + }); + + it('nicht dekodierbar (Ladefehler): abgelehnt, URL freigegeben', async () => { + behaviour = { ...behaviour, load: 'fail' }; + await expect(checkGalleryImage(blob)).rejects.toThrow(); + expect(revoked).toEqual(['blob:check-1']); + }); + + it('Maße 0 (kein echtes Bild): abgelehnt, URL freigegeben', async () => { + behaviour = { ...behaviour, width: 0, height: 0 }; + await expect(checkGalleryImage(blob)).rejects.toThrow(); + expect(revoked).toEqual(['blob:check-1']); + }); + + it('loadGalleryImageChecked lädt die Datei und prüft sie; Vorschau und Vollbild nutzen dieselbe Prüfung', async () => { + const card = toGalleryCard(row(3)); + mocks.fetchFile.mockResolvedValue(blob); + await expect(loadGalleryImageChecked(card, 'preview')).resolves.toEqual({ blob, url: 'blob:check-1' }); + expect(mocks.fetchFile).toHaveBeenLastCalledWith('gallery-bucket', 'pre3'); + await expect(loadGalleryImageChecked(card, 'full')).resolves.toEqual({ blob, url: 'blob:check-2' }); + expect(mocks.fetchFile).toHaveBeenLastCalledWith('gallery-bucket', 'img3'); + + behaviour = { ...behaviour, width: 8000, height: 8000 }; + await expect(loadGalleryImageChecked(card, 'full')).rejects.toThrow(); + expect(revoked).toEqual(['blob:check-3']); + }); + + it('ein Fehler beim Laden der Datei geht durch, ohne dass eine URL angelegt wird', async () => { + const card = toGalleryCard(row(3)); + mocks.fetchFile.mockRejectedValue(notFound('storage_file_not_found')); + await expect(loadGalleryImageChecked(card, 'full')).rejects.toMatchObject({ code: 404 }); + expect(urlCounter).toBe(0); + }); +}); diff --git a/web/src/community/gallery.ts b/web/src/community/gallery.ts index 76d2ca9..20cf6b4 100644 --- a/web/src/community/gallery.ts +++ b/web/src/community/gallery.ts @@ -32,7 +32,7 @@ export type GalleryFilter = 'all' | 'reported' | 'hidden'; export interface GalleryOptions { /** Suchtext; ab 3 Zeichen Volltext, kürzer als Teilstring (der Volltext-Index findet so kurze Wörter nicht). */ q?: string; - /** Nur Karten mit diesem Tag (Teilstring in `tags`, klein). */ + /** Nur Karten mit genau diesem Tag (klein; der Server speichert `tags` als `,a,b,`, gesucht wird `,,`). */ tag?: string; cursor?: string; filter?: GalleryFilter; @@ -90,7 +90,7 @@ export function galleryQueries(opts: GalleryOptions): string[] { const queries = [base, Query.orderDesc('$createdAt'), Query.limit(PAGE_SIZE)]; if (opts.cursor) queries.push(Query.cursorAfter(opts.cursor)); const tag = (opts.tag ?? '').trim().toLowerCase(); - if (tag) queries.push(Query.contains('tags', tag)); + if (tag) queries.push(Query.contains('tags', `,${tag},`)); // mit Trennzeichen: „gold“ trifft nicht „marigold“ const q = (opts.q ?? '').replace(/\s+/g, ' ').trim(); if (q) { queries.push( @@ -171,3 +171,47 @@ export async function loadGalleryImage(card: GalleryCard, size: 'full' | 'previe if (!ID_PATTERN.test(fileId)) throw new AppwriteException('File not found', 404, 'storage_file_not_found', ''); return fetchFile(IDS.galleryBucket, fileId); } + +/** Obergrenze für Breite × Höhe eines Galerie-Bildes: die Karte hat 1536 × 969 (rund 1,5 Megapixel). */ +export const MAX_GALLERY_PIXELS = 4_000_000; +const IMAGE_UNUSABLE = 'Das Bild konnte nicht geladen werden.'; + +/** Ein geprüftes Galerie-Bild; `url` ist eine Object-URL auf `blob`, die der Aufrufer mit `URL.revokeObjectURL` freigibt. */ +export interface CheckedImage { + blob: Blob; + url: string; +} + +/** + * Prüft ein Galerie-Bild, bevor es angezeigt wird: Das Bild wird über eine Object-URL in ein `Image` geladen (dann sind + * die Maße bekannt, es ist aber noch nicht dekodiert) und abgelehnt, wenn das nicht klappt oder Breite × Höhe über + * `MAX_GALLERY_PIXELS` liegt. Ein kleines, stark komprimiertes PNG/WebP kann sonst Gigabytes an Pixeln enthalten und den + * Tab einfrieren (Startseite und Galerie zeigen fremde Bilder). Die Server-Prüfung beim Veröffentlichen ersetzt das nicht. + * Bei Erfolg liefert es dieselbe Object-URL, die dann angezeigt wird; bei Ablehnung ist sie schon freigegeben. + */ +export async function checkGalleryImage(blob: Blob): Promise { + const url = URL.createObjectURL(blob); + try { + const img = new Image(); + const loaded = new Promise((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = () => reject(new Error(IMAGE_UNUSABLE)); + }); + img.src = url; + await loaded; + const { naturalWidth: w, naturalHeight: h } = img; + if (!w || !h || w * h > MAX_GALLERY_PIXELS) throw new Error(IMAGE_UNUSABLE); + return { blob, url }; + } catch (err) { + URL.revokeObjectURL(url); + throw err; + } +} + +/** + * Wie `loadGalleryImage`, aber geprüft (`checkGalleryImage`) und mit fertiger Object-URL: der gemeinsame Weg für Raster, + * Hero und Detailseite. Wirft bei Ladefehler, zu großen Maßen und nicht dekodierbaren Dateien. + */ +export async function loadGalleryImageChecked(card: GalleryCard, size: 'full' | 'preview'): Promise { + return checkGalleryImage(await loadGalleryImage(card, size)); +} diff --git a/web/src/community/publish-dialog.test.ts b/web/src/community/publish-dialog.test.ts index 53de4b8..d56b291 100644 --- a/web/src/community/publish-dialog.test.ts +++ b/web/src/community/publish-dialog.test.ts @@ -151,6 +151,42 @@ describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => { await invalid({ title: 'a\tb' }, 'Der Titel enthält ungültige Zeichen.', 'title'); }); + // Dasselbe Muster wie deploy/api/app.js: C1-Steuerzeichen, Nullbreiten-, Bidi-Zeichen und BOM sind unsichtbar oder ordnen Text um. + it.each([ + ['Nullbreiten-Leerzeichen', 'a\u200bb'], + ['Rechts-nach-links-Überschreibung', 'foto\u202egnp.exe'], + ['Isolate', 'a\u2066b'], + ['Wortverbinder', 'a\u2060b'], + ['C1-Steuerzeichen', 'a\u0085b'], + ['BOM mitten im Titel', 'a\ufeffb'], + ])('unsichtbare Zeichen werden abgelehnt: %s (Titel)', async (_name, title) => { + await invalid({ title }, 'Der Titel enthält ungültige Zeichen.', 'title'); + }); + + it('ein Titel nur aus unsichtbaren Zeichen gilt als ungültig', async () => { + await invalid({ title: '\u200b\u200d' }, 'Der Titel enthält ungültige Zeichen.', 'title'); + }); + + it('unsichtbare Zeichen in Herausgeber, Tags und Anzeigename', async () => { + await invalid({ issuer: 'Bank\u200f' }, 'Herausgeber / Bank enthält ungültige Zeichen.', 'issuer'); + }); + + it('unsichtbares Zeichen in einem Tag', async () => { + await invalid({ tags: 'visa, gol\u200bd' }, 'Tags enthalten ungültige Zeichen.', 'tags'); + }); + + it('unsichtbares Zeichen im Anzeigenamen', async () => { + await invalid({ displayName: 'Ken\u2060so' }, 'Der Anzeigename enthält ungültige Zeichen.', 'displayName'); + }); + + it('normale Sonderzeichen (Umlaute, Emoji, Bindestrich, Zeilenabstand durch Leerzeichen) bleiben erlaubt', async () => { + submit.mockResolvedValue({ id: 'g1' }); + const outcome = open(); + fill({ title: 'Größe 😀 – Möwe', issuer: 'Café-Bank', displayName: 'Zoë', tags: 'grün, ✨' }); + send(); + await expect(outcome).resolves.toBe('published'); + }); + it('Grenzen sind inklusive: 60/40/30 Zeichen und fünf Tags gehen durch; doppelte und leere Tags zählen nicht', async () => { submit.mockResolvedValue({ id: 'g1' }); const outcome = open(); diff --git a/web/src/community/publish-dialog.ts b/web/src/community/publish-dialog.ts index 90574b3..24e6cff 100644 --- a/web/src/community/publish-dialog.ts +++ b/web/src/community/publish-dialog.ts @@ -22,7 +22,11 @@ const ISSUER_MAX = 40; const DISPLAY_NAME_MAX = 30; const TAGS_MAX = 5; const TAG_MAX = 20; -const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/; +/** + * Steuerzeichen (C0, DEL, C1), Nullbreiten- und Bidi-Zeichen (U+200B-200F, U+202A-202E, U+2060-206F) und das BOM (U+FEFF): + * unsichtbar oder umordnend. Dasselbe Muster prüft der Server (deploy/api/app.js, CONTROL_CHARACTERS). + */ +const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/; export interface PublishFormValues extends PublishForm { consent: boolean; diff --git a/web/src/pages/community-page.test.ts b/web/src/pages/community-page.test.ts index a58d0bf..842917a 100644 --- a/web/src/pages/community-page.test.ts +++ b/web/src/pages/community-page.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { User } from '../account/auth'; import { ApiError, type ReportReason } from '../community/api'; -import type { GalleryCard, GalleryReport } from '../community/gallery'; +import type { CheckedImage, GalleryCard, GalleryReport } from '../community/gallery'; // gallery.ts, api.ts und auth.ts hängen am Appwrite-Client; die Seite bekommt alles über ihre Abhängigkeiten. vi.mock('../account/appwrite', () => ({ @@ -43,6 +43,8 @@ const settle = async () => { for (let i = 0; i < 40; i++) await Promise.resolve(); }; const blob = () => new Blob(['png'], { type: 'image/png' }); +/** Ein geprüftes Bild, wie es loadGalleryImageChecked liefert: Blob plus fertige Object-URL. */ +const image = (b: Blob = blob()): CheckedImage => ({ blob: b, url: `blob:detail-${++urlCounter}` }); const admin: User = { $id: 'a1', email: 'kenso@example.com', labels: ['admin'] }; const owner: User = { $id: 'u1', email: 'u@example.com', labels: [] }; @@ -58,7 +60,7 @@ let urlCounter: number; const revoked: string[] = []; const getCard = vi.fn<(id: string) => Promise>(); const listReports = vi.fn<(id: string) => Promise>(); -const loadImage = vi.fn<(c: GalleryCard, size: 'full' | 'preview') => Promise>(); +const loadImage = vi.fn<(c: GalleryCard, size: 'full' | 'preview') => Promise>(); const reportCard = vi.fn<(id: string, reason: ReportReason) => Promise<{ schonGemeldet: boolean }>>(); const withdrawCard = vi.fn<(id: string) => Promise>(); const setVisibility = vi.fn<(id: string, visible: boolean) => Promise>(); @@ -115,7 +117,7 @@ beforeEach(() => { } getCard.mockResolvedValue(card()); listReports.mockResolvedValue([]); - loadImage.mockImplementation(async () => blob()); + loadImage.mockImplementation(async () => image()); withdrawCard.mockResolvedValue(undefined); setVisibility.mockResolvedValue(undefined); getUser.mockResolvedValue(null); @@ -123,7 +125,6 @@ beforeEach(() => { authListeners = new Set(); urlCounter = 0; revoked.length = 0; - URL.createObjectURL = vi.fn(() => `blob:detail-${++urlCounter}`); URL.revokeObjectURL = vi.fn((url: string) => { revoked.push(url); }); @@ -276,6 +277,31 @@ describe('Detailseite: Karte', () => { expect(saveBlobAs).toHaveBeenCalledTimes(1); }); + it('ein von der Pixelprüfung abgelehntes Bild (zu groß, nicht dekodierbar) zeigt den Hinweis statt eines Bildes', async () => { + // so lehnt loadGalleryImageChecked ab: es wirft, noch bevor ein entsteht + loadImage.mockRejectedValue(new Error('Das Bild konnte nicht geladen werden.')); + await mount(); + expect(q('.detail-card')?.textContent).toContain('Das Bild konnte nicht geladen werden.'); + expect(q('.detail-card img')).toBeNull(); + expect(q('h1')?.textContent).toBe('Mitternachtsblau'); // die Angaben und Knöpfe (Melden!) bleiben bedienbar + expect(buttonByText('Melden')).toBeDefined(); + // auch Herunterladen bekommt das Bild nicht + buttonByText('Herunterladen')!.click(); + await settle(); + expect(saveBlobAs).not.toHaveBeenCalled(); + expect(errorText()).toBe('Das Bild konnte nicht geladen werden.'); + }); + + it('das Bild zum Herunterladen kommt aus der Prüfung; eine dabei angelegte URL wird sofort freigegeben', async () => { + await mount(); + q('.detail-card img')!.dispatchEvent(new Event('error')); // kein Bild im Speicher + revoked.length = 0; + buttonByText('Herunterladen')!.click(); + await settle(); + expect(saveBlobAs).toHaveBeenCalledTimes(1); + expect(revoked).toEqual(['blob:detail-2']); + }); + it('„Nochmal versuchen“ beim Bild lädt es erneut', async () => { loadImage.mockRejectedValueOnce(new TypeError('offline')); await mount(); @@ -311,17 +337,18 @@ describe('Detailseite: Karte', () => { expect(q('h1')).toBeNull(); // die Karte wird nicht mehr aufgebaut // Bild kommt erst nach dem Verlassen - let finishImage!: (b: Blob) => void; + let finishImage!: (i: CheckedImage) => void; getCard.mockResolvedValue(card()); loadImage.mockReturnValue(new Promise((r) => (finishImage = r))); unmount = mountCommunityPage(root, 'g1', deps()); await settle(); unmount(); unmount = null; - const created = urlCounter; - finishImage(blob()); + revoked.length = 0; + finishImage({ blob: blob(), url: 'blob:detail-late' }); await settle(); - expect(urlCounter).toBe(created); + expect(revoked).toEqual(['blob:detail-late']); // die verspätete URL wird sofort freigegeben + expect(q('img')).toBeNull(); }); }); @@ -419,7 +446,7 @@ describe('Detailseite: Aufräumen', () => { describe('Detailseite: Herunterladen und Vorlage', () => { it('Herunterladen speichert das volle PNG als aircard-.png', async () => { const full = blob(); - loadImage.mockResolvedValue(full); + loadImage.mockImplementation(async () => image(full)); getCard.mockResolvedValue(card({ title: 'Grüner Wald!' })); await mount(); buttonByText('Herunterladen')!.click(); diff --git a/web/src/pages/community-page.ts b/web/src/pages/community-page.ts index c3f5d3c..add6827 100644 --- a/web/src/pages/community-page.ts +++ b/web/src/pages/community-page.ts @@ -10,7 +10,8 @@ import { ApiError, reportCard, setVisibility, withdrawCard, type ReportReason } import { getGalleryCard, listReports, - loadGalleryImage, + loadGalleryImageChecked, + type CheckedImage, type GalleryCard, type GalleryReport, } from '../community/gallery'; @@ -25,7 +26,8 @@ import { navigate, titleFor } from './router'; export interface CommunityDeps { getCard(id: string): Promise; listReports(galleryId: string): Promise; - loadImage(card: GalleryCard, size: 'full' | 'preview'): Promise; + /** Lädt und prüft ein Galerie-Bild (Pixelgrenze); die fertige Object-URL gibt der Aufrufer frei. */ + loadImage(card: GalleryCard, size: 'full' | 'preview'): Promise; reportCard(id: string, reason: ReportReason): Promise<{ schonGemeldet: boolean }>; withdrawCard(id: string): Promise; setVisibility(id: string, visible: boolean): Promise; @@ -42,7 +44,7 @@ export interface CommunityDeps { export const defaultDeps: CommunityDeps = { getCard: getGalleryCard, listReports, - loadImage: loadGalleryImage, + loadImage: loadGalleryImageChecked, reportCard, withdrawCard, setVisibility, @@ -132,10 +134,13 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit imageSlot.setAttribute('aria-busy', 'true'); imageSlot.replaceChildren(el('div', { class: 'detail-card-empty' }, el('p', {}, IMAGE_LOADING))); deps.loadImage(forCard, 'full').then( - (blob) => { - if (disposed || token !== imageToken) return; // zu spät: keine URL anlegen, die niemand mehr freigibt - imageBlob = blob; - imageUrl = URL.createObjectURL(blob); + (loaded) => { + if (disposed || token !== imageToken) { + URL.revokeObjectURL(loaded.url); // zu spät: niemand gibt diese URL mehr frei + return; + } + imageBlob = loaded.blob; + imageUrl = loaded.url; const img = el('img', { src: imageUrl, alt: `Community-Karte „${forCard.title}“`, @@ -355,7 +360,12 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit setBusy('download'); try { // Normalerweise liegt das Bild schon vor (es ist die große Karte); sonst wird es jetzt geholt. - const blob = imageBlob ?? (await deps.loadImage(c, 'full')); + let blob = imageBlob; + if (!blob) { + const loaded = await deps.loadImage(c, 'full'); + URL.revokeObjectURL(loaded.url); // hier zählt nur die Datei, nicht die Anzeige-URL + blob = loaded.blob; + } if (disposed || mine !== generation) return; const created = new Date(c.createdAt); deps.saveBlobAs(blob, downloadFileName(c.title, Number.isNaN(created.getTime()) ? new Date() : created)); diff --git a/web/src/pages/home-page.test.ts b/web/src/pages/home-page.test.ts index c9f7217..86fabbe 100644 --- a/web/src/pages/home-page.test.ts +++ b/web/src/pages/home-page.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { User } from '../account/auth'; -import type { GalleryCard, GalleryOptions } from '../community/gallery'; +import type { CheckedImage, GalleryCard, GalleryOptions } from '../community/gallery'; // gallery.ts und auth.ts hängen am Appwrite-Client; hier läuft alles über die Abhängigkeiten von mountHomePage. vi.mock('../account/appwrite', () => ({ @@ -37,6 +37,8 @@ const settle = async () => { for (let i = 0; i < 40; i++) await Promise.resolve(); }; const blob = () => new Blob(['x'], { type: 'image/png' }); +/** Ein geprüftes Bild, wie es loadGalleryImageChecked liefert: Blob plus fertige Object-URL. */ +const image = (): CheckedImage => ({ blob: blob(), url: `blob:home-${++urlCounter}` }); const admin: User = { $id: 'a1', email: 'kenso@example.com', labels: ['admin'] }; const member: User = { $id: 'u1', email: 'u@example.com', labels: [] }; @@ -48,7 +50,7 @@ let authListeners: Set<(u: User | null) => void>; let urlCounter: number; const revoked: string[] = []; const listGallery = vi.fn<(opts: GalleryOptions) => Promise>(); -const loadImage = vi.fn<(c: GalleryCard, size: 'full' | 'preview') => Promise>(); +const loadImage = vi.fn<(c: GalleryCard, size: 'full' | 'preview') => Promise>(); const navigate = vi.fn<(path: string) => void>(); const scrollToHash = vi.fn<(hash: string) => void>(); @@ -81,13 +83,12 @@ beforeEach(() => { loadImage.mockReset(); navigate.mockReset(); scrollToHash.mockReset(); - loadImage.mockImplementation(async () => blob()); + loadImage.mockImplementation(async () => image()); listGallery.mockResolvedValue(page([])); user = null; authListeners = new Set(); urlCounter = 0; revoked.length = 0; - URL.createObjectURL = vi.fn(() => `blob:home-${++urlCounter}`); URL.revokeObjectURL = vi.fn((url: string) => { revoked.push(url); }); @@ -143,12 +144,41 @@ describe('Startseite: Hero', () => { listGallery.mockResolvedValue(page([card(1)])); loadImage.mockImplementation(async (_c, size) => { if (size === 'full') throw new Error('404'); - return blob(); + return image(); }); await mount(); expect(q('.hero-card img')?.getAttribute('src')).toBe('/hero-beispiel.png'); }); + it('fällt auf das Beispielmotiv zurück, wenn die Pixelprüfung das Kartenbild ablehnt (Dekompressionsbombe)', async () => { + listGallery.mockResolvedValue(page([card(1), card(2)])); + loadImage.mockImplementation(async (c, size) => { + if (size === 'full') throw new Error('Das Bild konnte nicht geladen werden.'); // so lehnt checkGalleryImage ab + return image(); + }); + await mount(); + expect(q('.hero-card img')?.getAttribute('src')).toBe('/hero-beispiel.png'); + expect(q('.hero-card a')).toBeNull(); + expect(q('.hero-card img')?.getAttribute('src')).not.toMatch(/^blob:/); + // die Kacheln darunter laden weiter, jede Vorschau mit eigener Prüfung + expect(qa('.gallery-tile')).toHaveLength(2); + }); + + it('eine nach dem Verlassen eintreffende Hero-URL wird sofort freigegeben', async () => { + listGallery.mockResolvedValue(page([card(1)])); + let finish!: (i: CheckedImage) => void; + loadImage.mockImplementation((_c, size) => + size === 'full' ? new Promise((r) => (finish = r)) : Promise.resolve(image()), + ); + await mount(); + unmount!(); + unmount = null; + revoked.length = 0; + finish({ blob: blob(), url: 'blob:home-late' }); + await settle(); + expect(revoked).toEqual(['blob:home-late']); + }); + it('Ladefehler der Galerie: Hero zeigt das Beispielmotiv', async () => { listGallery.mockRejectedValue(new Error('offline')); await mount(); diff --git a/web/src/pages/home-page.ts b/web/src/pages/home-page.ts index 399ff58..ddabde4 100644 --- a/web/src/pages/home-page.ts +++ b/web/src/pages/home-page.ts @@ -3,7 +3,14 @@ import '../community/community.css'; import { currentUser, isAdmin, onAuthChange, type User } from '../account/auth'; import { mountGalleryGrid, type GalleryGrid } from '../community/gallery-grid'; -import { listGallery, loadGalleryImage, type GalleryCard, type GalleryFilter, type GalleryOptions } from '../community/gallery'; +import { + listGallery, + loadGalleryImageChecked, + type CheckedImage, + type GalleryCard, + type GalleryFilter, + type GalleryOptions, +} from '../community/gallery'; import { el } from '../editor/dom'; import { navigate, scrollToHash } from './router'; @@ -12,7 +19,8 @@ type Page = { cards: GalleryCard[]; nextCursor: string | null }; /** Alles, was die Seite von außen braucht (für Tests austauschbar). */ export interface HomeDeps { listGallery(opts: GalleryOptions): Promise; - loadImage(card: GalleryCard, size: 'full' | 'preview'): Promise; + /** Lädt und prüft ein Galerie-Bild (Pixelgrenze); die fertige Object-URL gibt der Aufrufer frei. */ + loadImage(card: GalleryCard, size: 'full' | 'preview'): Promise; currentUser(): User | null; onAuthChange(fn: (user: User | null) => void): () => void; navigate(path: string): void; @@ -21,7 +29,7 @@ export interface HomeDeps { export const defaultDeps: HomeDeps = { listGallery, - loadImage: loadGalleryImage, + loadImage: loadGalleryImageChecked, currentUser, onAuthChange, navigate, @@ -112,10 +120,13 @@ export function mountHomePage(root: HTMLElement, deps: HomeDeps = defaultDeps): if (disposed) return; if (!card) return showExample(); try { - const blob = await deps.loadImage(card, 'full'); - if (disposed) return; - heroUrl = URL.createObjectURL(blob); - setHeroImage(heroUrl, `Community-Karte „${card.title}“`, card); + const { url } = await deps.loadImage(card, 'full'); + if (disposed) { + URL.revokeObjectURL(url); // zu spät: niemand gibt diese URL mehr frei + return; + } + heroUrl = url; + setHeroImage(url, `Community-Karte „${card.title}“`, card); } catch { if (!disposed) showExample(); }