birdclaw 0.5.1 → 0.6.0
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 +44 -1
- package/README.md +50 -5
- package/package.json +3 -2
- package/scripts/browser-perf.mjs +1 -0
- package/scripts/start-test-server.mjs +16 -3
- package/src/cli.ts +376 -13
- package/src/components/AccountSwitcher.tsx +186 -0
- package/src/components/AppNav.tsx +27 -7
- package/src/components/AvatarChip.tsx +9 -3
- package/src/components/DmWorkspace.tsx +18 -8
- package/src/components/LinkPreviewCard.tsx +40 -18
- package/src/components/MarkdownViewer.tsx +452 -0
- package/src/components/SyncNowButton.tsx +57 -25
- package/src/components/ThemeSlider.tsx +55 -50
- package/src/components/TimelineCard.tsx +225 -93
- package/src/components/TimelineRouteFrame.tsx +22 -8
- package/src/components/TweetMediaGrid.tsx +87 -38
- package/src/components/TweetRichText.tsx +15 -11
- package/src/components/account-selection.ts +64 -0
- package/src/components/useTimelineRouteData.ts +23 -7
- package/src/lib/account-sync-job.ts +654 -0
- package/src/lib/actions-transport.ts +216 -146
- package/src/lib/api-client.ts +128 -53
- package/src/lib/archive-finder.ts +78 -63
- package/src/lib/archive-import.ts +1364 -1300
- package/src/lib/authored-live.ts +261 -204
- package/src/lib/avatar-cache.ts +159 -44
- package/src/lib/backup.ts +1532 -951
- package/src/lib/bird-actions.ts +139 -57
- package/src/lib/bird-command.ts +101 -28
- package/src/lib/bird.ts +549 -194
- package/src/lib/blocklist.ts +40 -23
- package/src/lib/blocks-write.ts +129 -80
- package/src/lib/blocks.ts +165 -97
- package/src/lib/bookmark-sync-job.ts +250 -160
- package/src/lib/conversation-surface.ts +79 -48
- package/src/lib/db.ts +33 -3
- package/src/lib/dms-live.ts +720 -66
- package/src/lib/effect-runtime.ts +45 -0
- package/src/lib/follow-graph.ts +224 -180
- package/src/lib/http-effect.ts +222 -0
- package/src/lib/inbox.ts +74 -43
- package/src/lib/link-index.ts +88 -76
- package/src/lib/link-insights.ts +24 -0
- package/src/lib/link-preview-metadata.ts +472 -52
- package/src/lib/media-fetch.ts +286 -213
- package/src/lib/mention-threads-live.ts +352 -288
- package/src/lib/mentions-live.ts +390 -342
- package/src/lib/moderation-target.ts +102 -65
- package/src/lib/moderation-write.ts +77 -18
- package/src/lib/mutes-write.ts +129 -80
- package/src/lib/mutes.ts +8 -1
- package/src/lib/openai.ts +84 -53
- package/src/lib/period-digest.ts +953 -0
- package/src/lib/profile-affiliation-hydration.ts +93 -54
- package/src/lib/profile-hydration.ts +124 -72
- package/src/lib/profile-replies.ts +60 -43
- package/src/lib/profile-resolver.ts +402 -294
- package/src/lib/queries.ts +969 -199
- package/src/lib/research.ts +165 -120
- package/src/lib/sqlite.ts +1 -0
- package/src/lib/timeline-collections-live.ts +204 -167
- package/src/lib/timeline-live.ts +60 -39
- package/src/lib/tweet-lookup.ts +30 -19
- package/src/lib/types.ts +38 -1
- package/src/lib/ui.ts +30 -7
- package/src/lib/url-expansion.ts +226 -55
- package/src/lib/url-safety.ts +220 -0
- package/src/lib/web-sync.ts +216 -148
- package/src/lib/whois.ts +166 -147
- package/src/lib/x-web.ts +102 -71
- package/src/lib/xurl.ts +681 -411
- package/src/routeTree.gen.ts +42 -0
- package/src/routes/__root.tsx +25 -5
- package/src/routes/api/action.tsx +127 -78
- package/src/routes/api/avatar.tsx +39 -30
- package/src/routes/api/blocks.tsx +26 -23
- package/src/routes/api/conversation.tsx +25 -14
- package/src/routes/api/inbox.tsx +27 -21
- package/src/routes/api/link-insights.tsx +31 -25
- package/src/routes/api/link-preview.tsx +25 -21
- package/src/routes/api/period-digest.tsx +123 -0
- package/src/routes/api/profile-hydrate.tsx +31 -28
- package/src/routes/api/query.tsx +79 -55
- package/src/routes/api/status.tsx +18 -10
- package/src/routes/api/sync.tsx +75 -29
- package/src/routes/dms.tsx +95 -28
- package/src/routes/inbox.tsx +32 -19
- package/src/routes/today.tsx +441 -0
package/src/lib/avatar-cache.ts
CHANGED
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { Effect } from "effect";
|
|
4
5
|
import { getBirdclawPaths } from "./config";
|
|
5
6
|
import { getNativeDb } from "./db";
|
|
7
|
+
import { runEffectPromise, tryPromise } from "./effect-runtime";
|
|
8
|
+
import { assertSafePreviewUrl } from "./url-safety";
|
|
6
9
|
|
|
7
10
|
const AVATAR_SIZE_SUFFIX =
|
|
8
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 ALLOWED_REMOTE_AVATAR_HOSTS = new Set(["pbs.twimg.com"]);
|
|
16
|
+
const RASTER_CONTENT_TYPES = new Set([
|
|
17
|
+
"image/jpeg",
|
|
18
|
+
"image/png",
|
|
19
|
+
"image/webp",
|
|
20
|
+
"image/gif",
|
|
21
|
+
]);
|
|
9
22
|
|
|
10
23
|
function sanitizeFileToken(value: string) {
|
|
11
24
|
return value.replace(/[^a-zA-Z0-9_-]+/g, "_");
|
|
@@ -23,7 +36,6 @@ function getExtensionFromContentType(contentType: string | null) {
|
|
|
23
36
|
if (mime === "image/png") return ".png";
|
|
24
37
|
if (mime === "image/webp") return ".webp";
|
|
25
38
|
if (mime === "image/gif") return ".gif";
|
|
26
|
-
if (mime === "image/svg+xml") return ".svg";
|
|
27
39
|
return ".jpg";
|
|
28
40
|
}
|
|
29
41
|
|
|
@@ -35,8 +47,6 @@ function getContentTypeFromExtension(extension: string) {
|
|
|
35
47
|
return "image/webp";
|
|
36
48
|
case ".gif":
|
|
37
49
|
return "image/gif";
|
|
38
|
-
case ".svg":
|
|
39
|
-
return "image/svg+xml";
|
|
40
50
|
default:
|
|
41
51
|
return "image/jpeg";
|
|
42
52
|
}
|
|
@@ -49,7 +59,7 @@ function getExtensionFromAvatarUrl(avatarUrl: string) {
|
|
|
49
59
|
if (extension === ".png" || extension === ".webp" || extension === ".gif") {
|
|
50
60
|
return extension;
|
|
51
61
|
}
|
|
52
|
-
return
|
|
62
|
+
return ".jpg";
|
|
53
63
|
} catch {
|
|
54
64
|
return ".jpg";
|
|
55
65
|
}
|
|
@@ -59,6 +69,9 @@ function decodeDataUrl(dataUrl: string) {
|
|
|
59
69
|
if (!dataUrl.startsWith("data:")) {
|
|
60
70
|
throw new Error("Invalid avatar data URL");
|
|
61
71
|
}
|
|
72
|
+
if (dataUrl.length > MAX_AVATAR_DATA_URL_CHARS) {
|
|
73
|
+
throw new Error("Avatar data URL is too large");
|
|
74
|
+
}
|
|
62
75
|
|
|
63
76
|
const separatorIndex = dataUrl.indexOf(",");
|
|
64
77
|
if (separatorIndex < 0) {
|
|
@@ -68,12 +81,19 @@ function decodeDataUrl(dataUrl: string) {
|
|
|
68
81
|
const metadata = dataUrl.slice(5, separatorIndex);
|
|
69
82
|
const payload = dataUrl.slice(separatorIndex + 1);
|
|
70
83
|
const contentType = metadata.split(";")[0] || "application/octet-stream";
|
|
84
|
+
if (!RASTER_CONTENT_TYPES.has(contentType.toLowerCase())) {
|
|
85
|
+
throw new Error("Avatar data URL must be a raster image");
|
|
86
|
+
}
|
|
71
87
|
const isBase64 = metadata.includes(";base64");
|
|
88
|
+
const buffer = isBase64
|
|
89
|
+
? Buffer.from(payload, "base64")
|
|
90
|
+
: Buffer.from(decodeURIComponent(payload), "utf8");
|
|
91
|
+
if (buffer.byteLength > MAX_AVATAR_BYTES) {
|
|
92
|
+
throw new Error("Avatar data URL is too large");
|
|
93
|
+
}
|
|
72
94
|
return {
|
|
73
95
|
contentType,
|
|
74
|
-
buffer
|
|
75
|
-
? Buffer.from(payload, "base64")
|
|
76
|
-
: Buffer.from(decodeURIComponent(payload), "utf8"),
|
|
96
|
+
buffer,
|
|
77
97
|
};
|
|
78
98
|
}
|
|
79
99
|
|
|
@@ -84,6 +104,70 @@ function getAvatarUrlForProfile(profileId: string) {
|
|
|
84
104
|
return row?.avatar_url ?? null;
|
|
85
105
|
}
|
|
86
106
|
|
|
107
|
+
function toError(error: unknown) {
|
|
108
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function trySync<T>(try_: () => T) {
|
|
112
|
+
return Effect.try({
|
|
113
|
+
try: try_,
|
|
114
|
+
catch: toError,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function assertSafeRemoteAvatarUrl(avatarUrl: string) {
|
|
119
|
+
const parsed = assertSafePreviewUrl(avatarUrl);
|
|
120
|
+
if (parsed.protocol !== "https:") {
|
|
121
|
+
throw new Error("Remote avatar URL must use https");
|
|
122
|
+
}
|
|
123
|
+
if (!ALLOWED_REMOTE_AVATAR_HOSTS.has(parsed.hostname.toLowerCase())) {
|
|
124
|
+
throw new Error("Remote avatar host is not allowed");
|
|
125
|
+
}
|
|
126
|
+
return parsed.toString();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function normalizeContentType(value: string | null) {
|
|
130
|
+
return value?.split(";")[0]?.trim().toLowerCase() ?? "image/jpeg";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function detectRasterContentType(buffer: Buffer, declared: string) {
|
|
134
|
+
if (
|
|
135
|
+
buffer.length >= 3 &&
|
|
136
|
+
buffer[0] === 0xff &&
|
|
137
|
+
buffer[1] === 0xd8 &&
|
|
138
|
+
buffer[2] === 0xff
|
|
139
|
+
) {
|
|
140
|
+
return "image/jpeg";
|
|
141
|
+
}
|
|
142
|
+
if (
|
|
143
|
+
buffer.length >= 4 &&
|
|
144
|
+
buffer[0] === 0x89 &&
|
|
145
|
+
buffer[1] === 0x50 &&
|
|
146
|
+
buffer[2] === 0x4e &&
|
|
147
|
+
buffer[3] === 0x47
|
|
148
|
+
) {
|
|
149
|
+
return "image/png";
|
|
150
|
+
}
|
|
151
|
+
if (
|
|
152
|
+
buffer.length >= 12 &&
|
|
153
|
+
buffer.subarray(0, 4).toString("ascii") === "RIFF" &&
|
|
154
|
+
buffer.subarray(8, 12).toString("ascii") === "WEBP"
|
|
155
|
+
) {
|
|
156
|
+
return "image/webp";
|
|
157
|
+
}
|
|
158
|
+
if (
|
|
159
|
+
buffer.length >= 6 &&
|
|
160
|
+
(buffer.subarray(0, 6).toString("ascii") === "GIF87a" ||
|
|
161
|
+
buffer.subarray(0, 6).toString("ascii") === "GIF89a")
|
|
162
|
+
) {
|
|
163
|
+
return "image/gif";
|
|
164
|
+
}
|
|
165
|
+
if (declared === "image/jpeg") {
|
|
166
|
+
return "image/jpeg";
|
|
167
|
+
}
|
|
168
|
+
throw new Error("Avatar response is not a supported raster image");
|
|
169
|
+
}
|
|
170
|
+
|
|
87
171
|
export function normalizeAvatarUrl(value: unknown) {
|
|
88
172
|
if (typeof value !== "string" || value.trim().length === 0) {
|
|
89
173
|
return null;
|
|
@@ -122,61 +206,92 @@ export function getAvatarCachePath(profileId: string, avatarUrl: string) {
|
|
|
122
206
|
);
|
|
123
207
|
}
|
|
124
208
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
209
|
+
function fetchRemoteAvatarEffect(avatarUrl: string) {
|
|
210
|
+
return Effect.gen(function* () {
|
|
211
|
+
const safeUrl = yield* trySync(() => assertSafeRemoteAvatarUrl(avatarUrl));
|
|
212
|
+
const response = yield* tryPromise(() =>
|
|
213
|
+
fetch(safeUrl, {
|
|
214
|
+
headers: {
|
|
215
|
+
"user-agent": "birdclaw/avatar-cache",
|
|
216
|
+
},
|
|
217
|
+
redirect: "error",
|
|
218
|
+
signal: AbortSignal.timeout(REMOTE_AVATAR_TIMEOUT_MS),
|
|
219
|
+
}),
|
|
220
|
+
);
|
|
221
|
+
if (!response.ok) {
|
|
222
|
+
return yield* Effect.fail(
|
|
223
|
+
new Error(`Avatar fetch failed with ${response.status}`),
|
|
224
|
+
);
|
|
225
|
+
}
|
|
134
226
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
227
|
+
const buffer = Buffer.from(yield* tryPromise(() => response.arrayBuffer()));
|
|
228
|
+
if (buffer.byteLength > MAX_AVATAR_BYTES) {
|
|
229
|
+
return yield* Effect.fail(new Error("Avatar response is too large"));
|
|
230
|
+
}
|
|
231
|
+
const contentType = yield* trySync(() =>
|
|
232
|
+
detectRasterContentType(
|
|
233
|
+
buffer,
|
|
234
|
+
normalizeContentType(response.headers.get("content-type")),
|
|
235
|
+
),
|
|
236
|
+
);
|
|
237
|
+
return {
|
|
238
|
+
contentType,
|
|
239
|
+
buffer,
|
|
240
|
+
};
|
|
241
|
+
});
|
|
140
242
|
}
|
|
141
243
|
|
|
142
|
-
export
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
244
|
+
export function readCachedAvatarEffect(profileId: string) {
|
|
245
|
+
return Effect.gen(function* () {
|
|
246
|
+
const avatarUrl = yield* trySync(() => getAvatarUrlForProfile(profileId));
|
|
247
|
+
if (!avatarUrl) {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
147
250
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
251
|
+
const normalizedAvatarUrl = normalizeAvatarUrl(avatarUrl);
|
|
252
|
+
if (!normalizedAvatarUrl) {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
152
255
|
|
|
153
|
-
|
|
154
|
-
|
|
256
|
+
const cachePath = yield* trySync(() =>
|
|
257
|
+
getAvatarCachePath(profileId, normalizedAvatarUrl),
|
|
258
|
+
);
|
|
259
|
+
const cachedExtension = path.extname(cachePath);
|
|
260
|
+
|
|
261
|
+
const cached = yield* trySync(() => readFileSync(cachePath)).pipe(
|
|
262
|
+
Effect.map((buffer) => ({ ok: true as const, buffer })),
|
|
263
|
+
Effect.catchAll(() => Effect.succeed({ ok: false as const })),
|
|
264
|
+
);
|
|
265
|
+
if (cached.ok) {
|
|
266
|
+
return {
|
|
267
|
+
buffer: cached.buffer,
|
|
268
|
+
contentType: getContentTypeFromExtension(cachedExtension),
|
|
269
|
+
cachePath,
|
|
270
|
+
avatarUrl: normalizedAvatarUrl,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
155
273
|
|
|
156
|
-
try {
|
|
157
|
-
return {
|
|
158
|
-
buffer: readFileSync(cachePath),
|
|
159
|
-
contentType: getContentTypeFromExtension(cachedExtension),
|
|
160
|
-
cachePath,
|
|
161
|
-
avatarUrl: normalizedAvatarUrl,
|
|
162
|
-
};
|
|
163
|
-
} catch {
|
|
164
274
|
const payload = normalizedAvatarUrl.startsWith("data:")
|
|
165
|
-
? decodeDataUrl(normalizedAvatarUrl)
|
|
166
|
-
:
|
|
275
|
+
? yield* trySync(() => decodeDataUrl(normalizedAvatarUrl))
|
|
276
|
+
: yield* fetchRemoteAvatarEffect(normalizedAvatarUrl);
|
|
167
277
|
|
|
168
|
-
writeFileSync(cachePath, payload.buffer);
|
|
278
|
+
yield* trySync(() => writeFileSync(cachePath, payload.buffer));
|
|
169
279
|
return {
|
|
170
280
|
buffer: payload.buffer,
|
|
171
281
|
contentType: payload.contentType,
|
|
172
282
|
cachePath,
|
|
173
283
|
avatarUrl: normalizedAvatarUrl,
|
|
174
284
|
};
|
|
175
|
-
}
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function readCachedAvatar(profileId: string) {
|
|
289
|
+
return runEffectPromise(readCachedAvatarEffect(profileId));
|
|
176
290
|
}
|
|
177
291
|
|
|
178
292
|
export const __test__ = {
|
|
179
293
|
decodeDataUrl,
|
|
294
|
+
detectRasterContentType,
|
|
180
295
|
getAvatarCacheDir,
|
|
181
296
|
getContentTypeFromExtension,
|
|
182
297
|
getExtensionFromAvatarUrl,
|