birdclaw 0.8.2 → 0.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/README.md +10 -8
- package/bin/birdclaw.mjs +4 -66
- package/dist/cli/birdclaw.js +28165 -0
- package/dist/client/assets/AvatarChip-DLoqM5xD.js +1 -0
- package/dist/client/assets/ProfilePreview-BejAuYsJ.js +1 -0
- package/dist/client/assets/SavedTimelineView-BIS7HKpp.js +1 -0
- package/dist/client/assets/SmartTimestamp-BR0-9se2.js +1 -0
- package/dist/client/assets/TimelineRouteFrame-CyGPj0eM.js +1 -0
- package/dist/client/assets/blocks-7UDKQp8H.js +1 -0
- package/dist/client/assets/bookmarks-CbBkMvXk.js +1 -0
- package/dist/client/assets/chunk-C0phanGV.js +1 -0
- package/dist/client/assets/circle-check-C_Vq3ElD.js +1 -0
- package/dist/client/assets/client-stream-contracts-ys3XhWG4.js +5 -0
- package/dist/client/assets/createLucideIcon-JV8isFFR.js +1 -0
- package/dist/client/assets/data-sources-Cm2bpNvg.js +1 -0
- package/dist/client/assets/discuss-DTjaOugw.js +1 -0
- package/dist/client/assets/dms-CGd6Eogq.js +1 -0
- package/dist/client/assets/inbox-BHlGuOy2.js +1 -0
- package/dist/client/assets/index-CEIG7S6s.js +31 -0
- package/dist/client/assets/index-bpFrQ5yG.css +1 -0
- package/dist/client/assets/likes-cLrcAQem.js +1 -0
- package/dist/client/assets/links-CM9QO1LC.js +1 -0
- package/dist/client/assets/mapbox-BxTlqXt9.js +2 -0
- package/dist/client/assets/mapbox-gl-dgf_hX3a.js +3215 -0
- package/dist/client/assets/mentions-CxKCgzIf.js +1 -0
- package/dist/client/assets/network-map-B5a3whqj.js +2 -0
- package/dist/client/assets/profile-analyze-CRtNEwlb.js +1 -0
- package/dist/client/assets/profile-hydration-client-YEtJsNTv.js +1 -0
- package/dist/client/assets/profiles._handle-C82_HoKX.js +1 -0
- package/dist/client/assets/query-client-fYu2rzRC.js +17 -0
- package/dist/client/assets/rate-limits-ScaEwdVK.js +1 -0
- package/dist/client/assets/react-dom-DHl6Ug7F.js +1 -0
- package/dist/client/assets/react-nBylq1U5.js +1 -0
- package/dist/client/assets/repeat-2-xCwHM9hD.js +1 -0
- package/dist/client/assets/routes-q7ZaW4En.js +1 -0
- package/dist/client/assets/schemas-I9r6F6ya.js +64 -0
- package/dist/client/assets/search-Dq5kYsxA.js +1 -0
- package/dist/client/assets/sparkles-CQVlvHsZ.js +1 -0
- package/dist/client/assets/styles-B8s3tt3r.css +2 -0
- package/dist/client/assets/today-CeG1O7Py.js +1 -0
- package/dist/client/assets/triangle-alert-CBL7s2pf.js +1 -0
- package/dist/client/assets/useTimelineRouteData-1HsqgvUi.js +1 -0
- package/dist/client/assets/users-CHbKlqUr.js +1 -0
- package/dist/server/assets/AvatarChip-zUDAEmjL.js +105 -0
- package/dist/server/assets/FeedState-ntW0Q2GB.js +214 -0
- package/dist/server/assets/ProfileAnalysisStream-BFvhVdhn.js +198 -0
- package/dist/server/assets/ProfilePreview-C6gAor_j.js +752 -0
- package/dist/server/assets/SavedTimelineView-BG1jnBp1.js +64 -0
- package/dist/server/assets/SmartTimestamp-DfwE1NGf.js +45 -0
- package/dist/server/assets/SyncNowButton-BOn_IBXS.js +76 -0
- package/dist/server/assets/TimelineRouteFrame-CU7ilM7U.js +85 -0
- package/dist/server/assets/TweetRichText-DhCVv1-6.js +74 -0
- package/dist/server/assets/_tanstack-start-manifest_v-C-ZRnG_n.js +148 -0
- package/dist/server/assets/account-selection-CTdFGWRk.js +44 -0
- package/dist/server/assets/blocks-C1mgGTB3.js +299 -0
- package/dist/server/assets/blocks-DSgQghqP.js +17 -0
- package/dist/server/assets/bookmarks-BJN8tykY.js +14 -0
- package/dist/server/assets/client-stream-contracts-C2xbWVjm.js +769 -0
- package/dist/server/assets/data-sources-BnA1Q94p.js +180 -0
- package/dist/server/assets/discuss-CJ1dau1j.js +15 -0
- package/dist/server/assets/discuss-QTo08NCN.js +338 -0
- package/dist/server/assets/dms-BuLj4bp8.js +368 -0
- package/dist/server/assets/dms-DAyQrVM2.js +240 -0
- package/dist/server/assets/empty-plugin-adapters-DzrEkdBF.js +5 -0
- package/dist/server/assets/inbox-CkGOaW1q.js +214 -0
- package/dist/server/assets/inbox-DpWd1EzC.js +146 -0
- package/dist/server/assets/likes-DBOAtsJU.js +14 -0
- package/dist/server/assets/links-DAhSjynu.js +240 -0
- package/dist/server/assets/links-DmH5lxHO.js +468 -0
- package/dist/server/assets/mentions-BH2MzKtB.js +26 -0
- package/dist/server/assets/network-map-D3CzRcC4.js +224 -0
- package/dist/server/assets/network-map-D5DoLOwR.js +582 -0
- package/dist/server/assets/profile-analyze-BP65PED0.js +90 -0
- package/dist/server/assets/profile-analyze-C1NoM0tV.js +9 -0
- package/dist/server/assets/profile-hydration-client-GJ4kwJS8.js +133 -0
- package/dist/server/assets/profiles._handle-DjwlN_ac.js +184 -0
- package/dist/server/assets/profiles._handle-DsLNj2Ca.js +12 -0
- package/dist/server/assets/query-client-BdauHBgd.js +923 -0
- package/dist/server/assets/rate-limits-VAr2qzYD.js +262 -0
- package/dist/server/assets/route-search-BUlQ5lGM.js +117 -0
- package/dist/server/assets/router-DqkwSPiM.js +17026 -0
- package/dist/server/assets/routes-A9c9ZhwN.js +26 -0
- package/dist/server/assets/start-FaDOTmto.js +4 -0
- package/dist/server/assets/today-B5hVM57Z.js +17 -0
- package/dist/server/assets/today-fvGiZ7ud.js +303 -0
- package/dist/server/assets/ui-qbmcMSSX.js +137 -0
- package/{src/components/useDebouncedValue.ts → dist/server/assets/useDebouncedValue-DOnA6Xcy.js} +4 -4
- package/dist/server/assets/useTimelineRouteData-27cxynxa.js +1101 -0
- package/dist/server/server.js +5520 -0
- package/package.json +27 -34
- package/playwright.config.ts +0 -35
- package/scripts/browser-perf.mjs +0 -427
- package/scripts/build-docs-site.mjs +0 -967
- package/scripts/docs-site-assets.mjs +0 -311
- package/scripts/run-vitest.mjs +0 -21
- package/scripts/sanitize-node-options.mjs +0 -23
- package/scripts/start-test-server.mjs +0 -42
- package/src/cli-moderation.ts +0 -210
- package/src/cli.ts +0 -2336
- package/src/components/AccountSwitcher.tsx +0 -186
- package/src/components/AppNav.tsx +0 -118
- package/src/components/AvatarChip.tsx +0 -58
- package/src/components/BrandMark.tsx +0 -67
- package/src/components/ConversationThread.tsx +0 -131
- package/src/components/DmWorkspace.tsx +0 -286
- package/src/components/EmbeddedTweetCard.tsx +0 -55
- package/src/components/FeedState.tsx +0 -147
- package/src/components/FloatingPreview.tsx +0 -382
- package/src/components/InboxCard.tsx +0 -152
- package/src/components/LinkPreviewCard.tsx +0 -292
- package/src/components/MarkdownViewer.tsx +0 -777
- package/src/components/ProfileAnalysisStream.tsx +0 -428
- package/src/components/ProfilePreview.tsx +0 -129
- package/src/components/SavedTimelineView.tsx +0 -155
- package/src/components/SmartTimestamp.tsx +0 -72
- package/src/components/SyncNowButton.tsx +0 -140
- package/src/components/ThemeSlider.tsx +0 -80
- package/src/components/TimelineCard.tsx +0 -533
- package/src/components/TimelineRouteFrame.tsx +0 -186
- package/src/components/TweetArticleCard.tsx +0 -66
- package/src/components/TweetMediaGrid.tsx +0 -185
- package/src/components/TweetRichText.tsx +0 -159
- package/src/components/account-selection.ts +0 -64
- package/src/components/useTimelineRouteData.ts +0 -323
- package/src/lib/account-sync-job.ts +0 -666
- package/src/lib/actions-transport.ts +0 -300
- package/src/lib/api-client.ts +0 -419
- package/src/lib/archive-finder.ts +0 -185
- package/src/lib/archive-import.ts +0 -2764
- package/src/lib/authored-live.ts +0 -1072
- package/src/lib/avatar-cache.ts +0 -349
- package/src/lib/backup.ts +0 -2741
- package/src/lib/bird-actions.ts +0 -294
- package/src/lib/bird-command.ts +0 -130
- package/src/lib/bird.ts +0 -1140
- package/src/lib/blocklist.ts +0 -136
- package/src/lib/blocks-write.ts +0 -167
- package/src/lib/blocks.ts +0 -389
- package/src/lib/bookmark-sync-job.ts +0 -536
- package/src/lib/client-cache.ts +0 -109
- package/src/lib/config.ts +0 -224
- package/src/lib/conversation-surface.ts +0 -205
- package/src/lib/data-sources.ts +0 -219
- package/src/lib/db.ts +0 -1135
- package/src/lib/dms-live.ts +0 -933
- package/src/lib/effect-runtime.ts +0 -45
- package/src/lib/follow-graph.ts +0 -1097
- package/src/lib/geocoding.ts +0 -296
- package/src/lib/http-effect.ts +0 -222
- package/src/lib/identity-search-index.ts +0 -369
- package/src/lib/inbox.ts +0 -241
- package/src/lib/link-index.ts +0 -641
- package/src/lib/link-insights.ts +0 -860
- package/src/lib/link-preview-metadata.ts +0 -754
- package/src/lib/location.ts +0 -137
- package/src/lib/media-fetch.ts +0 -860
- package/src/lib/media-includes.ts +0 -165
- package/src/lib/mention-threads-live.ts +0 -917
- package/src/lib/mentions-export.ts +0 -147
- package/src/lib/mentions-live.ts +0 -1238
- package/src/lib/moderation-target.ts +0 -216
- package/src/lib/moderation-write.ts +0 -131
- package/src/lib/mutes-write.ts +0 -167
- package/src/lib/mutes.ts +0 -84
- package/src/lib/ndjson-stream.ts +0 -106
- package/src/lib/network-map.ts +0 -382
- package/src/lib/openai.ts +0 -117
- package/src/lib/period-digest.ts +0 -1552
- package/src/lib/present.ts +0 -118
- package/src/lib/profile-affiliation-hydration.ts +0 -228
- package/src/lib/profile-affiliations.ts +0 -262
- package/src/lib/profile-analysis.ts +0 -1272
- package/src/lib/profile-bio-entities.ts +0 -318
- package/src/lib/profile-history.ts +0 -164
- package/src/lib/profile-hydration.ts +0 -180
- package/src/lib/profile-replies.ts +0 -77
- package/src/lib/profile-resolver.ts +0 -650
- package/src/lib/queries.ts +0 -2381
- package/src/lib/research.ts +0 -652
- package/src/lib/search-discussion.ts +0 -1016
- package/src/lib/seed.ts +0 -613
- package/src/lib/sqlite.ts +0 -147
- package/src/lib/sync-cache.ts +0 -65
- package/src/lib/theme-transition.ts +0 -113
- package/src/lib/theme.tsx +0 -165
- package/src/lib/timeline-collections-live.ts +0 -581
- package/src/lib/timeline-live.ts +0 -452
- package/src/lib/tweet-account-edges.ts +0 -48
- package/src/lib/tweet-lookup.ts +0 -46
- package/src/lib/tweet-render.ts +0 -430
- package/src/lib/tweet-search-live.ts +0 -566
- package/src/lib/types.ts +0 -723
- package/src/lib/ui.ts +0 -529
- package/src/lib/url-expansion-store.ts +0 -110
- package/src/lib/url-expansion.ts +0 -343
- package/src/lib/url-safety.ts +0 -220
- package/src/lib/web-sync.ts +0 -516
- package/src/lib/whois.ts +0 -1079
- package/src/lib/x-profile.ts +0 -365
- package/src/lib/x-web.ts +0 -199
- package/src/lib/xurl-rate-limits.ts +0 -267
- package/src/lib/xurl.ts +0 -1677
- package/src/routeTree.gen.ts +0 -723
- package/src/router.tsx +0 -20
- package/src/routes/__root.tsx +0 -79
- package/src/routes/api/action.tsx +0 -147
- package/src/routes/api/avatar.tsx +0 -50
- package/src/routes/api/blocks.tsx +0 -35
- package/src/routes/api/conversation.tsx +0 -45
- package/src/routes/api/data-sources.tsx +0 -24
- package/src/routes/api/inbox.tsx +0 -43
- package/src/routes/api/link-insights.tsx +0 -85
- package/src/routes/api/link-preview.tsx +0 -47
- package/src/routes/api/network-map.tsx +0 -55
- package/src/routes/api/period-digest.tsx +0 -101
- package/src/routes/api/profile-analysis.tsx +0 -97
- package/src/routes/api/profile-hydrate.tsx +0 -54
- package/src/routes/api/query.tsx +0 -115
- package/src/routes/api/search-discussion.tsx +0 -113
- package/src/routes/api/status.tsx +0 -28
- package/src/routes/api/sync.tsx +0 -105
- package/src/routes/api/xurl-rate-limits.tsx +0 -24
- package/src/routes/blocks.tsx +0 -377
- package/src/routes/bookmarks.tsx +0 -18
- package/src/routes/data-sources.tsx +0 -257
- package/src/routes/discuss.tsx +0 -419
- package/src/routes/dms.tsx +0 -432
- package/src/routes/inbox.tsx +0 -226
- package/src/routes/index.tsx +0 -32
- package/src/routes/likes.tsx +0 -18
- package/src/routes/links.tsx +0 -994
- package/src/routes/mentions.tsx +0 -32
- package/src/routes/network-map.tsx +0 -1035
- package/src/routes/profile-analyze.tsx +0 -112
- package/src/routes/profiles.$handle.tsx +0 -228
- package/src/routes/rate-limits.tsx +0 -309
- package/src/routes/today.tsx +0 -511
- package/src/styles.css +0 -257
- package/tsconfig.json +0 -29
- package/vite.config.ts +0 -29
- /package/{public → dist/client}/birdclaw-mark.png +0 -0
- /package/{public → dist/client}/favicon.ico +0 -0
- /package/{public → dist/client}/logo192.png +0 -0
- /package/{public → dist/client}/logo512.png +0 -0
- /package/{public → dist/client}/manifest.json +0 -0
- /package/{public → dist/client}/robots.txt +0 -0
package/src/lib/avatar-cache.ts
DELETED
|
@@ -1,349 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { Effect } from "effect";
|
|
5
|
-
import { getBirdclawPaths } from "./config";
|
|
6
|
-
import { getNativeDb } from "./db";
|
|
7
|
-
import { runEffectPromise, tryPromise } from "./effect-runtime";
|
|
8
|
-
import { assertSafePreviewUrl } from "./url-safety";
|
|
9
|
-
|
|
10
|
-
const AVATAR_SIZE_SUFFIX =
|
|
11
|
-
/(?:(?:_normal|_bigger|_mini))(?=\.(?:jpg|jpeg|png|webp|gif)(?:$|\?))/i;
|
|
12
|
-
const MAX_AVATAR_BYTES = 2 * 1024 * 1024;
|
|
13
|
-
const MAX_AVATAR_DATA_URL_CHARS = MAX_AVATAR_BYTES * 4;
|
|
14
|
-
const REMOTE_AVATAR_TIMEOUT_MS = 10_000;
|
|
15
|
-
const DEFAULT_AVATAR_PREFETCH_CONCURRENCY = 8;
|
|
16
|
-
const ALLOWED_REMOTE_AVATAR_HOSTS = new Set(["pbs.twimg.com"]);
|
|
17
|
-
const RASTER_CONTENT_TYPES = new Set([
|
|
18
|
-
"image/jpeg",
|
|
19
|
-
"image/png",
|
|
20
|
-
"image/webp",
|
|
21
|
-
"image/gif",
|
|
22
|
-
]);
|
|
23
|
-
|
|
24
|
-
function sanitizeFileToken(value: string) {
|
|
25
|
-
return value.replace(/[^a-zA-Z0-9_-]+/g, "_");
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function getAvatarCacheDir() {
|
|
29
|
-
const { mediaThumbsDir } = getBirdclawPaths();
|
|
30
|
-
const dir = path.join(mediaThumbsDir, "avatars");
|
|
31
|
-
mkdirSync(dir, { recursive: true });
|
|
32
|
-
return dir;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function getExtensionFromContentType(contentType: string | null) {
|
|
36
|
-
const mime = contentType?.split(";")[0].trim().toLowerCase() ?? "";
|
|
37
|
-
if (mime === "image/png") return ".png";
|
|
38
|
-
if (mime === "image/webp") return ".webp";
|
|
39
|
-
if (mime === "image/gif") return ".gif";
|
|
40
|
-
return ".jpg";
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function getContentTypeFromExtension(extension: string) {
|
|
44
|
-
switch (extension.toLowerCase()) {
|
|
45
|
-
case ".png":
|
|
46
|
-
return "image/png";
|
|
47
|
-
case ".webp":
|
|
48
|
-
return "image/webp";
|
|
49
|
-
case ".gif":
|
|
50
|
-
return "image/gif";
|
|
51
|
-
default:
|
|
52
|
-
return "image/jpeg";
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function getExtensionFromAvatarUrl(avatarUrl: string) {
|
|
57
|
-
try {
|
|
58
|
-
const url = new URL(avatarUrl);
|
|
59
|
-
const extension = path.extname(url.pathname).toLowerCase();
|
|
60
|
-
if (extension === ".png" || extension === ".webp" || extension === ".gif") {
|
|
61
|
-
return extension;
|
|
62
|
-
}
|
|
63
|
-
return ".jpg";
|
|
64
|
-
} catch {
|
|
65
|
-
return ".jpg";
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function decodeDataUrl(dataUrl: string) {
|
|
70
|
-
if (!dataUrl.startsWith("data:")) {
|
|
71
|
-
throw new Error("Invalid avatar data URL");
|
|
72
|
-
}
|
|
73
|
-
if (dataUrl.length > MAX_AVATAR_DATA_URL_CHARS) {
|
|
74
|
-
throw new Error("Avatar data URL is too large");
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const separatorIndex = dataUrl.indexOf(",");
|
|
78
|
-
if (separatorIndex < 0) {
|
|
79
|
-
throw new Error("Invalid avatar data URL");
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const metadata = dataUrl.slice(5, separatorIndex);
|
|
83
|
-
const payload = dataUrl.slice(separatorIndex + 1);
|
|
84
|
-
const contentType = metadata.split(";")[0] || "application/octet-stream";
|
|
85
|
-
if (!RASTER_CONTENT_TYPES.has(contentType.toLowerCase())) {
|
|
86
|
-
throw new Error("Avatar data URL must be a raster image");
|
|
87
|
-
}
|
|
88
|
-
const isBase64 = metadata.includes(";base64");
|
|
89
|
-
const buffer = isBase64
|
|
90
|
-
? Buffer.from(payload, "base64")
|
|
91
|
-
: Buffer.from(decodeURIComponent(payload), "utf8");
|
|
92
|
-
if (buffer.byteLength > MAX_AVATAR_BYTES) {
|
|
93
|
-
throw new Error("Avatar data URL is too large");
|
|
94
|
-
}
|
|
95
|
-
return {
|
|
96
|
-
contentType,
|
|
97
|
-
buffer,
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function getAvatarUrlForProfile(profileId: string) {
|
|
102
|
-
const row = getNativeDb()
|
|
103
|
-
.prepare("select avatar_url from profiles where id = ?")
|
|
104
|
-
.get(profileId) as { avatar_url: string | null } | undefined;
|
|
105
|
-
return row?.avatar_url ?? null;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function toError(error: unknown) {
|
|
109
|
-
return error instanceof Error ? error : new Error(String(error));
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function trySync<T>(try_: () => T) {
|
|
113
|
-
return Effect.try({
|
|
114
|
-
try: try_,
|
|
115
|
-
catch: toError,
|
|
116
|
-
});
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function assertSafeRemoteAvatarUrl(avatarUrl: string) {
|
|
120
|
-
const parsed = assertSafePreviewUrl(avatarUrl);
|
|
121
|
-
if (parsed.protocol !== "https:") {
|
|
122
|
-
throw new Error("Remote avatar URL must use https");
|
|
123
|
-
}
|
|
124
|
-
if (!ALLOWED_REMOTE_AVATAR_HOSTS.has(parsed.hostname.toLowerCase())) {
|
|
125
|
-
throw new Error("Remote avatar host is not allowed");
|
|
126
|
-
}
|
|
127
|
-
return parsed.toString();
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function normalizeContentType(value: string | null) {
|
|
131
|
-
return value?.split(";")[0]?.trim().toLowerCase() ?? "image/jpeg";
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function detectRasterContentType(buffer: Buffer, declared: string) {
|
|
135
|
-
if (
|
|
136
|
-
buffer.length >= 3 &&
|
|
137
|
-
buffer[0] === 0xff &&
|
|
138
|
-
buffer[1] === 0xd8 &&
|
|
139
|
-
buffer[2] === 0xff
|
|
140
|
-
) {
|
|
141
|
-
return "image/jpeg";
|
|
142
|
-
}
|
|
143
|
-
if (
|
|
144
|
-
buffer.length >= 4 &&
|
|
145
|
-
buffer[0] === 0x89 &&
|
|
146
|
-
buffer[1] === 0x50 &&
|
|
147
|
-
buffer[2] === 0x4e &&
|
|
148
|
-
buffer[3] === 0x47
|
|
149
|
-
) {
|
|
150
|
-
return "image/png";
|
|
151
|
-
}
|
|
152
|
-
if (
|
|
153
|
-
buffer.length >= 12 &&
|
|
154
|
-
buffer.subarray(0, 4).toString("ascii") === "RIFF" &&
|
|
155
|
-
buffer.subarray(8, 12).toString("ascii") === "WEBP"
|
|
156
|
-
) {
|
|
157
|
-
return "image/webp";
|
|
158
|
-
}
|
|
159
|
-
if (
|
|
160
|
-
buffer.length >= 6 &&
|
|
161
|
-
(buffer.subarray(0, 6).toString("ascii") === "GIF87a" ||
|
|
162
|
-
buffer.subarray(0, 6).toString("ascii") === "GIF89a")
|
|
163
|
-
) {
|
|
164
|
-
return "image/gif";
|
|
165
|
-
}
|
|
166
|
-
if (declared === "image/jpeg") {
|
|
167
|
-
return "image/jpeg";
|
|
168
|
-
}
|
|
169
|
-
throw new Error("Avatar response is not a supported raster image");
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
export function normalizeAvatarUrl(value: unknown) {
|
|
173
|
-
if (typeof value !== "string" || value.trim().length === 0) {
|
|
174
|
-
return null;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
const trimmed = value.trim();
|
|
178
|
-
if (trimmed.startsWith("data:image/")) {
|
|
179
|
-
return trimmed;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
try {
|
|
183
|
-
const url = new URL(trimmed);
|
|
184
|
-
url.pathname = url.pathname.replace(AVATAR_SIZE_SUFFIX, "");
|
|
185
|
-
return url.toString();
|
|
186
|
-
} catch {
|
|
187
|
-
return trimmed;
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
export function getAvatarCachePath(profileId: string, avatarUrl: string) {
|
|
192
|
-
const normalizedAvatarUrl = normalizeAvatarUrl(avatarUrl);
|
|
193
|
-
if (!normalizedAvatarUrl) {
|
|
194
|
-
throw new Error("Missing avatar URL");
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
const hash = createHash("sha1").update(normalizedAvatarUrl).digest("hex");
|
|
198
|
-
const extension = normalizedAvatarUrl.startsWith("data:")
|
|
199
|
-
? getExtensionFromContentType(
|
|
200
|
-
/^data:([^;,]+)/i.exec(normalizedAvatarUrl)?.[1] ?? null,
|
|
201
|
-
)
|
|
202
|
-
: getExtensionFromAvatarUrl(normalizedAvatarUrl);
|
|
203
|
-
|
|
204
|
-
return path.join(
|
|
205
|
-
getAvatarCacheDir(),
|
|
206
|
-
`${sanitizeFileToken(profileId)}-${hash}${extension}`,
|
|
207
|
-
);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
function fetchRemoteAvatarEffect(avatarUrl: string) {
|
|
211
|
-
return Effect.gen(function* () {
|
|
212
|
-
const safeUrl = yield* trySync(() => assertSafeRemoteAvatarUrl(avatarUrl));
|
|
213
|
-
const response = yield* tryPromise(() =>
|
|
214
|
-
fetch(safeUrl, {
|
|
215
|
-
headers: {
|
|
216
|
-
"user-agent": "birdclaw/avatar-cache",
|
|
217
|
-
},
|
|
218
|
-
redirect: "error",
|
|
219
|
-
signal: AbortSignal.timeout(REMOTE_AVATAR_TIMEOUT_MS),
|
|
220
|
-
}),
|
|
221
|
-
);
|
|
222
|
-
if (!response.ok) {
|
|
223
|
-
return yield* Effect.fail(
|
|
224
|
-
new Error(`Avatar fetch failed with ${response.status}`),
|
|
225
|
-
);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
const buffer = Buffer.from(yield* tryPromise(() => response.arrayBuffer()));
|
|
229
|
-
if (buffer.byteLength > MAX_AVATAR_BYTES) {
|
|
230
|
-
return yield* Effect.fail(new Error("Avatar response is too large"));
|
|
231
|
-
}
|
|
232
|
-
const contentType = yield* trySync(() =>
|
|
233
|
-
detectRasterContentType(
|
|
234
|
-
buffer,
|
|
235
|
-
normalizeContentType(response.headers.get("content-type")),
|
|
236
|
-
),
|
|
237
|
-
);
|
|
238
|
-
return {
|
|
239
|
-
contentType,
|
|
240
|
-
buffer,
|
|
241
|
-
};
|
|
242
|
-
});
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
export function readCachedAvatarEffect(profileId: string) {
|
|
246
|
-
return Effect.gen(function* () {
|
|
247
|
-
const avatarUrl = yield* trySync(() => getAvatarUrlForProfile(profileId));
|
|
248
|
-
if (!avatarUrl) {
|
|
249
|
-
return null;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
const normalizedAvatarUrl = normalizeAvatarUrl(avatarUrl);
|
|
253
|
-
if (!normalizedAvatarUrl) {
|
|
254
|
-
return null;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
const cachePath = yield* trySync(() =>
|
|
258
|
-
getAvatarCachePath(profileId, normalizedAvatarUrl),
|
|
259
|
-
);
|
|
260
|
-
const cachedExtension = path.extname(cachePath);
|
|
261
|
-
|
|
262
|
-
const cached = yield* trySync(() => readFileSync(cachePath)).pipe(
|
|
263
|
-
Effect.map((buffer) => ({ ok: true as const, buffer })),
|
|
264
|
-
Effect.catchAll(() => Effect.succeed({ ok: false as const })),
|
|
265
|
-
);
|
|
266
|
-
if (cached.ok) {
|
|
267
|
-
return {
|
|
268
|
-
buffer: cached.buffer,
|
|
269
|
-
contentType: getContentTypeFromExtension(cachedExtension),
|
|
270
|
-
cachePath,
|
|
271
|
-
avatarUrl: normalizedAvatarUrl,
|
|
272
|
-
};
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
const payload = normalizedAvatarUrl.startsWith("data:")
|
|
276
|
-
? yield* trySync(() => decodeDataUrl(normalizedAvatarUrl))
|
|
277
|
-
: yield* fetchRemoteAvatarEffect(normalizedAvatarUrl);
|
|
278
|
-
|
|
279
|
-
yield* trySync(() => writeFileSync(cachePath, payload.buffer));
|
|
280
|
-
return {
|
|
281
|
-
buffer: payload.buffer,
|
|
282
|
-
contentType: payload.contentType,
|
|
283
|
-
cachePath,
|
|
284
|
-
avatarUrl: normalizedAvatarUrl,
|
|
285
|
-
};
|
|
286
|
-
});
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
export function readCachedAvatar(profileId: string) {
|
|
290
|
-
return runEffectPromise(readCachedAvatarEffect(profileId));
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
function normalizePrefetchConcurrency(value: number | undefined) {
|
|
294
|
-
if (!Number.isFinite(value) || value === undefined) {
|
|
295
|
-
return DEFAULT_AVATAR_PREFETCH_CONCURRENCY;
|
|
296
|
-
}
|
|
297
|
-
return Math.max(1, Math.min(Math.floor(value), 32));
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
export function prefetchCachedAvatarsForProfileIdsEffect(
|
|
301
|
-
profileIds: string[],
|
|
302
|
-
options: { concurrency?: number } = {},
|
|
303
|
-
) {
|
|
304
|
-
const uniqueProfileIds = Array.from(
|
|
305
|
-
new Set(
|
|
306
|
-
profileIds
|
|
307
|
-
.map((profileId) => profileId.trim())
|
|
308
|
-
.filter((profileId) => profileId.length > 0),
|
|
309
|
-
),
|
|
310
|
-
);
|
|
311
|
-
const concurrency = normalizePrefetchConcurrency(options.concurrency);
|
|
312
|
-
|
|
313
|
-
return Effect.forEach(
|
|
314
|
-
uniqueProfileIds,
|
|
315
|
-
(profileId) =>
|
|
316
|
-
readCachedAvatarEffect(profileId).pipe(
|
|
317
|
-
Effect.map((avatar) =>
|
|
318
|
-
avatar ? ("available" as const) : ("missing" as const),
|
|
319
|
-
),
|
|
320
|
-
Effect.catchAll(() => Effect.succeed("failed" as const)),
|
|
321
|
-
),
|
|
322
|
-
{ concurrency },
|
|
323
|
-
).pipe(
|
|
324
|
-
Effect.map((statuses) => ({
|
|
325
|
-
requested: uniqueProfileIds.length,
|
|
326
|
-
available: statuses.filter((status) => status === "available").length,
|
|
327
|
-
missing: statuses.filter((status) => status === "missing").length,
|
|
328
|
-
failed: statuses.filter((status) => status === "failed").length,
|
|
329
|
-
})),
|
|
330
|
-
);
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
export function prefetchCachedAvatarsForProfileIds(
|
|
334
|
-
profileIds: string[],
|
|
335
|
-
options: { concurrency?: number } = {},
|
|
336
|
-
) {
|
|
337
|
-
return runEffectPromise(
|
|
338
|
-
prefetchCachedAvatarsForProfileIdsEffect(profileIds, options),
|
|
339
|
-
);
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
export const __test__ = {
|
|
343
|
-
decodeDataUrl,
|
|
344
|
-
detectRasterContentType,
|
|
345
|
-
getAvatarCacheDir,
|
|
346
|
-
getContentTypeFromExtension,
|
|
347
|
-
getExtensionFromAvatarUrl,
|
|
348
|
-
sanitizeFileToken,
|
|
349
|
-
};
|