Files
tessera-ctl/.planning/quick/261008-j9f-nextcloud-status-logo-per-http-adresse-h/261008-j9f-PLAN.md
T
2026-10-08 14:14:38 +02:00

298 lines
47 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: quick-261008-j9f
plan: 01
type: execute
wave: 1
depends_on: []
quick_id: 261008-j9f
description: "Nextcloud-Status: Logo per http-Adresse wird einmalig von Tessera abgeholt und wie ein Upload gespeichert (SSRF-geschützt); keine englischen Rohmeldungen mehr im Cloud-Formular"
date: 2026-10-08
files_modified:
# Task 1 — tracer (API): http logo address -> shared SSRF guard -> capped download -> stored like an upload; all form errors as code + German text
- apps/api/src/common/public-url-guard.ts
- apps/api/src/favorites/icon-discovery.service.ts
- apps/api/src/nextcloud-status/nextcloud-form-errors.ts
- apps/api/src/nextcloud-status/nextcloud-logo-fetch.ts
- apps/api/src/nextcloud-status/nextcloud-logo-fetch.spec.ts
- apps/api/src/nextcloud-status/dto/nextcloud-instance.dto.ts
- apps/api/src/nextcloud-status/dto/nextcloud-instance.dto.spec.ts
- apps/api/src/nextcloud-status/nextcloud-status.service.ts
- apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts
# Task 2 — web: error codes -> de/en texts, client pre-check, new label and hint
- apps/web/src/lib/nextcloud-status-api.ts
- apps/web/src/components/nextcloud-status/cloud-form-errors.ts
- apps/web/src/components/nextcloud-status/cloud-form-errors.test.ts
- apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.tsx
- apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.test.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/web/src/messages/umlaut-dictionary.ts
# Task 3 — docs, changelog, full suites, rebuild, live API probe
- CHANGELOG.md
- docs/anleitung-anwender.md
- docs/anleitung-administration.md
autonomous: true
requirements: [QUICK-261008-j9f]
estimate:
tokens: 90000
raw_tokens: 90000
tasks: 3
confidence: low
must_haves:
truths:
- "A manager who enters a public http:// image address when adding or editing a cloud gets the image downloaded by Tessera exactly once on save and stored exactly like an upload (logoData/logoMime on the row, 1 MiB limit, PNG/JPEG/GIF/WebP decided by magic bytes via checkLogoUpload, SVG refused); logoUrl stays empty, the response carries hasUploadedLogo true and the tile shows the logo through the existing GET instances/:id/logo route (D-01)"
- "An http address whose host is localhost, *.localhost, *.local, 0.0.0.0, a private/loopback/link-local/CGNAT/multicast IP literal or a name resolving to one is refused BEFORE any request is sent to it — also when it is the target of a redirect; at most 3 redirects, one total time limit for all hops plus the body read, and reading stops as soon as more than 1 MiB has arrived (D-02)"
- "Every failed download is reported in the form as a German Sie-form text and nothing is saved (no row created, no row changed): internal address -> hint to use „Bild hochladen“; not reachable/timeout/HTTP error -> check address or upload; no image -> allowed formats; larger than 1 MB -> size hint (D-03)"
- "https:// logo addresses behave exactly as before: stored as logoUrl, loaded by the viewer's browser, the server never fetches them (D-04)"
- "The cloud form never shows an English raw message: the browser pre-checks customer name, cloud address, logo address and logo file and shows German texts; every API error of these routes carries a stable code (body.code, or the code as class-validator message) that the web maps to de/en texts; 413 maps to the file-size text, unknown errors fall back to the generic German save/delete text (D-05)"
- "The logo address option is labelled „Bildadresse“ and its hint explains: https addresses are loaded by the browser, http images are fetched once by Tessera and stored, internal addresses only via upload; de/en keys identical, Sie-form, real umlauts, no Mandant/Lizenz words (D-06)"
- "Favorites icon discovery behaves exactly as before: the SSRF guard functions moved verbatim into a shared file, icon-discovery.service.ts re-exports isPublicHttpUrl, and icon-discovery.service.spec.ts, favorites.service.ts and favorites.controller.ts are byte-identical to commit 4ff43c2 and green"
- "CHANGELOG.md (Unveröffentlicht), docs/anleitung-anwender.md and docs/anleitung-administration.md describe http logos fetched once and the German messages; full api/web suites and both tsc runs green; the rebuilt stack answers a POST with logoUrl http://127.0.0.1/logo.png with 400 and code logoFetchInternal (D-07)"
artifacts:
- path: "apps/api/src/common/public-url-guard.ts"
provides: "isPublicHttpUrl plus the private-IP and blocked-hostname helpers, moved verbatim from favorites/icon-discovery.service.ts"
contains: "export async function isPublicHttpUrl"
- path: "apps/api/src/nextcloud-status/nextcloud-logo-fetch.ts"
provides: "classifyLogoUrl (none/remote/fetch/invalid) and fetchLogoImage (per-hop SSRF guard, manual redirects, total deadline, streaming 1 MiB cap, magic-byte check, typed failure codes)"
exports: ["classifyLogoUrl", "fetchLogoImage", "LOGO_FETCH_TIMEOUT_MS", "LOGO_FETCH_MAX_REDIRECTS"]
- path: "apps/api/src/nextcloud-status/nextcloud-form-errors.ts"
provides: "the 12 form error codes with their German messages, nextcloudFormError(code) helper and validateInstanceInput"
contains: "logoFetchInternal"
- path: "apps/web/src/components/nextcloud-status/cloud-form-errors.ts"
provides: "validateCloudForm (client pre-check) and cloudFormErrorKey (error -> translation key)"
exports: ["CLOUD_FORM_ERROR_CODES", "validateCloudForm", "cloudFormErrorKey"]
key_links:
- from: "apps/api/src/nextcloud-status/nextcloud-status.service.ts"
to: "apps/api/src/nextcloud-status/nextcloud-logo-fetch.ts"
via: "createInstance/updateInstance call fetchLogoImage for http addresses BEFORE any write"
pattern: "fetchLogoImage"
- from: "apps/api/src/nextcloud-status/nextcloud-logo-fetch.ts"
to: "apps/api/src/common/public-url-guard.ts"
via: "isPublicHttpUrl checked for the start address and every redirect hop"
pattern: "isPublicHttpUrl"
- from: "apps/api/src/favorites/icon-discovery.service.ts"
to: "apps/api/src/common/public-url-guard.ts"
via: "import + re-export, behaviour unchanged"
pattern: "public-url-guard"
- from: "apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.tsx"
to: "apps/web/src/components/nextcloud-status/cloud-form-errors.ts"
via: "pre-check before saving, error -> t(key) after a failed save"
pattern: "cloudFormErrorKey"
---
<objective>
Allow http:// logo addresses in the Nextcloud-Status cloud form: Tessera downloads the image once on save (SSRF-protected, time and size limited) and stores it exactly like an uploaded logo; https addresses stay unchanged. At the same time remove every English raw validation message from this form (client pre-check plus API error codes mapped to de/en texts) and adjust form texts, changelog and manuals.
User decisions (Variante B, from the task description, numbered here for traceability):
- D-01: http logo addresses are allowed; Tessera downloads the image server-side ONCE and stores it exactly as if uploaded (same storage path, same limits: max 1 MB, image types by byte check like the upload); logoUrl is then not stored (emptied).
- D-02: Internet addresses only — SSRF protection is mandatory: block private/internal IPs, localhost, .local etc., also after redirects; reuse the existing guard (apps/api/src/favorites/icon-discovery.service.ts) where it can be shared cleanly WITHOUT changing favorites behaviour; time limit; size limit while streaming.
- D-03: On failure (internal, unreachable, no image, too large) a clear German message (Sie-form), e.g. pointing to upload for internal addresses.
- D-04: https addresses behave as before (browser loads them, logoUrl stored).
- D-05: No English raw messages in this form: all validation errors (customer name, address, logo address, logo file) appear in German. Preferred: client pre-checks + API returns error codes that the web maps to de/en texts — follow the module's existing pattern (codes + translation, like errorKind/error-hint and the domains module's `{ code, message }`).
- D-06: Form texts: label „Bildadresse (https)“ and the https-only hint change (http now allowed; hint that http images are fetched once and stored by Tessera, internal addresses only via upload). de/en keys identical, Sie-form, no Mandant/Lizenz terms.
- D-07: CHANGELOG.md under „## Unveröffentlicht“ (existing ### Neu / ### Geändert, add ### Behoben if needed) in plain words; docs/anleitung-anwender.md section Nextcloud-Status adjusted (and docs/anleitung-administration.md, which states the server never fetches the logo address).
Purpose: Customers' logos often sit on plain-http sites; today the form rejects them with "logoUrl must be a URL address". The user wants them to just work, safely.
Output: shared SSRF guard file, logo download module with tests, code-based form errors in API and web, updated texts and docs, rebuilt local stack.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@./CLAUDE.md
@.planning/quick/261002-k67-modul-nextcloud-status-mit-ampel-kacheln/261002-k67-SUMMARY.md
Project rules that apply here:
- API TS lib has no Object.hasOwn; use `Object.prototype.hasOwnProperty.call` or `in`.
- Global ValidationPipe (apps/api/src/main.ts): `whitelist: true, transform: true`, no exceptionFactory. Global pipes run before any controller/param pipe, so a per-route pipe cannot replace its messages — codes must come from the DTO decorators' `message` option and from the service.
- NestJS: static routes before `:id` routes (unchanged here — no new routes).
- Never read .env files. Rebuild locally with `docker compose up -d --build api web`. No deploy to the test server, no push (commits stay local; the user pushes bundled).
- UI texts: Sie-form, real umlauts (apps/web/src/messages/umlaut-guard.spec.ts fails on ae/oe/ue/ss tokens not on UMLAUT_ALLOWLIST in apps/web/src/messages/umlaut-dictionary.ts — add correct German words there if the guard trips), de/en keys identical, no „Mandant“/„Lizenz“.
- RLS inventory: nextcloud-status.service.ts currently has 14 `tenantPrisma.<model>.` call sites (docs/mandantentrennung-zugriffsklassifikation.md row nextcloud-status 0/23/1). Do NOT add new tenantPrisma calls; the logo download needs none. If the count changes anyway, update that row and the inventory spec the way quick-261002-k67 did.
- Local admin for the live probe: admin/admin123.
Existing code facts (already read by the planner):
- apps/api/src/favorites/icon-discovery.service.ts: module-private `isPrivateIpv4`, `isPrivateIpv6`, `isPrivateIpAddress`, `isBlockedHostname`, exported `isPublicHttpUrl(url: URL): Promise<boolean>` (http/https only, blocked names, IP literal check, otherwise `dns.lookup(all:true)` and every address must be public). `fetchWithRedirectGuard` is private, returns null for both "blocked" and "failed" (cannot tell them apart), uses a lenient-TLS agent and MAX_REDIRECTS 2 — not suitable to share as is. favorites.service.ts imports `IconDiscoveryService, normalizeUrl` from it; icon-discovery.service.spec.ts imports `discardBody, IconDiscoveryService, isPublicHttpUrl, normalizeUrl, readTextCapped` from it and mocks `undici`.
- apps/api/src/nextcloud-status/nextcloud-logo-rules.ts: `NEXTCLOUD_LOGO_MAX_BYTES` (1 MiB), `checkLogoUpload(buffer)` -> `DashboardImageMime | null` (empty, too large or not PNG/JPEG/GIF/WebP -> null).
- apps/api/src/nextcloud-status/nextcloud-status-fetch.ts: `fetchNextcloudStatus(baseUrl, { fetchImpl?, timeoutMs? })` is the module's pattern for an injectable `undici` fetch with `redirect: 'manual'`, one AbortController deadline and `Promise.race` against abort for hanging servers; `normalizeCloudUrl(raw)` returns null for invalid input. This status fetch deliberately allows internal addresses — do not change it.
- apps/api/src/nextcloud-status/nextcloud-status.service.ts: `INVALID_URL_MESSAGE`, `createInstance` (normalize -> create -> checkInstance), `updateInstance` (findFirst 404 -> build data -> update -> re-check if URL changed; non-empty logoUrl replaces upload, empty removes only the address, logoVersion increments), `uploadLogo` (checkLogoUpload -> German BadRequest), several `NotFoundException('Cloud nicht gefunden')`.
- Domains pattern for codes: `throw new BadRequestException({ code: 'customerNameRequired', message: 'Bitte geben Sie einen Namen an.' })`; web apps/web/src/lib/domains-api.ts `DomainsRequestError(status, code, message)` reads `body.code`.
- DTO spec pattern: apps/api/src/domains/dto/domains-settings.dto.spec.ts (`plainToInstance` + `validate`, `import 'reflect-metadata'`).
- Web: apps/web/src/lib/nextcloud-status-api.ts `readErrorMessage(res, fallback)` returns body.message (string or first array entry) — this is how "logoUrl must be a URL address" reaches the user today. CloudForm.tsx shows `t('required')`, `t('logoFileTooLarge')`, or the raw error text; CloudForm.test.tsx renders with real de.json and mocks the api module via importOriginal.
</context>
<tasks>
<task type="tracer" tdd="true">
<name>Task 1 (tracer, API): http logo address -> shared SSRF guard -> capped download -> stored like an upload; every form error as code + German text</name>
<files>apps/api/src/common/public-url-guard.ts, apps/api/src/favorites/icon-discovery.service.ts, apps/api/src/nextcloud-status/nextcloud-form-errors.ts, apps/api/src/nextcloud-status/nextcloud-logo-fetch.ts, apps/api/src/nextcloud-status/nextcloud-logo-fetch.spec.ts, apps/api/src/nextcloud-status/dto/nextcloud-instance.dto.ts, apps/api/src/nextcloud-status/dto/nextcloud-instance.dto.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.service.ts, apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts</files>
<read_first>apps/api/src/favorites/icon-discovery.service.ts (lines 1-160), apps/api/src/nextcloud-status/nextcloud-status-fetch.ts (lines 180-300), apps/api/src/nextcloud-status/nextcloud-status.service.ts, apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts (lines 1-150, 314-380), apps/api/src/nextcloud-status/dto/nextcloud-instance.dto.ts, apps/api/src/nextcloud-status/nextcloud-logo-rules.ts, apps/api/src/domains/dto/domains-settings.dto.spec.ts (lines 1-15)</read_first>
<behavior>
nextcloud-logo-fetch.spec.ts (no real network: inject a fake fetchImpl built from global `Response`; use IP literals so the REAL isPublicHttpUrl decides without DNS, or inject `isPublic` for hostname cases):
- classifyLogoUrl: undefined -> unchanged/none semantics as specified below; '' and ' ' -> none; ' https://a.de/x.png ' -> remote with trimmed url; 'https://intranet/logo.png' (no TLD) -> remote; 'http://a.de/x.png' -> fetch; 'ftp://a.de/x', 'javascript:alert(1)', 'kein link', 'http://user:pw@a.de/x.png', a 2049-char http URL -> invalid.
- Success: http://93.184.216.34/logo.png answers 200 with PNG magic bytes -> ok, mime image/png, data equals the bytes; the single fetchImpl call used method GET, redirect 'manual', an AbortSignal, and sent no Cookie/Authorization header.
- Content-type is not trusted: 200 with content-type image/png but HTML bytes -> logoFetchNotImage; SVG bytes -> logoFetchNotImage; empty body -> logoFetchNotImage.
- Internal start address: 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 -> logoFetchInternal and fetchImpl NEVER called.
- Redirect to internal: 302 Location http://10.0.0.5/logo.png -> logoFetchInternal, fetchImpl called exactly once.
- Redirect to public then image: 301 -> https://93.184.216.35/logo.png -> ok (two calls).
- Four redirects in a row -> logoFetchUnreachable; 302 without Location -> logoFetchUnreachable.
- HTTP 404 -> logoFetchUnreachable; fetchImpl rejects (ECONNREFUSED-like error) -> logoFetchUnreachable.
- Too large: content-length 2097152 -> logoFetchTooLarge without reading; a body stream without content-length that keeps delivering 256 KiB chunks -> logoFetchTooLarge, reading stops (the stream's pull count stays small, e.g. <= 6) and the stream is cancelled.
- Time limit: fetchImpl that never settles, timeoutMs 50 -> logoFetchUnreachable well under 1 s; a body that stalls after the headers, timeoutMs 50 -> logoFetchUnreachable.
nextcloud-instance.dto.spec.ts: for wrong types (number customerName, missing baseUrl on create, number logoUrl) every constraint message is one of the 12 codes — no English sentence.
nextcloud-status.service.spec.ts (mock './nextcloud-logo-fetch' via importOriginal, keep classifyLogoUrl real, stub fetchLogoImage):
- createInstance with http logo, fetch ok -> prisma create data has logoData (Uint8Array of the bytes), logoMime 'image/png', logoUrl null; the result view has hasUploadedLogo true.
- createInstance with http logo, fetch fails with logoFetchInternal -> BadRequestException whose getResponse() equals { code: 'logoFetchInternal', message: <German text> }; prisma create and fetchNextcloudStatus NOT called.
- createInstance with https logo -> fetchLogoImage not called, logoUrl stored as before (D-04).
- updateInstance with http logo, fetch ok -> update data sets logoData/logoMime, logoUrl null, logoVersion increment; fetch fails -> no update call; unknown id -> NotFound (code notFound) and fetchLogoImage not called.
- Codes: create with customerName ' ' -> customerNameRequired; 121-char name -> customerNameTooLong; baseUrl '' -> baseUrlRequired; baseUrl 'cloud.example.de' -> baseUrlInvalid (replaces the old INVALID_URL_MESSAGE test, same German text); logoUrl 'ftp://x' -> logoUrlInvalid; uploadLogo with a non-image -> logoFileInvalid, with a buffer over 1 MiB -> logoFileTooLarge.
</behavior>
<action>
Work in this order so the http path is proven first (tracer), then the remaining codes are added.
1. Shared guard (D-02, reuse without changing favorites). Create apps/api/src/common/public-url-guard.ts and MOVE, character for character, `isPrivateIpv4`, `isPrivateIpv6`, `isPrivateIpAddress`, `isBlockedHostname` and the exported `isPublicHttpUrl` (with their imports from node:dns/promises and node:net) from apps/api/src/favorites/icon-discovery.service.ts. Add a short German header comment (shared SSRF guard, origin T-08-05, now also used by the Nextcloud logo download). In icon-discovery.service.ts delete the moved definitions and now-unused imports, import `isPublicHttpUrl` from '../common/public-url-guard' for its own use and re-export it under the same name so existing imports keep working. Do not touch anything else in that file (lenient TLS agent, redirect budget, timeouts stay as they are) and do not edit icon-discovery.service.spec.ts, favorites.service.ts or favorites.controller.ts.
2. Error catalogue. Create apps/api/src/nextcloud-status/nextcloud-form-errors.ts with a readonly map from the 12 codes to their German Sie-form messages and the derived type `NextcloudFormErrorCode`:
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.“
Export `nextcloudFormError(code)` returning `new NotFoundException({ code, message })` for notFound and `new BadRequestException({ code, message })` for all others (domains pattern), and `NEXTCLOUD_FORM_ERROR_CODES` (array of the keys) for the DTO spec. Export `validateInstanceInput(input, mode)` with mode 'create' or 'update': customerName (required on create, checked when present on update) is trimmed — empty -> customerNameRequired, longer than 120 -> customerNameTooLong; baseUrl (same presence rule) — empty after trim -> baseUrlRequired, `normalizeCloudUrl` null -> baseUrlInvalid; returns the trimmed name and normalized URL. Check order: name, address, then (in the service) logo.
3. Download module (D-01, D-02, D-03). Create apps/api/src/nextcloud-status/nextcloud-logo-fetch.ts:
- `classifyLogoUrl(raw: string)` -> one of: kind 'none' (empty after trim), kind 'remote' with the trimmed string (https), kind 'fetch' with a URL object (http), kind 'invalid' (longer than 2048, not parseable by `new URL`, protocol other than http/https, username or password present, no hostname). Hosts without a dot stay allowed like before (the old validator had require_tld false).
- Constants `LOGO_FETCH_TIMEOUT_MS = 8000`, `LOGO_FETCH_MAX_REDIRECTS = 3`; failure codes typed as the four logoFetch* members of NextcloudFormErrorCode.
- `fetchLogoImage(url: URL, opts?: { fetchImpl?: typeof undiciFetch; isPublic?: (u: URL) => Promise<boolean>; timeoutMs?: number })` resolving to either ok with data (Buffer) and mime (DashboardImageMime) or not-ok with code. Defaults: undici's `fetch` (module import, like nextcloud-status-fetch.ts; normal certificate checks — no lenient agent) and `isPublicHttpUrl` from '../common/public-url-guard'.
- One AbortController plus one timer for the WHOLE operation (all hops and the body read); race the work against the abort like fetchNextcloudStatus so a hanging server cannot hold the request; always clear the timer.
- Loop over hops: before EVERY request await isPublic(current) — false -> logoFetchInternal without sending anything. Request: method GET, redirect 'manual', the signal, headers Accept (image/png, image/jpeg, image/gif, image/webp, image/*;q=0.8) and a browser-like User-Agent (as in IconDiscoveryService.fetchIconBytes, WAFs block bare clients); never cookies or credentials. 3xx: discard the body, missing/unparseable Location or more than LOGO_FETCH_MAX_REDIRECTS hops -> logoFetchUnreachable, otherwise resolve Location against the current URL and continue (the next iteration re-checks it). Non-2xx -> discard body, logoFetchUnreachable. Thrown errors or abort -> logoFetchUnreachable.
- Size: a numeric content-length above NEXTCLOUD_LOGO_MAX_BYTES -> discard, logoFetchTooLarge. Otherwise read `response.body` with a reader, summing chunk lengths; as soon as the sum exceeds NEXTCLOUD_LOGO_MAX_BYTES cancel the reader and return logoFetchTooLarge. A null body counts as empty.
- Type: `checkLogoUpload(bytes)`; null -> logoFetchNotImage (the content-type header is ignored for the decision). Never log or return response text; log at most host plus code at debug/warn level.
- German header comment: why http only is fetched (D-01/D-04), guard per hop, deadline, cap, magic bytes, residual DNS-rebinding window accepted like favorites (T-j9f-02).
4. DTO (D-05). In apps/api/src/nextcloud-status/dto/nextcloud-instance.dto.ts keep only type guards that carry codes: Create — customerName and baseUrl with IsString whose message is 'customerNameRequired' / 'baseUrlRequired'; logoUrl IsOptional plus IsString with message 'logoUrlInvalid'. Update — the same three fields, all IsOptional. Remove the URL, not-empty and max-length decorators and the ValidateIf; those rules now live in validateInstanceInput and classifyLogoUrl (lengths stay enforced: 120 / 2048 / 2048). Update the class comment (http allowed, fetched once; https loaded by the browser). Create apps/api/src/nextcloud-status/dto/nextcloud-instance.dto.spec.ts per the behavior list (each constraint message must be in NEXTCLOUD_FORM_ERROR_CODES; valid payloads with http and https logo and empty logoUrl produce no errors).
5. Service wiring (D-01, D-03, D-04). In nextcloud-status.service.ts remove INVALID_URL_MESSAGE.
- createInstance: validateInstanceInput(dto, 'create'); if dto.logoUrl is given classify it — invalid -> nextcloudFormError('logoUrlInvalid'); fetch kind -> await fetchLogoImage BEFORE creating, a failure throws nextcloudFormError(result.code). The single existing create call then writes logoUrl (remote only, else null) and, for a fetched image, logoData (new Uint8Array of the bytes) and logoMime. Then checkInstance as before.
- updateInstance: keep the existing findFirst (unknown id -> notFound BEFORE any download), then validateInstanceInput(dto, 'update'), then the logo: remote -> previous behaviour (logoUrl set, logoData/logoMime cleared); fetch -> download first, on success logoData/logoMime set, logoUrl null; none -> logoUrl null only (previous behaviour, an upload stays); every logo change increments logoVersion. A download failure throws before the update call. URL-change re-check logic unchanged.
- uploadLogo: missing file or checkLogoUpload null -> logoFileTooLarge when the buffer is longer than NEXTCLOUD_LOGO_MAX_BYTES, otherwise logoFileInvalid.
- Replace every `NotFoundException('Cloud nicht gefunden')` thrown by checkInstance, updateInstance, deleteInstance, uploadLogo and removeLogo with nextcloudFormError('notFound'); leave getLogo's „Kein Logo vorhanden“ (image route, not the form).
- Keep the number of tenantPrisma call sites at 14 (no extra queries). Update the method comments (D-A note: http image stored like an upload).
- Extend nextcloud-status.service.spec.ts per the behavior list; adapt the existing invalid-address and upload-rejection tests to assert the codes (instance checks on NotFoundException/BadRequestException stay valid).
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/nextcloud-status src/favorites rls-coverage rls-access-inventory && pnpm --filter @tessera/api exec tsc --noEmit && git diff --quiet 4ff43c2 -- apps/api/src/favorites/icon-discovery.service.spec.ts apps/api/src/favorites/favorites.service.ts apps/api/src/favorites/favorites.controller.ts && grep -q "export async function isPublicHttpUrl" apps/api/src/common/public-url-guard.ts && grep -q "public-url-guard" apps/api/src/favorites/icon-discovery.service.ts && grep -q "public-url-guard" apps/api/src/nextcloud-status/nextcloud-logo-fetch.ts && grep -q "fetchLogoImage" apps/api/src/nextcloud-status/nextcloud-status.service.ts && test "$(grep -cE 'tenantPrisma\.[a-zA-Z]*\.' apps/api/src/nextcloud-status/nextcloud-status.service.ts)" = "14" && echo "tracer api ok"</automated>
</verify>
<done>An http logo address on create/update is downloaded once through the shared per-hop guard (deadline, 1 MiB streaming cap, magic bytes) and stored in logoData/logoMime with logoUrl null; internal targets (also via redirect) are refused without a request; every failure and every validation error of these routes is a `{ code, message }` with German text (DTO messages are codes); https behaves as before; favorites files unchanged and green; RLS inventory unchanged.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2 (web): German errors only — client pre-check, code -> de/en text, new label and hint</name>
<files>apps/web/src/lib/nextcloud-status-api.ts, apps/web/src/components/nextcloud-status/cloud-form-errors.ts, apps/web/src/components/nextcloud-status/cloud-form-errors.test.ts, apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts</files>
<read_first>apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.test.tsx, apps/web/src/lib/nextcloud-status-api.ts (lines 95-200), apps/web/src/lib/domains-api.ts (lines 50-77), apps/api/src/nextcloud-status/nextcloud-form-errors.ts (from Task 1, for the exact code list)</read_first>
<behavior>
cloud-form-errors.test.ts:
- validateCloudForm: name ' ' -> customerNameRequired; 121 chars -> customerNameTooLong; address '' -> baseUrlRequired; 'cloud.example.de' or 'ftp://x' -> baseUrlInvalid; logo mode 'url' with 'ftp://x', 'kein link' or 'http://u:p@a.de/x.png' -> logoUrlInvalid; logo mode 'url' with 'http://logo.example.de/a.png' or 'https://…' or '' -> null; logo mode 'none'/'upload' ignores the logo field.
- cloudFormErrorKey: NextcloudFormError with each known code -> 'errors.<code>'; code null and status 413 -> 'errors.logoFileTooLarge'; status 404 without code -> 'errors.notFound'; unknown code or English-only message -> the given fallback ('saveError' or 'deleteError'); a plain Error -> fallback.
CloudForm.test.tsx (real de.json):
- Empty customer name -> „Bitte geben Sie einen Kundennamen ein.“, no API call; address without scheme -> „Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.“, no API call; logo address 'ftp://x' -> German logoUrlInvalid text, no API call.
- An http logo address is sent unchanged to createInstance (http allowed now).
- createInstance rejects with NextcloudFormError(400, 'logoFetchInternal', …) -> the German text containing „Bild hochladen“ is shown.
- createInstance rejects with NextcloudFormError(400, null, 'logoUrl must be a URL address') -> „Die Cloud konnte nicht gespeichert werden.“ is shown and the English text is NOT in the document.
- uploadLogo rejects with status 413 -> „Die Datei ist größer als 1 MB.“
- A picked file whose type is not PNG/JPEG/GIF/WebP -> logoFileInvalid text immediately.
- Existing tests adapted: label „Bildadresse“ (radio and input), the new hint (mentions that Tessera fetches http images once), the „Kein Logo“, edit, delete and too-large-file cases stay green.
</behavior>
<action>
1. API client (D-05). In apps/web/src/lib/nextcloud-status-api.ts add an exported class `NextcloudFormError extends Error` with readonly `status: number` and `code: string | null` (message = server message or fallback, so other callers that read `.message` keep working). Replace readErrorMessage by a reader that builds this error: code = `body.code` when it is a string; otherwise, when `body.message` is an array whose first entry is a string made only of letters starting lowercase (a class-validator code from Task 1's DTO), that entry; otherwise null. Throw it from createInstance, updateInstance, deleteInstance, uploadLogo, removeLogo (other functions may use it too; NextcloudRequestError for alerts stays).
2. Pure helpers (D-05). Create apps/web/src/components/nextcloud-status/cloud-form-errors.ts:
- `CLOUD_FORM_ERROR_CODES`: the same 12 codes as the API catalogue (customerNameRequired, customerNameTooLong, baseUrlRequired, baseUrlInvalid, logoUrlInvalid, logoFileInvalid, logoFileTooLarge, logoFetchInternal, logoFetchUnreachable, logoFetchNotImage, logoFetchTooLarge, notFound) and the union type.
- `validateCloudForm({ customerName, baseUrl, logoMode, logoUrl })` mirroring the API rules (trim; name 1–120; address required and parseable with http/https and no username/password; logo address only checked in mode 'url' and when non-empty: max 2048, parseable, http/https, no credentials) -> first failing code or null, in the order name, address, logo.
- `isAllowedLogoFileType(type: string)` for PNG/JPEG/GIF/WebP.
- `cloudFormErrorKey(err, fallback)` with fallback 'saveError' | 'deleteError' -> a translation key: 'errors.<code>' for a NextcloudFormError with a known code, 'errors.logoFileTooLarge' for status 413, 'errors.notFound' for status 404, else the fallback. It never returns server text.
Write cloud-form-errors.test.ts per the behavior list.
3. Form (D-05, D-06). In CloudForm.tsx keep the error state as a translation key (or null) and always render it through `t(...)` — the server's message text is never rendered. handleSubmit: run validateCloudForm before setSaving; on a code set 'errors.<code>' and return without any API call. The catch blocks use cloudFormErrorKey with 'saveError' / 'deleteError'. handleFile: a picked file whose type is not allowed -> 'errors.logoFileInvalid' and no upload; oversized after shrinking -> 'errors.logoFileTooLarge' (replaces the old top-level key). Update the component doc comment: logo is an upload or an image address; https is loaded by the browser, http is fetched once by Tessera and stored like an upload.
4. Texts (D-06), de.json and en.json under nextcloudStatus.form, keys identical in both files:
- Add `errors` with all 12 codes. de texts exactly as the API catalogue in Task 1; en: customerNameRequired "Please enter a customer name."; customerNameTooLong "The customer name may be at most 120 characters long."; baseUrlRequired "Please enter the address of the cloud."; baseUrlInvalid "Please enter a valid address starting with http:// or https://."; logoUrlInvalid "Please enter a valid image address starting with http:// or https://."; logoFileInvalid "Please upload a PNG, JPEG, GIF or WebP image of at most 1 MB."; logoFileTooLarge "The file is larger than 1 MB."; logoFetchInternal "This image address can only be reached internally. Tessera only fetches images from the internet. Please use “Upload image” instead."; logoFetchUnreachable "The image could not be retrieved from this address. Please check the address or use “Upload image”."; logoFetchNotImage "There is no PNG, JPEG, GIF or WebP image at this address."; logoFetchTooLarge "The image at this address is larger than 1 MB."; notFound "This cloud no longer exists. Please reload the page."
- Remove the now unused top-level keys `required` and `logoFileTooLarge` from both files.
- `logoUrl`: de „Bildadresse“, en "Image address". `logoUrlHint`: de „Adressen mit https:// lädt Ihr Browser direkt. Ein Bild unter einer http://-Adresse holt Tessera beim Speichern einmalig ab und speichert es wie ein hochgeladenes Bild. Bilder, die nur intern erreichbar sind, laden Sie bitte über „Bild hochladen“ hoch.“; en "Addresses starting with https:// are loaded directly by your browser. An image at an http:// address is fetched once by Tessera when you save and stored like an uploaded image. For images that can only be reached internally, please use “Upload image”." Placeholder unchanged.
- If umlaut-guard.spec.ts flags a correct German word, add it to UMLAUT_ALLOWLIST in apps/web/src/messages/umlaut-dictionary.ts (one line each); never rewrite a word into ae/oe/ue/ss.
5. Adapt and extend CloudForm.test.tsx per the behavior list (label queries „Bildadresse“, hint assertion on the new text, the old raw-message test becomes the code-based and the English-fallback test).
</action>
<verify>
<automated>pnpm --filter @tessera/web exec vitest run nextcloud-status src/messages && pnpm --filter @tessera/web exec tsc --noEmit && node -e 'const de=require("./apps/web/src/messages/de.json"),en=require("./apps/web/src/messages/en.json");const w=(o,p,r)=>{for(const[k,v]of Object.entries(o||{})){const q=p+"."+k;if(v&&typeof v==="object")w(v,q,r);else r[q]=v}return r};const a=w(de.nextcloudStatus,"n",{}),b=w(en.nextcloudStatus,"n",{});if(Object.keys(a).sort().join()!==Object.keys(b).sort().join()){console.error("key mismatch");process.exit(1)}const codes=["customerNameRequired","customerNameTooLong","baseUrlRequired","baseUrlInvalid","logoUrlInvalid","logoFileInvalid","logoFileTooLarge","logoFetchInternal","logoFetchUnreachable","logoFetchNotImage","logoFetchTooLarge","notFound"];for(const c of codes)if(!a["n.form.errors."+c]){console.error("missing",c);process.exit(1)}if(a["n.form.logoUrl"]!=="Bildadresse"||!/http:\/\//.test(a["n.form.logoUrlHint"])||/beginnen/.test(a["n.form.logoUrlHint"])){console.error("label/hint");process.exit(1)}if("n.form.required" in a||"n.form.logoFileTooLarge" in a){console.error("old keys");process.exit(1)}for(const v of [...Object.values(a),...Object.values(b)])if(/mandant|tenant|lizenz|licens/i.test(String(v))){console.error("bad text",v);process.exit(1)}console.log("web form ok")'</automated>
</verify>
<done>The form blocks invalid input with German texts before calling the API, maps every API error of these routes via its code (or 413/404) to de/en texts, never shows server text (an English-only server message shows the generic German save error), accepts http logo addresses, and shows the label „Bildadresse“ with the new hint; de/en keys identical; web tests, umlaut guard and tsc green.</done>
</task>
<task type="auto">
<name>Task 3: Changelog and manuals, full suites, rebuild and live API probe</name>
<files>CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-administration.md</files>
<read_first>CHANGELOG.md (lines 1-20), docs/anleitung-anwender.md (lines 225-240), docs/anleitung-administration.md (lines 348-362)</read_first>
<precondition>The local Docker stack from docker-compose.yml (db, api, web) can be built and started on the dev host.</precondition>
<action>
1. CHANGELOG.md under „## Unveröffentlicht“ (D-07), plain words, Sie-form, no technical terms:
- In the existing „### Geändert“ add a bullet: Nextcloud-Status — as logo you can now also enter an image address beginning with http://; Tessera fetches the image once when saving and keeps it like an uploaded image (at most 1 MB, PNG, JPEG, GIF or WebP). Addresses that are only reachable internally are not fetched — such images are uploaded via „Bild hochladen“. Addresses with https:// are still loaded directly by the browser. The field is now called „Bildadresse“.
- Add „### Behoben“ after „### Geändert“ (inside „Unveröffentlicht“ only) with a bullet: Nextcloud-Status — the cloud form showed English messages for some inputs (for example for an image address with http://). All notes in this form now appear in German (in English with English language setting).
2. docs/anleitung-anwender.md, section Nextcloud-Status (the „Clouds pflegen“ paragraph): replace the sentence about the https address with: upload (PNG, JPEG, GIF or WebP, at most 1 MB) or an image address — https addresses are loaded by the browser, for http addresses Tessera fetches the image once when saving and stores it like an upload; internal addresses cannot be fetched, the form then points to „Bild hochladen“.
3. docs/anleitung-administration.md, Nextcloud-Status „Logo:“ bullet: the sentence that the server never fetches the address is no longer true — state that https addresses are loaded by the viewer's browser and never by the server; http addresses are fetched exactly once by the Tessera server on saving, only from the internet (internal and private addresses, also via redirects, are refused before any request), with a time limit, at most 1 MB and only PNG/JPEG/GIF/WebP checked on the bytes; the result is stored like an upload, the address itself is not kept. Upload and address still exclude each other.
4. Run the full gates: both test suites, both tsc runs, Biome lint on the changed/new files only (fix findings in new files; do not touch unrelated warnings). Rebuild with `docker compose up -d --build api web` and wait until api is up.
5. Live probe (the verify command does it): log in as admin, activate nextcloud-status if inactive, POST instances with a valid name and address and logoUrl http://127.0.0.1/logo.png -> 400 with code logoFetchInternal; the same with logoUrl ftp://x -> 400 logoUrlInvalid; customerName '' -> 400 customerNameRequired. None of these creates a row (validation and download refusal happen before the create).
6. In the SUMMARY list browser steps for the orchestrator (see output).
</action>
<verify>
<automated>pnpm --filter @tessera/api test && pnpm --filter @tessera/web test && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && sed -n '/^## Unveröffentlicht/,/^## 1\./p' CHANGELOG.md | grep -q "http://" && sed -n '/^## Unveröffentlicht/,/^## 1\./p' CHANGELOG.md | grep -q "### Behoben" && grep -q "einmalig" docs/anleitung-anwender.md && grep -q "einmalig" docs/anleitung-administration.md && docker compose ps --status running --services | grep -qx api && docker compose ps --status running --services | grep -qx web && A=$(mktemp) && B=$(mktemp) && curl -sf -c "$A" -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin123"}' http://localhost:3001/auth/login >/dev/null && MID=$(curl -sf -b "$A" http://localhost:3001/modules/catalog | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const m=JSON.parse(s).find(x=>x.slug==="nextcloud-status");if(!m)process.exit(1);process.stdout.write(m.isActiveForTenant?"":m.id)})') && { [ -z "$MID" ] || curl -sf -b "$A" -X POST "http://localhost:3001/modules/$MID/activate" >/dev/null; } && P=http://localhost:3001/modules/nextcloud-status/instances && test "$(curl -s -o "$B" -w '%{http_code}' -b "$A" -H 'Content-Type: application/json' -d '{"customerName":"Probe j9f","baseUrl":"https://cloud.example.invalid","logoUrl":"http://127.0.0.1/logo.png"}' $P)" = 400 && grep -q '"code":"logoFetchInternal"' "$B" && test "$(curl -s -o "$B" -w '%{http_code}' -b "$A" -H 'Content-Type: application/json' -d '{"customerName":"Probe j9f","baseUrl":"https://cloud.example.invalid","logoUrl":"ftp://x"}' $P)" = 400 && grep -q '"code":"logoUrlInvalid"' "$B" && test "$(curl -s -o "$B" -w '%{http_code}' -b "$A" -H 'Content-Type: application/json' -d '{"customerName":"","baseUrl":"https://cloud.example.invalid"}' $P)" = 400 && grep -q '"code":"customerNameRequired"' "$B" && ! curl -sf -b "$A" $P | grep -q "Probe j9f" && echo "final gates ok"</automated>
</verify>
<done>CHANGELOG (Geändert + Behoben under Unveröffentlicht), user and admin manuals describe http logos fetched once and internal addresses via upload; full api/web suites and both tsc runs green; rebuilt stack refuses an internal http logo with 400 logoFetchInternal, an invalid one with logoUrlInvalid, an empty name with customerNameRequired, and no probe row exists.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser -> API (POST/PUT instances) | Manager-supplied logo address (untrusted string) crosses here; only @ModuleManage('nextcloud-status') routes accept it |
| API -> internet (logo download) | Tessera server issues an outbound GET to a user-chosen http address and follows redirects chosen by a remote server |
| remote server -> API (response bytes) | Untrusted bytes that are stored and later served under Tessera's origin |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-j9f-01 | Information disclosure / Elevation (SSRF) | nextcloud-logo-fetch.ts fetchLogoImage | high | mitigate | isPublicHttpUrl (shared guard) before the first request and before every redirect hop; refused targets get no request at all; redirect 'manual', at most 3 hops, http/https only, URLs with credentials rejected by classifyLogoUrl, no cookies/auth headers; tests for literal private IPs, localhost/.local, metadata IP 169.254.169.254 and redirect-to-internal |
| T-j9f-02 | Information disclosure (DNS rebinding) | guard lookup vs. connect | low | accept | Same residual window as favorites icon fetch (T-08-05): name checked by lookup, connection resolves again. Only managers can trigger it, the result is stored only if the bytes are a PNG/JPEG/GIF/WebP, and the client only ever sees one of four fixed codes. Documented in the module header comment |
| T-j9f-03 | Denial of service | fetchLogoImage body read | medium | mitigate | One deadline (8 s) for all hops and the body, race against abort for hanging servers, content-length pre-check, streaming cap that cancels the reader once more than 1 MiB arrived; tests for never-settling fetch, stalled body and endless stream |
| T-j9f-04 | Tampering (malicious content served under Tessera origin) | stored logo bytes | high | mitigate | Type decided only by checkLogoUpload magic bytes (no SVG, content-type ignored), stored in the same logoData/logoMime columns as uploads and served by the existing logo route with nosniff and sandbox CSP (T-k67-02) |
| T-j9f-05 | Information disclosure (error oracle) | service error responses | low | mitigate | Only fixed codes and fixed German texts reach the client; no response text, headers or resolved IPs are returned or logged; logoFetchInternal is decided from the guard alone (no request) |
| T-j9f-06 | Elevation of privilege | create/update routes | medium | mitigate | Unchanged: @ModuleManage('nextcloud-status') without role decorator, tenantId only from req.tenantId, where { id, tenantId }; unknown id -> notFound before any download; tenantPrisma call count stays 14 (gate) |
| T-j9f-07 | Tampering (regression in favorites SSRF guard) | common/public-url-guard.ts | medium | mitigate | Verbatim move plus re-export; icon-discovery.service.spec.ts, favorites.service.ts and favorites.controller.ts must be byte-identical to 4ff43c2 and green (Task 1 gate) |
| T-j9f-SC | Tampering | npm/pip/cargo installs | high | accept | No package is installed; undici, class-validator and class-transformer are already dependencies of apps/api |
</threat_model>
<verification>
- Task 1 gate: nextcloud-status and favorites specs, RLS coverage/inventory, api tsc; favorites files unchanged vs 4ff43c2; guard shared; tenantPrisma count 14.
- Task 2 gate: web nextcloud-status tests, message specs (umlaut guard), web tsc, de/en parity, 12 error keys, new label/hint, old keys gone, no Mandant/Lizenz words.
- Task 3 gate: full api and web suites, both tsc, changelog/manual greps, running api/web, live probe with three 400 codes and no probe row.
</verification>
<success_criteria>
- http logo addresses work and are stored like uploads; https unchanged (D-01, D-04).
- Internal/private targets, also via redirects, are refused before any request; time and size limits enforced (D-02).
- Every failure and validation error in the cloud form appears in German (English with English UI), never as a raw server message (D-03, D-05).
- Label „Bildadresse“ and new hint, de/en identical (D-06).
- CHANGELOG and both manuals updated; all suites green; local stack rebuilt (D-07).
- Favorites behaviour unchanged.
</success_criteria>
<output>
Create `.planning/quick/261008-j9f-nextcloud-status-logo-per-http-adresse-h/261008-j9f-SUMMARY.md` when done. Include: commits per task, test/gate measurements (test counts, tsc, Biome on new files, live probe output), any umlaut allowlist additions, and browser steps for the orchestrator (dark mode): (1) Nextcloud-Status -> edit a cloud -> „Bildadresse“ shows the new hint; (2) enter a public http:// image address of a real website, save -> tile shows the logo, reopening the form shows „Bild hochladen“ selected (stored like an upload); (3) http://192.168.x.x/logo.png -> German hint pointing to „Bild hochladen“, nothing saved; (4) http address of an HTML page -> „Unter dieser Adresse liegt kein Bild …“; (5) empty name, address without http(s) and ftp:// logo address -> German texts without any request; (6) https logo address still works as before; (7) switch the UI to English once and repeat (5) -> English texts; (8) favorites: an existing favorite still shows its icon.
</output>