diff --git a/apps/api/src/common/public-url-guard.ts b/apps/api/src/common/public-url-guard.ts new file mode 100644 index 0000000..8da6223 --- /dev/null +++ b/apps/api/src/common/public-url-guard.ts @@ -0,0 +1,104 @@ +import { lookup } from 'node:dns/promises'; +import { isIP } from 'node:net'; + +/** + * Gemeinsamer Schutz gegen Server-Side-Request-Forgery (SSRF). + * + * Herkunft: favorites/icon-discovery.service.ts (T-08-05), unveraendert hierher + * verschoben, damit auch der Logo-Abruf von Nextcloud-Status (quick-261008-j9f) + * dieselbe Pruefung nutzt. Eine Adresse gilt nur als oeffentlich, wenn sie + * http/https ist, der Name nicht localhost/.local/0.0.0.0 ist und jede + * aufgeloeste Adresse ausserhalb privater, Loopback-, Link-Local-, CGNAT- und + * Multicast-Bereiche liegt. + */ + +function isPrivateIpv4(address: string): boolean { + const parts = address.split('.').map((part) => Number.parseInt(part, 10)); + + if ( + parts.length !== 4 || + parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255) + ) { + return true; + } + + const [a, b] = parts; + + return ( + a === 0 || + a === 10 || + a === 127 || + (a === 100 && b !== undefined && b >= 64 && b <= 127) || + (a === 169 && b === 254) || + (a === 172 && b !== undefined && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + (a === 192 && b === 0) || + (a === 198 && (b === 18 || b === 19)) || + a >= 224 + ); +} + +function isPrivateIpv6(address: string): boolean { + const lower = address.toLowerCase(); + + if ( + lower === '::' || + lower === '::1' || + lower.startsWith('fc') || + lower.startsWith('fd') || + lower.startsWith('fe80:') || + lower.startsWith('ff') + ) { + return true; + } + + // IPv4-mapped IPv6 (::ffff:) — delegate to isPrivateIpv4 to cover all + // RFC 1918 ranges (10.x, 172.16-31.x, 192.168.x) and 169.254.x link-local + const v4MappedMatch = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); + if (v4MappedMatch) { + return isPrivateIpv4(v4MappedMatch[1]); + } + + return false; +} + +function isPrivateIpAddress(address: string): boolean { + const version = isIP(address); + + if (version === 4) return isPrivateIpv4(address); + if (version === 6) return isPrivateIpv6(address); + + return true; // Unknown format → block by default +} + +function isBlockedHostname(hostname: string): boolean { + const h = hostname.trim().toLowerCase(); + + return h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h === '0.0.0.0'; +} + +export async function isPublicHttpUrl(url: URL): Promise { + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return false; + } + + if (isBlockedHostname(url.hostname)) { + return false; + } + + const directVersion = isIP(url.hostname); + + if (directVersion !== 0) { + return !isPrivateIpAddress(url.hostname); + } + + try { + const addresses = await lookup(url.hostname, { all: true }); + + if (addresses.length === 0) return false; + + return addresses.every((a) => !isPrivateIpAddress(a.address)); + } catch { + return false; + } +} diff --git a/apps/api/src/favorites/icon-discovery.service.ts b/apps/api/src/favorites/icon-discovery.service.ts index e05f0eb..78e6053 100644 --- a/apps/api/src/favorites/icon-discovery.service.ts +++ b/apps/api/src/favorites/icon-discovery.service.ts @@ -1,7 +1,10 @@ -import { lookup } from 'node:dns/promises'; -import { isIP } from 'node:net'; import { Injectable } from '@nestjs/common'; import { Agent, type Response as UndiciResponse, fetch as undiciFetch } from 'undici'; +import { isPublicHttpUrl } from '../common/public-url-guard'; + +// Der Schutz liegt seit quick-261008-j9f in common/public-url-guard.ts; der Name +// bleibt hier erreichbar, damit bestehende Importe unveraendert funktionieren. +export { isPublicHttpUrl }; /** * Server-side favicon / icon discovery with SSRF protection (T-08-05). @@ -65,97 +68,6 @@ type FetchHtmlResult = { ok: boolean; }; -function isPrivateIpv4(address: string): boolean { - const parts = address.split('.').map((part) => Number.parseInt(part, 10)); - - if ( - parts.length !== 4 || - parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255) - ) { - return true; - } - - const [a, b] = parts; - - return ( - a === 0 || - a === 10 || - a === 127 || - (a === 100 && b !== undefined && b >= 64 && b <= 127) || - (a === 169 && b === 254) || - (a === 172 && b !== undefined && b >= 16 && b <= 31) || - (a === 192 && b === 168) || - (a === 192 && b === 0) || - (a === 198 && (b === 18 || b === 19)) || - a >= 224 - ); -} - -function isPrivateIpv6(address: string): boolean { - const lower = address.toLowerCase(); - - if ( - lower === '::' || - lower === '::1' || - lower.startsWith('fc') || - lower.startsWith('fd') || - lower.startsWith('fe80:') || - lower.startsWith('ff') - ) { - return true; - } - - // IPv4-mapped IPv6 (::ffff:) — delegate to isPrivateIpv4 to cover all - // RFC 1918 ranges (10.x, 172.16-31.x, 192.168.x) and 169.254.x link-local - const v4MappedMatch = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); - if (v4MappedMatch) { - return isPrivateIpv4(v4MappedMatch[1]); - } - - return false; -} - -function isPrivateIpAddress(address: string): boolean { - const version = isIP(address); - - if (version === 4) return isPrivateIpv4(address); - if (version === 6) return isPrivateIpv6(address); - - return true; // Unknown format → block by default -} - -function isBlockedHostname(hostname: string): boolean { - const h = hostname.trim().toLowerCase(); - - return h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h === '0.0.0.0'; -} - -export async function isPublicHttpUrl(url: URL): Promise { - if (url.protocol !== 'http:' && url.protocol !== 'https:') { - return false; - } - - if (isBlockedHostname(url.hostname)) { - return false; - } - - const directVersion = isIP(url.hostname); - - if (directVersion !== 0) { - return !isPrivateIpAddress(url.hostname); - } - - try { - const addresses = await lookup(url.hostname, { all: true }); - - if (addresses.length === 0) return false; - - return addresses.every((a) => !isPrivateIpAddress(a.address)); - } catch { - return false; - } -} - /** * Normalize a user-entered site URL by prepending https:// when no scheme is * present, so "ctl.de" becomes "https://ctl.de". Without this, `new URL()` diff --git a/apps/api/src/nextcloud-status/dto/nextcloud-instance.dto.spec.ts b/apps/api/src/nextcloud-status/dto/nextcloud-instance.dto.spec.ts new file mode 100644 index 0000000..bf275e4 --- /dev/null +++ b/apps/api/src/nextcloud-status/dto/nextcloud-instance.dto.spec.ts @@ -0,0 +1,52 @@ +import 'reflect-metadata'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { describe, expect, it } from 'vitest'; +import { NEXTCLOUD_FORM_ERROR_CODES } from '../nextcloud-form-errors'; +import { CreateNextcloudInstanceDto, UpdateNextcloudInstanceDto } from './nextcloud-instance.dto'; + +async function messagesOf( + cls: typeof CreateNextcloudInstanceDto | typeof UpdateNextcloudInstanceDto, + plain: Record, +) { + const errors = await validate(plainToInstance(cls, plain)); + return errors.flatMap((e) => Object.values(e.constraints ?? {})); +} + +describe('Nextcloud-DTOs — Meldungen sind Kennungen, kein englischer Satz', () => { + it('falsche Typen liefern nur bekannte Kennungen', async () => { + const messages = [ + ...(await messagesOf(CreateNextcloudInstanceDto, { + customerName: 5, + baseUrl: 7, + logoUrl: 9, + })), + ...(await messagesOf(CreateNextcloudInstanceDto, {})), + ...(await messagesOf(UpdateNextcloudInstanceDto, { + customerName: 5, + baseUrl: 7, + logoUrl: 9, + })), + ]; + expect(messages.length).toBeGreaterThanOrEqual(8); + for (const m of messages) expect(NEXTCLOUD_FORM_ERROR_CODES).toContain(m); + }); + + it('fehlende Pflichtfelder beim Anlegen nennen die passende Kennung', async () => { + const messages = await messagesOf(CreateNextcloudInstanceDto, { customerName: 'X' }); + expect(messages).toEqual(['baseUrlRequired']); + }); + + it('gueltige Eingaben mit http-, https- und leerer Logo-Adresse sind fehlerfrei', async () => { + for (const logoUrl of ['http://logo.example.de/a.png', 'https://logo.example.de/a.png', '']) { + expect( + await messagesOf(CreateNextcloudInstanceDto, { + customerName: 'Kunde', + baseUrl: 'https://cloud.example.de', + logoUrl, + }), + ).toEqual([]); + } + expect(await messagesOf(UpdateNextcloudInstanceDto, {})).toEqual([]); + }); +}); diff --git a/apps/api/src/nextcloud-status/dto/nextcloud-instance.dto.ts b/apps/api/src/nextcloud-status/dto/nextcloud-instance.dto.ts index 19ed288..0c5fa04 100644 --- a/apps/api/src/nextcloud-status/dto/nextcloud-instance.dto.ts +++ b/apps/api/src/nextcloud-status/dto/nextcloud-instance.dto.ts @@ -1,47 +1,42 @@ -import { IsNotEmpty, IsOptional, IsString, IsUrl, MaxLength, ValidateIf } from 'class-validator'; +import { IsOptional, IsString } from 'class-validator'; /** * Eingaben fuer das Anlegen und Aendern einer Nextcloud-Cloud - * (quick-261002-k67). Die Adresse wird im Dienst zusaetzlich normalisiert - * (`normalizeCloudUrl`); hier gilt nur die Formpruefung. Eine Logo-Adresse - * ist nur mit https erlaubt — sie wird vom Browser geladen, nie vom Server. + * (quick-261002-k67, erweitert in quick-261008-j9f). + * + * Hier gilt nur die Typpruefung, und jede Meldung ist eine Fehlerkennung aus + * `nextcloud-form-errors.ts` (die globale ValidationPipe liefert die Meldung + * unveraendert; die Weboberflaeche uebersetzt sie). Laenge, Pflichtfelder und + * Adressform prueft der Dienst (`validateInstanceInput`, `classifyLogoUrl`), + * damit auch diese Fehler als `{ code, message }` ankommen. + * + * Logo-Adresse: https wird vom Browser geladen, http holt Tessera einmalig + * beim Speichern ab und legt das Bild wie einen Upload ab. */ export class CreateNextcloudInstanceDto { - @IsString() - @IsNotEmpty() - @MaxLength(120) + @IsString({ message: 'customerNameRequired' }) customerName!: string; - @IsString() - @IsNotEmpty() - @MaxLength(2048) + @IsString({ message: 'baseUrlRequired' }) baseUrl!: string; - // Leere Zeichenkette = kein Logo; sonst nur eine https-Adresse. + // Leere Zeichenkette = kein Logo. @IsOptional() - @ValidateIf((_o, value) => typeof value === 'string' && value !== '') - @IsUrl({ protocols: ['https'], require_protocol: true, require_tld: false }) - @MaxLength(2048) + @IsString({ message: 'logoUrlInvalid' }) logoUrl?: string; } export class UpdateNextcloudInstanceDto { @IsOptional() - @IsString() - @IsNotEmpty() - @MaxLength(120) + @IsString({ message: 'customerNameRequired' }) customerName?: string; @IsOptional() - @IsString() - @IsNotEmpty() - @MaxLength(2048) + @IsString({ message: 'baseUrlRequired' }) baseUrl?: string; // Leere Zeichenkette = Logo-Adresse entfernen. @IsOptional() - @ValidateIf((_o, value) => typeof value === 'string' && value !== '') - @IsUrl({ protocols: ['https'], require_protocol: true, require_tld: false }) - @MaxLength(2048) + @IsString({ message: 'logoUrlInvalid' }) logoUrl?: string; } diff --git a/apps/api/src/nextcloud-status/nextcloud-form-errors.ts b/apps/api/src/nextcloud-status/nextcloud-form-errors.ts new file mode 100644 index 0000000..92d0db6 --- /dev/null +++ b/apps/api/src/nextcloud-status/nextcloud-form-errors.ts @@ -0,0 +1,74 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { normalizeCloudUrl } from './nextcloud-status-fetch'; + +/** + * Fehlerkatalog des Cloud-Formulars (quick-261008-j9f, D-03, D-05). + * + * Jeder Fehler der Routen zum Anlegen, Aendern, Loeschen und Logo-Pflegen + * traegt eine stabile Kennung (`code`) und einen deutschen Text (`message`), + * wie im Modul Domains. Die Weboberflaeche uebersetzt die Kennung selbst + * (de/en) und zeigt nie den Serverreport an. Die Kennungen muessen mit + * `apps/web/src/components/nextcloud-status/cloud-form-errors.ts` uebereinstimmen. + */ +export const NEXTCLOUD_FORM_ERRORS = { + customerNameRequired: 'Bitte geben Sie einen Kundennamen ein.', + customerNameTooLong: 'Der Kundenname darf höchstens 120 Zeichen lang sein.', + baseUrlRequired: 'Bitte geben Sie die Adresse der Cloud ein.', + baseUrlInvalid: 'Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.', + logoUrlInvalid: 'Bitte geben Sie eine gültige Bildadresse mit http:// oder https:// ein.', + logoFileInvalid: 'Bitte laden Sie ein Bild im Format PNG, JPEG, GIF oder WebP bis 1 MB hoch.', + logoFileTooLarge: 'Die Datei ist größer als 1 MB.', + logoFetchInternal: + 'Diese Bildadresse ist nur intern erreichbar. Tessera holt nur Bilder aus dem Internet ab. Bitte laden Sie das Bild über „Bild hochladen“ hoch.', + logoFetchUnreachable: + 'Das Bild konnte unter dieser Adresse nicht abgerufen werden. Bitte prüfen Sie die Adresse oder laden Sie das Bild über „Bild hochladen“ hoch.', + logoFetchNotImage: 'Unter dieser Adresse liegt kein Bild im Format PNG, JPEG, GIF oder WebP.', + logoFetchTooLarge: 'Das Bild unter dieser Adresse ist größer als 1 MB.', + notFound: 'Diese Cloud gibt es nicht mehr. Bitte laden Sie die Seite neu.', +} as const; + +export type NextcloudFormErrorCode = keyof typeof NEXTCLOUD_FORM_ERRORS; + +/** Alle Kennungen (fuer die DTO-Pruefung: jede Constraint-Meldung ist eine davon). */ +export const NEXTCLOUD_FORM_ERROR_CODES = Object.keys( + NEXTCLOUD_FORM_ERRORS, +) as NextcloudFormErrorCode[]; + +/** Baut die Ausnahme zu einer Kennung: `notFound` -> 404, alle anderen -> 400. */ +export function nextcloudFormError( + code: NextcloudFormErrorCode, +): BadRequestException | NotFoundException { + const body = { code, message: NEXTCLOUD_FORM_ERRORS[code] }; + return code === 'notFound' ? new NotFoundException(body) : new BadRequestException(body); +} + +const MAX_NAME_LENGTH = 120; + +/** + * Prueft Name und Cloud-Adresse. Beim Anlegen sind beide Pflicht, beim + * Aendern nur, wenn sie mitgeschickt werden. Reihenfolge: Name, Adresse + * (das Logo prueft der Dienst danach). + */ +export function validateInstanceInput( + input: { customerName?: string; baseUrl?: string }, + mode: 'create' | 'update', +): { customerName?: string; baseUrl?: string } { + const out: { customerName?: string; baseUrl?: string } = {}; + + if (mode === 'create' || input.customerName !== undefined) { + const name = (input.customerName ?? '').trim(); + if (!name) throw nextcloudFormError('customerNameRequired'); + if (name.length > MAX_NAME_LENGTH) throw nextcloudFormError('customerNameTooLong'); + out.customerName = name; + } + + if (mode === 'create' || input.baseUrl !== undefined) { + const raw = (input.baseUrl ?? '').trim(); + if (!raw) throw nextcloudFormError('baseUrlRequired'); + const normalized = normalizeCloudUrl(raw); + if (!normalized) throw nextcloudFormError('baseUrlInvalid'); + out.baseUrl = normalized; + } + + return out; +} diff --git a/apps/api/src/nextcloud-status/nextcloud-logo-fetch.spec.ts b/apps/api/src/nextcloud-status/nextcloud-logo-fetch.spec.ts new file mode 100644 index 0000000..6614551 --- /dev/null +++ b/apps/api/src/nextcloud-status/nextcloud-logo-fetch.spec.ts @@ -0,0 +1,230 @@ +import { describe, expect, it, vi } from 'vitest'; +import { classifyLogoUrl, fetchLogoImage, LOGO_FETCH_MAX_REDIRECTS } from './nextcloud-logo-fetch'; + +type FetchImpl = NonNullable[1]>['fetchImpl']; + +const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]); + +function imageResponse(bytes: Buffer = PNG, init: ResponseInit = {}) { + return new Response(new Uint8Array(bytes), { + status: 200, + headers: { 'content-type': 'image/png' }, + ...init, + }); +} + +function redirect(location: string | null, status = 302) { + return new Response(null, { status, headers: location ? { location } : {} }); +} + +function fakeFetch(...responses: Array Promise) | Error>) { + let i = 0; + const impl = vi.fn(async () => { + const next = responses[Math.min(i++, responses.length - 1)]; + if (next instanceof Error) throw next; + return typeof next === 'function' ? next() : next; + }); + return impl; +} + +const as = (fn: ReturnType) => fn as unknown as FetchImpl; + +describe('classifyLogoUrl', () => { + it('leer und nur Leerzeichen -> none, fehlend ebenfalls', () => { + expect(classifyLogoUrl('')).toEqual({ kind: 'none' }); + expect(classifyLogoUrl(' ')).toEqual({ kind: 'none' }); + expect(classifyLogoUrl(undefined)).toEqual({ kind: 'none' }); + }); + + it('https -> remote mit gekuerzter Adresse, auch ohne Punkt im Namen', () => { + expect(classifyLogoUrl(' https://a.de/x.png ')).toEqual({ + kind: 'remote', + url: 'https://a.de/x.png', + }); + expect(classifyLogoUrl('https://intranet/logo.png').kind).toBe('remote'); + }); + + it('http -> fetch mit URL-Objekt', () => { + const c = classifyLogoUrl('http://a.de/x.png'); + expect(c.kind).toBe('fetch'); + if (c.kind === 'fetch') expect(c.url.hostname).toBe('a.de'); + }); + + it('fremde Schemata, Unsinn, Zugangsdaten und zu lange Adressen -> invalid', () => { + for (const bad of [ + 'ftp://a.de/x', + 'javascript:alert(1)', + 'kein link', + 'http://user:pw@a.de/x.png', + `http://a.de/${'x'.repeat(2050)}`, + ]) { + expect(classifyLogoUrl(bad)).toEqual({ kind: 'invalid' }); + } + }); +}); + +describe('fetchLogoImage', () => { + it('holt ein PNG ab: GET, manuelle Weiterleitung, Abbruchsignal, keine Cookies/Zugangsdaten', async () => { + const f = fakeFetch(imageResponse()); + const result = await fetchLogoImage(new URL('http://93.184.216.34/logo.png'), { + fetchImpl: as(f), + }); + expect(result).toMatchObject({ ok: true, mime: 'image/png' }); + if (result.ok) expect(result.data).toEqual(PNG); + expect(f).toHaveBeenCalledTimes(1); + const init = (f.mock.calls[0] as unknown as [string, RequestInit])[1]; + expect(init.method).toBe('GET'); + expect(init.redirect).toBe('manual'); + expect(init.signal).toBeInstanceOf(AbortSignal); + const headers = Object.keys(init.headers ?? {}).map((h) => h.toLowerCase()); + expect(headers).not.toContain('cookie'); + expect(headers).not.toContain('authorization'); + }); + + it('vertraut dem Content-Type nicht: HTML, SVG und leerer Inhalt -> logoFetchNotImage', async () => { + for (const bytes of [ + Buffer.from('hi'), + Buffer.from(''), + Buffer.alloc(0), + ]) { + const f = fakeFetch(imageResponse(bytes)); + expect(await fetchLogoImage(new URL('http://93.184.216.34/x'), { fetchImpl: as(f) })).toEqual( + { ok: false, code: 'logoFetchNotImage' }, + ); + } + }); + + it('interne Startadressen -> logoFetchInternal, ohne Anfrage', async () => { + for (const target of [ + 'http://127.0.0.1/x', + 'http://10.0.0.5/x', + 'http://192.168.1.10/x', + 'http://169.254.169.254/latest', + 'http://localhost/x', + 'http://nas.local/x', + ]) { + const f = fakeFetch(imageResponse()); + expect(await fetchLogoImage(new URL(target), { fetchImpl: as(f) })).toEqual({ + ok: false, + code: 'logoFetchInternal', + }); + expect(f).not.toHaveBeenCalled(); + } + }); + + it('Weiterleitung auf eine interne Adresse -> logoFetchInternal nach genau einer Anfrage', async () => { + const f = fakeFetch(redirect('http://10.0.0.5/logo.png'), imageResponse()); + expect(await fetchLogoImage(new URL('http://93.184.216.34/a'), { fetchImpl: as(f) })).toEqual({ + ok: false, + code: 'logoFetchInternal', + }); + expect(f).toHaveBeenCalledTimes(1); + }); + + it('Weiterleitung auf eine oeffentliche Adresse wird verfolgt', async () => { + const f = fakeFetch(redirect('https://93.184.216.35/logo.png', 301), imageResponse()); + const result = await fetchLogoImage(new URL('http://93.184.216.34/a'), { fetchImpl: as(f) }); + expect(result.ok).toBe(true); + expect(f).toHaveBeenCalledTimes(2); + }); + + it('relative Weiterleitung wird gegen die aktuelle Adresse aufgeloest', async () => { + const f = fakeFetch(redirect('/neu.png'), imageResponse()); + const result = await fetchLogoImage(new URL('http://93.184.216.34/a'), { fetchImpl: as(f) }); + expect(result.ok).toBe(true); + expect((f.mock.calls[1] as unknown as [string])[0]).toBe('http://93.184.216.34/neu.png'); + }); + + it('zu viele Weiterleitungen und Weiterleitung ohne Ziel -> logoFetchUnreachable', async () => { + expect(LOGO_FETCH_MAX_REDIRECTS).toBe(3); + const loop = fakeFetch( + redirect('http://93.184.216.34/1'), + redirect('http://93.184.216.34/2'), + redirect('http://93.184.216.34/3'), + redirect('http://93.184.216.34/4'), + imageResponse(), + ); + expect( + await fetchLogoImage(new URL('http://93.184.216.34/0'), { fetchImpl: as(loop) }), + ).toEqual({ ok: false, code: 'logoFetchUnreachable' }); + const noLocation = fakeFetch(redirect(null)); + expect( + await fetchLogoImage(new URL('http://93.184.216.34/0'), { fetchImpl: as(noLocation) }), + ).toEqual({ ok: false, code: 'logoFetchUnreachable' }); + }); + + it('HTTP-Fehler und Verbindungsfehler -> logoFetchUnreachable', async () => { + const notFound = fakeFetch(new Response('nope', { status: 404 })); + expect( + await fetchLogoImage(new URL('http://93.184.216.34/x'), { fetchImpl: as(notFound) }), + ).toEqual({ ok: false, code: 'logoFetchUnreachable' }); + const refused = fakeFetch( + Object.assign(new Error('connect ECONNREFUSED'), { code: 'ECONNREFUSED' }), + ); + expect( + await fetchLogoImage(new URL('http://93.184.216.34/x'), { fetchImpl: as(refused) }), + ).toEqual({ ok: false, code: 'logoFetchUnreachable' }); + }); + + it('content-length ueber 1 MiB -> logoFetchTooLarge ohne den Inhalt zu lesen', async () => { + const res = imageResponse(PNG, { headers: { 'content-length': '2097152' } }); + const f = fakeFetch(res); + expect(await fetchLogoImage(new URL('http://93.184.216.34/x'), { fetchImpl: as(f) })).toEqual({ + ok: false, + code: 'logoFetchTooLarge', + }); + }); + + it('endloser Strom ohne content-length: Lesen stoppt frueh und der Strom wird abgebrochen', async () => { + let pulls = 0; + let cancelled = false; + const stream = new ReadableStream({ + pull(controller) { + pulls++; + controller.enqueue(new Uint8Array(256 * 1024)); + }, + cancel() { + cancelled = true; + }, + }); + const f = fakeFetch(new Response(stream, { status: 200 })); + expect(await fetchLogoImage(new URL('http://93.184.216.34/x'), { fetchImpl: as(f) })).toEqual({ + ok: false, + code: 'logoFetchTooLarge', + }); + expect(pulls).toBeLessThanOrEqual(6); + expect(cancelled).toBe(true); + }); + + it('Zeitlimit: haengende Anfrage und stockender Inhalt -> logoFetchUnreachable, schnell', async () => { + const hanging = fakeFetch(() => new Promise(() => {})); + const t0 = Date.now(); + expect( + await fetchLogoImage(new URL('http://93.184.216.34/x'), { + fetchImpl: as(hanging), + timeoutMs: 50, + }), + ).toEqual({ ok: false, code: 'logoFetchUnreachable' }); + + const stalled = new ReadableStream({ + start(controller) { + controller.enqueue(PNG); + }, + pull: () => new Promise(() => {}), + }); + const f = fakeFetch(new Response(stalled, { status: 200 })); + expect( + await fetchLogoImage(new URL('http://93.184.216.34/x'), { fetchImpl: as(f), timeoutMs: 50 }), + ).toEqual({ ok: false, code: 'logoFetchUnreachable' }); + expect(Date.now() - t0).toBeLessThan(1000); + }); + + it('Namensauflosung ueber eingespeiste Pruefung: nicht oeffentlich -> logoFetchInternal', async () => { + const f = fakeFetch(imageResponse()); + const isPublic = vi.fn(async () => false); + expect( + await fetchLogoImage(new URL('http://intern.example.de/x'), { fetchImpl: as(f), isPublic }), + ).toEqual({ ok: false, code: 'logoFetchInternal' }); + expect(f).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/nextcloud-status/nextcloud-logo-fetch.ts b/apps/api/src/nextcloud-status/nextcloud-logo-fetch.ts new file mode 100644 index 0000000..00de470 --- /dev/null +++ b/apps/api/src/nextcloud-status/nextcloud-logo-fetch.ts @@ -0,0 +1,198 @@ +import { Logger } from '@nestjs/common'; +import { fetch as undiciFetch } from 'undici'; +import { isPublicHttpUrl } from '../common/public-url-guard'; +import type { DashboardImageMime } from '../dashboard/dashboard-image-rules'; +import type { NextcloudFormErrorCode } from './nextcloud-form-errors'; +import { checkLogoUpload, NEXTCLOUD_LOGO_MAX_BYTES } from './nextcloud-logo-rules'; + +/** + * Einmaliger Abruf eines Cloud-Logos von einer http-Adresse + * (quick-261008-j9f, D-01 bis D-04). + * + * Warum nur http abgeholt wird: eine https-Adresse laedt der Browser des + * Betrachters selbst (D-04, wie bisher); ein http-Bild wuerde dort als + * gemischter Inhalt blockiert. Deshalb holt Tessera es beim Speichern genau + * einmal ab und legt es wie einen Upload ab (logoData/logoMime). + * + * Schutz (der Server ruft eine vom Verwalter gewaehlte Adresse auf): + * - Gemeinsamer SSRF-Schutz (`isPublicHttpUrl`) vor der ersten Anfrage UND vor + * jeder Weiterleitung. Eine abgelehnte Adresse bekommt gar keine Anfrage. + * - redirect 'manual', hoechstens 3 Weiterleitungen, nur http/https. + * - Ein einziges Zeitlimit fuer alle Spruenge und das Lesen des Inhalts; + * gegen haengende Server wird zusaetzlich gegen den Abbruch gewettet. + * - Groessendeckel 1 MiB: content-length vorab, danach beim Lesen — das + * Lesen stoppt, sobald mehr eingetroffen ist. + * - Der Typ kommt nur aus den Magic Bytes (`checkLogoUpload`), nie aus dem + * Content-Type; kein SVG. + * - Keine Cookies, keine Zugangsdaten; zum Aufrufer gelangt nur eine von vier + * festen Kennungen, nie Antworttext oder aufgeloeste Adressen. + * + * Bekanntes Restfenster (T-j9f-02, bewusst akzeptiert wie bei den Favoriten, + * T-08-05): der Name wird vor dem Verbinden aufgeloest und beim Verbinden + * erneut — DNS-Rebinding bleibt theoretisch moeglich. Nur Verwalter koennen + * den Abruf ausloesen, gespeichert wird nur ein echtes Bild. + */ + +export const LOGO_FETCH_TIMEOUT_MS = 8000; +export const LOGO_FETCH_MAX_REDIRECTS = 3; + +const MAX_URL_LENGTH = 2048; +const BROWSER_USER_AGENT = + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36'; + +export type LogoFetchFailureCode = Extract< + NextcloudFormErrorCode, + 'logoFetchInternal' | 'logoFetchUnreachable' | 'logoFetchNotImage' | 'logoFetchTooLarge' +>; + +export type LogoUrlClass = + | { kind: 'none' } + | { kind: 'remote'; url: string } + | { kind: 'fetch'; url: URL } + | { kind: 'invalid' }; + +/** + * Ordnet eine eingegebene Logo-Adresse ein: leer, https (Browser laedt), + * http (Tessera holt ab) oder ungueltig. Adressen ohne Punkt im Namen + * (z. B. `https://intranet/logo.png`) bleiben erlaubt wie bisher. + */ +export function classifyLogoUrl(raw: string | null | undefined): LogoUrlClass { + const trimmed = (raw ?? '').trim(); + if (!trimmed) return { kind: 'none' }; + if (trimmed.length > MAX_URL_LENGTH) return { kind: 'invalid' }; + let url: URL; + try { + url = new URL(trimmed); + } catch { + return { kind: 'invalid' }; + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') return { kind: 'invalid' }; + if (url.username || url.password) return { kind: 'invalid' }; + if (!url.hostname) return { kind: 'invalid' }; + return url.protocol === 'https:' ? { kind: 'remote', url: trimmed } : { kind: 'fetch', url }; +} + +export type LogoFetchResult = + | { ok: true; data: Buffer; mime: DashboardImageMime } + | { ok: false; code: LogoFetchFailureCode }; + +export interface FetchLogoOptions { + fetchImpl?: typeof undiciFetch; + isPublic?: (url: URL) => Promise; + timeoutMs?: number; +} + +const logger = new Logger('NextcloudLogoFetch'); + +function fail(code: LogoFetchFailureCode): LogoFetchResult { + return { ok: false, code }; +} + +function discard(response: { body?: { cancel(): Promise } | null }): void { + try { + response.body?.cancel().catch(() => {}); + } catch { + // schon verbraucht — nichts zu tun + } +} + +/** Holt das Bild unter `url` ab; siehe Kopfkommentar fuer alle Schutzmassnahmen. */ +export async function fetchLogoImage( + url: URL, + opts: FetchLogoOptions = {}, +): Promise { + const fetchImpl = opts.fetchImpl ?? undiciFetch; + const isPublic = opts.isPublic ?? isPublicHttpUrl; + const timeoutMs = opts.timeoutMs ?? LOGO_FETCH_TIMEOUT_MS; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + // Fuer haengende Server, die ein abgebrochenes fetch/read nicht beenden. + const aborted = new Promise<'timeout'>((resolve) => { + controller.signal.addEventListener('abort', () => resolve('timeout')); + }); + + const run = async (): Promise => { + let current = url; + for (let hop = 0; ; hop++) { + if (!(await isPublic(current))) return fail('logoFetchInternal'); + + let response: Awaited>; + try { + response = await fetchImpl(current.toString(), { + method: 'GET', + redirect: 'manual', + signal: controller.signal, + headers: { + Accept: 'image/png,image/jpeg,image/gif,image/webp,image/*;q=0.8', + 'User-Agent': BROWSER_USER_AGENT, + }, + }); + } catch { + return fail('logoFetchUnreachable'); + } + + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get('location'); + discard(response); + if (!location || hop >= LOGO_FETCH_MAX_REDIRECTS) return fail('logoFetchUnreachable'); + let next: URL; + try { + next = new URL(location, current); + } catch { + return fail('logoFetchUnreachable'); + } + if (next.protocol !== 'http:' && next.protocol !== 'https:') { + return fail('logoFetchUnreachable'); + } + current = next; + continue; + } + + if (!response.ok) { + discard(response); + return fail('logoFetchUnreachable'); + } + + const declared = Number(response.headers.get('content-length')); + if (Number.isFinite(declared) && declared > NEXTCLOUD_LOGO_MAX_BYTES) { + discard(response); + return fail('logoFetchTooLarge'); + } + + const chunks: Uint8Array[] = []; + let total = 0; + if (response.body) { + const reader = response.body.getReader(); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.length; + if (total > NEXTCLOUD_LOGO_MAX_BYTES) { + reader.cancel().catch(() => {}); + return fail('logoFetchTooLarge'); + } + chunks.push(value); + } + } catch { + reader.cancel().catch(() => {}); + return fail('logoFetchUnreachable'); + } + } + + const data = Buffer.concat(chunks); + const mime = checkLogoUpload(data); + if (!mime) return fail('logoFetchNotImage'); + return { ok: true, data, mime }; + } + }; + + try { + const outcome = await Promise.race([run(), aborted]); + const result = outcome === 'timeout' ? fail('logoFetchUnreachable') : outcome; + if (!result.ok) logger.warn(`Logo-Abruf von ${url.host} fehlgeschlagen: ${result.code}`); + return result; + } finally { + clearTimeout(timer); + } +} diff --git a/apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts b/apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts index 7174199..abb278b 100644 --- a/apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts +++ b/apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts @@ -10,7 +10,14 @@ vi.mock('./nextcloud-status-fetch', async (importOriginal) => { return { ...actual, fetchNextcloudStatus: vi.fn() }; }); +vi.mock('./nextcloud-logo-fetch', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, fetchLogoImage: vi.fn() }; +}); + import { forSystem, forTenant } from '../prisma/prisma-tenant.extension'; +import { NEXTCLOUD_FORM_ERRORS } from './nextcloud-form-errors'; +import { fetchLogoImage } from './nextcloud-logo-fetch'; import type { NextcloudReference } from './nextcloud-rating'; import { NextcloudStatusService, PUBLIC_SELECT } from './nextcloud-status.service'; import { fetchNextcloudStatus } from './nextcloud-status-fetch'; @@ -52,6 +59,7 @@ describe('NextcloudStatusService', () => { beforeEach(() => { vi.mocked(fetchNextcloudStatus).mockReset(); + vi.mocked(fetchLogoImage).mockReset(); vi.mocked(forTenant).mockClear(); prisma = { nextcloudInstance: { @@ -127,16 +135,122 @@ describe('NextcloudStatusService', () => { expect(view.id).toBe('i1'); }); - it('createInstance mit ungueltiger Adresse -> BadRequest mit deutscher Meldung', async () => { + /** Antwort der Kette Anlegen -> Pruefung, damit createInstance bis zum Ende laeuft. */ + function arrangeCreateSuccess() { + prisma.nextcloudInstance.create.mockResolvedValue({ id: 'i1' }); + prisma.nextcloudInstance.findFirst.mockResolvedValue({ + id: 'i1', + baseUrl: 'https://cloud.a.de', + }); + vi.mocked(fetchNextcloudStatus).mockResolvedValue({ + reachable: true, + maintenance: false, + needsDbUpgrade: false, + versionString: '35.0.1', + edition: null, + productName: 'Nextcloud', + errorKind: null, + errorDetail: null, + }); + } + + async function createError(dto: { customerName: string; baseUrl: string; logoUrl?: string }) { + try { + await service.createInstance('t1', dto); + } catch (err) { + return (err as { getResponse(): unknown }).getResponse(); + } + throw new Error('createInstance hat nicht geworfen'); + } + + const PNG_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2]); + + it('createInstance mit ungueltiger Adresse -> BadRequest mit Kennung und deutscher Meldung', async () => { await expect( service.createInstance('t1', { customerName: 'X', baseUrl: 'ftp://x' }), ).rejects.toThrow(BadRequestException); await expect( - service.createInstance('t1', { customerName: 'X', baseUrl: 'ftp://x' }), + service.createInstance('t1', { customerName: 'X', baseUrl: 'cloud.example.de' }), ).rejects.toThrow('Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.'); + expect(await createError({ customerName: 'X', baseUrl: 'cloud.example.de' })).toEqual({ + code: 'baseUrlInvalid', + message: NEXTCLOUD_FORM_ERRORS.baseUrlInvalid, + }); expect(prisma.nextcloudInstance.create).not.toHaveBeenCalled(); }); + it('createInstance: Name, Adresse und Logo-Adresse liefern je eine eigene Kennung', async () => { + const base = 'https://cloud.a.de'; + expect(await createError({ customerName: ' ', baseUrl: base })).toMatchObject({ + code: 'customerNameRequired', + }); + expect(await createError({ customerName: 'x'.repeat(121), baseUrl: base })).toMatchObject({ + code: 'customerNameTooLong', + }); + expect(await createError({ customerName: 'X', baseUrl: '' })).toMatchObject({ + code: 'baseUrlRequired', + }); + expect( + await createError({ customerName: 'X', baseUrl: base, logoUrl: 'ftp://x' }), + ).toMatchObject({ + code: 'logoUrlInvalid', + }); + expect(prisma.nextcloudInstance.create).not.toHaveBeenCalled(); + }); + + it('createInstance mit http-Logo: Bild wird abgeholt und wie ein Upload gespeichert', async () => { + arrangeCreateSuccess(); + vi.mocked(fetchLogoImage).mockResolvedValue({ ok: true, data: PNG_BYTES, mime: 'image/png' }); + prisma.nextcloudInstance.update.mockResolvedValue( + makeRow({ logoMime: 'image/png', logoVersion: 0 }), + ); + const view = await service.createInstance('t1', { + customerName: 'Kunde A', + baseUrl: 'https://cloud.a.de', + logoUrl: 'http://logo.example.de/a.png', + }); + expect(fetchLogoImage).toHaveBeenCalledTimes(1); + expect(vi.mocked(fetchLogoImage).mock.calls[0][0].toString()).toBe( + 'http://logo.example.de/a.png', + ); + const data = prisma.nextcloudInstance.create.mock.calls[0][0].data; + expect(data.logoUrl).toBeNull(); + expect(data.logoMime).toBe('image/png'); + expect(data.logoData).toBeInstanceOf(Uint8Array); + expect(Buffer.from(data.logoData)).toEqual(PNG_BYTES); + expect(view.hasUploadedLogo).toBe(true); + }); + + it('createInstance mit http-Logo, Abruf scheitert: Kennung, nichts angelegt, keine Pruefung', async () => { + vi.mocked(fetchLogoImage).mockResolvedValue({ ok: false, code: 'logoFetchInternal' }); + expect( + await createError({ + customerName: 'Kunde A', + baseUrl: 'https://cloud.a.de', + logoUrl: 'http://127.0.0.1/logo.png', + }), + ).toEqual({ code: 'logoFetchInternal', message: NEXTCLOUD_FORM_ERRORS.logoFetchInternal }); + expect(prisma.nextcloudInstance.create).not.toHaveBeenCalled(); + expect(fetchNextcloudStatus).not.toHaveBeenCalled(); + }); + + it('createInstance mit https-Logo: kein Abruf, Adresse bleibt gespeichert (D-04)', async () => { + arrangeCreateSuccess(); + prisma.nextcloudInstance.update.mockResolvedValue(makeRow()); + await service.createInstance('t1', { + customerName: 'Kunde A', + baseUrl: 'https://cloud.a.de', + logoUrl: ' https://logo.example.de/a.png ', + }); + expect(fetchLogoImage).not.toHaveBeenCalled(); + expect(prisma.nextcloudInstance.create.mock.calls[0][0].data).toEqual({ + tenantId: 't1', + customerName: 'Kunde A', + baseUrl: 'https://cloud.a.de', + logoUrl: 'https://logo.example.de/a.png', + }); + }); + const FAILED_RESULT = { reachable: false, maintenance: null, @@ -311,6 +425,43 @@ describe('NextcloudStatusService', () => { expect(rating).toMatchObject({ level: 'green', reason: 'current' }); }); + it('updateInstance: http-Logo wird abgeholt und ersetzt Adresse und Upload', async () => { + vi.mocked(fetchLogoImage).mockResolvedValue({ ok: true, data: PNG_BYTES, mime: 'image/png' }); + await service.updateInstance('t1', 'i1', { logoUrl: 'http://logo.example.de/a.png' }); + const data = prisma.nextcloudInstance.update.mock.calls[0][0].data; + expect(data).toMatchObject({ + logoUrl: null, + logoMime: 'image/png', + logoVersion: { increment: 1 }, + }); + expect(Buffer.from(data.logoData)).toEqual(PNG_BYTES); + }); + + it('updateInstance: Abruf scheitert -> Kennung, keine Aenderung', async () => { + vi.mocked(fetchLogoImage).mockResolvedValue({ ok: false, code: 'logoFetchNotImage' }); + await expect( + service.updateInstance('t1', 'i1', { customerName: 'Neu', logoUrl: 'http://a.de/x' }), + ).rejects.toMatchObject({ + response: { code: 'logoFetchNotImage', message: NEXTCLOUD_FORM_ERRORS.logoFetchNotImage }, + }); + expect(prisma.nextcloudInstance.update).not.toHaveBeenCalled(); + }); + + it('updateInstance: unbekannte Kennung -> notFound, kein Abruf', async () => { + prisma.nextcloudInstance.findFirst.mockResolvedValue(null); + await expect( + service.updateInstance('t1', 'fremd', { logoUrl: 'http://a.de/x.png' }), + ).rejects.toMatchObject({ + response: { code: 'notFound', message: NEXTCLOUD_FORM_ERRORS.notFound }, + }); + expect(fetchLogoImage).not.toHaveBeenCalled(); + }); + + it('updateInstance: https-Logo wird nicht abgeholt', async () => { + await service.updateInstance('t1', 'i1', { logoUrl: 'https://logo.example.de/a.png' }); + expect(fetchLogoImage).not.toHaveBeenCalled(); + }); + it('updateInstance: ungueltige Adresse -> BadRequest', async () => { await expect(service.updateInstance('t1', 'i1', { baseUrl: 'javascript:1' })).rejects.toThrow( BadRequestException, @@ -359,9 +510,18 @@ describe('NextcloudStatusService', () => { mimetype: 'image/png', size: html.length, }), - ).rejects.toThrow( - 'Bitte laden Sie ein Bild im Format PNG, JPEG, GIF oder WebP bis 1 MB hoch.', - ); + ).rejects.toMatchObject({ + response: { code: 'logoFileInvalid', message: NEXTCLOUD_FORM_ERRORS.logoFileInvalid }, + }); + const big = Buffer.concat([PNG_BYTES, Buffer.alloc(1024 * 1024)]); + await expect( + service.uploadLogo('t1', 'i1', { + buffer: big, + originalname: 'a.png', + mimetype: 'image/png', + size: big.length, + }), + ).rejects.toMatchObject({ response: { code: 'logoFileTooLarge' } }); await expect(service.uploadLogo('t1', 'i1', undefined)).rejects.toThrow(BadRequestException); prisma.nextcloudInstance.findFirst.mockResolvedValue(null); const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); diff --git a/apps/api/src/nextcloud-status/nextcloud-status.service.ts b/apps/api/src/nextcloud-status/nextcloud-status.service.ts index 9e3c6ef..1ccad83 100644 --- a/apps/api/src/nextcloud-status/nextcloud-status.service.ts +++ b/apps/api/src/nextcloud-status/nextcloud-status.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import type { UploadedFileLike } from '../auth/types/auth-user'; import { PrismaService } from '../prisma/prisma.service'; import { forSystem, forTenant } from '../prisma/prisma-tenant.extension'; @@ -8,7 +8,9 @@ import type { } from './dto/nextcloud-instance.dto'; import { NextcloudAlertService } from './nextcloud-alert.service'; import { planStatusWrite } from './nextcloud-alert-rules'; -import { checkLogoUpload } from './nextcloud-logo-rules'; +import { nextcloudFormError, validateInstanceInput } from './nextcloud-form-errors'; +import { classifyLogoUrl, fetchLogoImage } from './nextcloud-logo-fetch'; +import { checkLogoUpload, NEXTCLOUD_LOGO_MAX_BYTES } from './nextcloud-logo-rules'; import { type NextcloudRating, type NextcloudReference, @@ -16,7 +18,7 @@ import { rateNextcloud, } from './nextcloud-rating'; import { NextcloudReleaseService } from './nextcloud-release.service'; -import { fetchNextcloudStatus, normalizeCloudUrl } from './nextcloud-status-fetch'; +import { fetchNextcloudStatus } from './nextcloud-status-fetch'; /** * Alle Spalten ausser den Logo-Bytes (T-k67-07): Listen- und @@ -118,8 +120,6 @@ export async function runWithConcurrency( await Promise.all(workers); } -const INVALID_URL_MESSAGE = 'Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.'; - @Injectable() export class NextcloudStatusService { private readonly logger = new Logger(NextcloudStatusService.name); @@ -181,26 +181,55 @@ export class NextcloudStatusService { }; } - /** Legt eine Cloud an und fuehrt sofort die erste Pruefung aus. */ + /** + * Legt eine Cloud an und fuehrt sofort die erste Pruefung aus. Eine + * http-Logo-Adresse wird VOR dem Anlegen einmalig abgeholt und wie ein + * Upload gespeichert (D-01); scheitert der Abruf, wird nichts angelegt. + */ async createInstance( tenantId: string, dto: CreateNextcloudInstanceDto, ): Promise { - const baseUrl = normalizeCloudUrl(dto.baseUrl); - if (!baseUrl) throw new BadRequestException(INVALID_URL_MESSAGE); + const input = validateInstanceInput(dto, 'create'); + const logo = await this.resolveLogo(dto.logoUrl); const tenantPrisma = forTenant(this.prisma, tenantId); const created = await tenantPrisma.nextcloudInstance.create({ data: { tenantId, - customerName: dto.customerName.trim(), - baseUrl, - logoUrl: dto.logoUrl ? dto.logoUrl : null, + customerName: input.customerName as string, + baseUrl: input.baseUrl as string, + logoUrl: logo.kind === 'remote' ? logo.url : null, + ...(logo.kind === 'stored' + ? { logoData: new Uint8Array(logo.data), logoMime: logo.mime } + : {}), }, select: { id: true }, }); return this.checkInstance(tenantId, created.id); } + /** + * Wertet die eingegebene Logo-Adresse aus: ungueltig -> Fehler, https -> + * `remote` (der Browser laedt sie), http -> Bild abholen (`stored`, Fehler + * bei Misserfolg), leer/fehlend -> `none`. + */ + private async resolveLogo( + raw: string | undefined, + ): Promise< + | { kind: 'none' } + | { kind: 'remote'; url: string } + | { kind: 'stored'; data: Buffer; mime: string } + > { + if (raw === undefined) return { kind: 'none' }; + const classified = classifyLogoUrl(raw); + if (classified.kind === 'invalid') throw nextcloudFormError('logoUrlInvalid'); + if (classified.kind === 'none') return { kind: 'none' }; + if (classified.kind === 'remote') return { kind: 'remote', url: classified.url }; + const fetched = await fetchLogoImage(classified.url); + if (!fetched.ok) throw nextcloudFormError(fetched.code); + return { kind: 'stored', data: fetched.data, mime: fetched.mime }; + } + /** * Prueft eine Cloud (nur `status.php`, siehe `fetchNextcloudStatus`) und * schreibt das Ergebnis an die Zeile. Fremde oder unbekannte Kennung: 404. @@ -217,7 +246,7 @@ export class NextcloudStatusService { where: { id, tenantId }, select: { id: true, baseUrl: true, consecutiveFailures: true }, }); - if (!existing) throw new NotFoundException('Cloud nicht gefunden'); + if (!existing) throw nextcloudFormError('notFound'); const startedAt = Date.now(); const result = await fetchNextcloudStatus(existing.baseUrl); @@ -261,8 +290,10 @@ export class NextcloudStatusService { /** * Aendert Name, Adresse und/oder Logo-Adresse. Eine neue Adresse wird * normalisiert und sofort neu geprueft, eine unveraenderte nicht. Eine - * nicht leere Logo-Adresse ersetzt ein hochgeladenes Logo, eine leere - * entfernt nur die Adresse (D-A). + * https-Logo-Adresse ersetzt ein hochgeladenes Logo, eine http-Adresse wird + * einmalig abgeholt und wie ein Upload gespeichert (quick-261008-j9f), eine + * leere entfernt nur die Adresse (D-A). Unbekannte Kennung: 404 vor jedem + * Abruf; scheitert der Abruf, wird nichts geaendert. */ async updateInstance( tenantId: string, @@ -274,15 +305,17 @@ export class NextcloudStatusService { where: { id, tenantId }, select: { id: true, baseUrl: true }, }); - if (!existing) throw new NotFoundException('Cloud nicht gefunden'); + if (!existing) throw nextcloudFormError('notFound'); + + const input = validateInstanceInput(dto, 'update'); + const logo = await this.resolveLogo(dto.logoUrl); const data: Record = {}; - if (dto.customerName !== undefined) data.customerName = dto.customerName.trim(); + if (input.customerName !== undefined) data.customerName = input.customerName; let urlChanged = false; - if (dto.baseUrl !== undefined) { - const baseUrl = normalizeCloudUrl(dto.baseUrl); - if (!baseUrl) throw new BadRequestException(INVALID_URL_MESSAGE); + if (input.baseUrl !== undefined) { + const baseUrl = input.baseUrl; if (baseUrl !== existing.baseUrl) { data.baseUrl = baseUrl; urlChanged = true; @@ -306,10 +339,14 @@ export class NextcloudStatusService { } if (dto.logoUrl !== undefined) { - if (dto.logoUrl) { - data.logoUrl = dto.logoUrl; + if (logo.kind === 'remote') { + data.logoUrl = logo.url; data.logoData = null; data.logoMime = null; + } else if (logo.kind === 'stored') { + data.logoUrl = null; + data.logoData = new Uint8Array(logo.data); + data.logoMime = logo.mime; } else { data.logoUrl = null; } @@ -332,7 +369,7 @@ export class NextcloudStatusService { where: { id, tenantId }, select: { id: true }, }); - if (!existing) throw new NotFoundException('Cloud nicht gefunden'); + if (!existing) throw nextcloudFormError('notFound'); await tenantPrisma.nextcloudInstance.delete({ where: { id } }); return true; } @@ -349,8 +386,10 @@ export class NextcloudStatusService { ): Promise { const mime = file ? checkLogoUpload(file.buffer) : null; if (!file || !mime) { - throw new BadRequestException( - 'Bitte laden Sie ein Bild im Format PNG, JPEG, GIF oder WebP bis 1 MB hoch.', + throw nextcloudFormError( + file && file.buffer.length > NEXTCLOUD_LOGO_MAX_BYTES + ? 'logoFileTooLarge' + : 'logoFileInvalid', ); } const tenantPrisma = forTenant(this.prisma, tenantId); @@ -358,7 +397,7 @@ export class NextcloudStatusService { where: { id, tenantId }, select: { id: true }, }); - if (!existing) throw new NotFoundException('Cloud nicht gefunden'); + if (!existing) throw nextcloudFormError('notFound'); const updated = await tenantPrisma.nextcloudInstance.update({ where: { id }, data: { @@ -390,7 +429,7 @@ export class NextcloudStatusService { where: { id, tenantId }, select: { id: true }, }); - if (!existing) throw new NotFoundException('Cloud nicht gefunden'); + if (!existing) throw nextcloudFormError('notFound'); const updated = await tenantPrisma.nextcloudInstance.update({ where: { id }, data: { logoData: null, logoMime: null, logoVersion: { increment: 1 } },