tangled
alpha
login
or
join now
pds.ls
/
pdsls
398
fork
atom
atmosphere explorer
pds.ls
tool
typescript
atproto
398
fork
atom
overview
issues
1
pulls
pipelines
favicon fetching through worker
handle.invalid
1 day ago
21ae96cb
8f534482
verified
This commit was signed with the committer's
known signature
.
handle.invalid
SSH Key Fingerprint:
SHA256:mBrT4x0JdzLpbVR95g1hjI1aaErfC02kmLRkPXwsYCk=
+126
-11
4 changed files
expand all
collapse all
unified
split
.gitignore
src
components
favicon.tsx
navbar.tsx
worker.js
+1
.gitignore
···
5
5
public/oauth-client-metadata.json
6
6
public/opensearch.xml
7
7
public/_worker.js
8
8
+
.wrangler
+12
-5
src/components/favicon.tsx
···
6
6
wrapper?: (children: JSX.Element) => JSX.Element;
7
7
}) => {
8
8
const [loaded, setLoaded] = createSignal(false);
9
9
+
const [src, setSrc] = createSignal("");
9
10
const domain = () => (props.reverse ? props.domain.split(".").reverse().join(".") : props.domain);
11
11
+
12
12
+
const workerUrl = () => `/favicon?domain=${encodeURIComponent(domain())}`;
13
13
+
const directUrl = () => `https://${domain()}/favicon.ico`;
10
14
11
15
const content = (
12
16
<Switch>
13
17
<Match when={domain() === "tangled.sh" || domain() === "tangled.org"}>
14
18
<span class="iconify i-tangled size-4" />
15
19
</Match>
16
16
-
<Match when={["bsky.app", "bsky.chat"].includes(domain())}>
17
17
-
<img src="https://web-cdn.bsky.app/static/apple-touch-icon.png" class="size-4" />
18
18
-
</Match>
19
20
<Match when={true}>
20
21
<Show when={!loaded()}>
21
22
<span class="iconify lucide--globe size-4 text-neutral-400 dark:text-neutral-500" />
22
23
</Show>
23
24
<img
24
24
-
src={`https://${domain()}/favicon.ico`}
25
25
+
src={src() || workerUrl()}
25
26
class="size-4"
26
27
classList={{ hidden: !loaded() }}
27
28
onLoad={() => setLoaded(true)}
28
28
-
onError={() => setLoaded(false)}
29
29
+
onError={() => {
30
30
+
if (!src()) {
31
31
+
setSrc(directUrl());
32
32
+
} else {
33
33
+
setLoaded(false);
34
34
+
}
35
35
+
}}
29
36
/>
30
37
</Match>
31
38
</Switch>
+13
-6
src/components/navbar.tsx
···
33
33
const HoverFavicon = (props: { domain: string; hovered: boolean; children: JSX.Element }) => {
34
34
const [hasHovered, setHasHovered] = createSignal(false);
35
35
const [loaded, setLoaded] = createSignal(false);
36
36
+
const [src, setSrc] = createSignal("");
36
37
37
38
createEffect(() => {
38
39
props.domain;
39
40
setHasHovered(false);
40
41
setLoaded(false);
42
42
+
setSrc("");
41
43
});
42
44
43
45
createEffect(() => {
44
46
if (props.hovered) setHasHovered(true);
45
47
});
48
48
+
49
49
+
const workerUrl = () => `/favicon?domain=${encodeURIComponent(props.domain)}`;
50
50
+
const directUrl = () => `https://${props.domain}/favicon.ico`;
46
51
47
52
return (
48
53
<div class="relative flex h-5 w-3.5 shrink-0 items-center justify-center sm:w-4">
···
58
63
</Match>
59
64
<Match when={true}>
60
65
<img
61
61
-
src={
62
62
-
["bsky.app", "bsky.chat"].includes(props.domain) ?
63
63
-
"https://web-cdn.bsky.app/static/apple-touch-icon.png"
64
64
-
: `https://${props.domain}/favicon.ico`
65
65
-
}
66
66
+
src={src() || workerUrl()}
66
67
class="size-4"
67
68
classList={{ hidden: !props.hovered || !loaded() }}
68
69
onLoad={() => setLoaded(true)}
69
69
-
onError={() => setLoaded(false)}
70
70
+
onError={() => {
71
71
+
if (!src()) {
72
72
+
setSrc(directUrl());
73
73
+
} else {
74
74
+
setLoaded(false);
75
75
+
}
76
76
+
}}
70
77
/>
71
78
</Match>
72
79
</Switch>
+100
src/worker.js
···
486
486
}
487
487
}
488
488
489
489
+
const MAX_FAVICON_SIZE = 100 * 1024; // 100KB
490
490
+
491
491
+
async function handleFavicon(searchParams) {
492
492
+
const domain = searchParams.get("domain");
493
493
+
if (!domain) {
494
494
+
return new Response("Missing domain param", { status: 400 });
495
495
+
}
496
496
+
497
497
+
let faviconUrl = null;
498
498
+
try {
499
499
+
const pageRes = await fetch(`https://${domain}/`, {
500
500
+
signal: AbortSignal.timeout(5000),
501
501
+
headers: { "User-Agent": "PDSls-Favicon/1.0" },
502
502
+
redirect: "follow",
503
503
+
});
504
504
+
505
505
+
if (pageRes.ok && (pageRes.headers.get("content-type") ?? "").includes("text/html")) {
506
506
+
let bestHref = null;
507
507
+
let bestPriority = -1;
508
508
+
509
509
+
const rewriter = new HTMLRewriter().on("link", {
510
510
+
element(el) {
511
511
+
const rel = (el.getAttribute("rel") ?? "").toLowerCase();
512
512
+
if (!rel.includes("icon")) return;
513
513
+
const href = el.getAttribute("href");
514
514
+
if (!href) return;
515
515
+
516
516
+
// Prefer apple-touch-icon > icon with sizes > icon > shortcut icon
517
517
+
let priority = 0;
518
518
+
if (rel === "apple-touch-icon") priority = 3;
519
519
+
else if (rel === "icon" && el.getAttribute("sizes")) priority = 2;
520
520
+
else if (rel === "icon") priority = 1;
521
521
+
522
522
+
if (priority > bestPriority) {
523
523
+
bestPriority = priority;
524
524
+
bestHref = href;
525
525
+
}
526
526
+
},
527
527
+
});
528
528
+
529
529
+
const transformed = rewriter.transform(pageRes);
530
530
+
await transformed.text();
531
531
+
532
532
+
if (bestHref) {
533
533
+
try {
534
534
+
faviconUrl = new URL(bestHref, `https://${domain}/`).href;
535
535
+
} catch {
536
536
+
faviconUrl = null;
537
537
+
}
538
538
+
}
539
539
+
}
540
540
+
} catch {}
541
541
+
542
542
+
if (!faviconUrl) {
543
543
+
faviconUrl = `https://${domain}/favicon.ico`;
544
544
+
}
545
545
+
546
546
+
try {
547
547
+
const iconRes = await fetch(faviconUrl, {
548
548
+
signal: AbortSignal.timeout(5000),
549
549
+
redirect: "follow",
550
550
+
});
551
551
+
552
552
+
if (!iconRes.ok) {
553
553
+
return new Response("Favicon not found", { status: 404 });
554
554
+
}
555
555
+
556
556
+
const contentType = iconRes.headers.get("content-type") ?? "";
557
557
+
if (!contentType.includes("image") && !contentType.includes("icon")) {
558
558
+
return new Response("Not an image", { status: 404 });
559
559
+
}
560
560
+
561
561
+
const contentLength = parseInt(iconRes.headers.get("content-length") ?? "0", 10);
562
562
+
if (contentLength > MAX_FAVICON_SIZE) {
563
563
+
return new Response("Favicon too large", { status: 413 });
564
564
+
}
565
565
+
566
566
+
const body = await iconRes.arrayBuffer();
567
567
+
if (body.byteLength > MAX_FAVICON_SIZE) {
568
568
+
return new Response("Favicon too large", { status: 413 });
569
569
+
}
570
570
+
571
571
+
return new Response(body, {
572
572
+
headers: {
573
573
+
"Content-Type": contentType,
574
574
+
"Cache-Control": "public, max-age=86400",
575
575
+
"Access-Control-Allow-Origin": "*",
576
576
+
},
577
577
+
});
578
578
+
} catch {
579
579
+
return new Response("Failed to fetch favicon", { status: 502 });
580
580
+
}
581
581
+
}
582
582
+
489
583
export default {
490
584
async fetch(request, env) {
491
585
const url = new URL(request.url);
···
493
587
if (url.pathname === "/og-image") {
494
588
return handleOgImage(url.searchParams).catch(
495
589
(err) => new Response(`Failed to generate image: ${err?.message ?? err}`, { status: 500 }),
590
590
+
);
591
591
+
}
592
592
+
593
593
+
if (url.pathname === "/favicon") {
594
594
+
return handleFavicon(url.searchParams).catch(
595
595
+
(err) => new Response(`Failed to fetch favicon: ${err?.message ?? err}`, { status: 500 }),
496
596
);
497
597
}
498
598