feat(nextcloud-status): Cloud-Formular zeigt nur deutsche Meldungen, Bildadresse auch per http (j9f)

- Vorabpruefung im Browser, API-Kennungen werden zu de/en-Texten
- Beschriftung Bildadresse mit neuem Hinweis zu http und https

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-08 14:08:21 +02:00
parent 443256164d
commit c30a82ec0f
8 changed files with 401 additions and 61 deletions
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import { NextcloudFormError } from '@/lib/nextcloud-status-api';
import {
CLOUD_FORM_ERROR_CODES,
cloudFormErrorKey,
isAllowedLogoFileType,
validateCloudForm,
} from './cloud-form-errors';
const OK = {
customerName: 'Kunde',
baseUrl: 'https://cloud.example.de',
logoMode: 'none' as const,
logoUrl: '',
};
describe('validateCloudForm', () => {
it('gueltige Eingaben -> null', () => {
expect(validateCloudForm(OK)).toBeNull();
});
it('Name: leer und zu lang', () => {
expect(validateCloudForm({ ...OK, customerName: ' ' })).toBe('customerNameRequired');
expect(validateCloudForm({ ...OK, customerName: 'x'.repeat(121) })).toBe('customerNameTooLong');
});
it('Adresse: leer, ohne Schema, falsches Schema', () => {
expect(validateCloudForm({ ...OK, baseUrl: '' })).toBe('baseUrlRequired');
expect(validateCloudForm({ ...OK, baseUrl: 'cloud.example.de' })).toBe('baseUrlInvalid');
expect(validateCloudForm({ ...OK, baseUrl: 'ftp://x' })).toBe('baseUrlInvalid');
});
it('Bildadresse nur im Modus url geprueft', () => {
for (const logoUrl of ['ftp://x', 'kein link', 'http://u:p@a.de/x.png']) {
expect(validateCloudForm({ ...OK, logoMode: 'url', logoUrl })).toBe('logoUrlInvalid');
}
for (const logoUrl of ['http://logo.example.de/a.png', 'https://logo.example.de/a.png', '']) {
expect(validateCloudForm({ ...OK, logoMode: 'url', logoUrl })).toBeNull();
}
expect(validateCloudForm({ ...OK, logoMode: 'none', logoUrl: 'ftp://x' })).toBeNull();
expect(validateCloudForm({ ...OK, logoMode: 'upload', logoUrl: 'ftp://x' })).toBeNull();
});
});
describe('isAllowedLogoFileType', () => {
it('nur PNG, JPEG, GIF, WebP', () => {
for (const t of ['image/png', 'image/jpeg', 'image/gif', 'image/webp']) {
expect(isAllowedLogoFileType(t)).toBe(true);
}
expect(isAllowedLogoFileType('image/svg+xml')).toBe(false);
expect(isAllowedLogoFileType('')).toBe(false);
});
});
describe('cloudFormErrorKey', () => {
it('bekannte Kennungen -> errors.<code>', () => {
for (const code of CLOUD_FORM_ERROR_CODES) {
expect(cloudFormErrorKey(new NextcloudFormError(400, code, 'x'), 'saveError')).toBe(
`errors.${code}`,
);
}
});
it('413 und 404 ohne Kennung', () => {
expect(
cloudFormErrorKey(new NextcloudFormError(413, null, 'File too large'), 'saveError'),
).toBe('errors.logoFileTooLarge');
expect(cloudFormErrorKey(new NextcloudFormError(404, null, 'Not Found'), 'deleteError')).toBe(
'errors.notFound',
);
});
it('unbekannte Kennung, englischer Text oder einfacher Fehler -> allgemeiner Text', () => {
expect(cloudFormErrorKey(new NextcloudFormError(400, 'whatever', 'x'), 'saveError')).toBe(
'saveError',
);
expect(
cloudFormErrorKey(
new NextcloudFormError(400, null, 'logoUrl must be a URL address'),
'saveError',
),
).toBe('saveError');
expect(cloudFormErrorKey(new Error('boom'), 'deleteError')).toBe('deleteError');
});
});
@@ -0,0 +1,92 @@
import { NextcloudFormError } from '@/lib/nextcloud-status-api';
/**
* Fehlertexte des Cloud-Formulars (quick-261008-j9f, D-05): vorab pruefen und
* Fehlerkennungen der API in Uebersetzungsschluessel abbilden. Die Oberflaeche
* zeigt nie den Text des Servers. Die Kennungen entsprechen dem Katalog in
* `apps/api/src/nextcloud-status/nextcloud-form-errors.ts`.
*/
export const CLOUD_FORM_ERROR_CODES = [
'customerNameRequired',
'customerNameTooLong',
'baseUrlRequired',
'baseUrlInvalid',
'logoUrlInvalid',
'logoFileInvalid',
'logoFileTooLarge',
'logoFetchInternal',
'logoFetchUnreachable',
'logoFetchNotImage',
'logoFetchTooLarge',
'notFound',
] as const;
export type CloudFormErrorCode = (typeof CLOUD_FORM_ERROR_CODES)[number];
const MAX_NAME = 120;
const MAX_URL = 2048;
/** http(s)-Adresse ohne Zugangsdaten und mit Rechnername. */
function isHttpUrl(raw: string): boolean {
let url: URL;
try {
url = new URL(raw);
} catch {
return false;
}
return (
(url.protocol === 'http:' || url.protocol === 'https:') &&
!url.username &&
!url.password &&
url.hostname !== ''
);
}
export interface CloudFormInput {
customerName: string;
baseUrl: string;
logoMode: 'none' | 'upload' | 'url';
logoUrl: string;
}
/** Erster Fehler in der Reihenfolge Name, Adresse, Bildadresse — oder `null`. */
export function validateCloudForm(input: CloudFormInput): CloudFormErrorCode | null {
const name = input.customerName.trim();
if (!name) return 'customerNameRequired';
if (name.length > MAX_NAME) return 'customerNameTooLong';
const base = input.baseUrl.trim();
if (!base) return 'baseUrlRequired';
if (base.length > MAX_URL || !isHttpUrl(base)) return 'baseUrlInvalid';
if (input.logoMode === 'url') {
const logo = input.logoUrl.trim();
if (logo && (logo.length > MAX_URL || !isHttpUrl(logo))) return 'logoUrlInvalid';
}
return null;
}
const ALLOWED_LOGO_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
/** Dateitypen, die die API als Logo annimmt (SVG nie). */
export function isAllowedLogoFileType(type: string): boolean {
return ALLOWED_LOGO_TYPES.has(type);
}
function isKnownCode(code: string | null): code is CloudFormErrorCode {
return code !== null && (CLOUD_FORM_ERROR_CODES as readonly string[]).includes(code);
}
/**
* Uebersetzungsschluessel (unter `nextcloudStatus.form`) zu einem Fehler:
* bekannte Kennung -> `errors.<code>`, 413 -> Dateigroesse, 404 -> nicht
* gefunden, sonst der allgemeine Text (`saveError` / `deleteError`).
*/
export function cloudFormErrorKey(err: unknown, fallback: 'saveError' | 'deleteError'): string {
if (err instanceof NextcloudFormError) {
if (isKnownCode(err.code)) return `errors.${err.code}`;
if (err.status === 413) return 'errors.logoFileTooLarge';
if (err.status === 404) return 'errors.notFound';
}
return fallback;
}