Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
47 KiB
phase, plan, type, wave, depends_on, quick_id, description, date, files_modified, autonomous, requirements, estimate, must_haves
| phase | plan | type | wave | depends_on | quick_id | description | date | files_modified | autonomous | requirements | estimate | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| quick-261008-j9f | 01 | execute | 1 | 261008-j9f | 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 | 2026-10-08 |
|
true |
|
|
|
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.
<execution_context>
@/.claude/gsd-core/workflows/execute-plan.md
@/.claude/gsd-core/templates/summary.md
</execution_context>
Project rules that apply here:
- API TS lib has no Object.hasOwn; use
Object.prototype.hasOwnProperty.callorin. - 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'messageoption and from the service. - NestJS: static routes before
:idroutes (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, exportedisPublicHttpUrl(url: URL): Promise<boolean>(http/https only, blocked names, IP literal check, otherwisedns.lookup(all:true)and every address must be public).fetchWithRedirectGuardis 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 importsIconDiscoveryService, normalizeUrlfrom it; icon-discovery.service.spec.ts importsdiscardBody, IconDiscoveryService, isPublicHttpUrl, normalizeUrl, readTextCappedfrom it and mocksundici. - 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 injectableundicifetch withredirect: 'manual', one AbortController deadline andPromise.raceagainst 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), severalNotFoundException('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.tsDomainsRequestError(status, code, message)readsbody.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 showst('required'),t('logoFileTooLarge'), or the raw error text; CloudForm.test.tsx renders with real de.json and mocks the api module via importOriginal.
-
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,isBlockedHostnameand the exportedisPublicHttpUrl(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, importisPublicHttpUrlfrom '../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. -
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.“ ExportnextcloudFormError(code)returningnew NotFoundException({ code, message })for notFound andnew BadRequestException({ code, message })for all others (domains pattern), andNEXTCLOUD_FORM_ERROR_CODES(array of the keys) for the DTO spec. ExportvalidateInstanceInput(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,normalizeCloudUrlnull -> baseUrlInvalid; returns the trimmed name and normalized URL. Check order: name, address, then (in the service) logo. -
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 bynew 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'sfetch(module import, like nextcloud-status-fetch.ts; normal certificate checks — no lenient agent) andisPublicHttpUrlfrom '../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.bodywith 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).
-
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).
-
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).
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" 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.
'; 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.
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).
-
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.' 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.
-
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.' 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.
-
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.
-
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).
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")'
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.
Task 3: Changelog and manuals, full suites, rebuild and live API probe
CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-administration.md
CHANGELOG.md (lines 1-20), docs/anleitung-anwender.md (lines 225-240), docs/anleitung-administration.md (lines 348-362)
The local Docker stack from docker-compose.yml (db, api, web) can be built and started on the dev host.
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).
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"
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.
<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>
- 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.
<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>
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.