birdclaw 0.5.0 → 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 (105) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +55 -5
  3. package/bin/birdclaw.mjs +50 -11
  4. package/package.json +9 -7
  5. package/public/birdclaw-mark.png +0 -0
  6. package/scripts/browser-perf.mjs +400 -0
  7. package/scripts/build-docs-site.mjs +940 -0
  8. package/scripts/docs-site-assets.mjs +311 -0
  9. package/scripts/run-vitest.mjs +21 -0
  10. package/scripts/sanitize-node-options.mjs +23 -0
  11. package/scripts/start-test-server.mjs +42 -0
  12. package/src/cli.ts +422 -14
  13. package/src/components/AccountSwitcher.tsx +186 -0
  14. package/src/components/AppNav.tsx +29 -9
  15. package/src/components/AvatarChip.tsx +9 -3
  16. package/src/components/BrandMark.tsx +67 -0
  17. package/src/components/ConversationThread.tsx +11 -10
  18. package/src/components/DmWorkspace.tsx +39 -14
  19. package/src/components/FeedState.tsx +147 -0
  20. package/src/components/InboxCard.tsx +14 -2
  21. package/src/components/LinkPreviewCard.tsx +40 -18
  22. package/src/components/MarkdownViewer.tsx +452 -0
  23. package/src/components/SavedTimelineView.tsx +64 -56
  24. package/src/components/SyncNowButton.tsx +137 -0
  25. package/src/components/ThemeSlider.tsx +49 -93
  26. package/src/components/TimelineCard.tsx +364 -136
  27. package/src/components/TimelineRouteFrame.tsx +170 -0
  28. package/src/components/TweetMediaGrid.tsx +170 -24
  29. package/src/components/TweetRichText.tsx +28 -13
  30. package/src/components/account-selection.ts +64 -0
  31. package/src/components/useTimelineRouteData.ts +153 -0
  32. package/src/lib/account-sync-job.ts +654 -0
  33. package/src/lib/actions-transport.ts +216 -146
  34. package/src/lib/api-client.ts +304 -0
  35. package/src/lib/archive-finder.ts +72 -53
  36. package/src/lib/archive-import.ts +1377 -1298
  37. package/src/lib/authored-live.ts +261 -204
  38. package/src/lib/avatar-cache.ts +159 -44
  39. package/src/lib/backup.ts +1532 -951
  40. package/src/lib/bird-actions.ts +139 -57
  41. package/src/lib/bird-command.ts +101 -28
  42. package/src/lib/bird.ts +549 -194
  43. package/src/lib/blocklist.ts +40 -23
  44. package/src/lib/blocks-write.ts +129 -80
  45. package/src/lib/blocks.ts +165 -97
  46. package/src/lib/bookmark-sync-job.ts +250 -160
  47. package/src/lib/conversation-surface.ts +205 -0
  48. package/src/lib/db.ts +35 -3
  49. package/src/lib/dms-live.ts +720 -66
  50. package/src/lib/effect-runtime.ts +45 -0
  51. package/src/lib/follow-graph.ts +224 -180
  52. package/src/lib/http-effect.ts +222 -0
  53. package/src/lib/inbox.ts +74 -43
  54. package/src/lib/link-index.ts +88 -76
  55. package/src/lib/link-insights.ts +24 -0
  56. package/src/lib/link-preview-metadata.ts +472 -52
  57. package/src/lib/media-fetch.ts +286 -213
  58. package/src/lib/mention-threads-live.ts +352 -288
  59. package/src/lib/mentions-live.ts +390 -342
  60. package/src/lib/moderation-target.ts +102 -65
  61. package/src/lib/moderation-write.ts +77 -18
  62. package/src/lib/mutes-write.ts +129 -80
  63. package/src/lib/mutes.ts +8 -1
  64. package/src/lib/openai.ts +84 -53
  65. package/src/lib/period-digest.ts +953 -0
  66. package/src/lib/profile-affiliation-hydration.ts +93 -54
  67. package/src/lib/profile-hydration.ts +124 -72
  68. package/src/lib/profile-replies.ts +60 -43
  69. package/src/lib/profile-resolver.ts +402 -294
  70. package/src/lib/queries.ts +1024 -189
  71. package/src/lib/research.ts +165 -120
  72. package/src/lib/sqlite.ts +1 -0
  73. package/src/lib/timeline-collections-live.ts +204 -167
  74. package/src/lib/timeline-live.ts +60 -39
  75. package/src/lib/tweet-lookup.ts +30 -19
  76. package/src/lib/types.ts +38 -1
  77. package/src/lib/ui.ts +41 -10
  78. package/src/lib/url-expansion.ts +226 -55
  79. package/src/lib/url-safety.ts +220 -0
  80. package/src/lib/web-sync.ts +511 -0
  81. package/src/lib/whois.ts +166 -147
  82. package/src/lib/x-web.ts +102 -71
  83. package/src/lib/xurl.ts +681 -411
  84. package/src/routeTree.gen.ts +63 -0
  85. package/src/routes/__root.tsx +25 -5
  86. package/src/routes/api/action.tsx +127 -78
  87. package/src/routes/api/avatar.tsx +39 -30
  88. package/src/routes/api/blocks.tsx +26 -23
  89. package/src/routes/api/conversation.tsx +25 -14
  90. package/src/routes/api/inbox.tsx +27 -21
  91. package/src/routes/api/link-insights.tsx +31 -25
  92. package/src/routes/api/link-preview.tsx +25 -21
  93. package/src/routes/api/period-digest.tsx +123 -0
  94. package/src/routes/api/profile-hydrate.tsx +31 -28
  95. package/src/routes/api/query.tsx +79 -55
  96. package/src/routes/api/status.tsx +18 -10
  97. package/src/routes/api/sync.tsx +105 -0
  98. package/src/routes/dms.tsx +195 -55
  99. package/src/routes/inbox.tsx +32 -19
  100. package/src/routes/index.tsx +21 -127
  101. package/src/routes/links.tsx +50 -5
  102. package/src/routes/mentions.tsx +21 -127
  103. package/src/routes/today.tsx +441 -0
  104. package/src/styles.css +74 -11
  105. package/vite.config.ts +8 -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,