@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.
- package/LICENSE +16 -0
- package/README.md +82 -0
- package/migrations/0001_init.sql +495 -0
- package/migrations/0002_social_remote_actor_edges.sql +92 -0
- package/migrations/0003_activity_remote_object_edges.sql +68 -0
- package/migrations/0004_blocklist.sql +26 -0
- package/migrations/0005_story_community_scope.sql +13 -0
- package/migrations/0006_dm_community_read_status.sql +19 -0
- package/migrations/0007_moderation_reports.sql +22 -0
- package/migrations/0008_actor_fields_aka.sql +18 -0
- package/migrations/0009_object_tags.sql +13 -0
- package/migrations/0010_object_recipients_drop_actor_fk.sql +34 -0
- package/migrations/0011_drop_remote_actor_fks.sql +205 -0
- package/migrations/0012_objects_content_fts.sql +39 -0
- package/migrations/0013_efficiency_indexes.sql +13 -0
- package/migrations/0014_inbox_actor_created_idx.sql +15 -0
- package/migrations/0015_community_bans.sql +16 -0
- package/migrations/0016_namespace_takos_oidc_subject.sql +19 -0
- package/migrations/0017_mobile_push_registrations.sql +22 -0
- package/migrations/README.md +122 -0
- package/package.json +75 -0
- package/packages/api/LICENSE +16 -0
- package/packages/api/package.json +30 -0
- package/packages/api/src/index.ts +4 -0
- package/packages/api/src/lib/api/account.ts +20 -0
- package/packages/api/src/lib/api/actors.ts +149 -0
- package/packages/api/src/lib/api/auth.ts +46 -0
- package/packages/api/src/lib/api/communities.ts +329 -0
- package/packages/api/src/lib/api/dm.test.ts +67 -0
- package/packages/api/src/lib/api/dm.ts +236 -0
- package/packages/api/src/lib/api/fetch.ts +111 -0
- package/packages/api/src/lib/api/follow.ts +30 -0
- package/packages/api/src/lib/api/media.ts +100 -0
- package/packages/api/src/lib/api/moderation.ts +98 -0
- package/packages/api/src/lib/api/normalize.ts +71 -0
- package/packages/api/src/lib/api/notifications.test.ts +63 -0
- package/packages/api/src/lib/api/notifications.ts +61 -0
- package/packages/api/src/lib/api/posts.test.ts +110 -0
- package/packages/api/src/lib/api/posts.ts +181 -0
- package/packages/api/src/lib/api/recommendations.ts +22 -0
- package/packages/api/src/lib/api/search.ts +88 -0
- package/packages/api/src/lib/api/stories.ts +80 -0
- package/packages/api/src/lib/api.ts +15 -0
- package/packages/api/src/lib/fetch-with-timeout.ts +42 -0
- package/packages/api/src/lib/transport.ts +40 -0
- package/packages/api/src/social-server.ts +47 -0
- package/packages/api/src/types/index.ts +185 -0
- package/scripts/apply-takosumi-migrations.ts +621 -0
- package/src/backend/federation-helpers.ts +36 -0
- package/src/backend/index.ts +872 -0
- package/src/backend/lib/account-migration.ts +106 -0
- package/src/backend/lib/activitypub-actor-cache.ts +238 -0
- package/src/backend/lib/activitypub-helpers.ts +131 -0
- package/src/backend/lib/activitypub-validators.ts +323 -0
- package/src/backend/lib/ap-context.ts +16 -0
- package/src/backend/lib/ap-ids.ts +101 -0
- package/src/backend/lib/ap-response.ts +30 -0
- package/src/backend/lib/ap-signing.ts +87 -0
- package/src/backend/lib/ap-verify.ts +670 -0
- package/src/backend/lib/auth-lockout.ts +230 -0
- package/src/backend/lib/backend-paths.ts +34 -0
- package/src/backend/lib/base64.ts +30 -0
- package/src/backend/lib/blocklist-purge.ts +109 -0
- package/src/backend/lib/blocklist.ts +279 -0
- package/src/backend/lib/chunk.ts +33 -0
- package/src/backend/lib/client-ip.ts +169 -0
- package/src/backend/lib/community-visibility.ts +230 -0
- package/src/backend/lib/crypto.ts +424 -0
- package/src/backend/lib/delivery/circuit.ts +265 -0
- package/src/backend/lib/delivery/metrics.ts +30 -0
- package/src/backend/lib/delivery/planner.ts +190 -0
- package/src/backend/lib/delivery/queue-batching.ts +626 -0
- package/src/backend/lib/delivery/queue-delivery.ts +641 -0
- package/src/backend/lib/delivery/queue.ts +576 -0
- package/src/backend/lib/delivery/transformers.ts +56 -0
- package/src/backend/lib/delivery/types.ts +139 -0
- package/src/backend/lib/errors.ts +114 -0
- package/src/backend/lib/federation-fetch.ts +296 -0
- package/src/backend/lib/feed-cursor.ts +57 -0
- package/src/backend/lib/feed-exclude.ts +48 -0
- package/src/backend/lib/hex.ts +8 -0
- package/src/backend/lib/log-mask.ts +213 -0
- package/src/backend/lib/logger.ts +285 -0
- package/src/backend/lib/mobile-contract.ts +137 -0
- package/src/backend/lib/oauth-providers.ts +324 -0
- package/src/backend/lib/oauth-utils.ts +148 -0
- package/src/backend/lib/oidc-id-token.ts +151 -0
- package/src/backend/lib/parse-helpers.ts +31 -0
- package/src/backend/lib/post-visibility.ts +190 -0
- package/src/backend/lib/session-actor.ts +61 -0
- package/src/backend/lib/ssrf.ts +428 -0
- package/src/backend/lib/strip-image-metadata.ts +191 -0
- package/src/backend/middleware/bearer-auth.ts +70 -0
- package/src/backend/middleware/body-limit.ts +212 -0
- package/src/backend/middleware/cache.ts +429 -0
- package/src/backend/middleware/csrf.ts +130 -0
- package/src/backend/middleware/error-handler.ts +77 -0
- package/src/backend/middleware/rate-limit.ts +308 -0
- package/src/backend/public.ts +21 -0
- package/src/backend/routes/account-teardown.ts +430 -0
- package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +354 -0
- package/src/backend/routes/activitypub/handlers/inbound-timestamp.ts +29 -0
- package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1634 -0
- package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +547 -0
- package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +497 -0
- package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +262 -0
- package/src/backend/routes/activitypub/handlers/user-inbox-handlers.ts +35 -0
- package/src/backend/routes/activitypub/inbox-types.ts +74 -0
- package/src/backend/routes/activitypub/inbox.ts +1191 -0
- package/src/backend/routes/activitypub/outbox.ts +0 -0
- package/src/backend/routes/activitypub/query-helpers.ts +227 -0
- package/src/backend/routes/activitypub.ts +616 -0
- package/src/backend/routes/actors-helpers.ts +487 -0
- package/src/backend/routes/actors.ts +1311 -0
- package/src/backend/routes/apps.ts +313 -0
- package/src/backend/routes/auth-helpers.ts +566 -0
- package/src/backend/routes/auth.ts +615 -0
- package/src/backend/routes/communities/membership-invites.ts +208 -0
- package/src/backend/routes/communities/membership-join.ts +335 -0
- package/src/backend/routes/communities/membership-members.ts +539 -0
- package/src/backend/routes/communities/membership-requests.ts +296 -0
- package/src/backend/routes/communities/membership-shared.ts +364 -0
- package/src/backend/routes/communities/messages.ts +479 -0
- package/src/backend/routes/communities/routes.ts +624 -0
- package/src/backend/routes/communities.ts +21 -0
- package/src/backend/routes/dm/contacts.ts +525 -0
- package/src/backend/routes/dm/conversations-helpers.ts +197 -0
- package/src/backend/routes/dm/conversations.ts +25 -0
- package/src/backend/routes/dm/messages.ts +658 -0
- package/src/backend/routes/dm/query-helpers.ts +85 -0
- package/src/backend/routes/dm/read-archive.ts +228 -0
- package/src/backend/routes/dm/requests.ts +222 -0
- package/src/backend/routes/dm/typing.ts +81 -0
- package/src/backend/routes/dm.ts +15 -0
- package/src/backend/routes/follow-helpers.ts +370 -0
- package/src/backend/routes/follow.ts +588 -0
- package/src/backend/routes/media.ts +692 -0
- package/src/backend/routes/mobile.ts +159 -0
- package/src/backend/routes/moderation.ts +373 -0
- package/src/backend/routes/notifications.ts +757 -0
- package/src/backend/routes/posts/delete-cascade.ts +330 -0
- package/src/backend/routes/posts/interactions.ts +795 -0
- package/src/backend/routes/posts/post-helpers.ts +847 -0
- package/src/backend/routes/posts/queries.ts +537 -0
- package/src/backend/routes/posts/routes.ts +865 -0
- package/src/backend/routes/posts/transformers.ts +161 -0
- package/src/backend/routes/posts.ts +17 -0
- package/src/backend/routes/recommendations.ts +88 -0
- package/src/backend/routes/search.ts +730 -0
- package/src/backend/routes/stories/interactions.ts +576 -0
- package/src/backend/routes/stories/query-helpers.ts +482 -0
- package/src/backend/routes/stories/routes.ts +906 -0
- package/src/backend/routes/stories.ts +13 -0
- package/src/backend/routes/takos-tools/dm.ts +249 -0
- package/src/backend/routes/takos-tools/follows.ts +225 -0
- package/src/backend/routes/takos-tools/posts.ts +292 -0
- package/src/backend/routes/takos-tools/search.ts +228 -0
- package/src/backend/routes/takos-tools/timeline.ts +132 -0
- package/src/backend/routes/takos-tools/types.ts +10 -0
- package/src/backend/routes/takos-tools-response.ts +178 -0
- package/src/backend/routes/takos-tools.ts +153 -0
- package/src/backend/routes/timeline.ts +755 -0
- package/src/backend/runtime/bun.ts +620 -0
- package/src/backend/runtime/cloudflare.ts +202 -0
- package/src/backend/runtime/compat-bun/types.ts +44 -0
- package/src/backend/runtime/memory-kv.ts +104 -0
- package/src/backend/runtime/shared.ts +142 -0
- package/src/backend/runtime/types.ts +205 -0
- package/src/backend/server.ts +636 -0
- package/src/backend/types.ts +143 -0
- package/src/db/index.ts +97 -0
- package/src/db/schema/actors.ts +129 -0
- package/src/db/schema/communities.ts +133 -0
- package/src/db/schema/date-utils.ts +17 -0
- package/src/db/schema/index.ts +17 -0
- package/src/db/schema/messaging.ts +241 -0
- package/src/db/schema/mobile.ts +37 -0
- package/src/db/schema/posts.ts +150 -0
- package/src/db/schema/relations.ts +266 -0
- package/src/db/schema/reports.ts +33 -0
- package/src/db/schema/social.ts +106 -0
- package/src/db/schema/stories.ts +70 -0
- package/src/db/schema.ts +15 -0
- package/src/plugin/public.ts +7 -0
- 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
|
+
}
|