diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index dba18a2..8ee26bd 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -37,6 +37,14 @@ export const EN: Readonly> = { 'This card’s logo is no longer available. Open the card in the editor and choose another one.', 'Logo oben links': 'Top-left logo', + // Logo auswählen und verarbeiten (logos/prepare-logo.ts) + 'Bitte ein PNG-, JPG-, WebP- oder SVG-Bild wählen.': 'Please choose a PNG, JPG, WebP or SVG image.', + 'Die Datei ist größer als 5 MB.': 'The file is larger than 5 MB.', + 'Das Bild ist zu groß (max. 40 Megapixel).': 'The image is too large (max. 40 megapixels).', + 'Das Logo konnte nicht gelesen werden.': 'The logo couldn’t be read.', + 'Das Logo ist leer.': 'The logo is empty.', + 'Das Logo ist zu klein (mindestens 8 × 8 Pixel).': 'The logo is too small (at least 8 × 8 pixels).', + // API-Fehlertexte (i18n/api-messages.ts). Schlüssel und deutsche Texte stammen aus MSG (deploy/api/common.js); api-messages.test.ts hält sie gleich. 'Bitte melde dich an.': 'Please sign in.', 'Nicht gefunden.': 'Not found.', diff --git a/web/src/logos/prepare-logo.test.ts b/web/src/logos/prepare-logo.test.ts new file mode 100644 index 0000000..fd227fb --- /dev/null +++ b/web/src/logos/prepare-logo.test.ts @@ -0,0 +1,569 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { setLang } from '../i18n/i18n'; +import { + LOGO_ACCEPT, + fitWithin, + hasTransparency, + planLogo, + prepareLogo, + sizeSvgRoot, + svgRasterSize, + trimBox, + validateLogoFile, +} from './prepare-logo'; + +// Die Wortlaute der Spec (§ 4, § 11), hier bewusst noch einmal ausgeschrieben. +const TYPE = 'Bitte ein PNG-, JPG-, WebP- oder SVG-Bild wählen.'; +const SIZE = 'Die Datei ist größer als 5 MB.'; +const PIXELS = 'Das Bild ist zu groß (max. 40 Megapixel).'; +const READ = 'Das Logo konnte nicht gelesen werden.'; +const EMPTY = 'Das Logo ist leer.'; +const SMALL = 'Das Logo ist zu klein (mindestens 8 × 8 Pixel).'; + +/** RGBA-Daten eines w × h Bildes. Alle Farbkanäle sind 255 (weiß), `alpha(x, y)` bestimmt die Deckkraft je Pixel. */ +function pixels(w: number, h: number, alpha: (x: number, y: number) => number): Uint8ClampedArray { + const data = new Uint8ClampedArray(w * h * 4).fill(255); + for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) data[(y * w + x) * 4 + 3] = alpha(x, y); + return data; +} + +/** Deckkraft 255 nur im Rechteck (x0, y0, w, h), sonst 0. */ +const only = + (x0: number, y0: number, w: number, h: number) => + (x: number, y: number): number => + x >= x0 && x < x0 + w && y >= y0 && y < y0 + h ? 255 : 0; + +describe('LOGO_ACCEPT', () => { + it('PNG, JPG, WebP und SVG (auch über die Endung, falls der Browser den Typ nicht kennt)', () => { + expect(LOGO_ACCEPT).toBe('image/png,image/jpeg,image/webp,image/svg+xml,.svg'); + }); +}); + +describe('validateLogoFile', () => { + const check = (type: string, size = 1000, name = 'logo') => validateLogoFile({ type, size, name }); + + it.each(['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'])('nimmt %s an', (type) => { + expect(check(type)).toEqual({ ok: true }); + }); + + it('lehnt GIF mit der Typ-Meldung der Spec ab', () => { + expect(check('image/gif')).toEqual({ ok: false, message: TYPE }); + }); + + it.each(['image/heic', 'application/pdf', 'text/plain', 'image/bmp'])('lehnt %s ab', (type) => { + expect(check(type)).toEqual({ ok: false, message: TYPE }); + }); + + it('ein SVG ohne Typ (manche Systeme kennen .svg nicht) wird am Namen erkannt, auch groß geschrieben', () => { + expect(check('', 1000, 'logo.SVG')).toEqual({ ok: true }); + expect(check('', 1000, 'mein.logo.svg')).toEqual({ ok: true }); + }); + + it('ohne Typ gilt nur die Endung .svg; ein anderer Typ wird durch den Namen nicht gerettet', () => { + expect(check('', 1000, 'logo.png')).toEqual({ ok: false, message: TYPE }); + expect(check('', 1000, 'logosvg')).toEqual({ ok: false, message: TYPE }); + expect(check('', 1000, 'logo.svg.exe')).toEqual({ ok: false, message: TYPE }); + expect(check('text/plain', 1000, 'logo.svg')).toEqual({ ok: false, message: TYPE }); + }); + + it('5 242 881 Byte sind zu groß, genau 5 242 880 sind erlaubt', () => { + expect(check('image/png', 5_242_881)).toEqual({ ok: false, message: SIZE }); + expect(check('image/png', 5_242_880)).toEqual({ ok: true }); + }); + + it('der Typ hat Vorrang vor der Größe', () => { + expect(check('image/gif', 9_000_000)).toEqual({ ok: false, message: TYPE }); + }); + + it('eine leere Datei lässt sich nicht lesen', () => { + expect(check('image/png', 0)).toEqual({ ok: false, message: READ }); + }); + + it('die Meldung steht in der Sprache der Seite', () => { + setLang('en', { persist: false }); + expect(check('image/gif')).toEqual({ ok: false, message: 'Please choose a PNG, JPG, WebP or SVG image.' }); + expect(check('image/png', 5_242_881)).toEqual({ ok: false, message: 'The file is larger than 5 MB.' }); + }); +}); + +describe('trimBox', () => { + it('ein einzelnes Pixel (2, 1) mit Alpha 9 in einem 4 × 3 Bild', () => { + const data = pixels(4, 3, (x, y) => (x === 2 && y === 1 ? 9 : 0)); + expect(trimBox(data, 4, 3)).toEqual({ x: 2, y: 1, w: 1, h: 1 }); + }); + + it('Alpha ≤ 8 überall: nichts Sichtbares, null', () => { + expect(trimBox(pixels(4, 3, () => 0), 4, 3)).toBeNull(); + expect(trimBox(pixels(4, 3, () => 8), 4, 3)).toBeNull(); + }); + + it('ein volles Bild: die ganze Fläche', () => { + expect(trimBox(pixels(4, 3, () => 255), 4, 3)).toEqual({ x: 0, y: 0, w: 4, h: 3 }); + }); + + it('der Rahmen umfasst alle sichtbaren Pixel, auch weit auseinanderliegende', () => { + const visible = new Set(['1,0', '2,2']); + const data = pixels(5, 4, (x, y) => (visible.has(`${x},${y}`) ? 255 : 0)); + expect(trimBox(data, 5, 4)).toEqual({ x: 1, y: 0, w: 2, h: 3 }); + }); + + it('ein sichtbares Rechteck in der Mitte', () => { + expect(trimBox(pixels(10, 8, only(3, 2, 4, 5)), 10, 8)).toEqual({ x: 3, y: 2, w: 4, h: 5 }); + }); + + it('nur die Deckkraft zählt: weiße, aber durchsichtige Pixel sind leer', () => { + // Alle Farbkanäle sind 255, nur Alpha ist 0. + expect(trimBox(pixels(3, 3, () => 0), 3, 3)).toBeNull(); + }); + + it('die Schwelle lässt sich ändern (Alpha ≤ Schwelle gilt als leer)', () => { + const data = pixels(3, 3, (x, y) => (x === 1 && y === 1 ? 100 : 0)); + expect(trimBox(data, 3, 3, 100)).toBeNull(); + expect(trimBox(data, 3, 3, 99)).toEqual({ x: 1, y: 1, w: 1, h: 1 }); + }); + + it('ein Bild ohne Fläche hat nichts Sichtbares', () => { + expect(trimBox(new Uint8ClampedArray(0), 0, 0)).toBeNull(); + }); +}); + +describe('fitWithin', () => { + it('(1440, 500) in 720 × 250: genau halbiert', () => { + expect(fitWithin(1440, 500, 720, 250)).toEqual({ w: 720, h: 250 }); + }); + + it('was schon hineinpasst, bleibt unverändert (nie vergrößern)', () => { + expect(fitWithin(300, 100, 720, 250)).toEqual({ w: 300, h: 100 }); + expect(fitWithin(720, 250, 720, 250)).toEqual({ w: 720, h: 250 }); + expect(fitWithin(10, 10, 720, 250)).toEqual({ w: 10, h: 10 }); + }); + + it('die breite Seite begrenzt: (2000, 20) wird 720 × 7', () => { + expect(fitWithin(2000, 20, 720, 250)).toEqual({ w: 720, h: 7 }); + }); + + it('die hohe Seite begrenzt: (500, 1000) wird 125 × 250', () => { + expect(fitWithin(500, 1000, 720, 250)).toEqual({ w: 125, h: 250 }); + }); + + it('rundet auf ganze Pixel', () => { + // 333 × 0,72 = 239,76 + expect(fitWithin(1000, 333, 720, 250)).toEqual({ w: 720, h: 240 }); + }); + + it('jede Seite hat mindestens 1 Pixel', () => { + expect(fitWithin(100_000, 10, 720, 250)).toEqual({ w: 720, h: 1 }); + }); +}); + +describe('svgRasterSize', () => { + it.each([ + [1, 2048, 2048], + [2, 2048, 1024], + [3, 2048, 683], + [0.5, 1024, 2048], + ])('Seitenverhältnis %f: %i × %i', (aspect, w, h) => { + expect(svgRasterSize(aspect)).toEqual({ w, h }); + }); + + it.each([NaN, 0, -1, Infinity, -Infinity])('ungültiges Seitenverhältnis (%f): quadratisch 2048 × 2048', (aspect) => { + expect(svgRasterSize(aspect)).toEqual({ w: 2048, h: 2048 }); + }); + + it('die längste Seite hat immer genau 2048 px, nie mehr', () => { + for (const aspect of [0.01, 0.3, 0.9, 1, 1.1, 2.5, 7, 100]) { + const { w, h } = svgRasterSize(aspect); + expect(Math.max(w, h), String(aspect)).toBe(2048); + expect(Math.min(w, h), String(aspect)).toBeGreaterThanOrEqual(1); + } + }); + + it('ein extremes Seitenverhältnis lässt die kurze Seite bei 1 Pixel (nie 0)', () => { + expect(svgRasterSize(100_000)).toEqual({ w: 2048, h: 1 }); + expect(svgRasterSize(1e-9)).toEqual({ w: 1, h: 2048 }); + }); +}); + +describe('hasTransparency', () => { + it('alles deckend (Alpha 255): nein', () => { + expect(hasTransparency(pixels(4, 3, () => 255))).toBe(false); + }); + + it('ein einziges Pixel mit Alpha 254: ja', () => { + expect(hasTransparency(pixels(4, 3, (x, y) => (x === 3 && y === 2 ? 254 : 255)))).toBe(true); + }); + + it('völlig durchsichtige Pixel: ja', () => { + expect(hasTransparency(pixels(2, 2, (x) => (x === 0 ? 0 : 255)))).toBe(true); + }); + + it('nur Alpha zählt, nicht die Farbe', () => { + const data = new Uint8ClampedArray([0, 0, 0, 255, 0, 0, 0, 255]); + expect(hasTransparency(data)).toBe(false); + }); + + it('ohne Pixel gibt es nichts Durchsichtiges', () => { + expect(hasTransparency(new Uint8ClampedArray(0))).toBe(false); + }); +}); + +describe('planLogo', () => { + it('schneidet auf das Sichtbare zu und verkleinert auf höchstens 720 × 250', () => { + // 2000 × 1000 Pixel, sichtbar sind 1440 × 500 ab (100, 200). + const plan = planLogo(pixels(2000, 1000, only(100, 200, 1440, 500)), 2000, 1000); + expect(plan).toEqual({ box: { x: 100, y: 200, w: 1440, h: 500 }, size: { w: 720, h: 250 } }); + }); + + it('ein kleines Logo bleibt in seiner Größe (nie vergrößern)', () => { + const plan = planLogo(pixels(100, 60, only(10, 10, 40, 20)), 100, 60); + expect(plan).toEqual({ box: { x: 10, y: 10, w: 40, h: 20 }, size: { w: 40, h: 20 } }); + }); + + it('nichts Sichtbares: „Das Logo ist leer.“', () => { + expect(() => planLogo(pixels(10, 10, () => 8), 10, 10)).toThrow(EMPTY); + }); + + it('weniger als 8 Pixel in einer Richtung: „Das Logo ist zu klein (mindestens 8 × 8 Pixel).“', () => { + expect(() => planLogo(pixels(50, 50, only(5, 5, 7, 20)), 50, 50)).toThrow(SMALL); + expect(() => planLogo(pixels(50, 50, only(5, 5, 20, 7)), 50, 50)).toThrow(SMALL); + }); + + it('genau 8 × 8 Pixel sind erlaubt', () => { + expect(planLogo(pixels(50, 50, only(5, 5, 8, 8)), 50, 50).size).toEqual({ w: 8, h: 8 }); + }); + + it('die Zielgröße zählt: ein sehr breites Logo (2000 × 20) wird nur 7 Pixel hoch und ist zu klein', () => { + expect(() => planLogo(pixels(2000, 20, () => 255), 2000, 20)).toThrow(SMALL); + }); + + it('die Meldungen stehen in der Sprache der Seite', () => { + setLang('en', { persist: false }); + expect(() => planLogo(pixels(10, 10, () => 0), 10, 10)).toThrow('The logo is empty.'); + expect(() => planLogo(pixels(50, 50, only(5, 5, 7, 7)), 50, 50)).toThrow('The logo is too small (at least 8 × 8 pixels).'); + }); +}); + +describe('sizeSvgRoot', () => { + const NS = 'xmlns="http://www.w3.org/2000/svg"'; + /** Wie oft das Attribut `name` im öffnenden Tag des Wurzelelements steht. */ + const countIn = (svg: string, name: string): number => { + const root = /^[^]*?(]*>)/.exec(svg)?.[1] ?? ''; + return (root.match(new RegExp(`\\s${name}\\s*=`, 'g')) ?? []).length; + }; + + it('schreibt Breite und Höhe ins Wurzelelement und lässt den Rest unverändert', () => { + const svg = ``; + expect(sizeSvgRoot(svg, 2048, 683)).toBe( + ``, + ); + }); + + it('eine vorhandene eigene Größe wird ersetzt, nicht doppelt geschrieben (doppelte Attribute machen das SVG ungültig)', () => { + const svg = ``; + const sized = sizeSvgRoot(svg, 2048, 1024); + expect(sized).toBe(``); + expect(countIn(sized, 'width')).toBe(1); + expect(countIn(sized, 'height')).toBe(1); + }); + + it('Attribute in Zeilen- und Tabulatorform und mit Leerzeichen um das Gleichheitszeichen werden ebenfalls ersetzt', () => { + const svg = ``; + const sized = sizeSvgRoot(svg, 1024, 2048); + expect(countIn(sized, 'width')).toBe(1); + expect(countIn(sized, 'height')).toBe(1); + expect(sized.startsWith(' { + const inner = ''; + const sized = sizeSvgRoot(`${inner}`, 2048, 2048); + expect(sized).toBe(`${inner}`); + }); + + it('ein „>“ in einem Attributwert beendet das Wurzelelement nicht', () => { + const svg = ``; + expect(sizeSvgRoot(svg, 2048, 1024)).toBe(``); + }); + + it('XML-Kopf, Kommentar und Doctype vor dem Wurzelelement stören nicht', () => { + const head = + ''; + const sized = sizeSvgRoot(`${head}`, 2048, 2048); + expect(sized).toBe(`${head}`); + }); + + it('ein selbstschließendes Wurzelelement bleibt gültig', () => { + expect(sizeSvgRoot(``, 8, 8)).toBe(``); + }); + + it('ohne bleibt der Text unverändert (das Laden als Bild meldet den Fehler)', () => { + expect(sizeSvgRoot('kein Bild', 8, 8)).toBe('kein Bild'); + }); + + it('ein Wurzelelement ohne Ende bleibt unverändert', () => { + expect(sizeSvgRoot(` { + interface FakeCanvas { + width: number; + height: number; + /** Argumente aller `drawImage`-Aufrufe. */ + draws: unknown[][]; + smoothing: string; + } + let canvases: FakeCanvas[]; + let blobs: Blob[]; + let revoked: string[]; + let images: FakeImage[]; + let imageFails: boolean; + const close = vi.fn(); + + /** Ersatz für `Image`: meldet „geladen“ (oder einen Fehler), sobald `src` gesetzt ist. */ + class FakeImage { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + constructor() { + images.push(this); + } + set src(_value: string) { + queueMicrotask(() => (imageFails ? this.onerror?.() : this.onload?.())); + } + } + + /** + * happy-dom hat keinen 2D-Kontext: `document.createElement('canvas')` liefert Fakes. Der erste Canvas trägt das Quellbild + * (Deckkraft laut `visible`; mit `blank` ein unberührtes, leeres Feld, das auch bei 40 Megapixeln kaum Speicher braucht), der zweite + * das Ergebnis (überall deckend, mit `resultHole` fehlt dem ersten Pixel die Deckkraft). + */ + function stubCanvas( + opts: { visible?: (x: number, y: number) => number; blank?: boolean; resultHole?: boolean; noBlob?: boolean; noContext?: boolean } = {}, + ): void { + const create = document.createElement.bind(document); + vi.spyOn(document, 'createElement').mockImplementation(((tag: string) => { + if (tag !== 'canvas') return create(tag); + const isSource = canvases.length === 0; + const canvas: FakeCanvas = { width: 0, height: 0, draws: [], smoothing: '' }; + const ctx = { + set imageSmoothingQuality(value: string) { + canvas.smoothing = value; + }, + drawImage: (...args: unknown[]) => void canvas.draws.push(args), + getImageData: (_x: number, _y: number, w: number, h: number) => ({ + data: isSource + ? opts.blank + ? new Uint8ClampedArray(w * h * 4) + : pixels(w, h, opts.visible ?? (() => 255)) + : pixels(w, h, (x, y) => (opts.resultHole && x === 0 && y === 0 ? 0 : 255)), + }), + }; + canvases.push(canvas); + return Object.assign(canvas, { + getContext: () => (opts.noContext ? null : ctx), + toBlob: (done: BlobCallback, type?: string) => done(opts.noBlob ? null : new Blob(['png'], { type })), + }) as unknown as HTMLElement; + }) as typeof document.createElement); + } + + const bitmap = (width: number, height: number) => vi.fn(async () => ({ width, height, close })); + const rasterFile = () => new File(['x'], 'logo.png', { type: 'image/png' }); + const svgFile = (text: string, name = 'logo.svg') => new File([text], name, { type: 'image/svg+xml' }); + + beforeEach(() => { + canvases = []; + blobs = []; + revoked = []; + images = []; + imageFails = false; + close.mockReset(); + vi.spyOn(URL, 'createObjectURL').mockImplementation((blob) => { + blobs.push(blob as Blob); + return `blob:svg-${blobs.length}`; + }); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation((url) => void revoked.push(url)); + vi.stubGlobal('Image', FakeImage); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + describe('Rasterbild (PNG, JPG, WebP)', () => { + it('zeichnet in Originalgröße, schneidet auf das Sichtbare zu und gibt ein PNG aus', async () => { + const decode = bitmap(100, 40); + vi.stubGlobal('createImageBitmap', decode); + stubCanvas({ visible: only(10, 5, 20, 10) }); + const file = rasterFile(); + + const prepared = await prepareLogo(file); + + expect(decode).toHaveBeenCalledWith(file); + expect(canvases).toHaveLength(2); + expect(canvases[0]).toMatchObject({ width: 100, height: 40 }); + expect(canvases[0].draws).toEqual([[expect.objectContaining({ width: 100, height: 40 }), 0, 0, 100, 40]]); + // Ergebnis: genau der sichtbare Ausschnitt, 1:1 (nie vergrößert). + expect(canvases[1]).toMatchObject({ width: 20, height: 10, smoothing: 'high' }); + expect(canvases[1].draws).toEqual([[canvases[0], 10, 5, 20, 10, 0, 0, 20, 10]]); + expect(prepared).toMatchObject({ width: 20, height: 10, hasTransparency: false }); + expect(prepared.png.type).toBe('image/png'); + expect(close).toHaveBeenCalledOnce(); + }); + + it('verkleinert ein großes Logo auf höchstens 720 × 250', async () => { + vi.stubGlobal('createImageBitmap', bitmap(2000, 1000)); + stubCanvas({ visible: only(100, 200, 1440, 500) }); + + const prepared = await prepareLogo(rasterFile()); + + expect(canvases[1]).toMatchObject({ width: 720, height: 250 }); + expect(canvases[1].draws).toEqual([[canvases[0], 100, 200, 1440, 500, 0, 0, 720, 250]]); + expect([prepared.width, prepared.height]).toEqual([720, 250]); + }); + + it('hasTransparency kommt aus den Pixeln des fertigen Logos', async () => { + vi.stubGlobal('createImageBitmap', bitmap(100, 40)); + stubCanvas({ visible: only(10, 5, 20, 10), resultHole: true }); + await expect(prepareLogo(rasterFile())).resolves.toMatchObject({ hasTransparency: true }); + }); + + it('ein Bild über 40 Megapixel wird abgelehnt, bevor ein Canvas entsteht', async () => { + vi.stubGlobal('createImageBitmap', bitmap(8001, 5000)); + stubCanvas(); + await expect(prepareLogo(rasterFile())).rejects.toThrow(PIXELS); + expect(canvases).toHaveLength(0); + expect(close).toHaveBeenCalledOnce(); + }); + + it('genau 40 Megapixel sind noch erlaubt (das leere Bild scheitert erst als „leer“)', async () => { + vi.stubGlobal('createImageBitmap', bitmap(8000, 5000)); + stubCanvas({ blank: true }); + await expect(prepareLogo(rasterFile())).rejects.toThrow(EMPTY); + expect(canvases[0]).toMatchObject({ width: 8000, height: 5000 }); + }); + + it('nichts Sichtbares: „Das Logo ist leer.“', async () => { + vi.stubGlobal('createImageBitmap', bitmap(100, 40)); + stubCanvas({ visible: () => 0 }); + await expect(prepareLogo(rasterFile())).rejects.toThrow(EMPTY); + expect(close).toHaveBeenCalledOnce(); + }); + + it('unter 8 × 8 Pixel: „Das Logo ist zu klein (mindestens 8 × 8 Pixel).“', async () => { + vi.stubGlobal('createImageBitmap', bitmap(100, 40)); + stubCanvas({ visible: only(10, 5, 7, 7) }); + await expect(prepareLogo(rasterFile())).rejects.toThrow(SMALL); + }); + + it('der Dekoder lehnt die Datei ab: „Das Logo konnte nicht gelesen werden.“', async () => { + vi.stubGlobal('createImageBitmap', vi.fn().mockRejectedValue(new DOMException('kaputt', 'InvalidStateError'))); + stubCanvas(); + await expect(prepareLogo(rasterFile())).rejects.toThrow(READ); + expect(canvases).toHaveLength(0); + }); + + it('ohne 2D-Canvas: „Das Logo konnte nicht gelesen werden.“, das Bild wird trotzdem freigegeben', async () => { + vi.stubGlobal('createImageBitmap', bitmap(100, 40)); + stubCanvas({ noContext: true }); + await expect(prepareLogo(rasterFile())).rejects.toThrow(READ); + expect(close).toHaveBeenCalledOnce(); + }); + + it('erzeugt der Canvas kein PNG: „Das Logo konnte nicht gelesen werden.“', async () => { + vi.stubGlobal('createImageBitmap', bitmap(100, 40)); + stubCanvas({ visible: only(10, 5, 20, 10), noBlob: true }); + await expect(prepareLogo(rasterFile())).rejects.toThrow(READ); + }); + + it('der Fehlertext steht in der Sprache der Seite', async () => { + setLang('en', { persist: false }); + vi.stubGlobal('createImageBitmap', bitmap(8001, 5000)); + await expect(prepareLogo(rasterFile())).rejects.toThrow('The image is too large (max. 40 megapixels).'); + vi.stubGlobal('createImageBitmap', vi.fn().mockRejectedValue(new Error('kaputt'))); + await expect(prepareLogo(rasterFile())).rejects.toThrow('The logo couldn’t be read.'); + }); + }); + + describe('SVG', () => { + const SVG = + '' + + ''; + + it('schreibt die Rastergröße ins Wurzelelement, lädt es nur als Bild und rastert mit 2048 px an der längsten Seite', async () => { + stubCanvas({ visible: only(0, 100, 2048, 483) }); + + const prepared = await prepareLogo(svgFile(SVG)); + + // Geladen wird ein Blob als Bild; das Seitenverhältnis 3 : 1 aus der viewBox ergibt 2048 × 683. + expect(blobs).toHaveLength(1); + expect(blobs[0].type).toBe('image/svg+xml'); + expect(await blobs[0].text()).toBe(SVG.replace(' width="10" height="5"', '').replace(' { + stubCanvas({ visible: only(0, 0, 200, 100) }); + await prepareLogo(svgFile('')); + expect(canvases[0]).toMatchObject({ width: 2048, height: 2048 }); + expect(canvases[0].draws).toEqual([[images[0], 0, 0, 2048, 2048]]); + }); + + it('ein SVG ohne Typ wird am Namen erkannt', async () => { + stubCanvas({ visible: only(0, 0, 100, 100) }); + await prepareLogo(new File([SVG], 'Logo.SVG', { type: '' })); + expect(blobs[0].type).toBe('image/svg+xml'); + }); + + it('lässt sich das SVG nicht laden: „Das Logo konnte nicht gelesen werden.“, die Object-URL wird freigegeben', async () => { + imageFails = true; + stubCanvas(); + await expect(prepareLogo(svgFile('kein Bild'))).rejects.toThrow(READ); + expect(revoked).toEqual(['blob:svg-1']); + expect(canvases).toHaveLength(0); + }); + + it('ein leeres SVG: „Das Logo ist leer.“', async () => { + stubCanvas({ visible: () => 0 }); + await expect(prepareLogo(svgFile(SVG))).rejects.toThrow(EMPTY); + }); + }); + + describe('vorab geprüft', () => { + it('ein falscher Typ wird abgelehnt, ohne zu dekodieren', async () => { + const decode = bitmap(10, 10); + vi.stubGlobal('createImageBitmap', decode); + await expect(prepareLogo(new File(['x'], 'a.gif', { type: 'image/gif' }))).rejects.toThrow(TYPE); + expect(decode).not.toHaveBeenCalled(); + }); + + it('eine zu große Datei wird abgelehnt, ohne zu dekodieren', async () => { + const decode = bitmap(10, 10); + vi.stubGlobal('createImageBitmap', decode); + const big = new File([new Uint8Array(5_242_881)], 'a.png', { type: 'image/png' }); + await expect(prepareLogo(big)).rejects.toThrow(SIZE); + expect(decode).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/web/src/logos/prepare-logo.ts b/web/src/logos/prepare-logo.ts new file mode 100644 index 0000000..6153228 --- /dev/null +++ b/web/src/logos/prepare-logo.ts @@ -0,0 +1,241 @@ +// Ein ausgewähltes Logo für den Upload vorbereiten: Datei prüfen, dekodieren (SVG nur als Bild, also ohne Skripte), transparente +// Ränder abschneiden, auf höchstens 720 × 250 px verkleinern (nie vergrößern) und als PNG ausgeben. Die Maße sind die des Dienstes +// (deploy/api/logos.js), der trotzdem selbst prüft. Zuschnitt, Skalierung und Transparenz sind reine Helfer (einzeln testbar); +// `prepareLogo` fügt sie mit dem Canvas des Browsers zusammen. Kein Netzwerk, kein Appwrite. +import { svgAspect } from '../card/marks'; +import { msg, t } from '../i18n/i18n'; + +/** Für das `accept`-Attribut des Datei-Felds: die erlaubten Typen, dazu `.svg` für Systeme, die den Typ nicht kennen. */ +export const LOGO_ACCEPT = 'image/png,image/jpeg,image/webp,image/svg+xml,.svg'; + +/** Höchstmaße und Mindestmaß des fertigen Logos in Pixeln (wie der Dienst sie prüft). */ +export const LOGO_MAX_W = 720; +export const LOGO_MAX_H = 250; +export const LOGO_MIN = 8; +/** Pixel mit Alpha bis hierher gelten als leer (nicht sichtbar). */ +export const ALPHA_EMPTY = 8; +/** + * Spec § 4: ein SVG ohne eigene Größe gilt als 1000 × 1000, also quadratisch. Gerastert wird immer mit SVG_RASTER_MAX; aus dem + * Fallback bleibt nur das quadratische Seitenverhältnis (siehe svgRasterSize). + */ +export const SVG_FALLBACK_SIZE = 1000; +/** Ein SVG wird mit genau so vielen Pixeln an der längsten Seite gerastert (scharf genug fürs Verkleinern auf 720 × 250). */ +export const SVG_RASTER_MAX = 2048; +/** Grenzen der gewählten Datei: Dateigröße und Breite × Höhe (ein kleines, stark komprimiertes Bild kann sonst Gigabytes belegen). */ +export const LOGO_SOURCE_MAX_BYTES = 5_242_880; +export const LOGO_SOURCE_MAX_PIXELS = 40_000_000; + +// Die Meldungen (Spec § 4 und § 11); übersetzt wird beim Auslösen, siehe `problem`. +export const LOGO_TYPE_MESSAGE = msg('Bitte ein PNG-, JPG-, WebP- oder SVG-Bild wählen.'); +export const LOGO_SIZE_MESSAGE = msg('Die Datei ist größer als 5 MB.'); +export const LOGO_PIXELS_MESSAGE = msg('Das Bild ist zu groß (max. 40 Megapixel).'); +export const LOGO_READ_MESSAGE = msg('Das Logo konnte nicht gelesen werden.'); +export const LOGO_EMPTY_MESSAGE = msg('Das Logo ist leer.'); +export const LOGO_SMALL_MESSAGE = msg('Das Logo ist zu klein (mindestens 8 × 8 Pixel).'); + +/** Das fertige Logo: PNG samt Maßen; `hasTransparency` entscheidet, ob „Einfarbig“ wählbar ist. */ +export interface PreparedLogo { + png: Blob; + width: number; + height: number; + hasTransparency: boolean; +} + +export interface Box { + x: number; + y: number; + w: number; + h: number; +} + +export interface Size { + w: number; + h: number; +} + +/** Ein Fehler mit fertigem, übersetztem Text für die Oberfläche; jeder andere Fehler beim Verarbeiten gilt als „nicht lesbar“. */ +class LogoError extends Error {} + +/** Ein `LogoError` mit der Meldung in der Sprache der Seite. */ +function problem(text: string): LogoError { + return new LogoError(t(text)); +} + +const RASTER_TYPES = ['image/png', 'image/jpeg', 'image/webp']; +const SVG_TYPE = 'image/svg+xml'; + +/** SVG: am Typ erkannt, oder am Namen, wenn der Browser keinen Typ liefert (manche Systeme kennen .svg nicht). */ +function isSvgFile(f: { type: string; name: string }): boolean { + return f.type === SVG_TYPE || (f.type === '' && /\.svg$/i.test(f.name)); +} + +/** Prüft Typ und Größe, bevor die Datei dekodiert wird. Der Typ hat Vorrang vor der Größe; die Meldung ist schon übersetzt. */ +export function validateLogoFile(f: { type: string; size: number; name: string }): { ok: true } | { ok: false; message: string } { + if (!RASTER_TYPES.includes(f.type) && !isSvgFile(f)) return { ok: false, message: t(LOGO_TYPE_MESSAGE) }; + if (f.size > LOGO_SOURCE_MAX_BYTES) return { ok: false, message: t(LOGO_SIZE_MESSAGE) }; + if (f.size <= 0) return { ok: false, message: t(LOGO_READ_MESSAGE) }; + return { ok: true }; +} + +/** + * Der kleinste Rahmen um alle sichtbaren Pixel (Alpha größer als `threshold`) eines RGBA-Bildes; null, wenn nichts sichtbar ist. + * Nur die Deckkraft zählt, die Farbe nicht. + */ +export function trimBox(data: Uint8ClampedArray, w: number, h: number, threshold = ALPHA_EMPTY): Box | null { + let minX = w; + let minY = h; + let maxX = -1; + let maxY = -1; + for (let y = 0; y < h; y++) { + const row = y * w * 4 + 3; + for (let x = 0; x < w; x++) { + if (data[row + x * 4] > threshold) { + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + maxY = y; + } + } + } + return maxX < 0 ? null : { x: minX, y: minY, w: maxX - minX + 1, h: maxY - minY + 1 }; +} + +/** Verkleinert w × h so, dass es in maxW × maxH passt (Seitenverhältnis bleibt): nie größer als das Original, gerundet, mindestens 1. */ +export function fitWithin(w: number, h: number, maxW: number, maxH: number): Size { + const scale = Math.min(1, maxW / w, maxH / h); + return { w: Math.max(1, Math.round(w * scale)), h: Math.max(1, Math.round(h * scale)) }; +} + +/** + * Die Rastergröße eines SVGs bei diesem Seitenverhältnis (Breite durch Höhe): die längste Seite hat genau 2048 px, nie mehr, die + * kürzere mindestens 1. Ohne brauchbares Seitenverhältnis (nicht endlich oder nicht größer als 0) ist es quadratisch. + */ +export function svgRasterSize(aspect: number): Size { + if (!Number.isFinite(aspect) || aspect <= 0) return { w: SVG_RASTER_MAX, h: SVG_RASTER_MAX }; + if (aspect >= 1) return { w: SVG_RASTER_MAX, h: Math.max(1, Math.round(SVG_RASTER_MAX / aspect)) }; + return { w: Math.max(1, Math.round(SVG_RASTER_MAX * aspect)), h: SVG_RASTER_MAX }; +} + +/** Hat das Bild (RGBA) durchsichtige Stellen, also irgendein Pixel mit Alpha unter 255? */ +export function hasTransparency(data: Uint8ClampedArray): boolean { + for (let i = 3; i < data.length; i += 4) if (data[i] < 255) return true; + return false; +} + +/** + * Was aus den Pixeln eines Bildes wird: der sichtbare Ausschnitt und seine Zielgröße (höchstens 720 × 250, nie größer als der + * Ausschnitt). Wirft „Das Logo ist leer.“, wenn nichts Sichtbares bleibt, und „Das Logo ist zu klein …“, wenn die fertige Größe + * unter 8 × 8 Pixel läge (die Größe, die der Dienst prüft; ein sehr breites Logo kann dabei zu niedrig werden). + */ +export function planLogo(data: Uint8ClampedArray, w: number, h: number): { box: Box; size: Size } { + const box = trimBox(data, w, h); + if (!box) throw problem(LOGO_EMPTY_MESSAGE); + const size = fitWithin(box.w, box.h, LOGO_MAX_W, LOGO_MAX_H); + if (size.w < LOGO_MIN || size.h < LOGO_MIN) throw problem(LOGO_SMALL_MESSAGE); + return { box, size }; +} + +/** + * Gibt dem Wurzelelement eines SVGs die Größe `w` × `h` (damit alle Browser es scharf auf den Canvas zeichnen, wie `withIntrinsicSize` + * in card/marks.ts). Eine eigene `width`/`height` des Wurzelelements fällt dabei weg: ein doppeltes Attribut machte das SVG ungültig. + * Nur das erste `` wird angefasst; ohne eines (oder ohne dessen Ende) bleibt der Text unverändert. + */ +export function sizeSvgRoot(svg: string, w: number, h: number): string { + const start = svg.search(/“ außerhalb von Anführungszeichen. Genau ein Versuch ab dem ersten „/y; + tag.lastIndex = start; + const hit = tag.exec(svg); + if (!hit) return svg; + const kept = hit[1].replace(/\s(?:width|height)\s*=\s*(?:"[^"]*"|'[^']*')/g, ''); + return `${svg.slice(0, start)}${svg.slice(start + hit[0].length)}`; +} + +// ---- Dekodieren und Zeichnen (braucht einen echten Canvas) -------------------------------------------- + +/** Das Quellbild in Originalgröße auf einem Canvas samt seinen Pixeln (RGBA). */ +interface Drawn { + canvas: HTMLCanvasElement; + data: Uint8ClampedArray; + w: number; + h: number; +} + +/** Ein Canvas der Größe w × h mit 2D-Kontext. Es werden Pixel gelesen, daher `willReadFrequently`. */ +function newCanvas(w: number, h: number): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } { + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) throw problem(LOGO_READ_MESSAGE); + return { canvas, ctx }; +} + +/** Zeichnet die Quelle in Größe w × h auf einen neuen Canvas und liest dessen Pixel. */ +function draw(source: CanvasImageSource, w: number, h: number): Drawn { + const { canvas, ctx } = newCanvas(w, h); + ctx.drawImage(source, 0, 0, w, h); + return { canvas, data: ctx.getImageData(0, 0, w, h).data, w, h }; +} + +/** PNG, JPG oder WebP: dekodieren, Breite × Höhe prüfen (bevor etwas gezeichnet wird) und in Originalgröße zeichnen. */ +async function drawRaster(file: File): Promise { + const bitmap = await createImageBitmap(file); + try { + if (bitmap.width * bitmap.height > LOGO_SOURCE_MAX_PIXELS) throw problem(LOGO_PIXELS_MESSAGE); + return draw(bitmap, bitmap.width, bitmap.height); + } finally { + bitmap.close(); + } +} + +/** Lädt ein SVG als Bild, nie als Teil der Seite: so laufen Skripte darin nicht und es lädt nichts nach. */ +function loadSvgImage(svg: string): Promise { + const url = URL.createObjectURL(new Blob([svg], { type: SVG_TYPE })); + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(problem(LOGO_READ_MESSAGE)); + img.src = url; + }).finally(() => URL.revokeObjectURL(url)); +} + +/** SVG: Seitenverhältnis aus der viewBox (ohne: quadratisch), gerastert mit 2048 px an der längsten Seite. */ +async function drawSvg(file: File): Promise { + const text = await file.text(); + const { w, h } = svgRasterSize(svgAspect(text)); + return draw(await loadSvgImage(sizeSvgRoot(text, w, h)), w, h); +} + +function toPng(canvas: HTMLCanvasElement): Promise { + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => (blob ? resolve(blob) : reject(problem(LOGO_READ_MESSAGE))), 'image/png'); + }); +} + +/** + * Macht aus der gewählten Datei das fertige Logo (siehe Kopf der Datei). Wirft einen Fehler mit der Meldung in der Sprache der + * Seite: Typ, Größe, zu viele Pixel, leer, zu klein oder nicht lesbar (alles, was beim Dekodieren oder Zeichnen schiefgeht). + */ +export async function prepareLogo(file: File): Promise { + const check = validateLogoFile(file); + if (!check.ok) throw new LogoError(check.message); + try { + const { canvas, data, w, h } = isSvgFile(file) ? await drawSvg(file) : await drawRaster(file); + const { box, size } = planLogo(data, w, h); + const out = newCanvas(size.w, size.h); + // Beim Verkleinern ist die Standardqualität (low) grob. + out.ctx.imageSmoothingQuality = 'high'; + out.ctx.drawImage(canvas, box.x, box.y, box.w, box.h, 0, 0, size.w, size.h); + return { + png: await toPng(out.canvas), + width: size.w, + height: size.h, + hasTransparency: hasTransparency(out.ctx.getImageData(0, 0, size.w, size.h).data), + }; + } catch (err) { + throw err instanceof LogoError ? err : problem(LOGO_READ_MESSAGE); + } +} diff --git a/web/src/logos/upload-logo.test.ts b/web/src/logos/upload-logo.test.ts new file mode 100644 index 0000000..5691f41 --- /dev/null +++ b/web/src/logos/upload-logo.test.ts @@ -0,0 +1,286 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + createFile: vi.fn(), + deleteFile: vi.fn(), + requireUserId: vi.fn(), + uploadLogo: vi.fn(), +})); + +// Appwrite, Nutzer und der API-Aufruf sind nur für den Test der Standard-Abhängigkeiten gebraucht; sonst kommen Fakes (deps) zum Einsatz. +vi.mock('../account/appwrite', () => ({ + IDS: { logoBucket: 'logo-images' }, + account: {}, + client: {}, + tables: {}, + storage: { createFile: mocks.createFile, deleteFile: mocks.deleteFile }, + APPWRITE_ENDPOINT: 'https://aircard.test/v1', +})); +vi.mock('../account/cards', () => ({ requireUserId: mocks.requireUserId })); +vi.mock('./logo-api', () => ({ uploadLogo: mocks.uploadLogo })); + +import { ID_PATTERN } from '../card/config'; +import { ApiError } from '../community/api'; +import type { PreparedLogo } from './prepare-logo'; +import { uploadPreparedLogo, type LogoForm, type UploadLogoDeps } from './upload-logo'; + +const prepared = (patch: Partial = {}): PreparedLogo => ({ + png: new Blob(['png-bytes'], { type: 'image/png' }), + width: 200, + height: 80, + hasTransparency: true, + ...patch, +}); + +const form: LogoForm = { name: 'Beispielbank', displayName: 'Kenso', mono: false }; + +type Call = { name: string; args: unknown[] }; + +/** Fake-Abhängigkeiten, die jeden Aufruf in Reihenfolge aufzeichnen; die hochgeladene Datei bekommt die ID f1. */ +function fakeDeps(overrides: Partial = {}) { + const calls: Call[] = []; + const log = (name: string, args: unknown[], result: T): T => { + calls.push({ name, args }); + return result; + }; + const deps: UploadLogoDeps = { + userId: () => log('userId', [], 'u1'), + createFile: async (file, permissions) => log('createFile', [file, permissions], { $id: 'f1' }), + deleteFile: async (id) => log('deleteFile', [id], undefined), + uploadLogo: async (input) => log('uploadLogo', [input], { id: 'f1' }), + ...overrides, + }; + return { + deps, + calls, + names: () => calls.map((c) => c.name), + of: (name: string) => calls.filter((c) => c.name === name), + }; +} + +describe('uploadPreparedLogo', () => { + it('Reihenfolge: Nutzer, Upload, API', async () => { + const fake = fakeDeps(); + await uploadPreparedLogo(prepared(), form, fake.deps); + expect(fake.names()).toEqual(['userId', 'createFile', 'uploadLogo']); + }); + + it('lädt das PNG als logo.png (image/png) mit read(any) und delete(user:) hoch', async () => { + const fake = fakeDeps(); + const png = prepared().png; + await uploadPreparedLogo({ ...prepared(), png }, form, fake.deps); + const [file, permissions] = fake.of('createFile')[0].args as [File, string[]]; + expect(file.name).toBe('logo.png'); + expect(file.type).toBe('image/png'); + expect(file.size).toBe(png.size); + expect(permissions).toEqual(['read("any")', 'delete("user:u1")']); + }); + + it('schickt der API die neue Datei-ID und genau vier Felder, Name und Anzeigename getrimmt', async () => { + const fake = fakeDeps(); + await uploadPreparedLogo(prepared(), { name: ' Beispielbank ', displayName: ' Kenso\t', mono: false }, fake.deps); + expect(fake.of('uploadLogo')[0].args[0]).toEqual({ name: 'Beispielbank', displayName: 'Kenso', mono: false, fileId: 'f1' }); + }); + + it('ein leerer Anzeigename bleibt leer (der Server macht daraus „Anonym“)', async () => { + const fake = fakeDeps(); + await uploadPreparedLogo(prepared(), { ...form, displayName: ' ' }, fake.deps); + expect(fake.of('uploadLogo')[0].args[0]).toMatchObject({ displayName: '' }); + }); + + it.each([ + { mono: true, hasTransparency: true, sent: true }, + { mono: true, hasTransparency: false, sent: false }, + { mono: false, hasTransparency: true, sent: false }, + { mono: false, hasTransparency: false, sent: false }, + ])('mono $mono bei hasTransparency $hasTransparency: gesendet wird $sent', async ({ mono, hasTransparency, sent }) => { + const fake = fakeDeps(); + const logo = await uploadPreparedLogo(prepared({ hasTransparency }), { ...form, mono }, fake.deps); + expect(fake.of('uploadLogo')[0].args[0]).toMatchObject({ mono: sent }); + expect(logo.mono).toBe(sent); + }); + + it('Erfolg: das neue Logo (ID = Datei-ID, mein Konto, ohne Herzen und Meldungen), nichts wird gelöscht', async () => { + const fake = fakeDeps(); + const logo = await uploadPreparedLogo(prepared(), { name: ' Beispielbank ', displayName: 'Kenso', mono: true }, fake.deps); + expect(logo).toEqual({ + id: 'f1', + name: 'Beispielbank', + displayName: 'Kenso', + fileId: 'f1', + ownerId: 'u1', + mono: true, + likeCount: 0, + hidden: false, + reportCount: 0, + createdAt: expect.any(String), + }); + expect(new Date(logo.createdAt).toISOString()).toBe(logo.createdAt); + expect(fake.of('deleteFile')).toHaveLength(0); + }); + + describe('Fehler der API: gelöscht wird nur bei eindeutiger Ablehnung', () => { + it.each([400, 401, 403, 404])( + 'eindeutig abgelehnt (%i): die Datei dieses Versuchs wird gelöscht, der Fehler kommt an', + async (code) => { + const failure = new ApiError(code, 'Abgelehnt.'); + const fake = fakeDeps({ uploadLogo: async () => Promise.reject(failure) }); + await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure); + expect(fake.of('deleteFile').map((c) => c.args[0])).toEqual(['f1']); + }, + ); + + it('das Limit von 30 Logos (429 tooManyLogos, eindeutig): die Datei wird gelöscht', async () => { + const failure = new ApiError(429, 'Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.', true, 'tooManyLogos'); + const fake = fakeDeps({ uploadLogo: async () => Promise.reject(failure) }); + await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure); + expect(fake.of('deleteFile').map((c) => c.args[0])).toEqual(['f1']); + }); + + it.each([408, 429, 500, 502, 503, 504])( + 'unklarer Ausgang (%i): die Zeile kann angelegt sein, die Datei bleibt liegen, der Fehler kommt an', + async (code) => { + const failure = new ApiError(code, 'Unklar.'); + const fake = fakeDeps({ uploadLogo: async () => Promise.reject(failure) }); + await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure); + expect(fake.of('deleteFile')).toHaveLength(0); + }, + ); + + it('der Dienst ist nicht erreichbar (unavailable): die Datei bleibt liegen', async () => { + const failure = new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable'); + const fake = fakeDeps({ uploadLogo: async () => Promise.reject(failure) }); + await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure); + expect(fake.of('deleteFile')).toHaveLength(0); + }); + + it('409 logoTaken: die Datei gehört schon einem Logo und wird NICHT gelöscht, der Fehler kommt an', async () => { + const failure = new ApiError(409, 'Diese Datei wird schon für ein Logo verwendet.', undefined, 'logoTaken'); + expect(failure.definitive).toBe(true); + const fake = fakeDeps({ uploadLogo: async () => Promise.reject(failure) }); + await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure); + expect(fake.of('deleteFile')).toHaveLength(0); + }); + + it('entscheidend ist der Schlüssel logoTaken, nicht der Status: ein anderes 409 wird aufgeräumt', async () => { + const failure = new ApiError(409, 'Anderer Konflikt.', undefined, 'alreadyPublished'); + const fake = fakeDeps({ uploadLogo: async () => Promise.reject(failure) }); + await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure); + expect(fake.of('deleteFile').map((c) => c.args[0])).toEqual(['f1']); + }); + + it('JWT-Methode am Server aus (ApiError mit definitive-Flag, es ging keine Anfrage raus): gelöscht', async () => { + const failure = new ApiError(501, 'Anmeldung per Token ist auf dem Server ausgeschaltet.', true, 'jwtUnsupported'); + const fake = fakeDeps({ uploadLogo: async () => Promise.reject(failure) }); + await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure); + expect(fake.of('deleteFile')).toHaveLength(1); + }); + + it('ein anderer Fehler vor der Anfrage (z. B. createJWT mit 401): gelöscht', async () => { + const failure = Object.assign(new Error('missing scope'), { code: 401, type: 'general_unauthorized_scope' }); + const fake = fakeDeps({ uploadLogo: async () => Promise.reject(failure) }); + await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure); + expect(fake.of('deleteFile')).toHaveLength(1); + }); + + it('löscht nie etwas anderes als die Datei dieses Versuchs', async () => { + const fake = fakeDeps({ + createFile: async () => ({ $id: 'neu42' }), + uploadLogo: async () => Promise.reject(new ApiError(403, 'Dazu hast du keine Berechtigung.')), + }); + await uploadPreparedLogo(prepared(), form, fake.deps).catch(() => undefined); + expect(fake.of('deleteFile').map((c) => c.args[0])).toEqual(['neu42']); + }); + + it('ein Fehler beim Aufräumen (auch ein sofort geworfener) überdeckt den ursprünglichen Fehler nicht', async () => { + const failure = new ApiError(400, 'Bitte gib einen Namen an.'); + const rejecting = fakeDeps({ + uploadLogo: async () => Promise.reject(failure), + deleteFile: async () => Promise.reject(new Error('delete kaputt')), + }); + await expect(uploadPreparedLogo(prepared(), form, rejecting.deps)).rejects.toBe(failure); + + const throwing = fakeDeps({ + uploadLogo: async () => Promise.reject(failure), + deleteFile: () => { + throw new Error('delete kaputt'); + }, + }); + await expect(uploadPreparedLogo(prepared(), form, throwing.deps)).rejects.toBe(failure); + }); + }); + + describe('vor dem API-Aufruf', () => { + it('scheitert der Upload, wird die API nicht gerufen und nichts gelöscht, der Fehler kommt an', async () => { + const failure = new Error('upload kaputt'); + const fake = fakeDeps({ createFile: async () => Promise.reject(failure) }); + await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure); + expect(fake.of('uploadLogo')).toHaveLength(0); + expect(fake.of('deleteFile')).toHaveLength(0); + }); + + it('ohne angemeldeten Nutzer wird nichts hochgeladen', async () => { + const failure = Object.assign(new Error('unauthorized'), { code: 401 }); + const fake = fakeDeps({ + userId: () => { + throw failure; + }, + }); + await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure); + expect(fake.names()).toEqual([]); + }); + }); +}); + +describe('Standard-Abhängigkeiten (Appwrite-Bucket logo-images)', () => { + beforeEach(() => { + for (const fn of Object.values(mocks)) fn.mockReset(); + mocks.requireUserId.mockReturnValue('u1'); + // Wie Appwrite: die Datei hat die ID, mit der sie angelegt wurde. + mocks.createFile.mockImplementation(async ({ fileId }: { fileId: string }) => ({ $id: fileId })); + mocks.deleteFile.mockResolvedValue({}); + mocks.uploadLogo.mockImplementation(async ({ fileId }: { fileId: string }) => ({ id: fileId })); + }); + + it('lädt mit frischer ID in den Logo-Bucket und ruft die API mit genau dieser ID', async () => { + const logo = await uploadPreparedLogo(prepared(), form); + expect(mocks.createFile).toHaveBeenCalledTimes(1); + const { bucketId, fileId, file, permissions } = mocks.createFile.mock.calls[0][0] as { + bucketId: string; + fileId: string; + file: File; + permissions: string[]; + }; + expect(bucketId).toBe('logo-images'); + expect(fileId).toMatch(ID_PATTERN); + expect(file.name).toBe('logo.png'); + expect(permissions).toEqual(['read("any")', 'delete("user:u1")']); + expect(mocks.uploadLogo).toHaveBeenCalledWith({ name: 'Beispielbank', displayName: 'Kenso', mono: false, fileId }); + expect(logo.id).toBe(fileId); + expect(mocks.deleteFile).not.toHaveBeenCalled(); + }); + + it('jeder Versuch bekommt seine eigene Datei-ID', async () => { + await uploadPreparedLogo(prepared(), form); + await uploadPreparedLogo(prepared(), form); + const [first, second] = mocks.createFile.mock.calls.map((call) => (call[0] as { fileId: string }).fileId); + expect(first).not.toBe(second); + }); + + it('räumt bei eindeutiger Ablehnung genau diese Datei im Logo-Bucket auf', async () => { + mocks.uploadLogo.mockRejectedValue(new ApiError(403, 'Dazu hast du keine Berechtigung.')); + await expect(uploadPreparedLogo(prepared(), form)).rejects.toBeInstanceOf(ApiError); + const { fileId } = mocks.createFile.mock.calls[0][0] as { fileId: string }; + expect(mocks.deleteFile).toHaveBeenCalledTimes(1); + expect(mocks.deleteFile).toHaveBeenCalledWith({ bucketId: 'logo-images', fileId }); + }); + + it('ohne angemeldeten Nutzer (401 aus requireUserId): nichts wird hochgeladen', async () => { + const failure = Object.assign(new Error('unauthorized'), { code: 401 }); + mocks.requireUserId.mockImplementation(() => { + throw failure; + }); + await expect(uploadPreparedLogo(prepared(), form)).rejects.toBe(failure); + expect(mocks.createFile).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/logos/upload-logo.ts b/web/src/logos/upload-logo.ts new file mode 100644 index 0000000..2942f6d --- /dev/null +++ b/web/src/logos/upload-logo.ts @@ -0,0 +1,107 @@ +// Ein fertig verarbeitetes Logo hochladen: das PNG in den Logo-Bucket legen, dann `/api/logos/hochladen` rufen (logo-api.ts). +// Aufgeräumt wird nur bei eindeutigem Scheitern (wie beim Veröffentlichen, community/publish.ts): dann löscht der Client die Datei +// dieses Versuchs wieder. Bleibt der Ausgang offen, bleibt sie liegen (siehe uploadPreparedLogo). +import { ID, Permission, Role } from 'appwrite'; +import { IDS, storage } from '../account/appwrite'; +import { requireUserId } from '../account/cards'; +import { ApiError, isDefinitiveFailure } from '../community/api'; +import { uploadLogo, type LogoUploadInput } from './logo-api'; +import type { CommunityLogo } from './logos'; +import type { PreparedLogo } from './prepare-logo'; + +export interface LogoForm { + name: string; + /** Leer bedeutet „Anonym“ (das setzt der Dienst). */ + displayName: string; + /** Einfarbig: nur wirksam, wenn das Logo durchsichtige Stellen hat. */ + mono: boolean; +} + +/** Alles, was `uploadPreparedLogo` von außen braucht (im Test ersetzbar, ohne Netz und Appwrite). */ +export interface UploadLogoDeps { + /** Die ID des angemeldeten Nutzers; ohne Nutzer ein Fehler mit `code` 401. */ + userId(): string; + createFile(file: File, permissions: string[]): Promise<{ $id: string }>; + deleteFile(fileId: string): Promise; + uploadLogo(input: LogoUploadInput): Promise<{ id: string }>; +} + +function defaultDeps(): UploadLogoDeps { + return { + userId: requireUserId, + // Immer eine frische ID: aufgeräumt wird nur, was dieser Versuch selbst angelegt hat. + createFile: (file, permissions) => + storage.createFile({ bucketId: IDS.logoBucket, fileId: ID.unique(), file, permissions }), + deleteFile: async (fileId) => { + await storage.deleteFile({ bucketId: IDS.logoBucket, fileId }); + }, + uploadLogo, + }; +} + +/** Räumt auf, soweit es geht; ein Fehler (auch 404, auch ein sofort geworfener) darf den eigentlichen Fehler nie überdecken. */ +async function deleteQuietly(deps: UploadLogoDeps, fileId: string): Promise { + try { + await deps.deleteFile(fileId); + } catch { + // Die Datei bleibt dann liegen: unverlinkt, mit Zufalls-ID, vom Nutzer jederzeit löschbar. + } +} + +/** + * Zu dieser Datei gibt es schon ein Logo (409 `logoTaken`): die Datei gehört diesem Logo, auch wenn die Ablehnung eindeutig ist. + * Entschieden wird nach dem Schlüssel des Dienstes, nicht nach dem Status. + */ +function isLogoTaken(err: unknown): boolean { + return err instanceof ApiError && err.serverCode === 'logoTaken'; +} + +/** + * Lädt das Logo hoch: das PNG in den Logo-Bucket (`read("any")`, `delete("user:")`), dann legt die API das Logo daraus an. + * Liefert das neue Logo, so wie die Liste es danach zeigt (die Zeilen-ID ist die Datei-ID). „Einfarbig“ gilt nur für Logos mit + * durchsichtigen Stellen. Name und Anzeigename werden getrimmt, ihre Grenzen prüft der Dienst. + * + * Aufräumen (nur die eine Datei dieses Versuchs, nie etwas anderes): + * - Scheitert der Upload, wurde die API noch nicht gerufen: es gibt kein Logo, das auf die Datei zeigen könnte. + * - Scheitert die API eindeutig (4xx außer 408/429, auch das Limit von 30 Logos, oder schon der JWT-Abruf), wurde nichts + * angelegt: die Datei wird gelöscht. Ausnahme `logoTaken` (409): ein anderes Logo trägt diese Datei-ID, die Datei bleibt. + * - Bleibt der Ausgang offen (Netzfehler, Zeitlimit, 5xx, 408, 429, Antwort ohne `id`), kann die API das Logo schon angelegt + * haben. Dann bleibt die Datei liegen: sonst zeigte ein öffentliches Logo auf ein gelöschtes Bild. + * Der Fehler wird in jedem Fall weitergeworfen. + */ +export async function uploadPreparedLogo( + prepared: PreparedLogo, + form: LogoForm, + deps: UploadLogoDeps = defaultDeps(), +): Promise { + // Ohne Nutzer wirft das hier, bevor etwas hochgeladen wird. + const userId = deps.userId(); + const permissions = [Permission.read(Role.any()), Permission.delete(Role.user(userId))]; + const name = form.name.trim(); + const displayName = form.displayName.trim(); + const mono = form.mono && prepared.hasTransparency; + + const file = new File([prepared.png], 'logo.png', { type: 'image/png' }); + const fileId = (await deps.createFile(file, permissions)).$id; + + let id: string; + try { + ({ id } = await deps.uploadLogo({ name, displayName, mono, fileId })); + } catch (err) { + if (isDefinitiveFailure(err) && !isLogoTaken(err)) await deleteQuietly(deps, fileId); + throw err; + } + + return { + id, + name, + displayName, + fileId: id, + ownerId: userId, + mono, + likeCount: 0, + hidden: false, + reportCount: 0, + createdAt: new Date().toISOString(), + }; +}