@takosjp/yurucommu-core 3.4.3 → 3.4.4
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/migrations/0023_delivery_resolution_outbox.sql +33 -0
- package/migrations/0024_delivery_fanout_outbox.sql +35 -0
- package/migrations/0025_delivery_endpoint_terminal_retention.sql +6 -0
- package/migrations/0026_remote_actor_fetch_failures.sql +29 -0
- package/migrations/0027_remote_actor_tombstones.sql +12 -0
- package/migrations/0028_remote_actor_delivery_fence.sql +30 -0
- package/migrations/0029_delivery_endpoint_recipients.sql +47 -0
- package/package.json +2 -1
- package/packages/api/package.json +1 -1
- package/packages/api/src/lib/api/communities.ts +2 -0
- package/packages/api/src/lib/api/fetch.ts +69 -13
- package/packages/api/src/lib/api/normalize.ts +32 -10
- package/packages/api/src/lib/api/notifications.ts +4 -1
- package/packages/api/src/lib/rtc-client.ts +127 -29
- package/src/backend/federation-helpers.ts +1 -0
- package/src/backend/index.ts +16 -3
- package/src/backend/lib/account-migration.ts +340 -0
- package/src/backend/lib/activity-delete-cascade.ts +193 -0
- package/src/backend/lib/activitypub-actor-cache.ts +615 -48
- package/src/backend/lib/activitypub-actor-identity-sql.ts +179 -0
- package/src/backend/lib/activitypub-actor-identity.ts +39 -0
- package/src/backend/lib/activitypub-validators.ts +62 -4
- package/src/backend/lib/ap-ids.ts +36 -6
- package/src/backend/lib/ap-verify.ts +7 -17
- package/src/backend/lib/blocklist-purge.ts +676 -42
- package/src/backend/lib/blocklist.ts +278 -39
- package/src/backend/lib/community-visibility.ts +47 -33
- package/src/backend/lib/delivery/fanout-outbox.ts +336 -0
- package/src/backend/lib/delivery/planner.ts +16 -12
- package/src/backend/lib/delivery/queue-batching.ts +389 -145
- package/src/backend/lib/delivery/queue-delivery.ts +47 -25
- package/src/backend/lib/delivery/queue.ts +479 -73
- package/src/backend/lib/delivery/resolution-outbox.ts +456 -0
- package/src/backend/lib/delivery/types.ts +31 -7
- package/src/backend/lib/feed-exclude.ts +40 -28
- package/src/backend/lib/follow-edge-mutations.ts +217 -0
- package/src/backend/lib/notification-eligibility.ts +15 -9
- package/src/backend/lib/notification-push.ts +2 -2
- package/src/backend/lib/oidc-id-token.ts +66 -0
- package/src/backend/lib/personal-actor-moderation.ts +239 -0
- package/src/backend/lib/post-visibility.ts +79 -16
- package/src/backend/lib/remote-activity-id.ts +61 -0
- package/src/backend/lib/unread-counts.ts +3 -0
- package/src/backend/retention.ts +38 -1
- package/src/backend/routes/account-teardown.ts +422 -156
- package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +104 -105
- package/src/backend/routes/activitypub/handlers/inbound-community-scope.ts +121 -0
- package/src/backend/routes/activitypub/handlers/inbound-object-identity.ts +54 -0
- package/src/backend/routes/activitypub/handlers/inbound-reply-target.ts +34 -0
- package/src/backend/routes/activitypub/handlers/inbound-story-projection.ts +438 -0
- package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1121 -806
- package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +118 -153
- package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +185 -168
- package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +220 -32
- package/src/backend/routes/activitypub/inbound-activity-identity.ts +16 -0
- package/src/backend/routes/activitypub/inbound-activity-reference.ts +116 -0
- package/src/backend/routes/activitypub/inbound-addressing.ts +87 -0
- package/src/backend/routes/activitypub/inbox-addressing.ts +22 -19
- package/src/backend/routes/activitypub/inbox-types.ts +14 -2
- package/src/backend/routes/activitypub/inbox.ts +81 -105
- package/src/backend/routes/activitypub/outbox.ts +0 -0
- package/src/backend/routes/activitypub.ts +6 -5
- package/src/backend/routes/actors-helpers.ts +34 -8
- package/src/backend/routes/actors.ts +328 -164
- package/src/backend/routes/auth-helpers.ts +57 -9
- package/src/backend/routes/auth.ts +10 -2
- package/src/backend/routes/communities/membership-invites.ts +4 -1
- package/src/backend/routes/communities/membership-members.ts +201 -73
- package/src/backend/routes/communities/membership-requests.ts +141 -76
- package/src/backend/routes/communities/membership-shared.ts +228 -21
- package/src/backend/routes/communities/messages.ts +9 -2
- package/src/backend/routes/communities/routes.ts +48 -13
- package/src/backend/routes/dm/contacts.ts +29 -41
- package/src/backend/routes/dm/conversations-helpers.ts +9 -1
- package/src/backend/routes/dm/messages.ts +36 -42
- package/src/backend/routes/dm/read-archive.ts +4 -2
- package/src/backend/routes/dm/requests.ts +62 -54
- package/src/backend/routes/follow-helpers.ts +200 -60
- package/src/backend/routes/follow.ts +146 -140
- package/src/backend/routes/media.ts +20 -94
- package/src/backend/routes/moderation.ts +72 -4
- package/src/backend/routes/notes.ts +3 -4
- package/src/backend/routes/notifications.ts +209 -108
- package/src/backend/routes/posts/delete-cascade.ts +253 -89
- package/src/backend/routes/posts/federation.ts +373 -0
- package/src/backend/routes/posts/interactions.ts +160 -184
- package/src/backend/routes/posts/like-mutation.ts +240 -0
- package/src/backend/routes/posts/post-helpers.ts +112 -162
- package/src/backend/routes/posts/queries.ts +150 -54
- package/src/backend/routes/posts/routes.ts +169 -329
- package/src/backend/routes/posts/transformers.ts +61 -6
- package/src/backend/routes/recommendations.ts +37 -44
- package/src/backend/routes/rtc/index.ts +26 -6
- package/src/backend/routes/search.ts +39 -55
- package/src/backend/routes/stories/interactions.ts +22 -6
- package/src/backend/routes/stories/query-helpers.ts +28 -41
- package/src/backend/routes/stories/routes.ts +125 -108
- package/src/backend/routes/takos-tools/dm.ts +17 -17
- package/src/backend/routes/takos-tools/posts.ts +189 -106
- package/src/backend/routes/takos-tools/search.ts +13 -4
- package/src/backend/routes/takos-tools/timeline.ts +35 -27
- package/src/backend/routes/takos-tools-response.ts +6 -2
- package/src/backend/routes/timeline.ts +5 -5
- package/src/backend/runtime/call-signaling-do.ts +35 -4
- package/src/backend/runtime/one-time-ticket.ts +116 -0
- package/src/backend/runtime/realtime-stream-do.ts +5 -61
- package/src/backend/runtime/signaling-hub.ts +36 -3
- package/src/backend/server.ts +95 -80
- package/src/db/d1-write.ts +67 -43
- package/src/db/schema/federation.ts +42 -1
- package/src/db/schema/messaging.ts +110 -2
- package/src/db/schema.ts +1 -1
|
@@ -7,15 +7,21 @@
|
|
|
7
7
|
* 4xx would cause sender instances to retry on a backoff, wasting their
|
|
8
8
|
* delivery budget and ours.
|
|
9
9
|
*
|
|
10
|
-
* The helpers return `false` when the underlying read fails so
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* The best-effort helpers return `false` when the underlying read fails so UI
|
|
11
|
+
* discovery can degrade without hiding every result. Federation ingress and
|
|
12
|
+
* delivery authority use the strict variants: an unavailable blocklist must
|
|
13
|
+
* make the message retryable, never silently route around an operator block.
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
|
-
import { eq, inArray } from "drizzle-orm";
|
|
16
|
+
import { eq, inArray, sql, type SQL } from "drizzle-orm";
|
|
16
17
|
|
|
17
18
|
import type { Database } from "../../db/index.ts";
|
|
18
|
-
import { blockedActors, blockedDomains } from "../../db/index.ts";
|
|
19
|
+
import { blockedActors, blockedDomains, runBatch } from "../../db/index.ts";
|
|
20
|
+
import { normalizeActivityPubActorId } from "./activitypub-actor-identity.ts";
|
|
21
|
+
import {
|
|
22
|
+
activityPubActorIdentityMatchesSql,
|
|
23
|
+
activityPubActorIdentitySetSql,
|
|
24
|
+
} from "./activitypub-actor-identity-sql.ts";
|
|
19
25
|
import { logger } from "./logger.ts";
|
|
20
26
|
|
|
21
27
|
const log = logger.child({ component: "blocklist" });
|
|
@@ -30,6 +36,140 @@ const log = logger.child({ component: "blocklist" });
|
|
|
30
36
|
// defederation bypass we must not allow).
|
|
31
37
|
const BLOCKLIST_IN_CHUNK = 90;
|
|
32
38
|
|
|
39
|
+
type RawSqlDatabase = {
|
|
40
|
+
all?: (query: SQL) => Promise<unknown[]>;
|
|
41
|
+
get?: (query: SQL) => Promise<unknown>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function retainedBlockedActorIdsSql() {
|
|
45
|
+
return sql`SELECT ${blockedActors.actorApId} FROM ${blockedActors}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function retainedBlockedActorMatchesSql(actorApId: string) {
|
|
49
|
+
return activityPubActorIdentityMatchesSql(
|
|
50
|
+
retainedBlockedActorIdsSql(),
|
|
51
|
+
actorApId,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Return true when a row's actor expression is outside every blocked domain.
|
|
57
|
+
*
|
|
58
|
+
* The staged, materialized parser mirrors `normalizeDomain`: it folds scheme
|
|
59
|
+
* and authority case, removes an explicit port and one DNS root dot, rejects
|
|
60
|
+
* credentials, and leaves path/query bytes outside the host decision. Keeping
|
|
61
|
+
* each intermediate value named avoids the exponentially-expanded scalar SQL
|
|
62
|
+
* that can overflow SQLite's parser stack when embedded in a paginated query.
|
|
63
|
+
*/
|
|
64
|
+
function operatorActorDomainNotBlockedSql(actorApId: SQL): SQL {
|
|
65
|
+
return sql`
|
|
66
|
+
NOT EXISTS (
|
|
67
|
+
WITH
|
|
68
|
+
operator_actor_url(actor_id) AS MATERIALIZED (
|
|
69
|
+
SELECT ${actorApId}
|
|
70
|
+
),
|
|
71
|
+
operator_actor_fragmentless(actor_id) AS MATERIALIZED (
|
|
72
|
+
SELECT CASE WHEN instr(actor_id, '#') > 0
|
|
73
|
+
THEN substr(actor_id, 1, instr(actor_id, '#') - 1)
|
|
74
|
+
ELSE actor_id
|
|
75
|
+
END
|
|
76
|
+
FROM operator_actor_url
|
|
77
|
+
),
|
|
78
|
+
operator_actor_split(separator, scheme, tail) AS MATERIALIZED (
|
|
79
|
+
SELECT
|
|
80
|
+
instr(lower(actor_id), '://'),
|
|
81
|
+
substr(lower(actor_id), 1, instr(lower(actor_id), '://') - 1),
|
|
82
|
+
substr(lower(actor_id), instr(lower(actor_id), '://') + 3)
|
|
83
|
+
FROM operator_actor_fragmentless
|
|
84
|
+
),
|
|
85
|
+
operator_actor_bounds(separator, scheme, tail, boundary) AS MATERIALIZED (
|
|
86
|
+
SELECT
|
|
87
|
+
separator,
|
|
88
|
+
scheme,
|
|
89
|
+
tail,
|
|
90
|
+
CASE
|
|
91
|
+
WHEN instr(tail, '/') = 0 THEN instr(tail, '?')
|
|
92
|
+
WHEN instr(tail, '?') = 0 THEN instr(tail, '/')
|
|
93
|
+
WHEN instr(tail, '/') < instr(tail, '?') THEN instr(tail, '/')
|
|
94
|
+
ELSE instr(tail, '?')
|
|
95
|
+
END
|
|
96
|
+
FROM operator_actor_split
|
|
97
|
+
),
|
|
98
|
+
operator_actor_authority(separator, scheme, authority) AS MATERIALIZED (
|
|
99
|
+
SELECT
|
|
100
|
+
separator,
|
|
101
|
+
scheme,
|
|
102
|
+
CASE WHEN boundary = 0
|
|
103
|
+
THEN tail
|
|
104
|
+
ELSE substr(tail, 1, boundary - 1)
|
|
105
|
+
END
|
|
106
|
+
FROM operator_actor_bounds
|
|
107
|
+
),
|
|
108
|
+
operator_actor_host(separator, scheme, authority, host) AS MATERIALIZED (
|
|
109
|
+
SELECT
|
|
110
|
+
separator,
|
|
111
|
+
scheme,
|
|
112
|
+
authority,
|
|
113
|
+
CASE
|
|
114
|
+
WHEN substr(authority, 1, 1) = '[' AND instr(authority, ']') > 1
|
|
115
|
+
THEN substr(authority, 1, instr(authority, ']'))
|
|
116
|
+
WHEN instr(authority, ':') > 0
|
|
117
|
+
THEN substr(authority, 1, instr(authority, ':') - 1)
|
|
118
|
+
ELSE authority
|
|
119
|
+
END
|
|
120
|
+
FROM operator_actor_authority
|
|
121
|
+
),
|
|
122
|
+
operator_actor_hostname(hostname) AS MATERIALIZED (
|
|
123
|
+
SELECT CASE WHEN substr(host, -1) = '.'
|
|
124
|
+
THEN substr(host, 1, length(host) - 1)
|
|
125
|
+
ELSE host
|
|
126
|
+
END
|
|
127
|
+
FROM operator_actor_host
|
|
128
|
+
WHERE separator > 1
|
|
129
|
+
AND scheme IN ('http', 'https')
|
|
130
|
+
AND length(authority) > 0
|
|
131
|
+
AND instr(authority, '@') = 0
|
|
132
|
+
)
|
|
133
|
+
SELECT 1
|
|
134
|
+
FROM operator_actor_hostname
|
|
135
|
+
INNER JOIN ${blockedDomains}
|
|
136
|
+
ON operator_actor_hostname.hostname = ${blockedDomains.domain}
|
|
137
|
+
OR substr(
|
|
138
|
+
operator_actor_hostname.hostname,
|
|
139
|
+
-length('.' || ${blockedDomains.domain})
|
|
140
|
+
) = '.' || ${blockedDomains.domain}
|
|
141
|
+
)
|
|
142
|
+
`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* SQL read predicate for identities that are visible under the operator
|
|
147
|
+
* blocklist. It covers exact and retained cosmetic actor spellings plus exact
|
|
148
|
+
* host/parent-domain blocks. Callers apply it before pagination; block rows and
|
|
149
|
+
* relationship edges remain stored so an unblock stays reversible.
|
|
150
|
+
*/
|
|
151
|
+
export function operatorActorNotBlockedSql(actorApId: SQL): SQL {
|
|
152
|
+
const retainedActorMatches = activityPubActorIdentityMatchesSql(
|
|
153
|
+
retainedBlockedActorIdsSql(),
|
|
154
|
+
actorApId,
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
return sql`
|
|
158
|
+
NOT EXISTS (${retainedActorMatches})
|
|
159
|
+
AND ${operatorActorDomainNotBlockedSql(actorApId)}
|
|
160
|
+
`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function retainedBlockedActorKeySql(actorApId: string) {
|
|
164
|
+
const matches = retainedBlockedActorMatchesSql(actorApId);
|
|
165
|
+
return sql`
|
|
166
|
+
SELECT actor_id
|
|
167
|
+
FROM (${matches}) AS retained_blocked_actor_matches
|
|
168
|
+
ORDER BY actor_id
|
|
169
|
+
LIMIT 1
|
|
170
|
+
`;
|
|
171
|
+
}
|
|
172
|
+
|
|
33
173
|
/**
|
|
34
174
|
* Normalise an actor AP-ID hostname for blocklist lookups: lowercase and
|
|
35
175
|
* strip any trailing dot (DNS root form). Returns `null` when the input
|
|
@@ -73,25 +213,37 @@ export async function isDomainBlocked(
|
|
|
73
213
|
db: Database,
|
|
74
214
|
hostnameOrUrl: string,
|
|
75
215
|
): Promise<boolean> {
|
|
76
|
-
const domain = normalizeDomain(hostnameOrUrl);
|
|
77
|
-
if (!domain) return false;
|
|
78
|
-
|
|
79
216
|
try {
|
|
80
|
-
|
|
81
|
-
where: inArray(blockedDomains.domain, domainSuffixCandidates(domain)),
|
|
82
|
-
columns: { domain: true },
|
|
83
|
-
});
|
|
84
|
-
return !!row;
|
|
217
|
+
return await isDomainBlockedStrict(db, hostnameOrUrl);
|
|
85
218
|
} catch (err) {
|
|
219
|
+
const domain = normalizeDomain(hostnameOrUrl);
|
|
86
220
|
log.warn("blocklist.isDomainBlocked failed", {
|
|
87
221
|
event: "blocklist.domain_lookup_failed",
|
|
88
|
-
domain,
|
|
222
|
+
domain: domain ?? hostnameOrUrl,
|
|
89
223
|
error: err,
|
|
90
224
|
});
|
|
91
225
|
return false;
|
|
92
226
|
}
|
|
93
227
|
}
|
|
94
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Authority-bearing domain lookup. Unlike {@link isDomainBlocked}, a database
|
|
231
|
+
* read failure is propagated so the caller can retry instead of routing around
|
|
232
|
+
* an operator block whose state could not be read.
|
|
233
|
+
*/
|
|
234
|
+
export async function isDomainBlockedStrict(
|
|
235
|
+
db: Database,
|
|
236
|
+
hostnameOrUrl: string,
|
|
237
|
+
): Promise<boolean> {
|
|
238
|
+
const domain = normalizeDomain(hostnameOrUrl);
|
|
239
|
+
if (!domain) return false;
|
|
240
|
+
const row = await db.query.blockedDomains.findFirst({
|
|
241
|
+
where: inArray(blockedDomains.domain, domainSuffixCandidates(domain)),
|
|
242
|
+
columns: { domain: true },
|
|
243
|
+
});
|
|
244
|
+
return !!row;
|
|
245
|
+
}
|
|
246
|
+
|
|
95
247
|
/**
|
|
96
248
|
* Returns `true` when the operator has blocked the given actor AP-ID, or
|
|
97
249
|
* (transitively) when the actor's hostname is blocked.
|
|
@@ -100,16 +252,8 @@ export async function isActorBlocked(
|
|
|
100
252
|
db: Database,
|
|
101
253
|
actorApId: string,
|
|
102
254
|
): Promise<boolean> {
|
|
103
|
-
if (typeof actorApId !== "string" || actorApId.length === 0) {
|
|
104
|
-
return false;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
255
|
try {
|
|
108
|
-
|
|
109
|
-
where: eq(blockedActors.actorApId, actorApId),
|
|
110
|
-
columns: { actorApId: true },
|
|
111
|
-
});
|
|
112
|
-
if (row) return true;
|
|
256
|
+
return await isActorBlockedStrict(db, actorApId);
|
|
113
257
|
} catch (err) {
|
|
114
258
|
log.warn("blocklist.isActorBlocked failed", {
|
|
115
259
|
event: "blocklist.actor_lookup_failed",
|
|
@@ -118,23 +262,56 @@ export async function isActorBlocked(
|
|
|
118
262
|
});
|
|
119
263
|
return false;
|
|
120
264
|
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Authority-bearing actor/domain lookup. Database failures propagate so
|
|
269
|
+
* ingress or delivery work stays retryable and no possibly-blocked peer is
|
|
270
|
+
* admitted merely because moderation authority was temporarily unavailable.
|
|
271
|
+
*/
|
|
272
|
+
export async function isActorBlockedStrict(
|
|
273
|
+
db: Database,
|
|
274
|
+
actorApId: string,
|
|
275
|
+
): Promise<boolean> {
|
|
276
|
+
if (typeof actorApId !== "string" || actorApId.length === 0) {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const row = await db.query.blockedActors.findFirst({
|
|
281
|
+
where: eq(blockedActors.actorApId, actorApId),
|
|
282
|
+
columns: { actorApId: true },
|
|
283
|
+
});
|
|
284
|
+
if (row) return true;
|
|
285
|
+
|
|
286
|
+
// Operator rows written before the verified-key-owner invariant may carry
|
|
287
|
+
// an accepted cosmetic spelling. Match the complete retained set in SQL:
|
|
288
|
+
// a fixed prefix silently turned an older block into fail-open ingress.
|
|
289
|
+
const rawDb = db as unknown as RawSqlDatabase;
|
|
290
|
+
if (typeof rawDb.get === "function") {
|
|
291
|
+
const matches = retainedBlockedActorMatchesSql(actorApId);
|
|
292
|
+
const retained = (await rawDb.get(sql`
|
|
293
|
+
SELECT (${matches}) AS actor_id
|
|
294
|
+
`)) as { actor_id?: string | null } | undefined;
|
|
295
|
+
if (retained?.actor_id) return true;
|
|
296
|
+
}
|
|
121
297
|
|
|
122
298
|
const domain = normalizeDomain(actorApId);
|
|
123
299
|
if (!domain) return false;
|
|
124
|
-
return await
|
|
300
|
+
return await isDomainBlockedStrict(db, domain);
|
|
125
301
|
}
|
|
126
302
|
|
|
127
303
|
/**
|
|
128
304
|
* Batched blocklist filter for a list of recipient actor AP-IDs: returns the
|
|
129
305
|
* SUBSET that is blocked (by actor OR transitively by hostname) using exactly
|
|
130
306
|
* two queries (blocked_actors + blocked_domains) instead of two-per-recipient.
|
|
131
|
-
* Replaces an O(recipients) serial `isActorBlocked` loop
|
|
132
|
-
*
|
|
133
|
-
*
|
|
307
|
+
* Replaces an O(recipients) serial `isActorBlocked` loop. The default remains
|
|
308
|
+
* best-effort for UI discovery; delivery authority uses the strict wrapper
|
|
309
|
+
* below so an unreadable blocklist cannot become a defederation bypass.
|
|
134
310
|
*/
|
|
135
311
|
export async function filterBlockedActorApIds(
|
|
136
312
|
db: Database,
|
|
137
313
|
actorApIds: string[],
|
|
314
|
+
options: { readonly strict?: boolean } = {},
|
|
138
315
|
): Promise<Set<string>> {
|
|
139
316
|
const blocked = new Set<string>();
|
|
140
317
|
const uniqueIds = [...new Set(actorApIds.filter((id) => id.length > 0))];
|
|
@@ -143,9 +320,8 @@ export async function filterBlockedActorApIds(
|
|
|
143
320
|
try {
|
|
144
321
|
// Chunk the IN(...) lookups so a very large recipient set (e.g. a big
|
|
145
322
|
// community fan-out, tens of thousands of unique actors/domains) cannot
|
|
146
|
-
// exceed SQLite's bound-parameter ceiling
|
|
147
|
-
//
|
|
148
|
-
// that whole fan-out (a defederation bypass).
|
|
323
|
+
// exceed SQLite's bound-parameter ceiling. Strict delivery callers retry
|
|
324
|
+
// such failures; best-effort discovery callers degrade to an empty set.
|
|
149
325
|
const blockedActorSet = new Set<string>();
|
|
150
326
|
for (let i = 0; i < uniqueIds.length; i += BLOCKLIST_IN_CHUNK) {
|
|
151
327
|
const rows = await db
|
|
@@ -159,6 +335,43 @@ export async function filterBlockedActorApIds(
|
|
|
159
335
|
);
|
|
160
336
|
for (const r of rows) blockedActorSet.add(r.actorApId);
|
|
161
337
|
}
|
|
338
|
+
// Resolve legacy cosmetic spellings only against the requested identities.
|
|
339
|
+
// Each chunk stays below D1's parameter ceiling; the retained operator set
|
|
340
|
+
// is complete, and path/query bytes remain case-sensitive in the shared SQL
|
|
341
|
+
// identity expansion.
|
|
342
|
+
const retainedActorIdentities = new Set<string>();
|
|
343
|
+
const requestedIdentities = [
|
|
344
|
+
...new Set(
|
|
345
|
+
uniqueIds
|
|
346
|
+
.map((id) => normalizeActivityPubActorId(id))
|
|
347
|
+
.filter((identity): identity is string => identity !== null),
|
|
348
|
+
),
|
|
349
|
+
];
|
|
350
|
+
const rawDb = db as unknown as RawSqlDatabase;
|
|
351
|
+
if (typeof rawDb.all === "function") {
|
|
352
|
+
for (let i = 0; i < requestedIdentities.length; i += BLOCKLIST_IN_CHUNK) {
|
|
353
|
+
const requestedChunk = requestedIdentities.slice(
|
|
354
|
+
i,
|
|
355
|
+
i + BLOCKLIST_IN_CHUNK,
|
|
356
|
+
);
|
|
357
|
+
const retainedIdentitySet = activityPubActorIdentitySetSql(
|
|
358
|
+
retainedBlockedActorIdsSql(),
|
|
359
|
+
);
|
|
360
|
+
const rows = (await rawDb.all(sql`
|
|
361
|
+
SELECT actor_id
|
|
362
|
+
FROM (${retainedIdentitySet}) AS retained_actor_identities
|
|
363
|
+
WHERE actor_id IN (${sql.join(
|
|
364
|
+
requestedChunk.map((identity) => sql`${identity}`),
|
|
365
|
+
sql`, `,
|
|
366
|
+
)})
|
|
367
|
+
`)) as Array<{ actor_id?: string }>;
|
|
368
|
+
for (const row of rows) {
|
|
369
|
+
if (typeof row.actor_id === "string") {
|
|
370
|
+
retainedActorIdentities.add(row.actor_id);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
162
375
|
|
|
163
376
|
// Expand each actor's hostname to its parent-domain candidates so a domain
|
|
164
377
|
// block covers subdomains (see domainSuffixCandidates) — querying the union
|
|
@@ -190,8 +403,11 @@ export async function filterBlockedActorApIds(
|
|
|
190
403
|
|
|
191
404
|
for (const id of uniqueIds) {
|
|
192
405
|
const candidates = idToCandidates.get(id) ?? [];
|
|
406
|
+
const normalizedActorId = normalizeActivityPubActorId(id);
|
|
193
407
|
if (
|
|
194
408
|
blockedActorSet.has(id) ||
|
|
409
|
+
(normalizedActorId !== null &&
|
|
410
|
+
retainedActorIdentities.has(normalizedActorId)) ||
|
|
195
411
|
candidates.some((c) => blockedDomainSet.has(c))
|
|
196
412
|
) {
|
|
197
413
|
blocked.add(id);
|
|
@@ -202,11 +418,20 @@ export async function filterBlockedActorApIds(
|
|
|
202
418
|
event: "blocklist.batch_lookup_failed",
|
|
203
419
|
error: err,
|
|
204
420
|
});
|
|
205
|
-
|
|
421
|
+
if (options.strict) throw err;
|
|
422
|
+
return new Set();
|
|
206
423
|
}
|
|
207
424
|
return blocked;
|
|
208
425
|
}
|
|
209
426
|
|
|
427
|
+
/** Strict batch lookup for ingress/delivery authority. */
|
|
428
|
+
export async function filterBlockedActorApIdsStrict(
|
|
429
|
+
db: Database,
|
|
430
|
+
actorApIds: string[],
|
|
431
|
+
): Promise<Set<string>> {
|
|
432
|
+
return await filterBlockedActorApIds(db, actorApIds, { strict: true });
|
|
433
|
+
}
|
|
434
|
+
|
|
210
435
|
/**
|
|
211
436
|
* Insert (or update) a domain blocklist entry. Idempotent: re-blocking the
|
|
212
437
|
* same domain refreshes the recorded reason but keeps the original
|
|
@@ -258,13 +483,24 @@ export async function blockActor(
|
|
|
258
483
|
if (typeof actorApId !== "string" || actorApId.length === 0) {
|
|
259
484
|
throw new Error("blocklist.blockActor: actorApId is required");
|
|
260
485
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
486
|
+
const retainedKey = sql`COALESCE((${retainedBlockedActorKeySql(actorApId)}), ${actorApId})`;
|
|
487
|
+
const equivalentRows = retainedBlockedActorMatchesSql(actorApId);
|
|
488
|
+
await runBatch(db, [
|
|
489
|
+
db
|
|
490
|
+
.insert(blockedActors)
|
|
491
|
+
.values({ actorApId: retainedKey, reason })
|
|
492
|
+
.onConflictDoUpdate({
|
|
493
|
+
target: blockedActors.actorApId,
|
|
494
|
+
set: { reason },
|
|
495
|
+
}),
|
|
496
|
+
db.delete(blockedActors).where(sql`
|
|
497
|
+
${blockedActors.actorApId} IN (${equivalentRows})
|
|
498
|
+
AND ${blockedActors.actorApId} <> COALESCE(
|
|
499
|
+
(${retainedBlockedActorKeySql(actorApId)}),
|
|
500
|
+
${actorApId}
|
|
501
|
+
)
|
|
502
|
+
`),
|
|
503
|
+
]);
|
|
268
504
|
}
|
|
269
505
|
|
|
270
506
|
/**
|
|
@@ -275,5 +511,8 @@ export async function unblockActor(
|
|
|
275
511
|
actorApId: string,
|
|
276
512
|
): Promise<void> {
|
|
277
513
|
if (typeof actorApId !== "string" || actorApId.length === 0) return;
|
|
278
|
-
|
|
514
|
+
const equivalentRows = retainedBlockedActorMatchesSql(actorApId);
|
|
515
|
+
await db
|
|
516
|
+
.delete(blockedActors)
|
|
517
|
+
.where(sql`${blockedActors.actorApId} IN (${equivalentRows})`);
|
|
279
518
|
}
|
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
* what keeps them out of the public / home / following feeds (which filter on
|
|
8
8
|
* `audienceJson = "[]"`).
|
|
9
9
|
*
|
|
10
|
-
* For a PRIVATE community this is not enough: a single-object
|
|
11
|
-
* a reply, or an `/ap/objects/:id`) bypasses the audience
|
|
12
|
-
* "public"-visibility community post would leak to any
|
|
13
|
-
* centralizes the membership gate those
|
|
14
|
-
* the normal visibility check.
|
|
10
|
+
* For a PRIVATE or soft-deleted community this is not enough: a single-object
|
|
11
|
+
* fetch (GET a post, a reply, or an `/ap/objects/:id`) bypasses the audience
|
|
12
|
+
* filter entirely, so a "public"-visibility community post would leak to any
|
|
13
|
+
* caller. This module centralizes the lifecycle + membership gate those
|
|
14
|
+
* single-object paths must apply on top of the normal visibility check.
|
|
15
15
|
*
|
|
16
16
|
* Membership model: `community_members` has no status column — the presence of a
|
|
17
17
|
* row IS the acceptance (this mirrors `resolveCommunityRead` in routes/timeline.ts).
|
|
@@ -96,10 +96,11 @@ function communityApIdsFor(obj: CommunityGateObject): string[] {
|
|
|
96
96
|
/**
|
|
97
97
|
* Single-object community read-gate.
|
|
98
98
|
*
|
|
99
|
-
* If `obj` is addressed to a community
|
|
99
|
+
* If `obj` is addressed to a soft-deleted community, this always returns false.
|
|
100
|
+
* If it is addressed to a live community whose `visibility` is "private", this
|
|
100
101
|
* returns `true` only when `viewerApId` is an accepted member (a row in
|
|
101
|
-
* `community_members`) of that community; otherwise it returns `true` and
|
|
102
|
-
* the normal public/followers/direct visibility check to the caller.
|
|
102
|
+
* `community_members`) of that community; otherwise it returns `true` and
|
|
103
|
+
* leaves the normal public/followers/direct visibility check to the caller.
|
|
103
104
|
*
|
|
104
105
|
* An anonymous viewer (`viewerApId` null/undefined) against a private community
|
|
105
106
|
* always returns `false`. This NEVER widens access: a non-community or
|
|
@@ -114,17 +115,23 @@ export async function canViewerReadObject(
|
|
|
114
115
|
const communityIds = communityApIdsFor(obj);
|
|
115
116
|
if (communityIds.length === 0) return true;
|
|
116
117
|
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
118
|
+
// Resolve local lifecycle + visibility in one query. Unknown IDs are left to
|
|
119
|
+
// the caller's ordinary visibility rules (they may name a remote audience),
|
|
120
|
+
// while any known soft-deleted local community fails closed.
|
|
121
|
+
const communityRows = await db
|
|
122
|
+
.select({
|
|
123
|
+
apId: communities.apId,
|
|
124
|
+
visibility: communities.visibility,
|
|
125
|
+
deletedAt: communities.deletedAt,
|
|
126
|
+
})
|
|
121
127
|
.from(communities)
|
|
122
|
-
.where(
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
)
|
|
128
|
+
.where(inArray(communities.apId, communityIds));
|
|
129
|
+
|
|
130
|
+
if (communityRows.some((row) => row.deletedAt !== null)) return false;
|
|
131
|
+
|
|
132
|
+
const privateRows = communityRows.filter((row) =>
|
|
133
|
+
communityRequiresMembership(row.visibility),
|
|
134
|
+
);
|
|
128
135
|
|
|
129
136
|
if (privateRows.length === 0) return true;
|
|
130
137
|
|
|
@@ -154,9 +161,10 @@ export async function canViewerReadObject(
|
|
|
154
161
|
* Returns the set of `apId`s that PASS the community read-gate, in TWO queries
|
|
155
162
|
* total (private-community lookup + viewer-membership lookup) instead of the
|
|
156
163
|
* 1-2 queries PER object the per-row form costs. The per-object semantics are
|
|
157
|
-
* identical: an object
|
|
158
|
-
*
|
|
159
|
-
*
|
|
164
|
+
* identical: an object fails when it is addressed to a deleted community, and
|
|
165
|
+
* otherwise passes unless it is addressed to a private community the viewer is
|
|
166
|
+
* not a member of (an anonymous viewer never satisfies a private community).
|
|
167
|
+
* Objects with no community audience always pass.
|
|
160
168
|
*
|
|
161
169
|
* Both IN(...) lookups are chunked to stay under D1's 100-bound-parameter cap,
|
|
162
170
|
* since a page can address up to ~90 distinct communities.
|
|
@@ -183,19 +191,24 @@ export async function communityReadableApIds<
|
|
|
183
191
|
return readable;
|
|
184
192
|
}
|
|
185
193
|
|
|
186
|
-
//
|
|
194
|
+
// Resolve local lifecycle + visibility. Unknown IDs may be remote audiences
|
|
195
|
+
// and remain governed by the caller's ordinary visibility rules.
|
|
187
196
|
const privateSet = new Set<string>();
|
|
197
|
+
const deletedSet = new Set<string>();
|
|
188
198
|
for (const chunk of chunkForInClause([...unionIds])) {
|
|
189
199
|
const rows = await db
|
|
190
|
-
.select({
|
|
200
|
+
.select({
|
|
201
|
+
apId: communities.apId,
|
|
202
|
+
visibility: communities.visibility,
|
|
203
|
+
deletedAt: communities.deletedAt,
|
|
204
|
+
})
|
|
191
205
|
.from(communities)
|
|
192
|
-
.where(
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
)
|
|
197
|
-
|
|
198
|
-
for (const r of rows) privateSet.add(r.apId);
|
|
206
|
+
.where(inArray(communities.apId, chunk));
|
|
207
|
+
for (const row of rows) {
|
|
208
|
+
if (row.deletedAt !== null) deletedSet.add(row.apId);
|
|
209
|
+
else if (communityRequiresMembership(row.visibility))
|
|
210
|
+
privateSet.add(row.apId);
|
|
211
|
+
}
|
|
199
212
|
}
|
|
200
213
|
|
|
201
214
|
// Which of those private communities is the viewer a member of?
|
|
@@ -216,9 +229,10 @@ export async function communityReadableApIds<
|
|
|
216
229
|
}
|
|
217
230
|
|
|
218
231
|
for (const o of objs) {
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
232
|
+
const communityIds = perObjectCommunityIds.get(o.apId) ?? [];
|
|
233
|
+
if (communityIds.some((id) => deletedSet.has(id))) continue;
|
|
234
|
+
|
|
235
|
+
const privateForObj = communityIds.filter((id) => privateSet.has(id));
|
|
222
236
|
if (privateForObj.length === 0) {
|
|
223
237
|
readable.add(o.apId); // not addressed to any private community
|
|
224
238
|
} else if (privateForObj.some((id) => viewerMemberSet.has(id))) {
|