feat(nextcloud-status): http-Logo-Adresse wird einmalig abgeholt, Formularfehler mit Kennung (j9f)

- gemeinsamer SSRF-Schutz in common/public-url-guard.ts (Favoriten unveraendert)
- fetchLogoImage: Schutz je Sprung, Zeitlimit, 1-MiB-Deckel, Magic Bytes
- alle Formularfehler als { code, message } mit deutschem Text

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-08 14:06:20 +02:00
parent 4ff43c2252
commit 443256164d
9 changed files with 911 additions and 147 deletions
+104
View File
@@ -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:<ipv4>) — 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<boolean> {
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;
}
}
@@ -1,7 +1,10 @@
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Agent, type Response as UndiciResponse, fetch as undiciFetch } from 'undici'; 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). * Server-side favicon / icon discovery with SSRF protection (T-08-05).
@@ -65,97 +68,6 @@ type FetchHtmlResult = {
ok: boolean; 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:<ipv4>) — 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<boolean> {
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 * 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()` * present, so "ctl.de" becomes "https://ctl.de". Without this, `new URL()`
@@ -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<string, unknown>,
) {
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([]);
});
});
@@ -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 * Eingaben fuer das Anlegen und Aendern einer Nextcloud-Cloud
* (quick-261002-k67). Die Adresse wird im Dienst zusaetzlich normalisiert * (quick-261002-k67, erweitert in quick-261008-j9f).
* (`normalizeCloudUrl`); hier gilt nur die Formpruefung. Eine Logo-Adresse *
* ist nur mit https erlaubt — sie wird vom Browser geladen, nie vom Server. * 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 { export class CreateNextcloudInstanceDto {
@IsString() @IsString({ message: 'customerNameRequired' })
@IsNotEmpty()
@MaxLength(120)
customerName!: string; customerName!: string;
@IsString() @IsString({ message: 'baseUrlRequired' })
@IsNotEmpty()
@MaxLength(2048)
baseUrl!: string; baseUrl!: string;
// Leere Zeichenkette = kein Logo; sonst nur eine https-Adresse. // Leere Zeichenkette = kein Logo.
@IsOptional() @IsOptional()
@ValidateIf((_o, value) => typeof value === 'string' && value !== '') @IsString({ message: 'logoUrlInvalid' })
@IsUrl({ protocols: ['https'], require_protocol: true, require_tld: false })
@MaxLength(2048)
logoUrl?: string; logoUrl?: string;
} }
export class UpdateNextcloudInstanceDto { export class UpdateNextcloudInstanceDto {
@IsOptional() @IsOptional()
@IsString() @IsString({ message: 'customerNameRequired' })
@IsNotEmpty()
@MaxLength(120)
customerName?: string; customerName?: string;
@IsOptional() @IsOptional()
@IsString() @IsString({ message: 'baseUrlRequired' })
@IsNotEmpty()
@MaxLength(2048)
baseUrl?: string; baseUrl?: string;
// Leere Zeichenkette = Logo-Adresse entfernen. // Leere Zeichenkette = Logo-Adresse entfernen.
@IsOptional() @IsOptional()
@ValidateIf((_o, value) => typeof value === 'string' && value !== '') @IsString({ message: 'logoUrlInvalid' })
@IsUrl({ protocols: ['https'], require_protocol: true, require_tld: false })
@MaxLength(2048)
logoUrl?: string; logoUrl?: string;
} }
@@ -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;
}
@@ -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<Parameters<typeof fetchLogoImage>[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<Response | (() => Promise<Response>) | 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<typeof fakeFetch>) => 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('<html><body>hi</body></html>'),
Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"></svg>'),
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<Uint8Array>({
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<Response>(() => {}));
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<Uint8Array>({
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();
});
});
@@ -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<boolean>;
timeoutMs?: number;
}
const logger = new Logger('NextcloudLogoFetch');
function fail(code: LogoFetchFailureCode): LogoFetchResult {
return { ok: false, code };
}
function discard(response: { body?: { cancel(): Promise<void> } | 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<LogoFetchResult> {
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<LogoFetchResult> => {
let current = url;
for (let hop = 0; ; hop++) {
if (!(await isPublic(current))) return fail('logoFetchInternal');
let response: Awaited<ReturnType<typeof undiciFetch>>;
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);
}
}
@@ -10,7 +10,14 @@ vi.mock('./nextcloud-status-fetch', async (importOriginal) => {
return { ...actual, fetchNextcloudStatus: vi.fn() }; return { ...actual, fetchNextcloudStatus: vi.fn() };
}); });
vi.mock('./nextcloud-logo-fetch', async (importOriginal) => {
const actual = await importOriginal<typeof import('./nextcloud-logo-fetch')>();
return { ...actual, fetchLogoImage: vi.fn() };
});
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension'; 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 type { NextcloudReference } from './nextcloud-rating';
import { NextcloudStatusService, PUBLIC_SELECT } from './nextcloud-status.service'; import { NextcloudStatusService, PUBLIC_SELECT } from './nextcloud-status.service';
import { fetchNextcloudStatus } from './nextcloud-status-fetch'; import { fetchNextcloudStatus } from './nextcloud-status-fetch';
@@ -52,6 +59,7 @@ describe('NextcloudStatusService', () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(fetchNextcloudStatus).mockReset(); vi.mocked(fetchNextcloudStatus).mockReset();
vi.mocked(fetchLogoImage).mockReset();
vi.mocked(forTenant).mockClear(); vi.mocked(forTenant).mockClear();
prisma = { prisma = {
nextcloudInstance: { nextcloudInstance: {
@@ -127,16 +135,122 @@ describe('NextcloudStatusService', () => {
expect(view.id).toBe('i1'); 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( await expect(
service.createInstance('t1', { customerName: 'X', baseUrl: 'ftp://x' }), service.createInstance('t1', { customerName: 'X', baseUrl: 'ftp://x' }),
).rejects.toThrow(BadRequestException); ).rejects.toThrow(BadRequestException);
await expect( 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.'); ).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(); 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 = { const FAILED_RESULT = {
reachable: false, reachable: false,
maintenance: null, maintenance: null,
@@ -311,6 +425,43 @@ describe('NextcloudStatusService', () => {
expect(rating).toMatchObject({ level: 'green', reason: 'current' }); 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 () => { it('updateInstance: ungueltige Adresse -> BadRequest', async () => {
await expect(service.updateInstance('t1', 'i1', { baseUrl: 'javascript:1' })).rejects.toThrow( await expect(service.updateInstance('t1', 'i1', { baseUrl: 'javascript:1' })).rejects.toThrow(
BadRequestException, BadRequestException,
@@ -359,9 +510,18 @@ describe('NextcloudStatusService', () => {
mimetype: 'image/png', mimetype: 'image/png',
size: html.length, size: html.length,
}), }),
).rejects.toThrow( ).rejects.toMatchObject({
'Bitte laden Sie ein Bild im Format PNG, JPEG, GIF oder WebP bis 1 MB hoch.', 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); await expect(service.uploadLogo('t1', 'i1', undefined)).rejects.toThrow(BadRequestException);
prisma.nextcloudInstance.findFirst.mockResolvedValue(null); prisma.nextcloudInstance.findFirst.mockResolvedValue(null);
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
@@ -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 type { UploadedFileLike } from '../auth/types/auth-user';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension'; import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
@@ -8,7 +8,9 @@ import type {
} from './dto/nextcloud-instance.dto'; } from './dto/nextcloud-instance.dto';
import { NextcloudAlertService } from './nextcloud-alert.service'; import { NextcloudAlertService } from './nextcloud-alert.service';
import { planStatusWrite } from './nextcloud-alert-rules'; 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 { import {
type NextcloudRating, type NextcloudRating,
type NextcloudReference, type NextcloudReference,
@@ -16,7 +18,7 @@ import {
rateNextcloud, rateNextcloud,
} from './nextcloud-rating'; } from './nextcloud-rating';
import { NextcloudReleaseService } from './nextcloud-release.service'; 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 * Alle Spalten ausser den Logo-Bytes (T-k67-07): Listen- und
@@ -118,8 +120,6 @@ export async function runWithConcurrency<T>(
await Promise.all(workers); await Promise.all(workers);
} }
const INVALID_URL_MESSAGE = 'Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.';
@Injectable() @Injectable()
export class NextcloudStatusService { export class NextcloudStatusService {
private readonly logger = new Logger(NextcloudStatusService.name); 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( async createInstance(
tenantId: string, tenantId: string,
dto: CreateNextcloudInstanceDto, dto: CreateNextcloudInstanceDto,
): Promise<NextcloudInstanceView> { ): Promise<NextcloudInstanceView> {
const baseUrl = normalizeCloudUrl(dto.baseUrl); const input = validateInstanceInput(dto, 'create');
if (!baseUrl) throw new BadRequestException(INVALID_URL_MESSAGE); const logo = await this.resolveLogo(dto.logoUrl);
const tenantPrisma = forTenant(this.prisma, tenantId); const tenantPrisma = forTenant(this.prisma, tenantId);
const created = await tenantPrisma.nextcloudInstance.create({ const created = await tenantPrisma.nextcloudInstance.create({
data: { data: {
tenantId, tenantId,
customerName: dto.customerName.trim(), customerName: input.customerName as string,
baseUrl, baseUrl: input.baseUrl as string,
logoUrl: dto.logoUrl ? dto.logoUrl : null, logoUrl: logo.kind === 'remote' ? logo.url : null,
...(logo.kind === 'stored'
? { logoData: new Uint8Array(logo.data), logoMime: logo.mime }
: {}),
}, },
select: { id: true }, select: { id: true },
}); });
return this.checkInstance(tenantId, created.id); 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 * Prueft eine Cloud (nur `status.php`, siehe `fetchNextcloudStatus`) und
* schreibt das Ergebnis an die Zeile. Fremde oder unbekannte Kennung: 404. * schreibt das Ergebnis an die Zeile. Fremde oder unbekannte Kennung: 404.
@@ -217,7 +246,7 @@ export class NextcloudStatusService {
where: { id, tenantId }, where: { id, tenantId },
select: { id: true, baseUrl: true, consecutiveFailures: true }, 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 startedAt = Date.now();
const result = await fetchNextcloudStatus(existing.baseUrl); const result = await fetchNextcloudStatus(existing.baseUrl);
@@ -261,8 +290,10 @@ export class NextcloudStatusService {
/** /**
* Aendert Name, Adresse und/oder Logo-Adresse. Eine neue Adresse wird * Aendert Name, Adresse und/oder Logo-Adresse. Eine neue Adresse wird
* normalisiert und sofort neu geprueft, eine unveraenderte nicht. Eine * normalisiert und sofort neu geprueft, eine unveraenderte nicht. Eine
* nicht leere Logo-Adresse ersetzt ein hochgeladenes Logo, eine leere * https-Logo-Adresse ersetzt ein hochgeladenes Logo, eine http-Adresse wird
* entfernt nur die Adresse (D-A). * 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( async updateInstance(
tenantId: string, tenantId: string,
@@ -274,15 +305,17 @@ export class NextcloudStatusService {
where: { id, tenantId }, where: { id, tenantId },
select: { id: true, baseUrl: true }, 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<string, unknown> = {}; const data: Record<string, unknown> = {};
if (dto.customerName !== undefined) data.customerName = dto.customerName.trim(); if (input.customerName !== undefined) data.customerName = input.customerName;
let urlChanged = false; let urlChanged = false;
if (dto.baseUrl !== undefined) { if (input.baseUrl !== undefined) {
const baseUrl = normalizeCloudUrl(dto.baseUrl); const baseUrl = input.baseUrl;
if (!baseUrl) throw new BadRequestException(INVALID_URL_MESSAGE);
if (baseUrl !== existing.baseUrl) { if (baseUrl !== existing.baseUrl) {
data.baseUrl = baseUrl; data.baseUrl = baseUrl;
urlChanged = true; urlChanged = true;
@@ -306,10 +339,14 @@ export class NextcloudStatusService {
} }
if (dto.logoUrl !== undefined) { if (dto.logoUrl !== undefined) {
if (dto.logoUrl) { if (logo.kind === 'remote') {
data.logoUrl = dto.logoUrl; data.logoUrl = logo.url;
data.logoData = null; data.logoData = null;
data.logoMime = null; data.logoMime = null;
} else if (logo.kind === 'stored') {
data.logoUrl = null;
data.logoData = new Uint8Array(logo.data);
data.logoMime = logo.mime;
} else { } else {
data.logoUrl = null; data.logoUrl = null;
} }
@@ -332,7 +369,7 @@ export class NextcloudStatusService {
where: { id, tenantId }, where: { id, tenantId },
select: { id: true }, select: { id: true },
}); });
if (!existing) throw new NotFoundException('Cloud nicht gefunden'); if (!existing) throw nextcloudFormError('notFound');
await tenantPrisma.nextcloudInstance.delete({ where: { id } }); await tenantPrisma.nextcloudInstance.delete({ where: { id } });
return true; return true;
} }
@@ -349,8 +386,10 @@ export class NextcloudStatusService {
): Promise<NextcloudInstanceView> { ): Promise<NextcloudInstanceView> {
const mime = file ? checkLogoUpload(file.buffer) : null; const mime = file ? checkLogoUpload(file.buffer) : null;
if (!file || !mime) { if (!file || !mime) {
throw new BadRequestException( throw nextcloudFormError(
'Bitte laden Sie ein Bild im Format PNG, JPEG, GIF oder WebP bis 1 MB hoch.', file && file.buffer.length > NEXTCLOUD_LOGO_MAX_BYTES
? 'logoFileTooLarge'
: 'logoFileInvalid',
); );
} }
const tenantPrisma = forTenant(this.prisma, tenantId); const tenantPrisma = forTenant(this.prisma, tenantId);
@@ -358,7 +397,7 @@ export class NextcloudStatusService {
where: { id, tenantId }, where: { id, tenantId },
select: { id: true }, select: { id: true },
}); });
if (!existing) throw new NotFoundException('Cloud nicht gefunden'); if (!existing) throw nextcloudFormError('notFound');
const updated = await tenantPrisma.nextcloudInstance.update({ const updated = await tenantPrisma.nextcloudInstance.update({
where: { id }, where: { id },
data: { data: {
@@ -390,7 +429,7 @@ export class NextcloudStatusService {
where: { id, tenantId }, where: { id, tenantId },
select: { id: true }, select: { id: true },
}); });
if (!existing) throw new NotFoundException('Cloud nicht gefunden'); if (!existing) throw nextcloudFormError('notFound');
const updated = await tenantPrisma.nextcloudInstance.update({ const updated = await tenantPrisma.nextcloudInstance.update({
where: { id }, where: { id },
data: { logoData: null, logoMime: null, logoVersion: { increment: 1 } }, data: { logoData: null, logoMime: null, logoVersion: { increment: 1 } },