Kritische Bugfixes: Export/CA-Download (Service-Worker), Speichern-Bug bei Diensten/Lesezeichen, Dark-Mode-Direktlink, Favicon-Mixed-Content-Proxy, Live-Update Zuletzt-besucht, Kategorie-Farb-Picker, Startseite-Nav

This commit is contained in:
2026-07-20 13:02:58 +02:00
parent 96cf29fef4
commit cc4b285aea
13 changed files with 286 additions and 65 deletions

View File

@@ -0,0 +1,90 @@
import type { FastifyInstance } from "fastify";
import http from "node:http";
import https from "node:https";
const MAX_BYTES = 2 * 1024 * 1024; // 2 MB reicht für jedes realistische Favicon
const TIMEOUT_MS = 4000;
function fetchImage(
targetUrl: string
): Promise<{ statusCode: number; contentType: string; body: Buffer }> {
return new Promise((resolve, reject) => {
let parsed: URL;
try {
parsed = new URL(targetUrl);
} catch {
reject(new Error("Ungültige URL"));
return;
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
reject(new Error("Nur http/https erlaubt"));
return;
}
const client = parsed.protocol === "https:" ? https : http;
const req = client.get(
parsed,
{
timeout: TIMEOUT_MS,
// Homelab-Geräte haben oft selbstsignierte Zertifikate.
rejectUnauthorized: false,
},
(res) => {
const statusCode = res.statusCode ?? 0;
if (statusCode >= 300 && statusCode < 400 && res.headers.location) {
res.resume();
fetchImage(new URL(res.headers.location, targetUrl).toString())
.then(resolve)
.catch(reject);
return;
}
const chunks: Buffer[] = [];
let size = 0;
res.on("data", (chunk: Buffer) => {
size += chunk.length;
if (size > MAX_BYTES) {
req.destroy();
reject(new Error("Bild zu groß"));
return;
}
chunks.push(chunk);
});
res.on("end", () => {
resolve({
statusCode,
contentType: res.headers["content-type"] ?? "image/x-icon",
body: Buffer.concat(chunks),
});
});
res.on("error", reject);
}
);
req.on("timeout", () => req.destroy(new Error("Zeitüberschreitung")));
req.on("error", reject);
});
}
export async function faviconProxyRoutes(app: FastifyInstance): Promise<void> {
app.get("/api/favicon-proxy", async (request, reply) => {
const { url } = request.query as { url?: string };
if (!url) {
return reply.code(400).send({ error: "url erforderlich" });
}
try {
const image = await fetchImage(url);
if (image.statusCode >= 400 || image.body.length === 0) {
return reply.code(404).send();
}
reply.header("Cache-Control", "public, max-age=86400");
reply.type(image.contentType);
return reply.send(image.body);
} catch {
// Icon nicht ladbar (Gerät offline, kaputte URL, ...) - 404 statt 500,
// damit das Frontend sauber auf den Buchstaben-Fallback zurückfällt.
return reply.code(404).send();
}
});
}