fix(desktop,favorites): keine zweite Update-Installation, Lesegrenzen bei der Symbolsuche
- Desktop: Merker "Installation laeuft" sperrt Pruefschleife und Klick; ein angebotenes Update bleibt nach fehlgeschlagener Pruefung per Klick installierbar - Desktop: Benachrichtigungsrecht erst nach erfolgreichem add_capability vermerken - Favoriten: HTML nur bis MAX_HTML_CHARS und hoechstens 4 s lesen, Nicht-HTML-Antworten verwerfen Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { isIP } from 'node:net';
|
||||
import { Agent, fetch as undiciFetch, type Response as UndiciResponse } from 'undici';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Agent, type Response as UndiciResponse, fetch as undiciFetch } from 'undici';
|
||||
|
||||
/**
|
||||
* Server-side favicon / icon discovery with SSRF protection (T-08-05).
|
||||
@@ -58,9 +58,7 @@ function isPrivateIpv4(address: string): boolean {
|
||||
|
||||
if (
|
||||
parts.length !== 4 ||
|
||||
parts.some(
|
||||
(part) => !Number.isInteger(part) || part < 0 || part > 255,
|
||||
)
|
||||
parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
@@ -117,12 +115,7 @@ function isPrivateIpAddress(address: string): boolean {
|
||||
function isBlockedHostname(hostname: string): boolean {
|
||||
const h = hostname.trim().toLowerCase();
|
||||
|
||||
return (
|
||||
h === 'localhost' ||
|
||||
h.endsWith('.localhost') ||
|
||||
h.endsWith('.local') ||
|
||||
h === '0.0.0.0'
|
||||
);
|
||||
return h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h === '0.0.0.0';
|
||||
}
|
||||
|
||||
export async function isPublicHttpUrl(url: URL): Promise<boolean> {
|
||||
@@ -204,11 +197,7 @@ function toAbsoluteUrl(value: string | undefined, base: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function extractIconFromHtml(
|
||||
html: string,
|
||||
baseUrl: string,
|
||||
linkTagsOnly = false,
|
||||
): 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) ?? [];
|
||||
|
||||
@@ -220,27 +209,19 @@ function extractIconFromHtml(
|
||||
}))
|
||||
.filter((c) => c.href);
|
||||
|
||||
const appleTouchIcon = linkCandidates.find((c) =>
|
||||
c.rel.includes('apple-touch-icon'),
|
||||
)?.href;
|
||||
const appleTouchIcon = linkCandidates.find((c) => c.rel.includes('apple-touch-icon'))?.href;
|
||||
|
||||
if (appleTouchIcon) return appleTouchIcon;
|
||||
|
||||
const icon = linkCandidates.find((c) =>
|
||||
c.rel.split(/\s+/).includes('icon'),
|
||||
)?.href;
|
||||
const icon = linkCandidates.find((c) => c.rel.split(/\s+/).includes('icon'))?.href;
|
||||
|
||||
if (icon) return icon;
|
||||
|
||||
const shortcutIcon = linkCandidates.find((c) =>
|
||||
c.rel.includes('shortcut icon'),
|
||||
)?.href;
|
||||
const shortcutIcon = linkCandidates.find((c) => c.rel.includes('shortcut icon'))?.href;
|
||||
|
||||
if (shortcutIcon) return shortcutIcon;
|
||||
|
||||
const imageSrc = linkCandidates.find((c) =>
|
||||
c.rel.includes('image_src'),
|
||||
)?.href;
|
||||
const imageSrc = linkCandidates.find((c) => c.rel.includes('image_src'))?.href;
|
||||
|
||||
if (imageSrc) return imageSrc;
|
||||
|
||||
@@ -257,9 +238,7 @@ function extractIconFromHtml(
|
||||
.find(
|
||||
(c) =>
|
||||
c.content &&
|
||||
(c.property === 'og:image' ||
|
||||
c.property === 'og:logo' ||
|
||||
c.property === 'twitter:image'),
|
||||
(c.property === 'og:image' || c.property === 'og:logo' || c.property === 'twitter:image'),
|
||||
)?.content;
|
||||
|
||||
return metaImage ?? null;
|
||||
@@ -327,6 +306,71 @@ async function fetchWithRedirectGuard(
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Minimaler Ausschnitt einer Antwort, den die beiden Helfer brauchen. */
|
||||
type BodyResponse = Pick<UndiciResponse, 'body' | 'text'>;
|
||||
|
||||
/**
|
||||
* Verwirft den Body einer nicht gebrauchten Antwort. Fehler (bereits
|
||||
* gelesen/abgebrochen) sind egal.
|
||||
*/
|
||||
export function discardBody(response: Pick<UndiciResponse, 'body'>): void {
|
||||
try {
|
||||
response.body?.cancel().catch(() => {});
|
||||
} catch {
|
||||
// Body gesperrt oder schon verbraucht — nichts zu tun.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest den Antworttext hoechstens bis `maxChars` Zeichen und bricht den
|
||||
* Stream danach ab (T-08-09). Vorher wurde der komplette Body gelesen und
|
||||
* erst danach abgeschnitten — eine riesige Seite landete ganz im Speicher.
|
||||
* Dekodiert wird UTF-8 wie bei `Response.text()`; da jedes Zeichen aus
|
||||
* mindestens einem Byte entsteht, bleibt der Speicher bei ~maxChars plus
|
||||
* einem Chunk. Ohne Stream (`body === null`) wie bisher ueber `text()`.
|
||||
* `timeoutMs` begrenzt zusaetzlich die Lesedauer: die Zeitgrenze von
|
||||
* `fetchWithRedirectGuard` endet mit den Kopfzeilen, ein Server, der den
|
||||
* Body tropfenweise liefert, hielte die Anfrage sonst beliebig lange auf.
|
||||
* Nach Ablauf zaehlt, was bis dahin gelesen ist.
|
||||
*/
|
||||
export async function readTextCapped(
|
||||
response: BodyResponse,
|
||||
maxChars: number,
|
||||
timeoutMs = HTML_FETCH_TIMEOUT_MS,
|
||||
): Promise<string> {
|
||||
if (!response.body) {
|
||||
return (await response.text()).slice(0, maxChars);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let text = '';
|
||||
// cancel() beendet ein haengendes read() mit done: true.
|
||||
const deadline = setTimeout(() => void reader.cancel().catch(() => {}), timeoutMs);
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
text += decoder.decode();
|
||||
break;
|
||||
}
|
||||
|
||||
text += decoder.decode(value, { stream: true });
|
||||
|
||||
if (text.length >= maxChars) {
|
||||
await reader.cancel().catch(() => {});
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(deadline);
|
||||
}
|
||||
|
||||
return text.slice(0, maxChars);
|
||||
}
|
||||
|
||||
async function fetchHtml(pageUrl: URL): Promise<FetchHtmlResult | null> {
|
||||
const result = await fetchWithRedirectGuard(pageUrl, {
|
||||
accept: 'text/html,application/xhtml+xml,*/*',
|
||||
@@ -340,12 +384,18 @@ async function fetchHtml(pageUrl: URL): Promise<FetchHtmlResult | null> {
|
||||
|
||||
const contentType = result.response.headers.get('content-type') ?? '';
|
||||
|
||||
if (!contentType.toLowerCase().includes('text/html')) return null;
|
||||
if (!contentType.toLowerCase().includes('text/html')) {
|
||||
// Kein HTML (auch bei Fehlerstatus dank allowErrorStatus hier moeglich):
|
||||
// Body verwerfen, sonst haelt undici die Verbindung bis zum Timeout offen.
|
||||
discardBody(result.response);
|
||||
return null;
|
||||
}
|
||||
|
||||
const html = await result.response.text();
|
||||
// T-08-09: HTML cap — schon beim Lesen, nicht erst nach dem kompletten Body.
|
||||
const html = await readTextCapped(result.response, MAX_HTML_CHARS);
|
||||
|
||||
return {
|
||||
html: html.slice(0, MAX_HTML_CHARS), // T-08-09: HTML cap
|
||||
html,
|
||||
finalUrl: result.finalUrl.toString(),
|
||||
ok: result.response.ok,
|
||||
};
|
||||
@@ -370,10 +420,7 @@ export class IconDiscoveryService {
|
||||
|
||||
if (!htmlResult) return fallback;
|
||||
|
||||
return (
|
||||
extractIconFromHtml(htmlResult.html, htmlResult.finalUrl, !htmlResult.ok) ??
|
||||
fallback
|
||||
);
|
||||
return extractIconFromHtml(htmlResult.html, htmlResult.finalUrl, !htmlResult.ok) ?? fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
@@ -388,9 +435,7 @@ export class IconDiscoveryService {
|
||||
* or an oversized body. Callers must not return a placeholder image; let
|
||||
* the caller map the failure to an HTTP error status instead.
|
||||
*/
|
||||
async fetchIconBytes(
|
||||
iconUrl: string,
|
||||
): Promise<{ contentType: string; body: Buffer }> {
|
||||
async fetchIconBytes(iconUrl: string): Promise<{ contentType: string; body: Buffer }> {
|
||||
const url = new URL(iconUrl);
|
||||
|
||||
const result = await fetchWithRedirectGuard(url, {
|
||||
|
||||
Reference in New Issue
Block a user