Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e72814abd9 | |||
| 0e72ad45f8 | |||
| b15c74632b | |||
| 7188c5b958 |
@@ -482,6 +482,7 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
|
||||
| 260929-dmx | **Widget-Raster horizontal feiner + Kalender schmaler.** COLS lg 48/md 40/sm 24/xs 16/xxs 4, GRID_VERSION 3 (v2->v3 nur x/w/minW/maxW x2), alle minW/defaultW x2, Kalender minW 8 (~250 px). Browser: Anordnung pixelgleich, Kalender bis 252 px, Schritt 33 px. Auch: Hover-Anheben der Widgets entfernt (acd3c7a, Nutzerwunsch). | 2026-09-29 | 97744b5,9c9e142,46ebb4e | [260929-dmx-widget-raster-horizontal-feiner-48-spalt](./quick/260929-dmx-widget-raster-horizontal-feiner-48-spalt/) |
|
||||
| 260929-dzu | **Eigene Module fuer jeden Benutzer (persoenlich).** `CustomModule.ownerUserId` (null = gemeinsam), RLS-Muster SearchProvider, Einstellungen > Eigene Module (nur eigene), Verwaltung nur gemeinsame; Browser: Sichtbarkeit/Rechte wie verlangt. Nebenbei ohne eigenen Quick: Zentrierung entfernt (bc4c011), Desktop neue Fenster -> System-Browser (76a9234, Windows-VM bestaetigt), Single-Instance auf VM bestaetigt. | 2026-09-29 | c703d87,ee97b4e,8f41bd2 | [260929-dzu-eigene-module-fuer-jeden-benutzer-persoe](./quick/260929-dzu-eigene-module-fuer-jeden-benutzer-persoe/) |
|
||||
| 260929-if2 | **Erinnerungen-Widget (Reminder).** Modell `Reminder` + RLS, API /reminders (anlegen/listen/bearbeiten/loeschen/erledigt/snooze, 409/404-Regeln), E-Mail-Scheduler alle 30 s mit Claim-once + max. 3 Versuche, globaler ReminderNotifier (Browser-Notification, Desktop via Tauri-Notification mit Laufzeit-Capability nur fuer die Server-Origin, Pattern escaped + vorab geprueft). Verifier human_needed (Windows-Toast offen); Browser dunkel bestanden inkl. echter Mail ueber MailHog. api 1570, web 1069, cargo 57. Nebenbei: eigene Module ohne Kopfzeile (cd1f8f6), Update-Klick prueft frisch (41d00a3). | 2026-09-29 | 325c5dd,709b41a,6879c75 | [260929-if2-reminder-widget-mit-benachrichtigung](./quick/260929-if2-reminder-widget-mit-benachrichtigung/) |
|
||||
| 260929-lh3 | **Favoriten: eigene Symbol-Adresse wirkt.** Neue iconUrl ersetzt Upload + bumpt iconVersion; iconUrl wird auch gespeichert, wenn nur der Browser sie laden kann (kein 422 mehr, nur Formpruefung); Kachel: Proxy -> iconUrl direkt -> origin/favicon -> Buchstabe; Discovery liest <link rel=icon> auch aus Nicht-2xx-Seiten (docuvita 400). | 2026-09-29 | 7188c5b,b15c746,0e72ad4 | [260929-lh3-favoriten-eigenes-symbol-wirkt-nicht](./quick/260929-lh3-favoriten-eigenes-symbol-wirkt-nicht/) |
|
||||
|
||||
## Deferred Items
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
quick_id: 260929-lh3
|
||||
type: quick
|
||||
wave: 1
|
||||
autonomous: true
|
||||
---
|
||||
|
||||
# Quick 260929-lh3: Favoriten — eigene Symbol-Adresse wirkt nicht
|
||||
|
||||
## User reports (29.09.2026, alpha 8c644de)
|
||||
|
||||
1. Favorite with URL https://docuvita.ctl.local/server/services/web/ shows a black circle with a white "V"
|
||||
instead of the page's favicon (visible in the browser tab).
|
||||
2. Setting an explicit icon URL ("Symbol-Adresse", field `iconUrl`) to
|
||||
https://nextcloud.com/c/uploads/2025/10/Nextcloud_01-standard-logo.png on a favorite does not change the shown icon.
|
||||
|
||||
## Measured facts (orchestrator, from inside the alpha api container)
|
||||
|
||||
- docuvita.ctl.local resolves (172.16.0.46). Server-side GET of the page returns **400** but the HTML contains
|
||||
`<link rel="SHORTCUT ICON" type="image/png" href="/webclient/docuvita/resources/brandimage/favicon.ico" />`.
|
||||
Server-side GET of that icon returns **404 text/html** (also with a Chrome User-Agent). Root /favicon.ico → 404.
|
||||
→ docuvita refuses the files to the server; the browser can load them (user sees the icon in the tab).
|
||||
- Discovery (`apps/api/src/favorites/icon-discovery.service.ts` `discoverFavoriteIconUrl`) ignores non-2xx HTML
|
||||
(`fetchHtml` returns null) → falls back to `{origin}/favicon.ico`; proxy fails → browser direct
|
||||
`{origin}/favicon.ico` shows the "V" (a real icon served to browsers at the root).
|
||||
- Explicit `iconUrl` that the server cannot fetch → API answers 422 `iconUrlUnreachable` (quick 260923-lrr),
|
||||
so the user cannot set the docuvita icon URL at all.
|
||||
|
||||
## Task 1: Explicit icon URL change must show immediately (bug 2)
|
||||
|
||||
- files: apps/api/src/favorites/*, apps/web/src/components/dashboard/widgets/favorites-widget.tsx, apps/web/src/lib/favorites-api.ts (+ tests)
|
||||
- action: Reproduce locally (admin/admin123, favorites widget): set/change `iconUrl` to a reachable PNG (e.g. the Nextcloud URL,
|
||||
and a second different one). Find why the tile keeps the old image — likely the icon proxy URL
|
||||
(`/favorites/:id/icon?...`) does not change when `iconUrl` changes (browser/HTTP cache, Cache-Control on the proxy
|
||||
response, `iconVersion` only bumped on upload, or the server returns a cached/discovered icon instead of the explicit one).
|
||||
Fix at the root: the explicit `iconUrl` wins over discovery, and any change of `iconUrl` changes the image URL
|
||||
(e.g. cache-buster from `iconVersion` bumped on every iconUrl change, or a hash of iconUrl). Add regression tests
|
||||
(API: PATCH iconUrl bumps version / proxy serves new bytes; web: tile src changes when iconUrl changes).
|
||||
- verify: api + web tests for favorites green.
|
||||
- done: commit `fix(favorites): geaenderte Symbol-Adresse wird sofort angezeigt`.
|
||||
|
||||
## Task 2: Accept icon URLs the server cannot fetch; browser loads them directly (bug 1)
|
||||
|
||||
- action:
|
||||
- API: an explicit `iconUrl` that is a valid http/https URL is stored even if the server cannot fetch it
|
||||
(no more 422 for "unreachable"; keep validation of scheme/length and keep rejecting non-image responses only
|
||||
when the server DID get a response with a non-image content type — decide and document). Keep SSRF guard for
|
||||
server-side fetches unchanged.
|
||||
- Web tile: chain for an explicit `iconUrl`: proxy image → on error the browser loads `iconUrl` directly
|
||||
(`<img>` with referrerPolicy="no-referrer", only http/https) → letter fallback. Existing chain for discovered icons unchanged.
|
||||
- Discovery improvement (small, safe): if the page answers non-2xx but returns HTML with a `<link rel=icon>`,
|
||||
still use that icon URL (so docuvita-like servers yield `/webclient/.../favicon.ico`, which the browser can then load directly).
|
||||
- Remove/adjust the now-unused `iconUrlUnreachable` error text (de/en) if no longer reachable.
|
||||
- verify: api + web favorites tests green; type-check/lint green; biome web ≤ 55, api ≤ 82.
|
||||
- done: commit `fix(favorites): Symbol-Adresse auch speichern, wenn nur der Browser sie laden kann`.
|
||||
|
||||
## Task 3: CHANGELOG + rebuild
|
||||
|
||||
- CHANGELOG `## Unveröffentlicht` → `### Behoben`: two plain-German bullets (Sie-Form) for both fixes.
|
||||
- `docker compose up -d --build web api`.
|
||||
- Commit `docs(changelog): Favoriten-Symbole`.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Commit locally only, NEVER git push. Commits end with `Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>`.
|
||||
- Commit with explicit paths only.
|
||||
- Browser check is done by the orchestrator.
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
quick_id: 260929-lh3
|
||||
phase: quick
|
||||
plan: 260929-lh3
|
||||
subsystem: favorites
|
||||
tags: [favorites, icons, icon-discovery, browser-fallback]
|
||||
status: complete
|
||||
commits: 3
|
||||
plan_head_before: 8c644de5dad56a0394a14a00180a125919565246
|
||||
plan_head_after: 0e72ad45f8833cb93ae9a8c0afa1f6d4e948e3e0
|
||||
actuals:
|
||||
tasks: 3
|
||||
commits: 3
|
||||
key-files:
|
||||
modified:
|
||||
- apps/api/src/favorites/favorites.service.ts
|
||||
- apps/api/src/favorites/icon-discovery.service.ts
|
||||
- apps/api/src/favorites/dto/create-favorite.dto.ts
|
||||
- apps/api/src/favorites/dto/update-favorite.dto.ts
|
||||
- apps/web/src/components/dashboard/widgets/favorites-widget.tsx
|
||||
- apps/web/src/lib/favorites-api.ts
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
- CHANGELOG.md
|
||||
---
|
||||
|
||||
# Quick 260929-lh3: Favoriten-Symbol-Adresse Summary
|
||||
|
||||
Explicit icon URLs are now stored even when the server cannot fetch them, the tile loads them directly in the browser, discovery uses `<link rel=icon>` from non-2xx HTML pages, and a newly entered icon URL replaces a previously uploaded icon.
|
||||
|
||||
## Commits
|
||||
|
||||
- 7188c5b `fix(favorites): geaenderte Symbol-Adresse wird sofort angezeigt` (Task 1)
|
||||
- b15c746 `fix(favorites): Symbol-Adresse auch speichern, wenn nur der Browser sie laden kann` (Task 2)
|
||||
- 0e72ad4 `docs(changelog): Favoriten-Symbole` (Task 3)
|
||||
|
||||
## Root cause, bug 2 ("Symbol-Adresse wirkt nicht")
|
||||
|
||||
Measured, not assumed:
|
||||
|
||||
- Local reproduction (API with curl, and the real widget in headless Chromium via CDP): changing `iconUrl` on a favorite WITHOUT an uploaded icon works. `iconVersion` is bumped, the tile is remounted, the `<img>` src changes (`?v=1` -> `?v=2`), and the bytes change (naturalWidth 626 -> 48). The proxy, `Cache-Control` and Next rewrite are not the cause.
|
||||
- The alpha database (read-only psql) shows the save path works there too: favorite "Medon" holds the Nextcloud URL with `iconVersion = 1`. No favorite on alpha has `uploadedIconMime` set, and the alpha web image contains the `?v=` code.
|
||||
- The real defect found in the code: `getIconBytes` always serves the uploaded file when `uploadedIconMime` is set, and `update()` never cleared it. A newly entered `iconUrl` was therefore saved but invisible for any favorite with an uploaded icon (the form even said "Ein hochgeladenes Symbol hat Vorrang"). Fixed: a new, different, non-empty `iconUrl` now clears `uploadedIconMime`, removes the file, and bumps `iconVersion`. An unchanged `iconUrl` (the form resends it on every save) leaves the upload alone.
|
||||
- Caveat for the orchestrator: for the exact alpha "Medon" case (no upload) I could not reproduce a stale display; the alpha row and local browser behavior are both correct. The browser check on alpha (https, NPM, basic auth) remains the only place this can still show. If it still fails there with the new build, capture the network request of `/api-proxy/favorites/<id>/icon?v=1` in the alpha browser.
|
||||
|
||||
## Root cause, bug 1 (black circle with "V")
|
||||
|
||||
`fetchHtml` dropped every non-2xx response, so docuvita (answers the server with 400 but ships `<link rel="SHORTCUT ICON" href="/webclient/.../favicon.ico">`) fell back to `{origin}/favicon.ico`; the proxy failed and the browser showed the root favicon (the "V"). And an explicit `iconUrl` was rejected by the 422 fetch probe because docuvita returns 404 HTML to the server.
|
||||
|
||||
## Changes
|
||||
|
||||
- API: `assertIconUrlLoadable` (422) replaced by `assertIconUrlWellFormed` (http/https, <= 2048 chars, else 400); DTO `@MaxLength(2048)` on both DTOs. Decision, documented in code: even a response the server DID receive with a non-image type does not reject, because "server gets no image" does not mean "browser gets none". SSRF guard for server-side fetches is unchanged.
|
||||
- Discovery: `fetchWithRedirectGuard` got `allowErrorStatus` (used only by the HTML search; `fetchIconBytes` stays strict). On a non-2xx page only `<link rel=...icon>` counts, not `og:image`.
|
||||
- Web tile: proxy -> `iconUrl` direct (`referrerPolicy="no-referrer"`, http/https only) -> `{origin}/favicon.ico` (skipped when identical) -> letter. Existing chain for discovered icons behaves as before.
|
||||
- Removed the `iconUrlUnreachable` reason, 422 handling and de/en texts; adjusted the upload hint (a newly entered address replaces an upload).
|
||||
- CHANGELOG: two plain-German bullets under Unveröffentlicht / Behoben.
|
||||
- Rebuilt `web` and `api` (`docker compose up -d --build`, healthy). Smoke test against the rebuilt API: URL the server cannot fetch is saved (proxy answers 502, tile falls back to browser), `javascript:` is rejected with 400. Test favorite deleted afterwards.
|
||||
|
||||
## Verification
|
||||
|
||||
- API favorites specs: 99 tests green. Web favorites widget, favorites-api and messages tests: 47 green. `tsc --noEmit` clean for web and api.
|
||||
- Biome: web 55 warnings, api 82 warnings (at the limits, not above).
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
- [Rule 2 - Missing validation] Plan said "keep validation of scheme/length", but none existed for `iconUrl`; added form validation (service + DTO length) so the SSRF-relevant direct browser load never receives non-http(s) schemes.
|
||||
- Task 1 needed no web change: the existing test "Speichern mit neuer Logo-Adresse ... ?v=1" already covers the src change; API regression tests were added.
|
||||
- Commits were made on `main` as instructed (no worktree).
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
Commits 7188c5b, b15c746, 0e72ad4 exist; SUMMARY location correct; PLAN/SUMMARY/STATE not committed.
|
||||
|
||||
## Browser-Pruefung (Orchestrator, 29.09.)
|
||||
|
||||
- Favorit „Claude“: iconUrl -> Nextcloud-PNG (PATCH 200), nach Neuladen Proxy-Bild ?v=5 mit 626 px Breite geladen.
|
||||
- iconUrl -> docuvita-Brand-Icon (vom Dev-Host nicht aufloesbar): PATCH 200 (frueher 422), Kachel faellt sauber zurueck. Im Firmennetz laedt der Browser direkt.
|
||||
- Zurueckgesetzt auf das urspruengliche Symbol.
|
||||
@@ -15,6 +15,8 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
||||
### Behoben
|
||||
|
||||
- Desktop-App: „Auf Version … aktualisieren“ im Menü des Tessera-Symbols scheiterte mit „Signaturprüfung fehlgeschlagen“ und öffnete stattdessen die Download-Seite, wenn der Server seit der letzten Update-Prüfung der App eine neuere Version bekommen hatte. Die App fragt jetzt beim Klick zuerst frisch nach und installiert genau die Version, die der Server in diesem Moment anbietet.
|
||||
- Dashboard, Favoriten: Eine neu eingetragene Logo-Adresse wird jetzt sofort angezeigt. Bisher blieb ein früher hochgeladenes eigenes Symbol stehen und verdeckte die neue Adresse; jetzt ersetzt die neue Adresse es.
|
||||
- Dashboard, Favoriten: Eine Logo-Adresse lässt sich jetzt auch speichern, wenn Tessera das Bild selbst nicht laden kann – etwa bei Seiten im internen Netz, die nur Ihrem Browser das Symbol geben. Die Kachel lädt das Bild dann direkt in Ihrem Browser. Auch die automatische Erkennung findet das Symbol solcher Seiten jetzt eher, statt auf ein Ersatzsymbol zurückzufallen.
|
||||
|
||||
## 1.7.0 – 2026-09-29
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
IsString,
|
||||
IsUrl,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
@@ -24,6 +25,7 @@ export class CreateFavoriteDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2048)
|
||||
iconUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsInt, IsOptional, IsString, IsUrl } from 'class-validator';
|
||||
import { IsInt, IsOptional, IsString, IsUrl, MaxLength } from 'class-validator';
|
||||
|
||||
/**
|
||||
* DTO for updating an existing FavoriteLink.
|
||||
@@ -19,6 +19,8 @@ export class UpdateFavoriteDto {
|
||||
* No strict type validation so null passes through to Prisma.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2048)
|
||||
iconUrl?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
HttpException,
|
||||
NotFoundException,
|
||||
PayloadTooLargeException,
|
||||
UnprocessableEntityException,
|
||||
} from '@nestjs/common';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FavoritesService } from './favorites.service';
|
||||
@@ -859,6 +858,40 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('update — ausdrueckliche Logo-Adresse verdraengt ein hochgeladenes Symbol (260929-lh3)', () => {
|
||||
const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
|
||||
|
||||
it('neue, abweichende iconUrl bei vorhandenem Upload: Upload-Typ null, Datei weg, iconVersion erneut +1 — die neue Adresse wird angezeigt', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
await service.uploadIcon('t1', 'f1', 'user-a1', file);
|
||||
expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(true);
|
||||
|
||||
const updated = await service.update('t1', 'f1', 'user-a1', {
|
||||
iconUrl: 'https://neu.invalid/logo.png',
|
||||
} as any);
|
||||
|
||||
expect(updated.iconUrl).toBe('https://neu.invalid/logo.png');
|
||||
expect(updated.uploadedIconMime).toBeNull();
|
||||
expect(updated.iconVersion).toBe(2);
|
||||
expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(false);
|
||||
});
|
||||
|
||||
it('UNVERAENDERTE iconUrl bei vorhandenem Upload (das Formular schickt sie bei jedem Speichern mit): Upload bleibt', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
await service.uploadIcon('t1', 'f1', 'user-a1', file);
|
||||
|
||||
const updated = await service.update('t1', 'f1', 'user-a1', {
|
||||
iconUrl: baseRow.iconUrl,
|
||||
} as any);
|
||||
|
||||
expect(updated.uploadedIconMime).toBe('image/png');
|
||||
expect(updated.iconVersion).toBe(1);
|
||||
expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove() mit hochgeladenem Symbol', () => {
|
||||
it('Zeile und Datei weg', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
@@ -886,26 +919,50 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('create/update — Abrufprobe fuer eine explizite iconUrl (260923-lrr)', () => {
|
||||
it('create mit expliziter iconUrl: Probe genau einmal; wirft -> UnprocessableEntityException, favoriteLink.create NICHT aufgerufen', async () => {
|
||||
const prisma = makeFakePrisma([], [{ id: 'widget-a1', userId: 'user-a1', tenantId: 't1' }]);
|
||||
const iconDiscovery = makeIconDiscovery({
|
||||
fetchIconBytes: vi.fn(async () => {
|
||||
throw new Error('blocked');
|
||||
}),
|
||||
describe('create/update — ausdrueckliche iconUrl: nur Formpruefung, kein Abruf (260929-lh3)', () => {
|
||||
const widgets = [{ id: 'widget-a1', userId: 'user-a1', tenantId: 't1' }];
|
||||
const failingFetch = () =>
|
||||
vi.fn(async () => {
|
||||
throw new Error('server bekommt 404/HTML');
|
||||
});
|
||||
|
||||
it('create mit einer Adresse, die der SERVER nicht abrufen kann: wird gespeichert, KEIN Abruf, KEINE Erkennung', async () => {
|
||||
const prisma = makeFakePrisma([], widgets);
|
||||
const iconDiscovery = makeIconDiscovery({ fetchIconBytes: failingFetch() });
|
||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||
|
||||
const created = await service.create('t1', 'user-a1', {
|
||||
widgetId: 'widget-a1',
|
||||
title: 'Docuvita',
|
||||
url: 'https://docuvita.ctl.local/server/services/web/',
|
||||
iconUrl: 'https://docuvita.ctl.local/webclient/docuvita/resources/brandimage/favicon.ico',
|
||||
} as any);
|
||||
|
||||
expect(created.iconUrl).toBe(
|
||||
'https://docuvita.ctl.local/webclient/docuvita/resources/brandimage/favicon.ico',
|
||||
);
|
||||
expect(iconDiscovery.fetchIconBytes).not.toHaveBeenCalled();
|
||||
expect(iconDiscovery.discoverFavoriteIconUrl).not.toHaveBeenCalled();
|
||||
expect(prisma.__favorites.size).toBe(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['kein http/https', 'ftp://x.invalid/icon.png'],
|
||||
['javascript-Schema', 'javascript:alert(1)'],
|
||||
['keine Adresse', 'kein url'],
|
||||
['laenger als 2048 Zeichen', `https://x.invalid/${'a'.repeat(2050)}`],
|
||||
])('create mit ungueltiger iconUrl (%s) -> BadRequestException, nichts geschrieben', async (_label, iconUrl) => {
|
||||
const prisma = makeFakePrisma([], widgets);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
await expect(
|
||||
service.create('t1', 'user-a1', {
|
||||
widgetId: 'widget-a1',
|
||||
title: 'X',
|
||||
url: 'https://x.invalid',
|
||||
iconUrl: 'https://x.invalid/logo.png',
|
||||
iconUrl,
|
||||
} as any),
|
||||
).rejects.toThrow(UnprocessableEntityException);
|
||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledTimes(1);
|
||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith('https://x.invalid/logo.png');
|
||||
).rejects.toThrow(BadRequestException);
|
||||
expect(prisma.__favorites.size).toBe(0);
|
||||
});
|
||||
|
||||
@@ -922,24 +979,31 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
|
||||
iconVersion: 0,
|
||||
};
|
||||
|
||||
it('update mit neuer, abweichender iconUrl: fetchIconBytes genau einmal mit dieser Adresse; wirft -> UnprocessableEntityException, favoriteLink.update NICHT aufgerufen', async () => {
|
||||
it('update mit neuer iconUrl, die der Server nicht abrufen kann: gespeichert, iconVersion +1, KEIN Abruf', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const iconDiscovery = makeIconDiscovery({
|
||||
fetchIconBytes: vi.fn(async () => {
|
||||
throw new Error('blocked');
|
||||
}),
|
||||
});
|
||||
const iconDiscovery = makeIconDiscovery({ fetchIconBytes: failingFetch() });
|
||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||
|
||||
const updated = await service.update('t1', 'f1', 'user-a1', {
|
||||
iconUrl: 'https://neu.invalid/icon.png',
|
||||
} as any);
|
||||
|
||||
expect(updated.iconUrl).toBe('https://neu.invalid/icon.png');
|
||||
expect(updated.iconVersion).toBe(1);
|
||||
expect(iconDiscovery.fetchIconBytes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update mit ungueltiger neuer iconUrl -> BadRequestException, Zeile unveraendert', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
await expect(
|
||||
service.update('t1', 'f1', 'user-a1', { iconUrl: 'https://neu.invalid/icon.png' } as any),
|
||||
).rejects.toThrow(UnprocessableEntityException);
|
||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledTimes(1);
|
||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith('https://neu.invalid/icon.png');
|
||||
service.update('t1', 'f1', 'user-a1', { iconUrl: 'file:///etc/passwd' } as any),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
expect(prisma.__favorites.get('f1').iconUrl).toBe(baseRow.iconUrl);
|
||||
});
|
||||
|
||||
it('update mit UNVERAENDERTER iconUrl: keine Probe, keine Erhoehung', async () => {
|
||||
it('update mit UNVERAENDERTER iconUrl: keine Pruefung, keine Erhoehung', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const iconDiscovery = makeIconDiscovery();
|
||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||
@@ -958,18 +1022,5 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
|
||||
|
||||
expect(updated.iconVersion).toBe(0);
|
||||
});
|
||||
|
||||
it('update mit neuer, erreichbarer iconUrl: iconVersion +1', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const iconDiscovery = makeIconDiscovery();
|
||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||
|
||||
const updated = await service.update('t1', 'f1', 'user-a1', {
|
||||
iconUrl: 'https://neu.invalid/icon.png',
|
||||
} as any);
|
||||
|
||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith('https://neu.invalid/icon.png');
|
||||
expect(updated.iconVersion).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
PayloadTooLargeException,
|
||||
UnprocessableEntityException,
|
||||
} from '@nestjs/common';
|
||||
import type { UploadedFileLike } from '../auth/types/auth-user';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@@ -77,12 +76,16 @@ import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service';
|
||||
* Datei, dann die Zeile; scheitert die Zeile, wird die neue Datei wieder
|
||||
* entfernt. Entfernen/Loeschen aktualisiert zuerst die Zeile, ein
|
||||
* Dateifehler wird protokolliert und geschluckt.
|
||||
* - Abrufprobe: `assertIconUrlLoadable()` ruft `fetchIconBytes` einmal ab,
|
||||
* um eine im Formular NICHT abrufbare Logo-Adresse (z. B. hinter einer
|
||||
* Cloudflare-Pruefung) mit `UnprocessableEntityException` (422) statt
|
||||
* stiller Speicherung abzuweisen — keine Umgehung von Bot-Sperren, nur
|
||||
* derselbe Abruf, den `GET /favorites/:id/icon` ohnehin ausloest.
|
||||
* - 260929-lh3 (loest die Abrufprobe von 260923-lrr ab): eine ausdrueckliche
|
||||
* Logo-Adresse wird nur auf Form (http/https, <= 2048 Zeichen) geprueft und
|
||||
* auch gespeichert, wenn der Server sie nicht abrufen kann — der Browser der
|
||||
* Kachel laedt sie dann direkt. Ein hochgeladenes Symbol wird von einer
|
||||
* neuen, abweichenden Adresse verdraengt (Vorrang der Datei sonst: Adresse
|
||||
* gespeichert, aber unsichtbar).
|
||||
*/
|
||||
/** Hoechstlaenge einer ausdruecklichen Logo-Adresse (260929-lh3). */
|
||||
const ICON_URL_MAX_LENGTH = 2048;
|
||||
|
||||
@Injectable()
|
||||
export class FavoritesService {
|
||||
private readonly logger = new Logger(FavoritesService.name);
|
||||
@@ -107,19 +110,31 @@ export class FavoritesService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft, ob sich das Bild unter `iconUrl` serverseitig abrufen laesst
|
||||
* (260923-lrr) — derselbe `fetchIconBytes`-Aufruf, den `getIconBytes`
|
||||
* ohnehin ausloest, hier nur zur Speicherzeit als Probe. Jeder Fehler
|
||||
* (SSRF-Ablehnung, Zeitgrenze, kein `image/*`, Cloudflare-Pruefung o. ae.)
|
||||
* wird zu derselben deutschen 422-Meldung — keine Unterscheidung, aus der
|
||||
* sich etwas ueber die gepruefte Adresse ablesen liesse.
|
||||
* Prueft eine ausdruecklich eingetragene Logo-Adresse NUR auf Form (260929-lh3):
|
||||
* gueltige http/https-Adresse, hoechstens 2048 Zeichen. Bewusst KEIN
|
||||
* serverseitiger Abruf mehr — Server wie docuvita liefern dem Server ein
|
||||
* 404/HTML, dem Browser aber das Bild; die fruehere Abrufprobe (422,
|
||||
* 260923-lrr) machte genau diese Adressen unspeicherbar. Entscheidung: auch
|
||||
* eine Antwort, die der Server sieht und die kein Bild ist, weist NICHT ab —
|
||||
* "Server bekommt kein Bild" heisst nicht "Browser bekommt keins", und der
|
||||
* Server kann beides nicht unterscheiden. Der SSRF-Schutz bleibt unveraendert
|
||||
* dort, wo der Server tatsaechlich abruft (`getIconBytes`/Erkennung); scheitert
|
||||
* der Proxy, laedt die Kachel die Adresse direkt im Browser.
|
||||
*/
|
||||
private async assertIconUrlLoadable(iconUrl: string): Promise<void> {
|
||||
private assertIconUrlWellFormed(iconUrl: string): void {
|
||||
let parsed: URL | null = null;
|
||||
try {
|
||||
await this.iconDiscovery.fetchIconBytes(iconUrl);
|
||||
parsed = new URL(iconUrl);
|
||||
} catch {
|
||||
throw new UnprocessableEntityException(
|
||||
'Das Bild unter dieser Adresse konnte nicht geladen werden. Die Seite blockiert vermutlich automatische Abrufe (zum Beispiel durch eine Cloudflare-Prüfung) oder ist nicht erreichbar. Bitte laden Sie das Symbol stattdessen hoch.',
|
||||
parsed = null;
|
||||
}
|
||||
if (
|
||||
parsed === null ||
|
||||
(parsed.protocol !== 'http:' && parsed.protocol !== 'https:') ||
|
||||
iconUrl.length > ICON_URL_MAX_LENGTH
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'Die Logo-Adresse muss eine gültige http- oder https-Adresse sein (höchstens 2048 Zeichen).',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -129,8 +144,8 @@ export class FavoritesService {
|
||||
* Verifies the target widget belongs to the caller BEFORE any icon
|
||||
* discovery network call (T-GWH-05).
|
||||
* If iconUrl is not provided, triggers server-side icon discovery with SSRF protection.
|
||||
* If iconUrl IS provided (260923-lrr), it must load successfully or the
|
||||
* create is rejected with 422 — nothing is written on a failed probe.
|
||||
* If iconUrl IS provided it is stored as given after a form check only
|
||||
* (260929-lh3, see assertIconUrlWellFormed) — no server-side fetch.
|
||||
*/
|
||||
async create(tenantId: string, userId: string, dto: CreateFavoriteDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
@@ -154,8 +169,8 @@ export class FavoritesService {
|
||||
let iconUrl = dto.iconUrl ?? null;
|
||||
|
||||
if (iconUrl) {
|
||||
// 260923-lrr: explizit uebergebene Adresse wird einmal probiert.
|
||||
await this.assertIconUrlLoadable(iconUrl);
|
||||
// 260929-lh3: nur Formpruefung, kein serverseitiger Abruf.
|
||||
this.assertIconUrlWellFormed(iconUrl);
|
||||
} else {
|
||||
// Server-side icon discovery (D-05) — only when caller did not supply an icon
|
||||
iconUrl = await this.iconDiscovery.discoverFavoriteIconUrl(url);
|
||||
@@ -179,10 +194,11 @@ export class FavoritesService {
|
||||
* Verifies userId ownership before applying changes (T-08-06).
|
||||
* Accepts null as an explicit value for iconUrl (clears stored icon).
|
||||
*
|
||||
* 260923-lrr: eine neue, vom gespeicherten Wert ABWEICHENDE `iconUrl`
|
||||
* durchlaeuft die Abrufprobe (`assertIconUrlLoadable`), bevor irgendetwas
|
||||
* geschrieben wird; misslingt sie, bleibt die Zeile unveraendert. Jede
|
||||
* tatsaechliche Aenderung der Symbolquelle erhoeht `iconVersion`.
|
||||
* 260929-lh3: eine neue, vom gespeicherten Wert ABWEICHENDE `iconUrl`
|
||||
* durchlaeuft nur die Formpruefung (`assertIconUrlWellFormed`), bevor
|
||||
* irgendetwas geschrieben wird; sie wird auch gespeichert, wenn der Server
|
||||
* sie nicht abrufen kann. Jede tatsaechliche Aenderung der Symbolquelle
|
||||
* erhoeht `iconVersion`.
|
||||
*/
|
||||
async update(tenantId: string, id: string, userId: string, dto: UpdateFavoriteDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
@@ -205,8 +221,8 @@ export class FavoritesService {
|
||||
if ('iconUrl' in dto) {
|
||||
if (dto.iconUrl) {
|
||||
if (dto.iconUrl !== link.iconUrl) {
|
||||
// 260923-lrr: nur eine NEUE, abweichende Adresse wird probiert.
|
||||
await this.assertIconUrlLoadable(dto.iconUrl);
|
||||
// 260929-lh3: nur eine NEUE, abweichende Adresse wird geprueft (Form).
|
||||
this.assertIconUrlWellFormed(dto.iconUrl);
|
||||
}
|
||||
// Explicit icon URL supplied — respect it as-is.
|
||||
data.iconUrl = dto.iconUrl;
|
||||
@@ -219,14 +235,34 @@ export class FavoritesService {
|
||||
}
|
||||
}
|
||||
|
||||
if (data.iconUrl !== undefined && data.iconUrl !== link.iconUrl) {
|
||||
// 260929-lh3: eine NEUE, ausdruecklich eingetragene Logo-Adresse muss
|
||||
// Vorrang vor einem frueher hochgeladenen Symbol haben. `getIconBytes`
|
||||
// liefert bei gesetztem `uploadedIconMime` IMMER die Datei — ohne diesen
|
||||
// Schritt blieb die neue Adresse gespeichert, aber unsichtbar (die Kachel
|
||||
// zeigte weiter das alte hochgeladene Bild). Nur bei einer tatsaechlichen
|
||||
// Aenderung: das Formular schickt die unveraenderte Adresse bei jedem
|
||||
// Speichern mit, das darf ein hochgeladenes Symbol nicht verdraengen.
|
||||
const iconUrlChanged = data.iconUrl !== undefined && data.iconUrl !== link.iconUrl;
|
||||
const explicitUrlReplacesUpload =
|
||||
iconUrlChanged && Boolean(dto.iconUrl) && link.uploadedIconMime !== null;
|
||||
if (explicitUrlReplacesUpload) {
|
||||
data.uploadedIconMime = null;
|
||||
}
|
||||
|
||||
if (iconUrlChanged) {
|
||||
data.iconVersion = { increment: 1 };
|
||||
}
|
||||
|
||||
return tenantPrisma.favoriteLink.update({
|
||||
const updated = await tenantPrisma.favoriteLink.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
|
||||
if (explicitUrlReplacesUpload && link.uploadedIconMime !== null) {
|
||||
await this.removeIconFile(id, link.userId, link.uploadedIconMime, 'ersetzte');
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -133,6 +133,54 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('IconDiscoveryService.discoverFavoriteIconUrl — Seite mit Fehlerstatus (260929-lh3)', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function htmlResponse(status: number, html: string) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: {
|
||||
get: (n: string) =>
|
||||
n.toLowerCase() === 'content-type' ? 'text/html; charset=utf-8' : null,
|
||||
},
|
||||
text: async () => html,
|
||||
};
|
||||
}
|
||||
|
||||
it('Seite antwortet 400, traegt aber <link rel="SHORTCUT ICON"> (docuvita) -> dieser Verweis wird genutzt', async () => {
|
||||
const html =
|
||||
'<html><head><link rel="SHORTCUT ICON" type="image/png" href="/webclient/docuvita/resources/brandimage/favicon.ico" /></head></html>';
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(htmlResponse(400, html)));
|
||||
|
||||
const icon = await new IconDiscoveryService().discoverFavoriteIconUrl(
|
||||
'http://8.8.8.8/server/services/web/',
|
||||
);
|
||||
|
||||
expect(icon).toBe('http://8.8.8.8/webclient/docuvita/resources/brandimage/favicon.ico');
|
||||
});
|
||||
|
||||
it('Fehlerseite ohne Symbol-Verweis, nur og:image -> Rueckfall <origin>/favicon.ico (og:image einer Fehlerseite zaehlt nicht)', async () => {
|
||||
const html = '<html><head><meta property="og:image" content="https://cdn.invalid/x.png"></head></html>';
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(htmlResponse(404, html)));
|
||||
|
||||
const icon = await new IconDiscoveryService().discoverFavoriteIconUrl('http://8.8.8.8/x');
|
||||
|
||||
expect(icon).toBe('http://8.8.8.8/favicon.ico');
|
||||
});
|
||||
|
||||
it('fetchIconBytes bleibt streng: Fehlerstatus -> wirft (kein allowErrorStatus fuer Bilder)', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ...htmlResponse(404, ''), headers: { get: () => 'text/html' } }));
|
||||
|
||||
await expect(
|
||||
new IconDiscoveryService().fetchIconBytes('http://8.8.8.8/favicon.ico'),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('IconDiscoveryService.fetchIconBytes', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
|
||||
@@ -49,6 +49,8 @@ const LENIENT_TLS_AGENT = new Agent({ connect: { rejectUnauthorized: false } });
|
||||
type FetchHtmlResult = {
|
||||
html: string;
|
||||
finalUrl: string;
|
||||
/** false = die Seite antwortete mit einem Fehlerstatus (z. B. 400/404), lieferte aber HTML (260929-lh3). */
|
||||
ok: boolean;
|
||||
};
|
||||
|
||||
function isPrivateIpv4(address: string): boolean {
|
||||
@@ -202,7 +204,11 @@ function toAbsoluteUrl(value: string | undefined, base: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function extractIconFromHtml(html: string, baseUrl: string): string | null {
|
||||
function extractIconFromHtml(
|
||||
html: string,
|
||||
baseUrl: string,
|
||||
linkTagsOnly = false,
|
||||
): string | null {
|
||||
const linkTags = html.match(/<link\b[^>]*>/gi) ?? [];
|
||||
const metaTags = html.match(/<meta\b[^>]*>/gi) ?? [];
|
||||
|
||||
@@ -238,6 +244,10 @@ function extractIconFromHtml(html: string, baseUrl: string): string | null {
|
||||
|
||||
if (imageSrc) return imageSrc;
|
||||
|
||||
// 260929-lh3: eine Fehlerseite (Status != 2xx) traegt kein Vorschaubild der
|
||||
// Seite — nur die ausdruecklichen Symbol-Verweise (<link rel=...icon>) zaehlen.
|
||||
if (linkTagsOnly) return null;
|
||||
|
||||
const metaImage = metaTags
|
||||
.map((tag) => parseAttributes(tag))
|
||||
.map((a) => ({
|
||||
@@ -266,7 +276,13 @@ function extractIconFromHtml(html: string, baseUrl: string): string | null {
|
||||
*/
|
||||
async function fetchWithRedirectGuard(
|
||||
pageUrl: URL,
|
||||
options: { accept: string; timeoutMs: number; userAgent?: string },
|
||||
options: {
|
||||
accept: string;
|
||||
timeoutMs: number;
|
||||
userAgent?: string;
|
||||
/** 260929-lh3: auch eine 4xx/5xx-Antwort zurueckgeben (nur fuer die HTML-Suche). */
|
||||
allowErrorStatus?: boolean;
|
||||
},
|
||||
): Promise<{ response: UndiciResponse; finalUrl: URL } | null> {
|
||||
let currentUrl = pageUrl;
|
||||
|
||||
@@ -298,7 +314,7 @@ async function fetchWithRedirectGuard(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!response.ok) return null;
|
||||
if (!response.ok && !options.allowErrorStatus) return null;
|
||||
|
||||
return { response, finalUrl: currentUrl };
|
||||
} catch {
|
||||
@@ -315,6 +331,9 @@ async function fetchHtml(pageUrl: URL): Promise<FetchHtmlResult | null> {
|
||||
const result = await fetchWithRedirectGuard(pageUrl, {
|
||||
accept: 'text/html,application/xhtml+xml,*/*',
|
||||
timeoutMs: HTML_FETCH_TIMEOUT_MS,
|
||||
// 260929-lh3: Server wie docuvita antworten dem Server mit 400, tragen im
|
||||
// HTML aber trotzdem den <link rel=icon> — den Verweis wollen wir haben.
|
||||
allowErrorStatus: true,
|
||||
});
|
||||
|
||||
if (!result) return null;
|
||||
@@ -328,6 +347,7 @@ async function fetchHtml(pageUrl: URL): Promise<FetchHtmlResult | null> {
|
||||
return {
|
||||
html: html.slice(0, MAX_HTML_CHARS), // T-08-09: HTML cap
|
||||
finalUrl: result.finalUrl.toString(),
|
||||
ok: result.response.ok,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -350,7 +370,10 @@ export class IconDiscoveryService {
|
||||
|
||||
if (!htmlResult) return fallback;
|
||||
|
||||
return extractIconFromHtml(htmlResult.html, htmlResult.finalUrl) ?? fallback;
|
||||
return (
|
||||
extractIconFromHtml(htmlResult.html, htmlResult.finalUrl, !htmlResult.ok) ??
|
||||
fallback
|
||||
);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
@@ -490,6 +490,75 @@ describe('FavoritesWidget', () => {
|
||||
expect(screen.getByTestId('letter-fallback-fav-id-1')).toHaveTextContent('G');
|
||||
});
|
||||
|
||||
it('ausdrueckliche Symbol-Adresse (260929-lh3): Proxy -> Browser laedt iconUrl direkt (no-referrer) -> Origin-Favicon -> Buchstabe', async () => {
|
||||
mockFetch.mockResolvedValue([
|
||||
{
|
||||
id: 'fav-id-9',
|
||||
widgetId: 'fav-1',
|
||||
title: 'Docuvita',
|
||||
url: 'https://docuvita.ctl.local/server/services/web/',
|
||||
iconUrl: 'https://docuvita.ctl.local/webclient/docuvita/resources/brandimage/favicon.ico',
|
||||
position: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Docuvita')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fireEvent.error(screen.getByTestId('icon-proxy-fav-id-9'));
|
||||
});
|
||||
|
||||
const direct1 = screen.getByTestId('icon-direct-fav-id-9') as HTMLImageElement;
|
||||
expect(direct1.src).toBe(
|
||||
'https://docuvita.ctl.local/webclient/docuvita/resources/brandimage/favicon.ico',
|
||||
);
|
||||
expect(direct1.getAttribute('referrerpolicy')).toBe('no-referrer');
|
||||
|
||||
act(() => {
|
||||
fireEvent.error(direct1);
|
||||
});
|
||||
|
||||
const direct2 = screen.getByTestId('icon-direct-fav-id-9') as HTMLImageElement;
|
||||
expect(direct2.src).toBe('https://docuvita.ctl.local/favicon.ico');
|
||||
|
||||
act(() => {
|
||||
fireEvent.error(direct2);
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('icon-direct-fav-id-9')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('letter-fallback-fav-id-9')).toHaveTextContent('D');
|
||||
});
|
||||
|
||||
it('iconUrl mit Nicht-http-Schema wird NIE direkt geladen (javascript:/data:)', async () => {
|
||||
mockFetch.mockResolvedValue([
|
||||
{
|
||||
id: 'fav-id-8',
|
||||
widgetId: 'fav-1',
|
||||
title: 'Boese',
|
||||
url: 'https://boese.example',
|
||||
iconUrl: 'javascript:alert(1)',
|
||||
position: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Boese')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fireEvent.error(screen.getByTestId('icon-proxy-fav-id-8'));
|
||||
});
|
||||
|
||||
const direct = screen.getByTestId('icon-direct-fav-id-8') as HTMLImageElement;
|
||||
expect(direct.src).toBe('https://boese.example/favicon.ico');
|
||||
});
|
||||
|
||||
it('kein Direktbild bei Nicht-http-URL', async () => {
|
||||
mockFetch.mockResolvedValue([
|
||||
{ id: 'fav-id-3', widgetId: 'fav-1', title: 'Ablage', url: 'ftp://files.example', iconUrl: null, position: 0 },
|
||||
@@ -730,8 +799,8 @@ describe('FavoritesWidget', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('updateFavorite wirft FavoriteRequestError(iconUrlUnreachable) -> Meldung im Formular (role alert), Formular bleibt offen, uploadFavoriteIcon NICHT aufgerufen', async () => {
|
||||
mockUpdate.mockRejectedValue(new FavoriteRequestError('iconUrlUnreachable'));
|
||||
it('updateFavorite wirft (z. B. 400 ungueltige Adresse) -> allgemeine Meldung favorites.error im Formular (role alert), Formular bleibt offen, uploadFavoriteIcon NICHT aufgerufen', async () => {
|
||||
mockUpdate.mockRejectedValue(new Error('Failed to update favorite'));
|
||||
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||
|
||||
@@ -753,7 +822,7 @@ describe('FavoritesWidget', () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('favorites.iconUrlUnreachable');
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('favorites.error');
|
||||
});
|
||||
expect(mockUploadIcon).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument();
|
||||
|
||||
@@ -23,7 +23,7 @@ const TITLE_DEBOUNCE_MS = 1500;
|
||||
/**
|
||||
* Bildet einen Fehler aus dem Favoriten-Klienten auf einen Uebersetzungs-
|
||||
* schluessel ab (260923-lrr): `FavoriteRequestError` traegt den passenden
|
||||
* Grund (`iconUrlUnreachable`/`iconTooLarge`/`iconInvalidType`/
|
||||
* Grund (`iconTooLarge`/`iconInvalidType`/
|
||||
* `iconUploadFailed`) bereits als `reason`, jeder andere Fehler faellt auf
|
||||
* die bisherige allgemeine Meldung zurueck.
|
||||
*/
|
||||
@@ -527,13 +527,37 @@ function getDirectFaviconSrc(url: string): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* FavoriteIcon — dreistufiger Symbol-Ersatzweg (260917-jdd):
|
||||
* Direkt-ladbare Adressen fuer Stufe 2 des Ersatzwegs (260929-lh3): erst die
|
||||
* gespeicherte `iconUrl`, dann das Origin-Favicon der Favoriten-URL; nur
|
||||
* http:/https: (kein javascript:/data:, T-JDD-04), ohne Doppelte.
|
||||
*/
|
||||
function getDirectCandidates(iconUrl: string | null, url: string): string[] {
|
||||
const candidates: string[] = [];
|
||||
if (iconUrl) {
|
||||
try {
|
||||
const u = new URL(iconUrl);
|
||||
if (u.protocol === 'http:' || u.protocol === 'https:') candidates.push(u.toString());
|
||||
} catch {
|
||||
// ungueltige Adresse: uebersprungen
|
||||
}
|
||||
}
|
||||
const origin = getDirectFaviconSrc(url);
|
||||
if (origin && !candidates.includes(origin)) candidates.push(origin);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* FavoriteIcon — Symbol-Ersatzweg (260917-jdd, erweitert 260929-lh3):
|
||||
*
|
||||
* 1. `proxy` — Server-Proxy (GET /favorites/:id/icon), der seit diesem Plan
|
||||
* auch bei Zertifikatsfehlern des Zielhosts liefert (undici-Dispatcher).
|
||||
* 2. `direct` — Direktbild aus dem Browser des Nutzers
|
||||
* (`referrerPolicy="no-referrer"`, Origin nur aus http/https); erreicht
|
||||
* interne Hosts, die der SSRF-Schutz des Servers absichtlich ablehnt.
|
||||
* 2. `direct` — Direktbilder aus dem Browser des Nutzers
|
||||
* (`referrerPolicy="no-referrer"`, nur http/https), nacheinander:
|
||||
* zuerst die gespeicherte `iconUrl` (ausdrueckliche Symbol-Adresse oder
|
||||
* erkannter Verweis — Server wie docuvita geben dem Server 404/HTML, dem
|
||||
* Browser aber das Bild), dann `{origin}/favicon.ico` (entfaellt, wenn
|
||||
* identisch). Erreicht auch interne Hosts, die der SSRF-Schutz des
|
||||
* Servers absichtlich ablehnt.
|
||||
* 3. `none` — der Buchstaben-Platzhalter liegt IMMER darunter.
|
||||
*
|
||||
* Bewusst KEIN Drittanbieter-Favicon-Dienst: der wuerde Hostnamen nach
|
||||
@@ -575,10 +599,12 @@ function FavoriteIcon({
|
||||
const proxySrc = hasServerIcon
|
||||
? `/api-proxy/favorites/${encodeURIComponent(fav.id)}/icon?v=${fav.iconVersion ?? 0}`
|
||||
: null;
|
||||
const directSrc = getDirectFaviconSrc(fav.url);
|
||||
const [stage, setStage] = useState<'proxy' | 'direct' | 'none'>(
|
||||
proxySrc ? 'proxy' : 'direct',
|
||||
);
|
||||
// 260929-lh3: Direkt-Kandidaten in Reihenfolge — gespeicherte Adresse zuerst,
|
||||
// dann das Origin-Favicon; nur http/https, ohne Doppelte.
|
||||
const directSrcs = getDirectCandidates(fav.iconUrl, fav.url);
|
||||
// 'proxy' | Index in directSrcs | Ende der Kette (Buchstabe).
|
||||
const [stage, setStage] = useState<'proxy' | number>(proxySrc ? 'proxy' : 0);
|
||||
const directSrc = typeof stage === 'number' ? (directSrcs[stage] ?? null) : null;
|
||||
|
||||
return (
|
||||
<div className={`relative flex shrink-0 items-center justify-center bg-muted ${box}`}>
|
||||
@@ -598,10 +624,10 @@ function FavoriteIcon({
|
||||
height={px}
|
||||
loading="lazy"
|
||||
className={`relative rounded-sm ${img}`}
|
||||
onError={() => setStage('direct')}
|
||||
onError={() => setStage(0)}
|
||||
/>
|
||||
)}
|
||||
{stage === 'direct' && directSrc && (
|
||||
{directSrc && (
|
||||
<img
|
||||
data-testid={`icon-direct-${fav.id}`}
|
||||
src={directSrc}
|
||||
@@ -612,7 +638,7 @@ function FavoriteIcon({
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
className={`relative rounded-sm ${img}`}
|
||||
onError={() => setStage('none')}
|
||||
onError={() => setStage((prev) => (typeof prev === 'number' ? prev + 1 : 0))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
* Die vier neuen/geaenderten Aufrufe: `uploadFavoriteIcon` (FormData-Feld
|
||||
* `icon`, Groessenpruefung vor dem Netzwerkaufruf, 413/400/sonst ->
|
||||
* `FavoriteRequestError`), `removeFavoriteIcon` (DELETE), sowie
|
||||
* `createFavorite`/`updateFavorite` (422 -> `FavoriteRequestError` mit
|
||||
* `reason: 'iconUrlUnreachable'`, jeder andere Fehler bleibt eine generische
|
||||
* `Error` wie bisher). Muster `dashboard-images-api.test.ts`.
|
||||
* `createFavorite`/`updateFavorite` (jeder Fehler bleibt eine generische
|
||||
* `Error`; die 422-Abrufprobe ist seit 260929-lh3 entfallen). Muster
|
||||
* `dashboard-images-api.test.ts`.
|
||||
*/
|
||||
const { mockFetch } = vi.hoisted(() => ({ mockFetch: vi.fn() }));
|
||||
|
||||
@@ -112,9 +112,9 @@ describe('favorites-api (quick-260923-lrr)', () => {
|
||||
expect(init.credentials).toBe('include');
|
||||
});
|
||||
|
||||
describe('createFavorite / updateFavorite — Abrufprobe (260923-lrr)', () => {
|
||||
it('createFavorite: 422 -> FavoriteRequestError reason iconUrlUnreachable', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('{}', { status: 422 }));
|
||||
describe('createFavorite / updateFavorite — Fehler bleiben generisch (260929-lh3)', () => {
|
||||
it('createFavorite: 400 (ungueltige Logo-Adresse) -> generische Error, KEIN FavoriteRequestError', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('{}', { status: 400 }));
|
||||
|
||||
const err = await createFavorite({
|
||||
widgetId: 'w1',
|
||||
@@ -123,8 +123,8 @@ describe('favorites-api (quick-260923-lrr)', () => {
|
||||
iconUrl: 'https://x.invalid/logo.png',
|
||||
}).catch((e) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(FavoriteRequestError);
|
||||
expect((err as FavoriteRequestError).reason).toBe('iconUrlUnreachable');
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).not.toBeInstanceOf(FavoriteRequestError);
|
||||
});
|
||||
|
||||
it('createFavorite: anderer Fehler -> wie bisher eine generische Error', async () => {
|
||||
@@ -138,15 +138,13 @@ describe('favorites-api (quick-260923-lrr)', () => {
|
||||
expect(err).not.toBeInstanceOf(FavoriteRequestError);
|
||||
});
|
||||
|
||||
it('updateFavorite: 422 -> FavoriteRequestError reason iconUrlUnreachable', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('{}', { status: 422 }));
|
||||
it('updateFavorite: 400 (ungueltige Logo-Adresse) -> generische Error, KEIN FavoriteRequestError', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('{}', { status: 400 }));
|
||||
|
||||
const err = await updateFavorite('fav-1', { iconUrl: 'https://x.invalid/logo.png' }).catch(
|
||||
(e) => e,
|
||||
);
|
||||
const err = await updateFavorite('fav-1', { iconUrl: 'https://x.invalid/logo.png' }).catch((e) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(FavoriteRequestError);
|
||||
expect((err as FavoriteRequestError).reason).toBe('iconUrlUnreachable');
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).not.toBeInstanceOf(FavoriteRequestError);
|
||||
});
|
||||
|
||||
it('updateFavorite: anderer Fehler -> wie bisher eine generische Error', async () => {
|
||||
|
||||
@@ -7,10 +7,9 @@
|
||||
* 260923-lrr — eigenes Symbol hochladen, Zwischenspeicher nach Aenderung
|
||||
* erneuern: `FavoriteLink` traegt jetzt `uploadedIconMime`/`iconVersion`
|
||||
* (optional, weil Testdaten und aeltere Antworten sie nicht tragen).
|
||||
* `createFavorite`/`updateFavorite` uebersetzen ein 422 (Abrufprobe der API
|
||||
* fuer eine nicht abrufbare Logo-Adresse) in `FavoriteRequestError`, damit
|
||||
* das Widget eine sprachrichtige Meldung zeigen kann, statt der bisherigen
|
||||
* generischen `Error`. `uploadFavoriteIcon`/`removeFavoriteIcon` sind neu,
|
||||
* `FavoriteRequestError` traegt den Grund einer abgewiesenen Symbol-Datei
|
||||
* (Widget bildet ihn auf eine sprachrichtige Meldung ab). 260929-lh3: die
|
||||
* 422-Abrufprobe fuer Logo-Adressen ist entfallen. `uploadFavoriteIcon`/`removeFavoriteIcon` sind neu,
|
||||
* Muster `uploadDashboardImage`/`deleteDashboardImage` (dashboard-images-api.ts).
|
||||
*/
|
||||
|
||||
@@ -34,7 +33,6 @@ export interface FavoriteLink {
|
||||
|
||||
/** Grund einer abgewiesenen Favoriten-Anfrage (260923-lrr). */
|
||||
export type FavoriteErrorReason =
|
||||
| 'iconUrlUnreachable'
|
||||
| 'iconTooLarge'
|
||||
| 'iconInvalidType'
|
||||
| 'iconUploadFailed';
|
||||
@@ -72,8 +70,9 @@ export async function fetchFavorites(widgetId: string): Promise<FavoriteLink[]>
|
||||
/**
|
||||
* Create a new favorite link.
|
||||
* Server-side icon discovery runs automatically if iconUrl is not provided.
|
||||
* 260923-lrr: eine explizite `iconUrl`, die sich serverseitig nicht laden
|
||||
* laesst, ergibt 422 -> `FavoriteRequestError('iconUrlUnreachable')`.
|
||||
* 260929-lh3: eine ausdrueckliche `iconUrl` wird auch gespeichert, wenn der
|
||||
* Server sie nicht abrufen kann (die Kachel laedt sie dann im Browser); die
|
||||
* frueheren 422-Antworten gibt es nicht mehr.
|
||||
*/
|
||||
export async function createFavorite(payload: {
|
||||
widgetId: string;
|
||||
@@ -87,7 +86,6 @@ export async function createFavorite(payload: {
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.status === 422) throw new FavoriteRequestError('iconUrlUnreachable');
|
||||
if (!res.ok) throw new Error('Failed to create favorite');
|
||||
|
||||
return res.json();
|
||||
@@ -96,8 +94,7 @@ export async function createFavorite(payload: {
|
||||
/**
|
||||
* Update an existing favorite link.
|
||||
* Pass iconUrl: null to clear a stored icon.
|
||||
* 260923-lrr: eine neue, nicht abrufbare `iconUrl` ergibt ebenso 422 ->
|
||||
* `FavoriteRequestError('iconUrlUnreachable')`.
|
||||
* 260929-lh3: wie `createFavorite` — keine 422-Abweisung wegen Abrufbarkeit mehr.
|
||||
*/
|
||||
export async function updateFavorite(
|
||||
id: string,
|
||||
@@ -109,7 +106,6 @@ export async function updateFavorite(
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.status === 422) throw new FavoriteRequestError('iconUrlUnreachable');
|
||||
if (!res.ok) throw new Error('Failed to update favorite');
|
||||
|
||||
return res.json();
|
||||
|
||||
@@ -380,10 +380,9 @@
|
||||
"viewModeLabel": "Ansicht wechseln",
|
||||
"iconUrlPlaceholder": "Logo-Adresse (optional)",
|
||||
"iconUploadLabel": "Eigenes Symbol hochladen",
|
||||
"iconUploadHint": "PNG, JPEG, GIF, WebP, ICO oder SVG, höchstens 512 KB. Ein hochgeladenes Symbol hat Vorrang vor der Logo-Adresse.",
|
||||
"iconUploadHint": "PNG, JPEG, GIF, WebP, ICO oder SVG, höchstens 512 KB. Ein hochgeladenes Symbol hat Vorrang vor der Logo-Adresse; eine neu eingetragene Logo-Adresse ersetzt es aber.",
|
||||
"iconUploadedHint": "Für diesen Favoriten ist ein eigenes Symbol hochgeladen.",
|
||||
"iconRemoveButton": "Hochgeladenes Symbol entfernen",
|
||||
"iconUrlUnreachable": "Das Bild unter dieser Adresse konnte nicht geladen werden. Die Seite blockiert vermutlich automatische Abrufe (zum Beispiel durch eine Cloudflare-Prüfung) oder ist nicht erreichbar. Bitte laden Sie das Symbol stattdessen hoch.",
|
||||
"iconTooLarge": "Die Datei ist zu groß – erlaubt sind höchstens 512 KB.",
|
||||
"iconInvalidType": "Nur Bilder im Format PNG, JPEG, GIF, WebP, ICO oder SVG sind erlaubt.",
|
||||
"iconUploadFailed": "Das Symbol konnte nicht hochgeladen werden."
|
||||
|
||||
@@ -380,10 +380,9 @@
|
||||
"viewModeLabel": "Switch view",
|
||||
"iconUrlPlaceholder": "Logo URL (optional)",
|
||||
"iconUploadLabel": "Upload custom icon",
|
||||
"iconUploadHint": "PNG, JPEG, GIF, WebP, ICO or SVG, at most 512 KB. An uploaded icon takes precedence over the logo URL.",
|
||||
"iconUploadHint": "PNG, JPEG, GIF, WebP, ICO or SVG, at most 512 KB. An uploaded icon takes precedence over the logo URL; a newly entered logo URL replaces it, though.",
|
||||
"iconUploadedHint": "A custom icon has been uploaded for this favorite.",
|
||||
"iconRemoveButton": "Remove uploaded icon",
|
||||
"iconUrlUnreachable": "The image at this address could not be loaded. The site likely blocks automated requests (for example via a Cloudflare check) or is unreachable. Please upload the icon instead.",
|
||||
"iconTooLarge": "The file is too large – at most 512 KB is allowed.",
|
||||
"iconInvalidType": "Only PNG, JPEG, GIF, WebP, ICO or SVG images are allowed.",
|
||||
"iconUploadFailed": "The icon could not be uploaded."
|
||||
|
||||
Reference in New Issue
Block a user