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;