445b1d3100
- Resolver: crop (x/y/w/h, geklemmt auf 1280er-Seite, x verschoben statt abgewiesen), zoom (50..150, groesste Stufe <= n), readOnly (nur echtes true) - xframe-crop.ts: reine Geometrie - computeCropLayout (contain + zentriert, scale 0 bis gemessen), applyCropDrag (Verschieben, vier Ecken, Gegenecke bleibt) - Kachel: Clip + verschobener, skalierter <iframe> bei fester Layoutbreite 1280, ResizeObserver am Koerper; Zoom-Zweig mit Prozentmassen; 100 % wie bisher; readOnly-Flaeche nur im Ansichtsmodus - Formular: Checkbox Ausschnitt (ein Aufruf mit crop + readOnly), Vorschau 1280 px breit mit derselben Sandbox und pointer-events none, Rahmen mit vier Griffen (Pointer-Events, Capture-Waechter fuer jsdom), Zahlenfelder, Zoom-Auswahl nur ohne Ausschnitt, Nur anzeigen, dauerhafter Hinweis - Rahmen als <fieldset> statt div role=group (Biome useSemanticElements, kein biome-ignore) - Test-Helfer stubResizeObserver, 13 neue Schluessel de/en, Ausschnitt auf der Umlaut-Allowlist; Web-Tests 604 -> 640 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
157 lines
6.4 KiB
TypeScript
157 lines
6.4 KiB
TypeScript
/**
|
|
* 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 };
|
|
|
|
/** Virtuelle Layoutbreite der eingebetteten Seite in Pixeln — Konstante, keine UI. */
|
|
export const XFRAME_PAGE_WIDTH = 1280;
|
|
export const XFRAME_CROP_MIN_W = 100;
|
|
export const XFRAME_CROP_MIN_H = 60;
|
|
/** Speichergrenze fuer getippte Werte; die Vorschau zeigt 3000 (xframe-crop.ts). */
|
|
export const XFRAME_CROP_MAX_H = 4000;
|
|
/** Ausschnitt in Seitenpixeln bei XFRAME_PAGE_WIDTH. */
|
|
export interface XframeCrop {
|
|
x: number;
|
|
y: number;
|
|
w: number;
|
|
h: number;
|
|
}
|
|
export const XFRAME_CROP_DEFAULT: XframeCrop = { x: 0, y: 0, w: XFRAME_PAGE_WIDTH, h: 720 };
|
|
/** Vergroesserung der ganzen Seite in Prozent (nur ohne Ausschnitt). */
|
|
export const XFRAME_ZOOM_OPTIONS: number[] = [50, 60, 75, 90, 100, 125, 150];
|
|
export const XFRAME_ZOOM_DEFAULT = 100;
|
|
|
|
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;
|
|
/** null = ganze Seite; sonst geklemmter Ausschnitt (quick-260922-ge2). */
|
|
crop: XframeCrop | null;
|
|
/** Stufe aus XFRAME_ZOOM_OPTIONS, wirkt nur bei crop === null. */
|
|
zoom: number;
|
|
/** Transparente Flaeche ueber dem Rahmen im Ansichtsmodus. */
|
|
readOnly: boolean;
|
|
}
|
|
|
|
/**
|
|
* EINE Klemmregel fuer Resolver, Zahlenfelder und Ziehen: runden, Breite
|
|
* [100, 1280], Hoehe [60, 4000], Position >= 0, dann x so verschieben, dass
|
|
* der Ausschnitt in der Seite bleibt (nie abweisen, T-GE2-05).
|
|
*/
|
|
export function clampXframeCrop(raw: XframeCrop): XframeCrop {
|
|
const w = Math.min(XFRAME_PAGE_WIDTH, Math.max(XFRAME_CROP_MIN_W, Math.round(raw.w)));
|
|
const h = Math.min(XFRAME_CROP_MAX_H, Math.max(XFRAME_CROP_MIN_H, Math.round(raw.h)));
|
|
let x = Math.max(0, Math.round(raw.x));
|
|
const y = Math.max(0, Math.round(raw.y));
|
|
if (x + w > XFRAME_PAGE_WIDTH) x = XFRAME_PAGE_WIDTH - w;
|
|
return { x, y, w, h };
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function finiteNumber(raw: unknown): raw is number {
|
|
return typeof raw === 'number' && Number.isFinite(raw);
|
|
}
|
|
|
|
/** Typwaechter statt Cast: ein Nicht-null-Objekt darf als Schluessel-Wert-Menge gelesen werden. */
|
|
function isRecord(raw: unknown): raw is Record<string, unknown> {
|
|
return typeof raw === 'object' && raw !== null;
|
|
}
|
|
|
|
/** Nur ein Nicht-null-Objekt mit vier endlichen Zahlen x, y, w, h -> geklemmt; alles andere -> null. */
|
|
function resolveCrop(raw: unknown): XframeCrop | null {
|
|
if (!isRecord(raw)) return null;
|
|
const { x, y, w, h } = raw;
|
|
if (!finiteNumber(x) || !finiteNumber(y) || !finiteNumber(w) || !finiteNumber(h)) return null;
|
|
return clampXframeCrop({ x, y, w, h });
|
|
}
|
|
|
|
/** Groesste Zoom-Stufe <= n (Muster resolveReload); unter 50 -> 50; nicht endlich -> 100. */
|
|
function resolveZoom(raw: unknown): number {
|
|
if (!finiteNumber(raw)) return XFRAME_ZOOM_DEFAULT;
|
|
let best = XFRAME_ZOOM_OPTIONS[0];
|
|
for (const option of XFRAME_ZOOM_OPTIONS) {
|
|
if (option <= raw && option > best) best = option;
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function resolveReadOnly(raw: unknown): boolean {
|
|
return raw === true;
|
|
}
|
|
|
|
export function resolveXframeConfig(config: Record<string, unknown>): XframeConfig {
|
|
return {
|
|
url: resolveUrl(config.url),
|
|
title: resolveTitle(config.title),
|
|
reloadSeconds: resolveReload(config.reloadSeconds),
|
|
crop: resolveCrop(config.crop),
|
|
zoom: resolveZoom(config.zoom),
|
|
readOnly: resolveReadOnly(config.readOnly),
|
|
};
|
|
}
|