fix(community): Pixelgrenze beim Anzeigen, exakter Tag-Filter, unsichtbare Zeichen

- Gemeinsamer Helfer loadGalleryImageChecked/checkGalleryImage (gallery.ts): lädt das Bild über new Image(), lehnt über
  4 Megapixel oder nicht dekodierbare Dateien ab und liefert eine fertige Object-URL (der Aufrufer gibt sie frei).
  Raster -> „Keine Vorschau“ (auch bei <img>-Fehler), Hero -> Beispielmotiv, Detailseite -> „Das Bild konnte nicht geladen werden.“
  Verspätet eintreffende URLs werden sofort freigegeben.
- Tag-Filter sucht ",<tag>," (der Server speichert ",a,b,"): „gold“ trifft nicht mehr „marigold“.
- Veröffentlichen-Dialog lehnt dieselben unsichtbaren Zeichen ab wie die API (C1, Nullbreite, Bidi, BOM).
- Das Limit der API (429 „schon 20 Karten veröffentlicht“) gilt als eindeutige Ablehnung: die hochgeladenen Dateien werden aufgeräumt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 16:35:10 +00:00
1 parent 5500495443
commit c62075f2d4
12 files changed
+435 -66

No files matched your search

+20
View File
@@ -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);
+7 -1
View File
@@ -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);
+40 -15
View File
@@ -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> = {}): GalleryCard => ({
@@ -18,14 +18,15 @@ const card = (n: number, patch: Partial<GalleryCard> = {}): 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<Blob>((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<CheckedImage>((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<Blob>((r) => pending.push(r)));
const pending: Array<(i: CheckedImage) => void> = [];
const load = vi.fn(() => new Promise<CheckedImage>((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 <img> 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
});
});
+23 -11
View File
@@ -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<Blob>;
/** Lädt und prüft die Vorschau einer Karte; wirft, wenn sie sich nicht anzeigen lässt („Keine Vorschau“). */
load: (card: GalleryCard) => Promise<CheckedImage>;
/** 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<void> {
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');
}
+151 -7
View File
@@ -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);
});
});
+46 -2
View File
@@ -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>,`). */
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<CheckedImage> {
const url = URL.createObjectURL(blob);
try {
const img = new Image();
const loaded = new Promise<void>((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<CheckedImage> {
return checkGalleryImage(await loadGalleryImage(card, size));
}
+36
View File
@@ -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();
+5 -1
View File
@@ -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;
+36 -9
View File
@@ -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<GalleryCard | null>>();
const listReports = vi.fn<(id: string) => Promise<GalleryReport[]>>();
const loadImage = vi.fn<(c: GalleryCard, size: 'full' | 'preview') => Promise<Blob>>();
const loadImage = vi.fn<(c: GalleryCard, size: 'full' | 'preview') => Promise<CheckedImage>>();
const reportCard = vi.fn<(id: string, reason: ReportReason) => Promise<{ schonGemeldet: boolean }>>();
const withdrawCard = vi.fn<(id: string) => Promise<void>>();
const setVisibility = vi.fn<(id: string, visible: boolean) => Promise<void>>();
@@ -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 <img> 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-<titel-slug>.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();
+18 -8
View File
@@ -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<GalleryCard | null>;
listReports(galleryId: string): Promise<GalleryReport[]>;
loadImage(card: GalleryCard, size: 'full' | 'preview'): Promise<Blob>;
/** Lädt und prüft ein Galerie-Bild (Pixelgrenze); die fertige Object-URL gibt der Aufrufer frei. */
loadImage(card: GalleryCard, size: 'full' | 'preview'): Promise<CheckedImage>;
reportCard(id: string, reason: ReportReason): Promise<{ schonGemeldet: boolean }>;
withdrawCard(id: string): Promise<void>;
setVisibility(id: string, visible: boolean): Promise<void>;
@@ -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));
+35 -5
View File
@@ -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<Page>>();
const loadImage = vi.fn<(c: GalleryCard, size: 'full' | 'preview') => Promise<Blob>>();
const loadImage = vi.fn<(c: GalleryCard, size: 'full' | 'preview') => Promise<CheckedImage>>();
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<HTMLImageElement>('.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<HTMLImageElement>('.hero-card img')?.getAttribute('src')).toBe('/hero-beispiel.png');
expect(q('.hero-card a')).toBeNull();
expect(q<HTMLImageElement>('.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<CheckedImage>((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();
+18 -7
View File
@@ -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<Page>;
loadImage(card: GalleryCard, size: 'full' | 'preview'): Promise<Blob>;
/** Lädt und prüft ein Galerie-Bild (Pixelgrenze); die fertige Object-URL gibt der Aufrufer frei. */
loadImage(card: GalleryCard, size: 'full' | 'preview'): Promise<CheckedImage>;
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();
}