@takosjp/yurucommu-core 3.0.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 (185) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +82 -0
  3. package/migrations/0001_init.sql +495 -0
  4. package/migrations/0002_social_remote_actor_edges.sql +92 -0
  5. package/migrations/0003_activity_remote_object_edges.sql +68 -0
  6. package/migrations/0004_blocklist.sql +26 -0
  7. package/migrations/0005_story_community_scope.sql +13 -0
  8. package/migrations/0006_dm_community_read_status.sql +19 -0
  9. package/migrations/0007_moderation_reports.sql +22 -0
  10. package/migrations/0008_actor_fields_aka.sql +18 -0
  11. package/migrations/0009_object_tags.sql +13 -0
  12. package/migrations/0010_object_recipients_drop_actor_fk.sql +34 -0
  13. package/migrations/0011_drop_remote_actor_fks.sql +205 -0
  14. package/migrations/0012_objects_content_fts.sql +39 -0
  15. package/migrations/0013_efficiency_indexes.sql +13 -0
  16. package/migrations/0014_inbox_actor_created_idx.sql +15 -0
  17. package/migrations/0015_community_bans.sql +16 -0
  18. package/migrations/0016_namespace_takos_oidc_subject.sql +19 -0
  19. package/migrations/0017_mobile_push_registrations.sql +22 -0
  20. package/migrations/README.md +122 -0
  21. package/package.json +75 -0
  22. package/packages/api/LICENSE +16 -0
  23. package/packages/api/package.json +30 -0
  24. package/packages/api/src/index.ts +4 -0
  25. package/packages/api/src/lib/api/account.ts +20 -0
  26. package/packages/api/src/lib/api/actors.ts +149 -0
  27. package/packages/api/src/lib/api/auth.ts +46 -0
  28. package/packages/api/src/lib/api/communities.ts +329 -0
  29. package/packages/api/src/lib/api/dm.test.ts +67 -0
  30. package/packages/api/src/lib/api/dm.ts +236 -0
  31. package/packages/api/src/lib/api/fetch.ts +111 -0
  32. package/packages/api/src/lib/api/follow.ts +30 -0
  33. package/packages/api/src/lib/api/media.ts +100 -0
  34. package/packages/api/src/lib/api/moderation.ts +98 -0
  35. package/packages/api/src/lib/api/normalize.ts +71 -0
  36. package/packages/api/src/lib/api/notifications.test.ts +63 -0
  37. package/packages/api/src/lib/api/notifications.ts +61 -0
  38. package/packages/api/src/lib/api/posts.test.ts +110 -0
  39. package/packages/api/src/lib/api/posts.ts +181 -0
  40. package/packages/api/src/lib/api/recommendations.ts +22 -0
  41. package/packages/api/src/lib/api/search.ts +88 -0
  42. package/packages/api/src/lib/api/stories.ts +80 -0
  43. package/packages/api/src/lib/api.ts +15 -0
  44. package/packages/api/src/lib/fetch-with-timeout.ts +42 -0
  45. package/packages/api/src/lib/transport.ts +40 -0
  46. package/packages/api/src/social-server.ts +47 -0
  47. package/packages/api/src/types/index.ts +185 -0
  48. package/scripts/apply-takosumi-migrations.ts +621 -0
  49. package/src/backend/federation-helpers.ts +36 -0
  50. package/src/backend/index.ts +872 -0
  51. package/src/backend/lib/account-migration.ts +106 -0
  52. package/src/backend/lib/activitypub-actor-cache.ts +238 -0
  53. package/src/backend/lib/activitypub-helpers.ts +131 -0
  54. package/src/backend/lib/activitypub-validators.ts +323 -0
  55. package/src/backend/lib/ap-context.ts +16 -0
  56. package/src/backend/lib/ap-ids.ts +101 -0
  57. package/src/backend/lib/ap-response.ts +30 -0
  58. package/src/backend/lib/ap-signing.ts +87 -0
  59. package/src/backend/lib/ap-verify.ts +670 -0
  60. package/src/backend/lib/auth-lockout.ts +230 -0
  61. package/src/backend/lib/backend-paths.ts +34 -0
  62. package/src/backend/lib/base64.ts +30 -0
  63. package/src/backend/lib/blocklist-purge.ts +109 -0
  64. package/src/backend/lib/blocklist.ts +279 -0
  65. package/src/backend/lib/chunk.ts +33 -0
  66. package/src/backend/lib/client-ip.ts +169 -0
  67. package/src/backend/lib/community-visibility.ts +230 -0
  68. package/src/backend/lib/crypto.ts +424 -0
  69. package/src/backend/lib/delivery/circuit.ts +265 -0
  70. package/src/backend/lib/delivery/metrics.ts +30 -0
  71. package/src/backend/lib/delivery/planner.ts +190 -0
  72. package/src/backend/lib/delivery/queue-batching.ts +626 -0
  73. package/src/backend/lib/delivery/queue-delivery.ts +641 -0
  74. package/src/backend/lib/delivery/queue.ts +576 -0
  75. package/src/backend/lib/delivery/transformers.ts +56 -0
  76. package/src/backend/lib/delivery/types.ts +139 -0
  77. package/src/backend/lib/errors.ts +114 -0
  78. package/src/backend/lib/federation-fetch.ts +296 -0
  79. package/src/backend/lib/feed-cursor.ts +57 -0
  80. package/src/backend/lib/feed-exclude.ts +48 -0
  81. package/src/backend/lib/hex.ts +8 -0
  82. package/src/backend/lib/log-mask.ts +213 -0
  83. package/src/backend/lib/logger.ts +285 -0
  84. package/src/backend/lib/mobile-contract.ts +137 -0
  85. package/src/backend/lib/oauth-providers.ts +324 -0
  86. package/src/backend/lib/oauth-utils.ts +148 -0
  87. package/src/backend/lib/oidc-id-token.ts +151 -0
  88. package/src/backend/lib/parse-helpers.ts +31 -0
  89. package/src/backend/lib/post-visibility.ts +190 -0
  90. package/src/backend/lib/session-actor.ts +61 -0
  91. package/src/backend/lib/ssrf.ts +428 -0
  92. package/src/backend/lib/strip-image-metadata.ts +191 -0
  93. package/src/backend/middleware/bearer-auth.ts +70 -0
  94. package/src/backend/middleware/body-limit.ts +212 -0
  95. package/src/backend/middleware/cache.ts +429 -0
  96. package/src/backend/middleware/csrf.ts +130 -0
  97. package/src/backend/middleware/error-handler.ts +77 -0
  98. package/src/backend/middleware/rate-limit.ts +308 -0
  99. package/src/backend/public.ts +21 -0
  100. package/src/backend/routes/account-teardown.ts +430 -0
  101. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +354 -0
  102. package/src/backend/routes/activitypub/handlers/inbound-timestamp.ts +29 -0
  103. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1634 -0
  104. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +547 -0
  105. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +497 -0
  106. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +262 -0
  107. package/src/backend/routes/activitypub/handlers/user-inbox-handlers.ts +35 -0
  108. package/src/backend/routes/activitypub/inbox-types.ts +74 -0
  109. package/src/backend/routes/activitypub/inbox.ts +1191 -0
  110. package/src/backend/routes/activitypub/outbox.ts +0 -0
  111. package/src/backend/routes/activitypub/query-helpers.ts +227 -0
  112. package/src/backend/routes/activitypub.ts +616 -0
  113. package/src/backend/routes/actors-helpers.ts +487 -0
  114. package/src/backend/routes/actors.ts +1311 -0
  115. package/src/backend/routes/apps.ts +313 -0
  116. package/src/backend/routes/auth-helpers.ts +566 -0
  117. package/src/backend/routes/auth.ts +615 -0
  118. package/src/backend/routes/communities/membership-invites.ts +208 -0
  119. package/src/backend/routes/communities/membership-join.ts +335 -0
  120. package/src/backend/routes/communities/membership-members.ts +539 -0
  121. package/src/backend/routes/communities/membership-requests.ts +296 -0
  122. package/src/backend/routes/communities/membership-shared.ts +364 -0
  123. package/src/backend/routes/communities/messages.ts +479 -0
  124. package/src/backend/routes/communities/routes.ts +624 -0
  125. package/src/backend/routes/communities.ts +21 -0
  126. package/src/backend/routes/dm/contacts.ts +525 -0
  127. package/src/backend/routes/dm/conversations-helpers.ts +197 -0
  128. package/src/backend/routes/dm/conversations.ts +25 -0
  129. package/src/backend/routes/dm/messages.ts +658 -0
  130. package/src/backend/routes/dm/query-helpers.ts +85 -0
  131. package/src/backend/routes/dm/read-archive.ts +228 -0
  132. package/src/backend/routes/dm/requests.ts +222 -0
  133. package/src/backend/routes/dm/typing.ts +81 -0
  134. package/src/backend/routes/dm.ts +15 -0
  135. package/src/backend/routes/follow-helpers.ts +370 -0
  136. package/src/backend/routes/follow.ts +588 -0
  137. package/src/backend/routes/media.ts +692 -0
  138. package/src/backend/routes/mobile.ts +159 -0
  139. package/src/backend/routes/moderation.ts +373 -0
  140. package/src/backend/routes/notifications.ts +757 -0
  141. package/src/backend/routes/posts/delete-cascade.ts +330 -0
  142. package/src/backend/routes/posts/interactions.ts +795 -0
  143. package/src/backend/routes/posts/post-helpers.ts +847 -0
  144. package/src/backend/routes/posts/queries.ts +537 -0
  145. package/src/backend/routes/posts/routes.ts +865 -0
  146. package/src/backend/routes/posts/transformers.ts +161 -0
  147. package/src/backend/routes/posts.ts +17 -0
  148. package/src/backend/routes/recommendations.ts +88 -0
  149. package/src/backend/routes/search.ts +730 -0
  150. package/src/backend/routes/stories/interactions.ts +576 -0
  151. package/src/backend/routes/stories/query-helpers.ts +482 -0
  152. package/src/backend/routes/stories/routes.ts +906 -0
  153. package/src/backend/routes/stories.ts +13 -0
  154. package/src/backend/routes/takos-tools/dm.ts +249 -0
  155. package/src/backend/routes/takos-tools/follows.ts +225 -0
  156. package/src/backend/routes/takos-tools/posts.ts +292 -0
  157. package/src/backend/routes/takos-tools/search.ts +228 -0
  158. package/src/backend/routes/takos-tools/timeline.ts +132 -0
  159. package/src/backend/routes/takos-tools/types.ts +10 -0
  160. package/src/backend/routes/takos-tools-response.ts +178 -0
  161. package/src/backend/routes/takos-tools.ts +153 -0
  162. package/src/backend/routes/timeline.ts +755 -0
  163. package/src/backend/runtime/bun.ts +620 -0
  164. package/src/backend/runtime/cloudflare.ts +202 -0
  165. package/src/backend/runtime/compat-bun/types.ts +44 -0
  166. package/src/backend/runtime/memory-kv.ts +104 -0
  167. package/src/backend/runtime/shared.ts +142 -0
  168. package/src/backend/runtime/types.ts +205 -0
  169. package/src/backend/server.ts +636 -0
  170. package/src/backend/types.ts +143 -0
  171. package/src/db/index.ts +97 -0
  172. package/src/db/schema/actors.ts +129 -0
  173. package/src/db/schema/communities.ts +133 -0
  174. package/src/db/schema/date-utils.ts +17 -0
  175. package/src/db/schema/index.ts +17 -0
  176. package/src/db/schema/messaging.ts +241 -0
  177. package/src/db/schema/mobile.ts +37 -0
  178. package/src/db/schema/posts.ts +150 -0
  179. package/src/db/schema/relations.ts +266 -0
  180. package/src/db/schema/reports.ts +33 -0
  181. package/src/db/schema/social.ts +106 -0
  182. package/src/db/schema/stories.ts +70 -0
  183. package/src/db/schema.ts +15 -0
  184. package/src/plugin/public.ts +7 -0
  185. package/src/runtime/site-worker.ts +10 -0
@@ -0,0 +1,230 @@
1
+ import type { IKeyValueStore } from "../runtime/types.ts";
2
+ import { logger } from "./logger.ts";
3
+
4
+ const log = logger.child({ component: "auth.lockout" });
5
+
6
+ export interface LoginLockoutStatus {
7
+ locked: boolean;
8
+ failedAttempts: number;
9
+ retryAfterSeconds: number;
10
+ }
11
+
12
+ interface LoginLockoutRecord {
13
+ failedAttempts: number;
14
+ firstFailedAt: number;
15
+ lockoutUntil: number | null;
16
+ }
17
+
18
+ export const LOGIN_LOCKOUT_CONFIG = {
19
+ maxFailedAttempts: 5,
20
+ // How long a tripped lock holds.
21
+ lockoutMs: 15 * 60 * 1000,
22
+ // How long failed attempts accumulate toward the lock. This MUST be longer
23
+ // than lockoutMs: when it equalled lockoutMs (both 15m), a paced attacker who
24
+ // made <=4 failures then idled 15m got a fully-reset record (failedAttempts
25
+ // back to 0) and NEVER tripped the lock — repeatable forever, so the control
26
+ // contributed nothing against a low-and-slow brute force. A 60m window over a
27
+ // 5-attempt cap means idling no longer resets the counter, so the 5th failure
28
+ // within the hour actually engages the lock.
29
+ trackingWindowMs: 60 * 60 * 1000,
30
+ } as const;
31
+
32
+ const LOCKOUT_KEY_PREFIX = "auth-lockout:v1";
33
+ const lockoutFallbackStore = new Map<string, LoginLockoutRecord>();
34
+
35
+ function getLockoutStorageKey(clientKey: string): string {
36
+ return `${LOCKOUT_KEY_PREFIX}:${encodeURIComponent(clientKey)}`;
37
+ }
38
+
39
+ function isValidRecord(v: unknown): v is LoginLockoutRecord {
40
+ if (typeof v !== "object" || v === null) return false;
41
+ const entry = v as Record<string, unknown>;
42
+ return (
43
+ typeof entry.failedAttempts === "number" &&
44
+ typeof entry.firstFailedAt === "number" &&
45
+ (entry.lockoutUntil === null || typeof entry.lockoutUntil === "number")
46
+ );
47
+ }
48
+
49
+ function parseLockoutRecord(raw: string | null): LoginLockoutRecord | null {
50
+ if (!raw) return null;
51
+
52
+ try {
53
+ const parsed: unknown = JSON.parse(raw);
54
+ return isValidRecord(parsed) ? parsed : null;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ function isRecordExpired(record: LoginLockoutRecord, now: number): boolean {
61
+ if (record.lockoutUntil !== null && record.lockoutUntil <= now) return true;
62
+ if (now - record.firstFailedAt > LOGIN_LOCKOUT_CONFIG.trackingWindowMs) {
63
+ return true;
64
+ }
65
+ return false;
66
+ }
67
+
68
+ function normalizeLockoutRecord(
69
+ record: LoginLockoutRecord | null,
70
+ now: number,
71
+ ): LoginLockoutRecord | null {
72
+ if (!record) return null;
73
+ return isRecordExpired(record, now) ? null : record;
74
+ }
75
+
76
+ const UNLOCKED_STATUS: LoginLockoutStatus = Object.freeze({
77
+ locked: false,
78
+ failedAttempts: 0,
79
+ retryAfterSeconds: 0,
80
+ });
81
+
82
+ function toStatus(
83
+ record: LoginLockoutRecord | null,
84
+ now: number,
85
+ ): LoginLockoutStatus {
86
+ if (!record) return UNLOCKED_STATUS;
87
+
88
+ const locked = record.lockoutUntil !== null && record.lockoutUntil > now;
89
+ return {
90
+ locked,
91
+ failedAttempts: record.failedAttempts,
92
+ retryAfterSeconds: locked
93
+ ? Math.max(1, Math.ceil((record.lockoutUntil! - now) / 1000))
94
+ : 0,
95
+ };
96
+ }
97
+
98
+ function fallbackRead(
99
+ storageKey: string,
100
+ now: number,
101
+ ): LoginLockoutRecord | null {
102
+ const record = normalizeLockoutRecord(
103
+ lockoutFallbackStore.get(storageKey) || null,
104
+ now,
105
+ );
106
+ if (!record) {
107
+ lockoutFallbackStore.delete(storageKey);
108
+ }
109
+ return record;
110
+ }
111
+
112
+ function fallbackWrite(storageKey: string, record: LoginLockoutRecord): void {
113
+ lockoutFallbackStore.set(storageKey, record);
114
+ }
115
+
116
+ async function readRecord(
117
+ kv: IKeyValueStore,
118
+ storageKey: string,
119
+ now: number,
120
+ ): Promise<LoginLockoutRecord | null> {
121
+ try {
122
+ const raw = await kv.get(storageKey);
123
+ const record = normalizeLockoutRecord(parseLockoutRecord(raw), now);
124
+ if (!record) {
125
+ await kv.delete(storageKey);
126
+ }
127
+ return record;
128
+ } catch (err) {
129
+ log.warn("Failed to read login lockout from KV, using local fallback", {
130
+ event: "auth.lockout.kv_read_failed",
131
+ error: err,
132
+ });
133
+ return fallbackRead(storageKey, now);
134
+ }
135
+ }
136
+
137
+ async function writeRecord(
138
+ kv: IKeyValueStore,
139
+ storageKey: string,
140
+ record: LoginLockoutRecord,
141
+ now: number,
142
+ ): Promise<void> {
143
+ const ttlMs = record.lockoutUntil
144
+ ? record.lockoutUntil - now
145
+ : LOGIN_LOCKOUT_CONFIG.trackingWindowMs - (now - record.firstFailedAt);
146
+ const expirationTtl = Math.max(60, Math.ceil(ttlMs / 1000) + 60);
147
+
148
+ try {
149
+ await kv.put(storageKey, JSON.stringify(record), { expirationTtl });
150
+ } catch (err) {
151
+ log.warn("Failed to write login lockout to KV, using local fallback", {
152
+ event: "auth.lockout.kv_write_failed",
153
+ error: err,
154
+ });
155
+ fallbackWrite(storageKey, record);
156
+ }
157
+ }
158
+
159
+ async function deleteRecord(
160
+ kv: IKeyValueStore,
161
+ storageKey: string,
162
+ ): Promise<void> {
163
+ lockoutFallbackStore.delete(storageKey);
164
+ try {
165
+ await kv.delete(storageKey);
166
+ } catch (err) {
167
+ log.warn("Failed to clear login lockout from KV", {
168
+ event: "auth.lockout.kv_delete_failed",
169
+ error: err,
170
+ });
171
+ }
172
+ }
173
+
174
+ export async function getLoginLockoutStatus(
175
+ kv: IKeyValueStore,
176
+ clientKey: string,
177
+ now = Date.now(),
178
+ ): Promise<LoginLockoutStatus> {
179
+ const storageKey = getLockoutStorageKey(clientKey);
180
+ const record = await readRecord(kv, storageKey, now);
181
+ return toStatus(record, now);
182
+ }
183
+
184
+ export async function recordFailedLoginAttempt(
185
+ kv: IKeyValueStore,
186
+ clientKey: string,
187
+ now = Date.now(),
188
+ ): Promise<LoginLockoutStatus> {
189
+ const storageKey = getLockoutStorageKey(clientKey);
190
+ const existing = await readRecord(kv, storageKey, now);
191
+
192
+ // Already locked out -- return current status without extending
193
+ if (
194
+ existing?.lockoutUntil !== null &&
195
+ existing?.lockoutUntil !== undefined &&
196
+ existing.lockoutUntil > now
197
+ ) {
198
+ return toStatus(existing, now);
199
+ }
200
+
201
+ // CONCURRENCY NOTE (accepted, bounded): this read-then-write of the KV record
202
+ // is not atomic — KV has no atomic increment and is eventually consistent — so
203
+ // many SIMULTANEOUS failed logins can lose increments (last-writer-wins),
204
+ // letting an attacker who fires concurrent attempts squeeze a few extra tries
205
+ // in before the lockout trips. The lockout still triggers; the race only
206
+ // slightly delays it within a narrow window. A strongly-atomic counter needs a
207
+ // Durable Object or the Workers Rate Limiting API; adopting one is the correct
208
+ // upgrade if brute-force pressure ever warrants it, but is out of scope for the
209
+ // current KV-backed control.
210
+ const failedAttempts = (existing?.failedAttempts ?? 0) + 1;
211
+ const firstFailedAt = existing?.firstFailedAt ?? now;
212
+ const shouldLock = failedAttempts >= LOGIN_LOCKOUT_CONFIG.maxFailedAttempts;
213
+
214
+ const next: LoginLockoutRecord = {
215
+ failedAttempts,
216
+ firstFailedAt,
217
+ lockoutUntil: shouldLock ? now + LOGIN_LOCKOUT_CONFIG.lockoutMs : null,
218
+ };
219
+
220
+ await writeRecord(kv, storageKey, next, now);
221
+ return toStatus(next, now);
222
+ }
223
+
224
+ export async function clearLoginLockout(
225
+ kv: IKeyValueStore,
226
+ clientKey: string,
227
+ ): Promise<void> {
228
+ const storageKey = getLockoutStorageKey(clientKey);
229
+ await deleteRecord(kv, storageKey);
230
+ }
@@ -0,0 +1,34 @@
1
+ // Backend route prefixes (API, ActivityPub, well-known, nodeinfo, media, health,
2
+ // tools, hosted). A request under one of these that reaches the static-asset /
3
+ // SPA fallback means the backend router did NOT match it — that is a genuine
4
+ // 404 for an API / AP / media client, NOT a candidate for the SPA HTML fallback
5
+ // (which would return 200 text/html and break clients that expect JSON or an AP
6
+ // document).
7
+ //
8
+ // This is the SINGLE source of truth shared by every runtime's static fallback
9
+ // (the Bun BunAssets handler and the Cloudflare Workers `mountStaticFallback`),
10
+ // so the two cannot diverge — they previously did: the Bun path guarded these
11
+ // prefixes while the Cloudflare path forwarded everything to ASSETS, so an
12
+ // unmatched /api/* returned the SPA HTML shell (200) on the production worker.
13
+ export const NON_SPA_PREFIXES = [
14
+ "/api",
15
+ "/ap",
16
+ "/.well-known",
17
+ "/nodeinfo",
18
+ "/media",
19
+ "/hosted",
20
+ "/.takos",
21
+ "/healthz",
22
+ "/readyz",
23
+ ] as const;
24
+
25
+ /**
26
+ * True when `pathname` is under a backend route prefix — exact match or a
27
+ * `/prefix/...` child. Used to refuse the SPA HTML fallback for unmatched
28
+ * backend routes so they 404 (JSON) instead of returning the app shell.
29
+ */
30
+ export function isBackendPath(pathname: string): boolean {
31
+ return NON_SPA_PREFIXES.some(
32
+ (p) => pathname === p || pathname.startsWith(p + "/"),
33
+ );
34
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Base64 helpers shared by federation signing/verification paths.
3
+ *
4
+ * `bufferToBase64` walks the buffer in fixed-size chunks rather than spreading
5
+ * the whole `Uint8Array` into `String.fromCharCode(...)`. The spread form
6
+ * throws a "Maximum call stack size exceeded" RangeError once the buffer is
7
+ * large enough (the spread becomes one argument per byte), so the chunked loop
8
+ * is the stack-safe variant for arbitrary-size inputs.
9
+ */
10
+ export function bufferToBase64(buffer: ArrayBuffer): string {
11
+ const bytes = new Uint8Array(buffer);
12
+ const chunkSize = 8192;
13
+ let binary = "";
14
+ for (let i = 0; i < bytes.length; i += chunkSize) {
15
+ const chunk = bytes.subarray(i, Math.min(i + chunkSize, bytes.length));
16
+ for (const byte of chunk) {
17
+ binary += String.fromCharCode(byte);
18
+ }
19
+ }
20
+ return btoa(binary);
21
+ }
22
+
23
+ export function base64ToBytes(value: string): Uint8Array {
24
+ const binary = atob(value);
25
+ const bytes = new Uint8Array(binary.length);
26
+ for (let i = 0; i < binary.length; i++) {
27
+ bytes[i] = binary.charCodeAt(i);
28
+ }
29
+ return bytes;
30
+ }
@@ -0,0 +1,109 @@
1
+ import { eq, inArray, like, or } from "drizzle-orm";
2
+ import { activities, objects } from "../../db/index.ts";
3
+ import type { Database } from "../../db/index.ts";
4
+ import type { IObjectStorage } from "../runtime/types.ts";
5
+ import { chunkForInClause } from "./chunk.ts";
6
+ import { normalizeDomain } from "./blocklist.ts";
7
+ import {
8
+ deleteObjectCascade,
9
+ purgeMediaBlobs,
10
+ } from "../routes/posts/delete-cascade.ts";
11
+ import { logger } from "./logger.ts";
12
+
13
+ const log = logger.child({ component: "blocklist" });
14
+
15
+ // Hard-delete a set of objects (with their child cascade + R2 blobs). Shared by
16
+ // the actor / domain purge below. apIds are EXACTLY the objects to remove.
17
+ async function purgeObjects(
18
+ db: Database,
19
+ apIds: string[],
20
+ media?: IObjectStorage,
21
+ ): Promise<void> {
22
+ if (apIds.length === 0) return;
23
+ const mediaKeys: string[] = [];
24
+ for (const apId of apIds) {
25
+ mediaKeys.push(...(await deleteObjectCascade(db, apId, media)));
26
+ }
27
+ for (const chunk of chunkForInClause(apIds)) {
28
+ await db.delete(objects).where(inArray(objects.apId, chunk));
29
+ }
30
+ await purgeMediaBlobs(media, mediaKeys);
31
+ }
32
+
33
+ /**
34
+ * Purge a blocked REMOTE actor's already-ingested content. The operator
35
+ * blocklist is otherwise ingest/delivery-only, so without this a defederated
36
+ * actor's prior posts/replies/stories stay live in timelines, search, and
37
+ * object serving — contradicting the operator's "they're gone" expectation.
38
+ * Removes the actor's authored objects (cascade) + their activity ledger rows.
39
+ * Best-effort; never throws into the operator's response path.
40
+ */
41
+ export async function purgeActorContent(
42
+ db: Database,
43
+ blockedApId: string,
44
+ media?: IObjectStorage,
45
+ ): Promise<void> {
46
+ try {
47
+ const rows = await db
48
+ .select({ apId: objects.apId })
49
+ .from(objects)
50
+ .where(eq(objects.attributedTo, blockedApId));
51
+ await purgeObjects(
52
+ db,
53
+ rows.map((r) => r.apId),
54
+ media,
55
+ );
56
+ await db.delete(activities).where(eq(activities.actorApId, blockedApId));
57
+ } catch (err) {
58
+ log.warn("blocklist.purgeActorContent failed", {
59
+ event: "blocklist.purge_actor_failed",
60
+ actor: blockedApId,
61
+ error: err,
62
+ });
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Purge already-ingested content authored by any actor on a blocked DOMAIN (the
68
+ * host itself OR a subdomain). Host-anchored LIKE so `evil.com` matches
69
+ * `https://evil.com/...` and `https://node1.evil.com/...` but NOT `notevil.com`.
70
+ * Best-effort. Local content is never matched (local objects carry the local
71
+ * host; the operator never blocks their own domain).
72
+ */
73
+ export async function purgeDomainContent(
74
+ db: Database,
75
+ domainOrUrl: string,
76
+ media?: IObjectStorage,
77
+ ): Promise<void> {
78
+ const domain = normalizeDomain(domainOrUrl);
79
+ if (!domain) return;
80
+ try {
81
+ const hostMatch = or(
82
+ like(objects.attributedTo, `https://${domain}/%`),
83
+ like(objects.attributedTo, `https://%.${domain}/%`),
84
+ );
85
+ const rows = await db
86
+ .select({ apId: objects.apId })
87
+ .from(objects)
88
+ .where(hostMatch);
89
+ await purgeObjects(
90
+ db,
91
+ rows.map((r) => r.apId),
92
+ media,
93
+ );
94
+ await db
95
+ .delete(activities)
96
+ .where(
97
+ or(
98
+ like(activities.actorApId, `https://${domain}/%`),
99
+ like(activities.actorApId, `https://%.${domain}/%`),
100
+ ),
101
+ );
102
+ } catch (err) {
103
+ log.warn("blocklist.purgeDomainContent failed", {
104
+ event: "blocklist.purge_domain_failed",
105
+ domain,
106
+ error: err,
107
+ });
108
+ }
109
+ }
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Federation moderation blocklist.
3
+ *
4
+ * Backed by two tables (`blocked_domains` and `blocked_actors`) populated by
5
+ * operators. Inbox content handlers consult both helpers at activity ingest
6
+ * so that blocked traffic is silently discarded (200/202 ACK, not 4xx) — a
7
+ * 4xx would cause sender instances to retry on a backoff, wasting their
8
+ * delivery budget and ours.
9
+ *
10
+ * The helpers return `false` when the underlying read fails so that a
11
+ * transient database error never causes federation traffic to be black-holed.
12
+ * Each call site logs the failure so that the operator can investigate.
13
+ */
14
+
15
+ import { eq, inArray } from "drizzle-orm";
16
+
17
+ import type { Database } from "../../db/index.ts";
18
+ import { blockedActors, blockedDomains } from "../../db/index.ts";
19
+ import { logger } from "./logger.ts";
20
+
21
+ const log = logger.child({ component: "blocklist" });
22
+
23
+ // Max ids per IN(...) lookup. Cloudflare D1 caps a query at 100 bound
24
+ // parameters (libsql/better-sqlite3 — what the tests run on — allow ~32k, which
25
+ // is why an over-large chunk passes CI but throws "too many SQL variables" on
26
+ // production D1). Each chunk element binds one parameter, so keep this <=90 to
27
+ // leave headroom: a large recipient set is queried in chunks rather than
28
+ // throwing — and a throw here is swallowed by the fail-open catch below,
29
+ // silently disabling the operator blocklist for the whole fan-out (a
30
+ // defederation bypass we must not allow).
31
+ const BLOCKLIST_IN_CHUNK = 90;
32
+
33
+ /**
34
+ * Normalise an actor AP-ID hostname for blocklist lookups: lowercase and
35
+ * strip any trailing dot (DNS root form). Returns `null` when the input
36
+ * cannot be parsed.
37
+ */
38
+ export function normalizeDomain(input: string): string | null {
39
+ try {
40
+ // Allow callers to pass either a bare hostname or a full URL.
41
+ const candidate = input.includes("://") ? new URL(input).hostname : input;
42
+ const trimmed = candidate.trim().replace(/\.$/, "").toLowerCase();
43
+ return trimmed.length > 0 ? trimmed : null;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * The hostname plus each of its parent suffixes that has ≥2 labels, e.g.
51
+ * `a.b.attacker.net` → [`a.b.attacker.net`, `b.attacker.net`, `attacker.net`].
52
+ * A domain block must cover subdomains (the near-universal fediverse
53
+ * convention) — otherwise blocking `attacker.net` is trivially evaded by
54
+ * federating from `node1.attacker.net`. Bounded by the label count (index-
55
+ * friendly inArray), and excludes the bare TLD so a TLD is never an implied
56
+ * match. A single-label host (e.g. `localhost`) yields just itself.
57
+ */
58
+ export function domainSuffixCandidates(domain: string): string[] {
59
+ const labels = domain.split(".");
60
+ const out: string[] = [];
61
+ for (let i = 0; i <= labels.length - 2; i++) {
62
+ out.push(labels.slice(i).join("."));
63
+ }
64
+ if (out.length === 0) out.push(domain);
65
+ return out;
66
+ }
67
+
68
+ /**
69
+ * Returns `true` when the operator has blocked the given hostname OR any parent
70
+ * domain of it. Accepts both bare hostnames (`example.org`) and full actor URLs.
71
+ */
72
+ export async function isDomainBlocked(
73
+ db: Database,
74
+ hostnameOrUrl: string,
75
+ ): Promise<boolean> {
76
+ const domain = normalizeDomain(hostnameOrUrl);
77
+ if (!domain) return false;
78
+
79
+ try {
80
+ const row = await db.query.blockedDomains.findFirst({
81
+ where: inArray(blockedDomains.domain, domainSuffixCandidates(domain)),
82
+ columns: { domain: true },
83
+ });
84
+ return !!row;
85
+ } catch (err) {
86
+ log.warn("blocklist.isDomainBlocked failed", {
87
+ event: "blocklist.domain_lookup_failed",
88
+ domain,
89
+ error: err,
90
+ });
91
+ return false;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Returns `true` when the operator has blocked the given actor AP-ID, or
97
+ * (transitively) when the actor's hostname is blocked.
98
+ */
99
+ export async function isActorBlocked(
100
+ db: Database,
101
+ actorApId: string,
102
+ ): Promise<boolean> {
103
+ if (typeof actorApId !== "string" || actorApId.length === 0) {
104
+ return false;
105
+ }
106
+
107
+ try {
108
+ const row = await db.query.blockedActors.findFirst({
109
+ where: eq(blockedActors.actorApId, actorApId),
110
+ columns: { actorApId: true },
111
+ });
112
+ if (row) return true;
113
+ } catch (err) {
114
+ log.warn("blocklist.isActorBlocked failed", {
115
+ event: "blocklist.actor_lookup_failed",
116
+ actor: actorApId,
117
+ error: err,
118
+ });
119
+ return false;
120
+ }
121
+
122
+ const domain = normalizeDomain(actorApId);
123
+ if (!domain) return false;
124
+ return await isDomainBlocked(db, domain);
125
+ }
126
+
127
+ /**
128
+ * Batched blocklist filter for a list of recipient actor AP-IDs: returns the
129
+ * SUBSET that is blocked (by actor OR transitively by hostname) using exactly
130
+ * two queries (blocked_actors + blocked_domains) instead of two-per-recipient.
131
+ * Replaces an O(recipients) serial `isActorBlocked` loop on the delivery
132
+ * fan-out hot path. Fail-open like the singular helpers: a read error yields an
133
+ * empty blocked set so a transient DB error never black-holes federation.
134
+ */
135
+ export async function filterBlockedActorApIds(
136
+ db: Database,
137
+ actorApIds: string[],
138
+ ): Promise<Set<string>> {
139
+ const blocked = new Set<string>();
140
+ const uniqueIds = [...new Set(actorApIds.filter((id) => id.length > 0))];
141
+ if (uniqueIds.length === 0) return blocked;
142
+
143
+ try {
144
+ // Chunk the IN(...) lookups so a very large recipient set (e.g. a big
145
+ // community fan-out, tens of thousands of unique actors/domains) cannot
146
+ // exceed SQLite's bound-parameter ceiling and throw — which, under the
147
+ // fail-open catch below, would silently DISABLE the operator blocklist for
148
+ // that whole fan-out (a defederation bypass).
149
+ const blockedActorSet = new Set<string>();
150
+ for (let i = 0; i < uniqueIds.length; i += BLOCKLIST_IN_CHUNK) {
151
+ const rows = await db
152
+ .select({ actorApId: blockedActors.actorApId })
153
+ .from(blockedActors)
154
+ .where(
155
+ inArray(
156
+ blockedActors.actorApId,
157
+ uniqueIds.slice(i, i + BLOCKLIST_IN_CHUNK),
158
+ ),
159
+ );
160
+ for (const r of rows) blockedActorSet.add(r.actorApId);
161
+ }
162
+
163
+ // Expand each actor's hostname to its parent-domain candidates so a domain
164
+ // block covers subdomains (see domainSuffixCandidates) — querying the union
165
+ // of all candidates and matching an actor if ANY of its candidates is
166
+ // blocked.
167
+ const idToCandidates = new Map<string, string[]>();
168
+ const candidateUnion = new Set<string>();
169
+ for (const id of uniqueIds) {
170
+ const d = normalizeDomain(id);
171
+ const candidates = d ? domainSuffixCandidates(d) : [];
172
+ idToCandidates.set(id, candidates);
173
+ for (const c of candidates) candidateUnion.add(c);
174
+ }
175
+
176
+ const blockedDomainSet = new Set<string>();
177
+ const domainList = [...candidateUnion];
178
+ for (let i = 0; i < domainList.length; i += BLOCKLIST_IN_CHUNK) {
179
+ const blockedDomainRows = await db
180
+ .select({ domain: blockedDomains.domain })
181
+ .from(blockedDomains)
182
+ .where(
183
+ inArray(
184
+ blockedDomains.domain,
185
+ domainList.slice(i, i + BLOCKLIST_IN_CHUNK),
186
+ ),
187
+ );
188
+ for (const r of blockedDomainRows) blockedDomainSet.add(r.domain);
189
+ }
190
+
191
+ for (const id of uniqueIds) {
192
+ const candidates = idToCandidates.get(id) ?? [];
193
+ if (
194
+ blockedActorSet.has(id) ||
195
+ candidates.some((c) => blockedDomainSet.has(c))
196
+ ) {
197
+ blocked.add(id);
198
+ }
199
+ }
200
+ } catch (err) {
201
+ log.warn("blocklist.filterBlockedActorApIds failed", {
202
+ event: "blocklist.batch_lookup_failed",
203
+ error: err,
204
+ });
205
+ return new Set(); // fail-open
206
+ }
207
+ return blocked;
208
+ }
209
+
210
+ /**
211
+ * Insert (or update) a domain blocklist entry. Idempotent: re-blocking the
212
+ * same domain refreshes the recorded reason but keeps the original
213
+ * `created_at`.
214
+ */
215
+ export async function blockDomain(
216
+ db: Database,
217
+ hostnameOrUrl: string,
218
+ reason: string | null,
219
+ ): Promise<void> {
220
+ const domain = normalizeDomain(hostnameOrUrl);
221
+ if (!domain) {
222
+ throw new Error(`blocklist.blockDomain: invalid input "${hostnameOrUrl}"`);
223
+ }
224
+
225
+ await db
226
+ .insert(blockedDomains)
227
+ .values({ domain, reason })
228
+ .onConflictDoUpdate({
229
+ target: blockedDomains.domain,
230
+ set: { reason },
231
+ });
232
+ }
233
+
234
+ /**
235
+ * Remove a domain from the blocklist. No-op when the domain was not blocked.
236
+ */
237
+ export async function unblockDomain(
238
+ db: Database,
239
+ hostnameOrUrl: string,
240
+ ): Promise<void> {
241
+ const domain = normalizeDomain(hostnameOrUrl);
242
+ if (!domain) {
243
+ throw new Error(
244
+ `blocklist.unblockDomain: invalid input "${hostnameOrUrl}"`,
245
+ );
246
+ }
247
+ await db.delete(blockedDomains).where(eq(blockedDomains.domain, domain));
248
+ }
249
+
250
+ /**
251
+ * Insert (or update) an actor blocklist entry. Idempotent.
252
+ */
253
+ export async function blockActor(
254
+ db: Database,
255
+ actorApId: string,
256
+ reason: string | null,
257
+ ): Promise<void> {
258
+ if (typeof actorApId !== "string" || actorApId.length === 0) {
259
+ throw new Error("blocklist.blockActor: actorApId is required");
260
+ }
261
+ await db
262
+ .insert(blockedActors)
263
+ .values({ actorApId, reason })
264
+ .onConflictDoUpdate({
265
+ target: blockedActors.actorApId,
266
+ set: { reason },
267
+ });
268
+ }
269
+
270
+ /**
271
+ * Remove an actor from the blocklist. No-op when the actor was not blocked.
272
+ */
273
+ export async function unblockActor(
274
+ db: Database,
275
+ actorApId: string,
276
+ ): Promise<void> {
277
+ if (typeof actorApId !== "string" || actorApId.length === 0) return;
278
+ await db.delete(blockedActors).where(eq(blockedActors.actorApId, actorApId));
279
+ }