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.
Files changed (89) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +50 -5
  3. package/package.json +3 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +376 -13
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +27 -7
  9. package/src/components/AvatarChip.tsx +9 -3
  10. package/src/components/DmWorkspace.tsx +18 -8
  11. package/src/components/LinkPreviewCard.tsx +40 -18
  12. package/src/components/MarkdownViewer.tsx +452 -0
  13. package/src/components/SyncNowButton.tsx +57 -25
  14. package/src/components/ThemeSlider.tsx +55 -50
  15. package/src/components/TimelineCard.tsx +225 -93
  16. package/src/components/TimelineRouteFrame.tsx +22 -8
  17. package/src/components/TweetMediaGrid.tsx +87 -38
  18. package/src/components/TweetRichText.tsx +15 -11
  19. package/src/components/account-selection.ts +64 -0
  20. package/src/components/useTimelineRouteData.ts +23 -7
  21. package/src/lib/account-sync-job.ts +654 -0
  22. package/src/lib/actions-transport.ts +216 -146
  23. package/src/lib/api-client.ts +128 -53
  24. package/src/lib/archive-finder.ts +78 -63
  25. package/src/lib/archive-import.ts +1364 -1300
  26. package/src/lib/authored-live.ts +261 -204
  27. package/src/lib/avatar-cache.ts +159 -44
  28. package/src/lib/backup.ts +1532 -951
  29. package/src/lib/bird-actions.ts +139 -57
  30. package/src/lib/bird-command.ts +101 -28
  31. package/src/lib/bird.ts +549 -194
  32. package/src/lib/blocklist.ts +40 -23
  33. package/src/lib/blocks-write.ts +129 -80
  34. package/src/lib/blocks.ts +165 -97
  35. package/src/lib/bookmark-sync-job.ts +250 -160
  36. package/src/lib/conversation-surface.ts +79 -48
  37. package/src/lib/db.ts +33 -3
  38. package/src/lib/dms-live.ts +720 -66
  39. package/src/lib/effect-runtime.ts +45 -0
  40. package/src/lib/follow-graph.ts +224 -180
  41. package/src/lib/http-effect.ts +222 -0
  42. package/src/lib/inbox.ts +74 -43
  43. package/src/lib/link-index.ts +88 -76
  44. package/src/lib/link-insights.ts +24 -0
  45. package/src/lib/link-preview-metadata.ts +472 -52
  46. package/src/lib/media-fetch.ts +286 -213
  47. package/src/lib/mention-threads-live.ts +352 -288
  48. package/src/lib/mentions-live.ts +390 -342
  49. package/src/lib/moderation-target.ts +102 -65
  50. package/src/lib/moderation-write.ts +77 -18
  51. package/src/lib/mutes-write.ts +129 -80
  52. package/src/lib/mutes.ts +8 -1
  53. package/src/lib/openai.ts +84 -53
  54. package/src/lib/period-digest.ts +953 -0
  55. package/src/lib/profile-affiliation-hydration.ts +93 -54
  56. package/src/lib/profile-hydration.ts +124 -72
  57. package/src/lib/profile-replies.ts +60 -43
  58. package/src/lib/profile-resolver.ts +402 -294
  59. package/src/lib/queries.ts +969 -199
  60. package/src/lib/research.ts +165 -120
  61. package/src/lib/sqlite.ts +1 -0
  62. package/src/lib/timeline-collections-live.ts +204 -167
  63. package/src/lib/timeline-live.ts +60 -39
  64. package/src/lib/tweet-lookup.ts +30 -19
  65. package/src/lib/types.ts +38 -1
  66. package/src/lib/ui.ts +30 -7
  67. package/src/lib/url-expansion.ts +226 -55
  68. package/src/lib/url-safety.ts +220 -0
  69. package/src/lib/web-sync.ts +216 -148
  70. package/src/lib/whois.ts +166 -147
  71. package/src/lib/x-web.ts +102 -71
  72. package/src/lib/xurl.ts +681 -411
  73. package/src/routeTree.gen.ts +42 -0
  74. package/src/routes/__root.tsx +25 -5
  75. package/src/routes/api/action.tsx +127 -78
  76. package/src/routes/api/avatar.tsx +39 -30
  77. package/src/routes/api/blocks.tsx +26 -23
  78. package/src/routes/api/conversation.tsx +25 -14
  79. package/src/routes/api/inbox.tsx +27 -21
  80. package/src/routes/api/link-insights.tsx +31 -25
  81. package/src/routes/api/link-preview.tsx +25 -21
  82. package/src/routes/api/period-digest.tsx +123 -0
  83. package/src/routes/api/profile-hydrate.tsx +31 -28
  84. package/src/routes/api/query.tsx +79 -55
  85. package/src/routes/api/status.tsx +18 -10
  86. package/src/routes/api/sync.tsx +75 -29
  87. package/src/routes/dms.tsx +95 -28
  88. package/src/routes/inbox.tsx +32 -19
  89. package/src/routes/today.tsx +441 -0
@@ -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 extension === ".svg" ? ".svg" : ".jpg";
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: isBase64
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
- async function fetchRemoteAvatar(avatarUrl: string) {
126
- const response = await fetch(avatarUrl, {
127
- headers: {
128
- "user-agent": "birdclaw/avatar-cache",
129
- },
130
- });
131
- if (!response.ok) {
132
- throw new Error(`Avatar fetch failed with ${response.status}`);
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
- const buffer = Buffer.from(await response.arrayBuffer());
136
- return {
137
- contentType: response.headers.get("content-type") ?? "image/jpeg",
138
- buffer,
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 async function readCachedAvatar(profileId: string) {
143
- const avatarUrl = getAvatarUrlForProfile(profileId);
144
- if (!avatarUrl) {
145
- return null;
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
- const normalizedAvatarUrl = normalizeAvatarUrl(avatarUrl);
149
- if (!normalizedAvatarUrl) {
150
- return null;
151
- }
251
+ const normalizedAvatarUrl = normalizeAvatarUrl(avatarUrl);
252
+ if (!normalizedAvatarUrl) {
253
+ return null;
254
+ }
152
255
 
153
- const cachePath = getAvatarCachePath(profileId, normalizedAvatarUrl);
154
- const cachedExtension = path.extname(cachePath);
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
- : await fetchRemoteAvatar(normalizedAvatarUrl);
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,