feat(quick-260921-qd3): XFrame-Widget - Webseite als Rahmen im Dashboard, Sandbox ohne Top-Navigation, Neuladen-Intervall
- xframe-config.ts: Resolver (https-Pruefung via isHttpsUrl des Bilderrahmens, Titel bis 100 Zeichen, Neuladen 0/60/300/600/1800/3600 s geklemmt), XFRAME_SANDBOX ohne allow-top-navigation und allow-modals; 12 Tests zuerst rot - xframe-widget.tsx: genau ein <iframe> (sandbox, allow="", no-referrer, lazy), Kopfleiste mit Titel oder Ecksymbol "In neuem Tab oeffnen", Neuladen ueber key-Wechsel mit Timer-Raeumung, transparente Flaeche im Bearbeitungsmodus damit die Kachel Ziehgriff bleibt; 12 Tests zuerst rot - xframe-config-form.tsx: Adresse/Titel mit Uebernahme bei Blur/Enter, http wird mit Meldung abgewiesen und nicht gespeichert, Intervall-Auswahl, dauerhafter Hinweis auf verweigertes Einbetten; 8 Tests - Panel-Zweig samt "— Titel" in der Kopfzeile, Registry (12x12, Fenster-Symbol), Katalog, Seite, DTO @IsIn, de/en widgets.xframe (15 Schluessel), Umlaut-Allowlist "neuem"; der Server ruft die Adresse nie ab Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* xframe-config — Typen, Grenzen und reiner Resolver des XFrame-Widgets
|
||||
* (quick-260921-qd3). Ohne React-Import, damit der Test schlank bleibt
|
||||
* (Muster picture-frame-config.ts).
|
||||
*
|
||||
* T-QD3-03: Die API prueft Widget-Konfigurationen nicht inhaltlich
|
||||
* (`@IsObject()`, flache Zusammenfuehrung). Deshalb liegt die https-Pruefung
|
||||
* ALLEIN hier und im Formular: das Formular weist eine Nicht-https-Adresse
|
||||
* mit Meldung ab und speichert sie nicht; `resolveXframeConfig` laesst beim
|
||||
* Rendern jede Adresse auf `null` fallen, die der echte `URL`-Parser nicht
|
||||
* als `https:` erkennt (`http:`, `javascript:`, `data:`, `ftp:`, Unparsbares)
|
||||
* — ein von Hand manipulierter Config-Wert wird auf dem eigenen Dashboard
|
||||
* nicht einmal als `<iframe>` gerendert.
|
||||
*
|
||||
* T-QD3-01, Sandbox: `allow-same-origin` bleibt bewusst drin — ohne dieses
|
||||
* Token laeuft die Fremdseite in einem opaken Origin, ihre Cookies, ihr
|
||||
* `localStorage` und ihre Same-Origin-Aufrufe brechen, die meisten Seiten
|
||||
* sind dann unbenutzbar. Der Origin ist der der FREMDSEITE, nicht Tesseras;
|
||||
* die Sandbox hat hier allein die Aufgabe, die Navigation des obersten
|
||||
* Fensters und Modaldialoge zu unterbinden. Bewusst FEHLEN deshalb
|
||||
* `allow-top-navigation`, `allow-top-navigation-by-user-activation` (die
|
||||
* eingebettete Seite kann den Tessera-Tab nicht umlenken) und `allow-modals`
|
||||
* (keine `alert`/`confirm`/`prompt`, die als Tessera-Dialoge missdeutet
|
||||
* werden koennten). `allow-popups-to-escape-sandbox` betrifft nur NEUE
|
||||
* Fenster (z. B. Anmelde-Popups der Fremdseite), nie den Tessera-Tab.
|
||||
*/
|
||||
|
||||
import { isHttpsUrl } from './picture-frame-config';
|
||||
|
||||
/** Auswahlstufen des Formulars in Sekunden; 0 = nie. */
|
||||
export const XFRAME_RELOAD_OPTIONS: number[] = [0, 60, 300, 600, 1800, 3600];
|
||||
/** Unter der kleinsten Stufe heisst „nie“ — schuetzt die Fremdseite vor Sekundentakt (T-QD3-08). */
|
||||
export const XFRAME_RELOAD_MIN = 60;
|
||||
export const XFRAME_RELOAD_MAX = 3600;
|
||||
export const XFRAME_TITLE_MAX = 100;
|
||||
/** Exakter Sandbox-Wert des <iframe> — Begruendung im Dateikopf. */
|
||||
export const XFRAME_SANDBOX =
|
||||
'allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox';
|
||||
|
||||
// Bewusst geteilt mit dem Bilderrahmen: EINE Regel fuer „https-Adresse“ im
|
||||
// ganzen Dashboard (echter URL-Parser, Protokoll kleingeschrieben verglichen).
|
||||
export { isHttpsUrl };
|
||||
|
||||
export interface XframeConfig {
|
||||
/** null = keine gueltige https-Adresse -> Leerzustand. */
|
||||
url: string | null;
|
||||
title: string;
|
||||
/** 0 = nie, sonst eine Stufe aus XFRAME_RELOAD_OPTIONS. */
|
||||
reloadSeconds: number;
|
||||
}
|
||||
|
||||
function resolveUrl(raw: unknown): string | null {
|
||||
if (typeof raw !== 'string') return null;
|
||||
const trimmed = raw.trim();
|
||||
return isHttpsUrl(trimmed) ? trimmed : null;
|
||||
}
|
||||
|
||||
function resolveTitle(raw: unknown): string {
|
||||
if (typeof raw !== 'string') return '';
|
||||
return raw.trim().slice(0, XFRAME_TITLE_MAX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Groesste Auswahlstufe <= n, damit das <select> im Formular immer eine
|
||||
* passende Option zeigt; unter der kleinsten Stufe -> 0 (nie).
|
||||
*/
|
||||
function resolveReload(raw: unknown): number {
|
||||
if (typeof raw !== 'number' || !Number.isFinite(raw)) return 0;
|
||||
if (raw < XFRAME_RELOAD_MIN) return 0;
|
||||
if (raw >= XFRAME_RELOAD_MAX) return XFRAME_RELOAD_MAX;
|
||||
let best = 0;
|
||||
for (const option of XFRAME_RELOAD_OPTIONS) {
|
||||
if (option <= raw && option > best) best = option;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function resolveXframeConfig(config: Record<string, unknown>): XframeConfig {
|
||||
return {
|
||||
url: resolveUrl(config.url),
|
||||
title: resolveTitle(config.title),
|
||||
reloadSeconds: resolveReload(config.reloadSeconds),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user