mirror of
https://github.com/Nezumi-2711/uptime-monitoring.git
synced 2026-09-22 13:48:31 +00:00
feat: show icon page instead of mock
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { Database } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
type SiteIconProps = {
|
||||
monitorId: number;
|
||||
};
|
||||
|
||||
export function SiteIcon({ monitorId }: SiteIconProps) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (failed) return <Database aria-hidden="true" />;
|
||||
|
||||
return (
|
||||
<img
|
||||
src={`/api/monitors/${monitorId}/favicon`}
|
||||
alt=""
|
||||
width={22}
|
||||
height={22}
|
||||
loading="lazy"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { ArrowRight, Database, RefreshCw, Zap } from "lucide-react";
|
||||
import type { Monitor, MonitorInput, MonitorMethod } from "../api/monitors";
|
||||
import { SiteIcon } from "../components/SiteIcon";
|
||||
import { useLogoutMutation } from "../queries/auth";
|
||||
import { navigate } from "../lib/router";
|
||||
import {
|
||||
@@ -186,7 +187,7 @@ export function DashboardPage() {
|
||||
const toggling = updateMutation.isPending && updateMutation.variables?.id === monitor.id;
|
||||
return (
|
||||
<article className={`service-row ${monitor.enabled ? "" : "is-disabled"}`} key={monitor.id}>
|
||||
<div className="service-name"><span className="service-icon"><Database /></span><div><button className="monitor-name-link" type="button" onClick={() => navigate(`/monitors/${monitor.id}`)}>{monitor.name}</button><small title={monitor.url}>{monitor.url}</small><span className="monitor-meta">{monitor.method} · expect {monitor.expectedStatus} · every {monitor.intervalSeconds / 60}m</span></div></div>
|
||||
<div className="service-name"><span className="service-icon"><SiteIcon key={monitor.url} monitorId={monitor.id} /></span><div><button className="monitor-name-link" type="button" onClick={() => navigate(`/monitors/${monitor.id}`)}>{monitor.name}</button><small title={monitor.url}>{monitor.url}</small><span className="monitor-meta">{monitor.method} · expect {monitor.expectedStatus} · every {monitor.intervalSeconds / 60}m</span></div></div>
|
||||
<div className="monitor-result"><span className={`row-status ${checking ? "checking" : status.className}`}><i />{checking ? "Checking" : status.label}</span><code>{monitor.lastStatusCode === null ? "—" : `HTTP ${monitor.lastStatusCode}`} · {monitor.lastLatencyMs === null ? "—" : `${monitor.lastLatencyMs} ms`}</code><small title={monitor.lastError ?? undefined}>{monitor.lastError ?? formatCheckedAt(monitor.lastCheckedAt)}</small></div>
|
||||
<div className="row-actions"><button type="button" onClick={() => navigate(`/monitors/${monitor.id}`)}>History</button><button type="button" onClick={() => checkMutation.mutate(monitor.id)} disabled={checking}>Check now</button><button type="button" onClick={() => openEditForm(monitor)}>Edit</button><button type="button" onClick={() => updateMutation.mutate({ id: monitor.id, input: { enabled: !monitor.enabled } })} disabled={toggling}>{monitor.enabled ? "Disable" : "Enable"}</button><button className="danger-action" type="button" onClick={() => handleDelete(monitor)} disabled={deleting}>{deleting ? "Deleting…" : "Delete"}</button></div>
|
||||
</article>
|
||||
|
||||
@@ -74,7 +74,7 @@ export function MonitorDetailPage({ id }: { id: number }) {
|
||||
</div>
|
||||
|
||||
<div className="detail-grid lower-grid">
|
||||
<section className="data-panel"><div className="data-panel-heading"><div><p className="overline">Event stream</p><h2>Recent checks</h2></div></div><div className="data-table-wrap"><table className="data-table"><thead><tr><th>Status</th><th>Response</th><th>Latency</th><th>Checked</th></tr></thead><tbody>{checks.slice(0, 20).map((check) => <tr key={check.id}><td><span className={`row-status ${check.ok ? "online" : "offline"}`}><i />{check.ok ? "Up" : "Down"}</span></td><td><code>{check.statusCode ? `HTTP ${check.statusCode}` : check.error ?? "Failed"}</code></td><td>{check.latencyMs} ms</td><td>{formatDate(check.checkedAt)}</td></tr>)}</tbody></table>{checks.length === 0 && <div className="table-empty">No checks recorded.</div>}</div></section>
|
||||
<section className="data-panel recent-checks-panel"><div className="data-panel-heading"><div><p className="overline">Event stream</p><h2>Recent checks</h2></div><span>{Math.min(checks.length, 20)} shown</span></div><div className="data-table-wrap recent-checks-scroll" role="region" aria-label="Recent checks" tabIndex={0}><table className="data-table"><thead><tr><th>Status</th><th>Response</th><th>Latency</th><th>Checked</th></tr></thead><tbody>{checks.slice(0, 20).map((check) => <tr key={check.id}><td><span className={`row-status ${check.ok ? "online" : "offline"}`}><i />{check.ok ? "Up" : "Down"}</span></td><td><code>{check.statusCode ? `HTTP ${check.statusCode}` : check.error ?? "Failed"}</code></td><td>{check.latencyMs} ms</td><td>{formatDate(check.checkedAt)}</td></tr>)}</tbody></table>{checks.length === 0 && <div className="table-empty">No checks recorded.</div>}</div></section>
|
||||
<section className="data-panel"><div className="data-panel-heading"><div><p className="overline">Downtime</p><h2>Incidents</h2></div><span>{incidents.length} recorded</span></div><div className="incident-list">{incidents.map((incident) => <article className={incident.resolvedAt ? "resolved" : "open"} key={incident.id}><span>{incident.resolvedAt ? <CheckCircle2 /> : <Clock3 />}</span><div><strong>{incident.resolvedAt ? "Resolved incident" : "Incident in progress"}</strong><p>{incident.startError ?? (incident.startStatusCode ? `HTTP ${incident.startStatusCode}` : "Endpoint became unavailable")}</p><small>{formatDate(incident.startedAt)} · {formatDuration(incident.durationMs, incident.startedAt)}</small></div></article>)}{incidents.length === 0 && <div className="table-empty">No downtime incidents recorded.</div>}</div></section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -105,6 +105,7 @@ button { color: inherit; }
|
||||
.service-name > div { min-width: 0; }
|
||||
.service-icon { display: grid; place-items: center; flex: 0 0 auto; width: 40px; height: 40px; border: 1px solid #dedede; border-radius: 6px; color: #555; background: #fafafa; }
|
||||
.service-icon svg { width: 19px; height: 19px; }
|
||||
.service-icon img { width: 22px; height: 22px; border-radius: 4px; object-fit: contain; }
|
||||
.service-name strong, .service-name small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.service-name strong { font-size: 15px; font-weight: 500; }
|
||||
.monitor-name-link { display: block; max-width: 100%; padding: 0; overflow: hidden; border: 0; font-size: 15px; font-weight: 500; text-align: left; text-overflow: ellipsis; white-space: nowrap; background: transparent; cursor: pointer; }
|
||||
@@ -222,6 +223,15 @@ button { color: inherit; }
|
||||
.uptime-legend i { width: 6px; height: 6px; border-radius: 50%; background: var(--primary-deep); }
|
||||
.uptime-legend i.legend-down { margin-left: 5px; background: #d95c5c; }
|
||||
.data-table-wrap { overflow-x: auto; }
|
||||
.recent-checks-panel { display: flex; min-height: 0; flex-direction: column; }
|
||||
.recent-checks-scroll { max-height: clamp(320px, 52vh, 556px); overflow: auto; overscroll-behavior: contain; scrollbar-color: #b8c7c0 #f4f6f5; scrollbar-gutter: stable; scrollbar-width: thin; }
|
||||
.recent-checks-scroll:focus-visible { outline: 2px solid rgb(36 180 126 / 0.45); outline-offset: -2px; }
|
||||
.recent-checks-scroll::-webkit-scrollbar { width: 9px; height: 9px; }
|
||||
.recent-checks-scroll::-webkit-scrollbar-track { background: #f4f6f5; }
|
||||
.recent-checks-scroll::-webkit-scrollbar-thumb { border: 2px solid #f4f6f5; border-radius: 999px; background: #b8c7c0; }
|
||||
.recent-checks-scroll::-webkit-scrollbar-thumb:hover { background: #91a69d; }
|
||||
.recent-checks-scroll .data-table { min-width: 600px; }
|
||||
.recent-checks-scroll .data-table th { position: sticky; z-index: 1; top: 0; box-shadow: 0 1px #e5e5e5; }
|
||||
.data-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.data-table th { padding: 11px 16px; font-weight: 400; text-align: left; color: #888; background: #fafafa; }
|
||||
.data-table td { padding: 14px 16px; border-top: 1px solid #ededed; color: #555; white-space: nowrap; }
|
||||
|
||||
@@ -25,6 +25,218 @@ type ParseResult =
|
||||
| { ok: false; message: string };
|
||||
|
||||
const METHODS = new Set<MonitorMethod>(["GET", "HEAD", "POST"]);
|
||||
const FAVICON_CACHE_SECONDS = 86_400;
|
||||
const FAVICON_FETCH_TIMEOUT_MS = 5_000;
|
||||
const MAX_FAVICON_BYTES = 1024 * 1024;
|
||||
const MAX_HEAD_BYTES = 128 * 1024;
|
||||
const MAX_REDIRECTS = 3;
|
||||
|
||||
type FaviconResult = {
|
||||
body: ArrayBuffer;
|
||||
contentType: string;
|
||||
};
|
||||
|
||||
type EdgeCache = {
|
||||
match(request: RequestInfo | URL): Promise<Response | undefined>;
|
||||
put(request: RequestInfo | URL, response: Response): Promise<void>;
|
||||
};
|
||||
|
||||
function isPrivateHostname(rawHostname: string): boolean {
|
||||
const hostname = rawHostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
|
||||
if (hostname === "localhost" || hostname.endsWith(".localhost")) return true;
|
||||
|
||||
const ipv4 = hostname.split(".").map(Number);
|
||||
if (ipv4.length === 4 && ipv4.every((part) => Number.isInteger(part) && part >= 0 && part <= 255)) {
|
||||
const [first, second] = ipv4;
|
||||
return first === 0
|
||||
|| first === 10
|
||||
|| first === 127
|
||||
|| (first === 169 && second === 254)
|
||||
|| (first === 172 && second >= 16 && second <= 31)
|
||||
|| (first === 192 && second === 168);
|
||||
}
|
||||
|
||||
if (hostname === "::" || hostname === "::1") return true;
|
||||
if (/^f[cd][0-9a-f]{2}(?::|$)/i.test(hostname) || /^fe[89ab][0-9a-f](?::|$)/i.test(hostname)) return true;
|
||||
const mappedIpv4 = hostname.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
||||
return mappedIpv4 ? isPrivateHostname(mappedIpv4[1]) : false;
|
||||
}
|
||||
|
||||
function isSafeRemoteUrl(url: URL) {
|
||||
return (url.protocol === "http:" || url.protocol === "https:")
|
||||
&& !url.username
|
||||
&& !url.password
|
||||
&& !isPrivateHostname(url.hostname);
|
||||
}
|
||||
|
||||
async function readBodyLimited(response: Response, maximum: number, truncate: boolean) {
|
||||
if (!response.body) return null;
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
let exceeded = false;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const remaining = maximum - total;
|
||||
if (value.byteLength > remaining) {
|
||||
if (truncate && remaining > 0) {
|
||||
chunks.push(value.subarray(0, remaining));
|
||||
total += remaining;
|
||||
}
|
||||
exceeded = true;
|
||||
await reader.cancel();
|
||||
break;
|
||||
}
|
||||
chunks.push(value);
|
||||
total += value.byteLength;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (exceeded && !truncate) return null;
|
||||
const body = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
body.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return body.buffer;
|
||||
}
|
||||
|
||||
async function fetchRemote(url: URL, maximumBytes: number, truncate = false) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), FAVICON_FETCH_TIMEOUT_MS);
|
||||
let currentUrl = url;
|
||||
|
||||
try {
|
||||
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) {
|
||||
if (!isSafeRemoteUrl(currentUrl)) return null;
|
||||
const response = await fetch(currentUrl, {
|
||||
headers: {
|
||||
Accept: "image/avif,image/webp,image/png,image/svg+xml,image/*;q=0.8,text/html;q=0.5,*/*;q=0.1",
|
||||
"User-Agent": "Upwatch Favicon Proxy/1.0",
|
||||
},
|
||||
redirect: "manual",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
||||
if (response.body) await response.body.cancel().catch(() => undefined);
|
||||
const location = response.headers.get("Location");
|
||||
if (!location || redirects === MAX_REDIRECTS) return null;
|
||||
try {
|
||||
currentUrl = new URL(location, currentUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.body) await response.body.cancel().catch(() => undefined);
|
||||
return null;
|
||||
}
|
||||
|
||||
const declaredLength = Number(response.headers.get("Content-Length"));
|
||||
if (!truncate && Number.isFinite(declaredLength) && declaredLength > maximumBytes) {
|
||||
if (response.body) await response.body.cancel().catch(() => undefined);
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = await readBodyLimited(response, maximumBytes, truncate);
|
||||
if (!body) return null;
|
||||
return { body, headers: response.headers, url: currentUrl };
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function imageContentType(headers: Headers) {
|
||||
const contentType = headers.get("Content-Type")?.split(";", 1)[0].trim().toLowerCase();
|
||||
if (!contentType || contentType === "application/octet-stream") return "image/x-icon";
|
||||
if (!contentType.startsWith("image/")) {
|
||||
return null;
|
||||
}
|
||||
return contentType;
|
||||
}
|
||||
|
||||
function readTagAttribute(tag: string, name: string) {
|
||||
const attributes = /([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
|
||||
for (const match of tag.matchAll(attributes)) {
|
||||
if (match[1].toLowerCase() === name) return match[2] ?? match[3] ?? match[4] ?? "";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findFaviconUrl(html: string, pageUrl: URL) {
|
||||
const closingHead = html.search(/<\/head\s*>/i);
|
||||
const head = closingHead >= 0 ? html.slice(0, closingHead) : html;
|
||||
let baseUrl = pageUrl;
|
||||
const baseTag = head.match(/<base\b[^>]*>/i)?.[0];
|
||||
const baseHref = baseTag ? readTagAttribute(baseTag, "href") : null;
|
||||
if (baseHref) {
|
||||
try {
|
||||
const candidate = new URL(baseHref, pageUrl);
|
||||
if (isSafeRemoteUrl(candidate)) baseUrl = candidate;
|
||||
} catch {
|
||||
// Keep the page URL as the base when the document declares an invalid URL.
|
||||
}
|
||||
}
|
||||
|
||||
for (const match of head.matchAll(/<link\b[^>]*>/gi)) {
|
||||
const rel = readTagAttribute(match[0], "rel")?.toLowerCase().split(/\s+/) ?? [];
|
||||
if (!rel.includes("icon") && !rel.includes("apple-touch-icon")) continue;
|
||||
const href = readTagAttribute(match[0], "href");
|
||||
if (!href) continue;
|
||||
try {
|
||||
const faviconUrl = new URL(href, baseUrl);
|
||||
if (isSafeRemoteUrl(faviconUrl)) return faviconUrl;
|
||||
} catch {
|
||||
// Try the next icon declaration.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function resolveFavicon(siteUrl: string): Promise<FaviconResult | null> {
|
||||
let site: URL;
|
||||
try {
|
||||
site = new URL(siteUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!isSafeRemoteUrl(site)) return null;
|
||||
|
||||
const origin = new URL(site.origin);
|
||||
const defaultIcon = await fetchRemote(new URL("/favicon.ico", origin), MAX_FAVICON_BYTES);
|
||||
if (defaultIcon && defaultIcon.body.byteLength > 0) {
|
||||
const contentType = imageContentType(defaultIcon.headers);
|
||||
if (contentType) return { body: defaultIcon.body, contentType };
|
||||
}
|
||||
|
||||
const page = await fetchRemote(origin, MAX_HEAD_BYTES, true);
|
||||
if (!page || page.body.byteLength === 0) return null;
|
||||
const pageContentType = page.headers.get("Content-Type")?.toLowerCase();
|
||||
if (pageContentType && !pageContentType.includes("text/html") && !pageContentType.includes("application/xhtml+xml")) {
|
||||
return null;
|
||||
}
|
||||
const faviconUrl = findFaviconUrl(new TextDecoder().decode(page.body), page.url);
|
||||
if (!faviconUrl) return null;
|
||||
|
||||
const icon = await fetchRemote(faviconUrl, MAX_FAVICON_BYTES);
|
||||
if (!icon || icon.body.byteLength === 0) return null;
|
||||
const contentType = imageContentType(icon.headers);
|
||||
return contentType ? { body: icon.body, contentType } : null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
@@ -228,6 +440,44 @@ monitorRoutes.get("/:id/stats", async (context) => {
|
||||
return context.json({ windows: Object.fromEntries(results) as Record<(typeof windows)[number]["key"], StatsWindow> });
|
||||
});
|
||||
|
||||
monitorRoutes.get("/:id/favicon", async (context) => {
|
||||
const id = parseId(context.req.param("id"));
|
||||
if (id === null) return context.json({ message: "Monitor not found" }, 404);
|
||||
const [monitor] = await getDb(context.env)
|
||||
.select({ url: monitors.url })
|
||||
.from(monitors)
|
||||
.where(eq(monitors.id, id))
|
||||
.limit(1);
|
||||
if (!monitor) return context.json({ message: "Monitor not found" }, 404);
|
||||
|
||||
const cacheKey = new Request(`${new URL(context.req.url).origin}/api/monitors/${id}/favicon`);
|
||||
let cache: EdgeCache | null = null;
|
||||
try {
|
||||
const defaultCache = (caches as CacheStorage & { readonly default: EdgeCache }).default;
|
||||
const cached = await defaultCache.match(cacheKey);
|
||||
if (cached) return cached;
|
||||
cache = defaultCache;
|
||||
} catch {
|
||||
// Cache API availability is best-effort, particularly in local and preview environments.
|
||||
}
|
||||
|
||||
const favicon = await resolveFavicon(monitor.url);
|
||||
if (!favicon) return context.json({ message: "No favicon" }, 404);
|
||||
|
||||
const response = new Response(favicon.body, {
|
||||
headers: {
|
||||
"Cache-Control": `public, max-age=${FAVICON_CACHE_SECONDS}`,
|
||||
"Content-Length": String(favicon.body.byteLength),
|
||||
"Content-Type": favicon.contentType,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
if (cache) {
|
||||
context.executionCtx.waitUntil(cache.put(cacheKey, response.clone()).catch(() => undefined));
|
||||
}
|
||||
return response;
|
||||
});
|
||||
|
||||
monitorRoutes.post("/", async (context) => {
|
||||
let body: unknown;
|
||||
try {
|
||||
|
||||
+55
-1
@@ -1,6 +1,7 @@
|
||||
import { applyD1Migrations, env, SELF, type D1Migration } from "cloudflare:test";
|
||||
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { hashPassword } from "../src/worker/lib/password";
|
||||
import { resolveFavicon } from "../src/worker/routes/monitors";
|
||||
|
||||
const ADMIN_PASSWORD = "correct-horse-battery-staple";
|
||||
const VALID_MONITOR = {
|
||||
@@ -64,10 +65,12 @@ describe("monitor API", () => {
|
||||
await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS);
|
||||
});
|
||||
beforeEach(seedAdmin);
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("protects every monitor endpoint, including the collection path without a trailing slash", async () => {
|
||||
const requests = [
|
||||
apiFetch("/api/monitors"),
|
||||
apiFetch("/api/monitors/1/favicon"),
|
||||
apiFetch("/api/monitors", "POST", "", VALID_MONITOR),
|
||||
apiFetch("/api/monitors/1", "PATCH", "", { name: "Changed" }),
|
||||
apiFetch("/api/monitors/1", "DELETE"),
|
||||
@@ -80,6 +83,57 @@ describe("monitor API", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("proxies and caches a monitor favicon response", async () => {
|
||||
const cookie = await authenticatedCookie();
|
||||
const created = await (await createMonitor(cookie, { url: "https://favicon-route.example.test/health" }))
|
||||
.json<{ monitor: { id: number } }>();
|
||||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = new URL(input.toString());
|
||||
expect(url.href).toBe("https://favicon-route.example.test/favicon.ico");
|
||||
return new Response(new Uint8Array([0, 0, 1, 0]), {
|
||||
headers: { "Content-Type": "image/x-icon" },
|
||||
});
|
||||
}));
|
||||
|
||||
const response = await apiFetch(`/api/monitors/${created.monitor.id}/favicon`, "GET", cookie);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("image/x-icon");
|
||||
expect(response.headers.get("Cache-Control")).toBe("public, max-age=86400");
|
||||
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
||||
expect(new Uint8Array(await response.arrayBuffer())).toEqual(new Uint8Array([0, 0, 1, 0]));
|
||||
});
|
||||
|
||||
it("discovers a favicon declared in the website head", async () => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = new URL(input.toString());
|
||||
if (url.pathname === "/favicon.ico") return new Response(null, { status: 404 });
|
||||
if (url.pathname === "/") {
|
||||
return new Response('<html><head><link rel="apple-touch-icon" href="/assets/icon.png"></head></html>', {
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
return new Response(new Uint8Array([137, 80, 78, 71]), {
|
||||
headers: { "Content-Type": "image/png" },
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const favicon = await resolveFavicon("https://favicon-head.example.test/status");
|
||||
expect(favicon?.contentType).toBe("image/png");
|
||||
expect(new Uint8Array(favicon?.body ?? new ArrayBuffer(0))).toEqual(new Uint8Array([137, 80, 78, 71]));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("does not fetch favicons from private network hosts", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(resolveFavicon("http://127.0.0.1/admin")).resolves.toBeNull();
|
||||
await expect(resolveFavicon("http://[::1]/admin")).resolves.toBeNull();
|
||||
await expect(resolveFavicon("http://10.0.0.1/admin")).resolves.toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates a valid monitor and returns it in the list", async () => {
|
||||
const cookie = await authenticatedCookie();
|
||||
const response = await createMonitor(cookie);
|
||||
|
||||
Reference in New Issue
Block a user