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:
1 parent
5500495443
commit
c62075f2d4
12 files changed
+435
-66
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user