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:
@@ -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 { 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:<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
|
||||
* 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
|
||||
* (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;
|
||||
}
|
||||
|
||||
@@ -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() };
|
||||
});
|
||||
|
||||
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 { 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]);
|
||||
|
||||
@@ -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<T>(
|
||||
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<NextcloudInstanceView> {
|
||||
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<string, unknown> = {};
|
||||
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<NextcloudInstanceView> {
|
||||
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 } },
|
||||
|
||||
Reference in New Issue
Block a user