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
@@ -0,0 +1,220 @@
1
+ const PRIVATE_IPV4_RANGES = [
2
+ ["0.0.0.0", 8],
3
+ ["10.0.0.0", 8],
4
+ ["100.64.0.0", 10],
5
+ ["127.0.0.0", 8],
6
+ ["169.254.0.0", 16],
7
+ ["172.16.0.0", 12],
8
+ ["192.0.0.0", 24],
9
+ ["192.0.2.0", 24],
10
+ ["192.168.0.0", 16],
11
+ ["198.18.0.0", 15],
12
+ ["198.51.100.0", 24],
13
+ ["203.0.113.0", 24],
14
+ ["224.0.0.0", 4],
15
+ ["240.0.0.0", 4],
16
+ ] satisfies Array<[string, number]>;
17
+
18
+ function ipv4ToNumber(value: string) {
19
+ const parts = value.split(".").map((part) => Number(part));
20
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part))) {
21
+ return null;
22
+ }
23
+ if (parts.some((part) => part < 0 || part > 255)) return null;
24
+ return (
25
+ (parts[0] ?? 0) * 256 ** 3 +
26
+ (parts[1] ?? 0) * 256 ** 2 +
27
+ (parts[2] ?? 0) * 256 +
28
+ (parts[3] ?? 0)
29
+ );
30
+ }
31
+
32
+ function isIpv4InRange(address: string, range: string, prefix: number) {
33
+ const addressNumber = ipv4ToNumber(address);
34
+ const rangeNumber = ipv4ToNumber(range);
35
+ if (addressNumber === null || rangeNumber === null) return false;
36
+ const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
37
+ return (addressNumber & mask) === (rangeNumber & mask);
38
+ }
39
+
40
+ function isPrivateIpv4(address: string) {
41
+ return PRIVATE_IPV4_RANGES.some(([range, prefix]) =>
42
+ isIpv4InRange(address, range, prefix),
43
+ );
44
+ }
45
+
46
+ function normalizeIpv6(address: string) {
47
+ return address.toLowerCase().replace(/^\[|\]$/g, "");
48
+ }
49
+
50
+ function parseIpv6Parts(address: string) {
51
+ let normalized = normalizeIpv6(address);
52
+ if (!normalized.includes(":")) return null;
53
+
54
+ if (normalized.includes(".")) {
55
+ const lastColon = normalized.lastIndexOf(":");
56
+ const ipv4 = normalized.slice(lastColon + 1);
57
+ const addressNumber = ipv4ToNumber(ipv4);
58
+ if (addressNumber === null) return null;
59
+ normalized = `${normalized.slice(0, lastColon + 1)}${(
60
+ (addressNumber >>> 16) &
61
+ 0xffff
62
+ ).toString(16)}:${(addressNumber & 0xffff).toString(16)}`;
63
+ }
64
+
65
+ const halves = normalized.split("::");
66
+ if (halves.length > 2) return null;
67
+ const parseGroups = (value: string) =>
68
+ value === ""
69
+ ? []
70
+ : value.split(":").map((part) => Number.parseInt(part, 16));
71
+ const left = parseGroups(halves[0] ?? "");
72
+ const right = halves.length === 2 ? parseGroups(halves[1] ?? "") : [];
73
+ const missingGroups = 8 - left.length - right.length;
74
+ if (
75
+ (halves.length === 1 && missingGroups !== 0) ||
76
+ (halves.length === 2 && missingGroups < 0) ||
77
+ ![...left, ...right].every(
78
+ (part) => Number.isInteger(part) && part >= 0 && part <= 0xffff,
79
+ )
80
+ ) {
81
+ return null;
82
+ }
83
+ return [...left, ...Array.from({ length: missingGroups }, () => 0), ...right];
84
+ }
85
+
86
+ function ipv4FromHexPair(parts: string[]) {
87
+ if (parts.length !== 2) return null;
88
+ const high = Number.parseInt(parts[0] ?? "", 16);
89
+ const low = Number.parseInt(parts[1] ?? "", 16);
90
+ if (
91
+ ![high, low].every(
92
+ (part) => Number.isInteger(part) && part >= 0 && part <= 0xffff,
93
+ )
94
+ ) {
95
+ return null;
96
+ }
97
+ return [(high >> 8) & 255, high & 255, (low >> 8) & 255, low & 255].join(".");
98
+ }
99
+
100
+ function ipv4FromIpv6Parts(parts: number[]) {
101
+ const tailIpv4 = () =>
102
+ [
103
+ (parts[6] >> 8) & 255,
104
+ parts[6] & 255,
105
+ (parts[7] >> 8) & 255,
106
+ parts[7] & 255,
107
+ ].join(".");
108
+ const hasZeroPrefix = (length: number) =>
109
+ parts.slice(0, length).every((part) => part === 0);
110
+ if (hasZeroPrefix(5) && parts[5] === 0xffff) return tailIpv4();
111
+ if (hasZeroPrefix(4) && parts[4] === 0xffff && parts[5] === 0) {
112
+ return tailIpv4();
113
+ }
114
+ if (hasZeroPrefix(6)) return tailIpv4();
115
+ if (parts[0] === 0x64 && parts[1] === 0xff9b && parts[2] === 0) {
116
+ return tailIpv4();
117
+ }
118
+ if (parts[0] === 0x64 && parts[1] === 0xff9b && parts[2] === 1) {
119
+ return tailIpv4();
120
+ }
121
+ if (parts[0] === 0x2002) {
122
+ return [
123
+ (parts[1] >> 8) & 255,
124
+ parts[1] & 255,
125
+ (parts[2] >> 8) & 255,
126
+ parts[2] & 255,
127
+ ].join(".");
128
+ }
129
+ return null;
130
+ }
131
+
132
+ function ipv4FromIpv6Suffix(address: string) {
133
+ const normalized = normalizeIpv6(address);
134
+ const parts = parseIpv6Parts(normalized);
135
+ if (parts) return ipv4FromIpv6Parts(parts);
136
+ const prefix = ["::ffff:", "64:ff9b::", "64:ff9b:1::", "::"].find((value) =>
137
+ normalized.startsWith(value),
138
+ );
139
+ if (!prefix) return null;
140
+ const suffix = normalized.slice(prefix.length);
141
+ if (ipv4ToNumber(suffix) !== null) return suffix;
142
+ return ipv4FromHexPair(suffix.split(":"));
143
+ }
144
+
145
+ function isPrivateIpv6(address: string) {
146
+ const normalized = normalizeIpv6(address);
147
+ const parts = parseIpv6Parts(normalized);
148
+ const mappedIpv4 = parts
149
+ ? ipv4FromIpv6Parts(parts)
150
+ : ipv4FromIpv6Suffix(normalized);
151
+ if (mappedIpv4) return isPrivateIpv4(mappedIpv4);
152
+ if (parts) {
153
+ const first = parts[0] ?? 0;
154
+ return (
155
+ parts.every((part) => part === 0) ||
156
+ parts.slice(0, 7).every((part) => part === 0) ||
157
+ (first & 0xfe00) === 0xfc00 ||
158
+ (first & 0xffc0) === 0xfe80 ||
159
+ (first & 0xffc0) === 0xfec0 ||
160
+ (first & 0xff00) === 0xff00
161
+ );
162
+ }
163
+ return false;
164
+ }
165
+
166
+ export function isBlockedAddress(address: string) {
167
+ const normalized = address.replace(/^\[|\]$/g, "");
168
+ if (ipv4ToNumber(normalized) !== null) return isPrivateIpv4(normalized);
169
+ if (normalized.includes(":")) return isPrivateIpv6(normalized);
170
+ return false;
171
+ }
172
+
173
+ export function isLocalHostname(hostname: string) {
174
+ const normalized = hostname.toLowerCase().replace(/\.$/, "");
175
+ return (
176
+ normalized === "localhost" ||
177
+ normalized.endsWith(".localhost") ||
178
+ normalized.endsWith(".local") ||
179
+ normalized.endsWith(".internal") ||
180
+ normalized.endsWith(".test")
181
+ );
182
+ }
183
+
184
+ export function assertSafePreviewUrl(url: string) {
185
+ const parsed = new URL(url);
186
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
187
+ throw new Error("Link preview URL must use http or https");
188
+ }
189
+ if (parsed.username || parsed.password) {
190
+ throw new Error("Link preview URL must not include credentials");
191
+ }
192
+ if (isLocalHostname(parsed.hostname) || isBlockedAddress(parsed.hostname)) {
193
+ throw new Error("Link preview URL points to a private host");
194
+ }
195
+ return parsed;
196
+ }
197
+
198
+ export function safeHttpUrl(value: string | null | undefined) {
199
+ if (!value) return null;
200
+ try {
201
+ const parsed = new URL(value);
202
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
203
+ return null;
204
+ }
205
+ return parsed.toString();
206
+ } catch {
207
+ return null;
208
+ }
209
+ }
210
+
211
+ export const __test__ = {
212
+ assertSafePreviewUrl,
213
+ ipv4FromIpv6Suffix,
214
+ ipv4ToNumber,
215
+ isBlockedAddress,
216
+ isIpv4InRange,
217
+ isPrivateIpv6,
218
+ parseIpv6Parts,
219
+ safeHttpUrl,
220
+ };