@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
|
@@ -2,7 +2,6 @@ import { and, eq, isNull, or, sql } from "drizzle-orm";
|
|
|
2
2
|
import {
|
|
3
3
|
activities,
|
|
4
4
|
communities,
|
|
5
|
-
communityMembers,
|
|
6
5
|
follows,
|
|
7
6
|
objectRecipients,
|
|
8
7
|
objects,
|
|
@@ -10,19 +9,26 @@ import {
|
|
|
10
9
|
import {
|
|
11
10
|
activityApId,
|
|
12
11
|
generateId,
|
|
13
|
-
getDomain,
|
|
14
12
|
isLocal,
|
|
15
|
-
objectApId,
|
|
16
13
|
} from "../../../federation-helpers.ts";
|
|
17
14
|
import { enqueueDeliveryToActor } from "../../../lib/delivery/queue.ts";
|
|
18
|
-
import {
|
|
15
|
+
import { isSameActivityPubActor } from "../../../lib/activitypub-actor-identity.ts";
|
|
16
|
+
import { severFollowDirection } from "../../../lib/follow-edge-mutations.ts";
|
|
19
17
|
import { isMemberBanned } from "../../communities/membership-shared.ts";
|
|
20
18
|
import {
|
|
21
|
-
boundAttachmentsJson,
|
|
22
19
|
boundInboundContent,
|
|
20
|
+
boundInboundNoteAttachmentsJson,
|
|
23
21
|
boundInboundSummary,
|
|
22
|
+
boundInboundTagsJson,
|
|
24
23
|
} from "../../posts/transformers.ts";
|
|
25
24
|
import { normalizeInboundTimestamp } from "./inbound-timestamp.ts";
|
|
25
|
+
import { canActorPostToInboundCommunity } from "./inbound-community-scope.ts";
|
|
26
|
+
import { validateInboundObjectIdentity } from "./inbound-object-identity.ts";
|
|
27
|
+
import {
|
|
28
|
+
findFollowByActivityId,
|
|
29
|
+
findFollowByFollowerIdentity,
|
|
30
|
+
runBatch,
|
|
31
|
+
} from "./inbox-shared-helpers.ts";
|
|
26
32
|
import type { InstanceActorResult } from "../query-helpers.ts";
|
|
27
33
|
import {
|
|
28
34
|
type Activity,
|
|
@@ -67,12 +73,11 @@ export async function handleGroupFollow(
|
|
|
67
73
|
followingApId: group.apId,
|
|
68
74
|
};
|
|
69
75
|
|
|
70
|
-
const existing = await
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
});
|
|
76
|
+
const existing = await findFollowByFollowerIdentity(
|
|
77
|
+
db,
|
|
78
|
+
followerKey.followerApId,
|
|
79
|
+
followerKey.followingApId,
|
|
80
|
+
);
|
|
76
81
|
|
|
77
82
|
// A banned actor re-Following an OPEN community must NOT auto-rejoin: force a
|
|
78
83
|
// Reject and record no edge (durable kick — mirrors the local open-join ban
|
|
@@ -173,30 +178,24 @@ export async function handleGroupUndo(
|
|
|
173
178
|
const objectId = getActivityObjectId(activity);
|
|
174
179
|
if (!objectId) return;
|
|
175
180
|
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
181
|
+
// Resolve either the retained local ledger IRI or the peer's wire id within
|
|
182
|
+
// the verified signer. This also makes a bare-string Undo(Follow) work for a
|
|
183
|
+
// Group instead of requiring an embedded typed Follow object.
|
|
184
|
+
const follow = await findFollowByActivityId(db, objectId, {
|
|
185
|
+
actorApId: actorApIdStr,
|
|
186
|
+
localBaseUrl: c.env.APP_URL,
|
|
182
187
|
});
|
|
183
188
|
|
|
184
189
|
if (follow) {
|
|
190
|
+
if (follow.followingApId !== group.apId) return;
|
|
185
191
|
// Bind to the VERIFIED signer: only the follow's own follower may undo it.
|
|
186
192
|
// The activity id is public (it appears in the follower's outbox), so
|
|
187
193
|
// without this check a remote attacker who signs an Undo as any actor on
|
|
188
194
|
// their own domain could resolve a VICTIM's follow by that id and sever the
|
|
189
195
|
// victim's follow/membership edge — the same cross-actor forgery already
|
|
190
196
|
// guarded in the user-inbox undoFollow handler.
|
|
191
|
-
if (follow.followerApId
|
|
192
|
-
await db
|
|
193
|
-
.delete(follows)
|
|
194
|
-
.where(
|
|
195
|
-
and(
|
|
196
|
-
eq(follows.followerApId, follow.followerApId),
|
|
197
|
-
eq(follows.followingApId, follow.followingApId),
|
|
198
|
-
),
|
|
199
|
-
);
|
|
197
|
+
if (!isSameActivityPubActor(follow.followerApId, actorApIdStr)) return;
|
|
198
|
+
await severFollowDirection(db, follow.followerApId, follow.followingApId);
|
|
200
199
|
return;
|
|
201
200
|
}
|
|
202
201
|
|
|
@@ -204,14 +203,17 @@ export async function handleGroupUndo(
|
|
|
204
203
|
// the verified signer, so it can only remove the signer's OWN follow.
|
|
205
204
|
if (getActivityObject(activity)?.type !== "Follow") return;
|
|
206
205
|
|
|
207
|
-
await
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
206
|
+
const retainedFollow = await findFollowByFollowerIdentity(
|
|
207
|
+
db,
|
|
208
|
+
actorApIdStr,
|
|
209
|
+
group.apId,
|
|
210
|
+
);
|
|
211
|
+
if (!retainedFollow) return;
|
|
212
|
+
await severFollowDirection(
|
|
213
|
+
db,
|
|
214
|
+
retainedFollow.followerApId,
|
|
215
|
+
retainedFollow.followingApId,
|
|
216
|
+
);
|
|
215
217
|
}
|
|
216
218
|
|
|
217
219
|
export async function handleGroupCreate(
|
|
@@ -225,18 +227,12 @@ export async function handleGroupCreate(
|
|
|
225
227
|
const object = getActivityObject(activity);
|
|
226
228
|
if (!object || !typeIncludes(object.type, "Note")) return;
|
|
227
229
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
} catch {
|
|
237
|
-
return;
|
|
238
|
-
}
|
|
239
|
-
}
|
|
230
|
+
const identity = validateInboundObjectIdentity(
|
|
231
|
+
object.id,
|
|
232
|
+
actorApIdStr,
|
|
233
|
+
baseUrl,
|
|
234
|
+
);
|
|
235
|
+
if (!identity.ok) return;
|
|
240
236
|
|
|
241
237
|
const roomUrl = object.room || activity.room;
|
|
242
238
|
if (!roomUrl || typeof roomUrl !== "string") return;
|
|
@@ -273,40 +269,13 @@ export async function handleGroupCreate(
|
|
|
273
269
|
.get();
|
|
274
270
|
if (!community) return;
|
|
275
271
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
eq(follows.followingApId, community.apId),
|
|
280
|
-
eq(follows.status, "accepted"),
|
|
281
|
-
),
|
|
282
|
-
});
|
|
283
|
-
const memberRow = await db.query.communityMembers.findFirst({
|
|
284
|
-
where: and(
|
|
285
|
-
eq(communityMembers.communityApId, community.apId),
|
|
286
|
-
eq(communityMembers.actorApId, actorApIdStr),
|
|
287
|
-
),
|
|
288
|
-
columns: { role: true },
|
|
289
|
-
});
|
|
290
|
-
const isMember = Boolean(memberFollow) || Boolean(memberRow);
|
|
291
|
-
const role = memberRow?.role;
|
|
292
|
-
const isManager = role === "owner" || role === "moderator";
|
|
293
|
-
const policy = community.postPolicy || "members";
|
|
294
|
-
|
|
295
|
-
// A non-public community requires membership to post regardless of policy.
|
|
296
|
-
if (communityRequiresMembership(community.visibility) && !isMember) return;
|
|
297
|
-
if (policy !== "anyone" && !isMember) return;
|
|
298
|
-
if (policy === "mods" && !isManager) return;
|
|
299
|
-
if (policy === "owners" && role !== "owner") return;
|
|
272
|
+
if (!(await canActorPostToInboundCommunity(db, actorApIdStr, community))) {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
300
275
|
|
|
301
|
-
const newObjectId =
|
|
302
|
-
const
|
|
303
|
-
where: eq(objects.apId, newObjectId),
|
|
304
|
-
});
|
|
305
|
-
if (existingObj) return;
|
|
276
|
+
const newObjectId = identity.objectId;
|
|
277
|
+
const audienceJson = JSON.stringify([community.apId]);
|
|
306
278
|
|
|
307
|
-
const attachments = object.attachment
|
|
308
|
-
? JSON.stringify(object.attachment)
|
|
309
|
-
: "[]";
|
|
310
279
|
// Clamp + normalize the remote-controlled `published` exactly like the other
|
|
311
280
|
// inbound Note paths (handleCreate / insertDirectNote / handleCreateStory). The
|
|
312
281
|
// community chat reader sorts + keyset-paginates on `desc(objects.published)`
|
|
@@ -325,31 +294,61 @@ export async function handleGroupCreate(
|
|
|
325
294
|
// from the feed object-set (which carries `communityApId`). Leave
|
|
326
295
|
// `communityApId` NULL here so the message surfaces in the group chat and
|
|
327
296
|
// unread count rather than being misrouted into the community feed.
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
297
|
+
// The object and its audience membership are one logical message. Commit
|
|
298
|
+
// them atomically so an isolate failure cannot leave an invisible object;
|
|
299
|
+
// both statements are idempotent so a retry also repairs partial rows left by
|
|
300
|
+
// older deployments. Migration 0010 deliberately removed the incorrect
|
|
301
|
+
// object_recipients -> actors FK, so a community ap_id is valid here.
|
|
302
|
+
await runBatch(db, [
|
|
303
|
+
db
|
|
304
|
+
.insert(objects)
|
|
305
|
+
.values({
|
|
306
|
+
apId: newObjectId,
|
|
307
|
+
type: "Note",
|
|
308
|
+
attributedTo: actorApIdStr,
|
|
309
|
+
content: boundInboundContent(object.content),
|
|
310
|
+
summary: boundInboundSummary(object.summary),
|
|
311
|
+
attachmentsJson: boundInboundNoteAttachmentsJson(object.attachment),
|
|
312
|
+
tagsJson: boundInboundTagsJson(object.tag),
|
|
313
|
+
visibility: "group",
|
|
314
|
+
// Record the community in audienceJson too (not just the
|
|
315
|
+
// object_recipients row): the canonical single-object read-gate
|
|
316
|
+
// (canViewerReadObject) keys on audienceJson/communityApId and does NOT
|
|
317
|
+
// consult object_recipients, so without this a federated chat message in
|
|
318
|
+
// a PRIVATE community was served to ANY caller via GET /api/posts/:id.
|
|
319
|
+
audienceJson,
|
|
320
|
+
communityApId: null,
|
|
321
|
+
published: now,
|
|
322
|
+
isLocal: 0,
|
|
323
|
+
})
|
|
324
|
+
.onConflictDoNothing(),
|
|
325
|
+
db
|
|
326
|
+
.insert(objectRecipients)
|
|
327
|
+
// Select from the object that ACTUALLY exists after the preceding insert,
|
|
328
|
+
// rather than trusting the attempted values. This closes the concurrent
|
|
329
|
+
// id-reuse race: if another community won the object-id insert, its
|
|
330
|
+
// different audience shape cannot be linked into this room.
|
|
331
|
+
.select(
|
|
332
|
+
db
|
|
333
|
+
.select({
|
|
334
|
+
objectApId: objects.apId,
|
|
335
|
+
recipientApId: sql<string>`${community.apId}`.as("recipient_ap_id"),
|
|
336
|
+
type: sql<string>`'audience'`.as("type"),
|
|
337
|
+
createdAt: sql<string>`${now}`.as("created_at"),
|
|
338
|
+
})
|
|
339
|
+
.from(objects)
|
|
340
|
+
.where(
|
|
341
|
+
and(
|
|
342
|
+
eq(objects.apId, newObjectId),
|
|
343
|
+
eq(objects.type, "Note"),
|
|
344
|
+
eq(objects.attributedTo, actorApIdStr),
|
|
345
|
+
eq(objects.visibility, "group"),
|
|
346
|
+
eq(objects.audienceJson, audienceJson),
|
|
347
|
+
isNull(objects.communityApId),
|
|
348
|
+
eq(objects.isLocal, 0),
|
|
349
|
+
),
|
|
350
|
+
),
|
|
351
|
+
)
|
|
352
|
+
.onConflictDoNothing(),
|
|
353
|
+
]);
|
|
355
354
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { and, eq, inArray, isNull } from "drizzle-orm";
|
|
2
|
+
|
|
3
|
+
import type { Database } from "../../../../db/index.ts";
|
|
4
|
+
import { communities, communityMembers } from "../../../../db/index.ts";
|
|
5
|
+
import { communityRequiresMembership } from "../../../lib/community-visibility.ts";
|
|
6
|
+
import { isMemberBanned } from "../../communities/membership-shared.ts";
|
|
7
|
+
import { findFollowByFollowerIdentity } from "./inbox-shared-helpers.ts";
|
|
8
|
+
|
|
9
|
+
export type InboundCommunityTarget = {
|
|
10
|
+
apId: string;
|
|
11
|
+
visibility: string | null;
|
|
12
|
+
postPolicy: string | null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type InboundCommunityScope =
|
|
16
|
+
| { allowed: true; communityApId: string | null }
|
|
17
|
+
| { allowed: false; communityApId: null };
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Apply the same posting authority to every inbound community projection.
|
|
21
|
+
*
|
|
22
|
+
* Remote membership is an accepted Follow of the community Group; local
|
|
23
|
+
* membership carries a role in community_members. A durable ban wins before
|
|
24
|
+
* either representation. Keeping this decision in one helper prevents the
|
|
25
|
+
* Group inbox and user-inbox Note/Story paths from drifting into different
|
|
26
|
+
* definitions of who may inject content into a community feed.
|
|
27
|
+
*/
|
|
28
|
+
export async function canActorPostToInboundCommunity(
|
|
29
|
+
db: Database,
|
|
30
|
+
actorApId: string,
|
|
31
|
+
community: InboundCommunityTarget,
|
|
32
|
+
): Promise<boolean> {
|
|
33
|
+
const [banned, memberFollow, memberRow] = await Promise.all([
|
|
34
|
+
isMemberBanned(db, community.apId, actorApId),
|
|
35
|
+
findFollowByFollowerIdentity(db, actorApId, community.apId),
|
|
36
|
+
db.query.communityMembers.findFirst({
|
|
37
|
+
where: and(
|
|
38
|
+
eq(communityMembers.communityApId, community.apId),
|
|
39
|
+
eq(communityMembers.actorApId, actorApId),
|
|
40
|
+
),
|
|
41
|
+
columns: { role: true },
|
|
42
|
+
}),
|
|
43
|
+
]);
|
|
44
|
+
if (banned) return false;
|
|
45
|
+
|
|
46
|
+
const isMember = memberFollow?.status === "accepted" || Boolean(memberRow);
|
|
47
|
+
const role = memberRow?.role;
|
|
48
|
+
const isManager = role === "owner" || role === "moderator";
|
|
49
|
+
const policy = community.postPolicy || "members";
|
|
50
|
+
|
|
51
|
+
if (communityRequiresMembership(community.visibility) && !isMember) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
if (policy !== "anyone" && !isMember) return false;
|
|
55
|
+
if (policy === "mods" && !isManager) return false;
|
|
56
|
+
if (policy === "owners" && role !== "owner") return false;
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve a bounded AS2 audience against local live communities and authorize
|
|
62
|
+
* the signer for the one matching scope.
|
|
63
|
+
*
|
|
64
|
+
* Unknown/external audiences are preserved by the caller but do not acquire a
|
|
65
|
+
* local community_ap_id. More than one local match is ambiguous and rejected.
|
|
66
|
+
* The ap_id and followers_url lookups are intentionally separate: with the
|
|
67
|
+
* 64-address ingress cap, combining both IN lists would compile to 128 bound
|
|
68
|
+
* parameters and fail on production D1's 100-variable ceiling.
|
|
69
|
+
*/
|
|
70
|
+
export async function resolveInboundCommunityScope(
|
|
71
|
+
db: Database,
|
|
72
|
+
actorApId: string,
|
|
73
|
+
audience: readonly string[],
|
|
74
|
+
): Promise<InboundCommunityScope> {
|
|
75
|
+
const addresses = [...new Set(audience)].filter(Boolean).slice(0, 64);
|
|
76
|
+
if (addresses.length === 0) {
|
|
77
|
+
return { allowed: true, communityApId: null };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const columns = {
|
|
81
|
+
apId: communities.apId,
|
|
82
|
+
visibility: communities.visibility,
|
|
83
|
+
postPolicy: communities.postPolicy,
|
|
84
|
+
};
|
|
85
|
+
const [byApId, byFollowersUrl] = await Promise.all([
|
|
86
|
+
db
|
|
87
|
+
.select(columns)
|
|
88
|
+
.from(communities)
|
|
89
|
+
.where(
|
|
90
|
+
and(
|
|
91
|
+
inArray(communities.apId, addresses),
|
|
92
|
+
isNull(communities.deletedAt),
|
|
93
|
+
),
|
|
94
|
+
),
|
|
95
|
+
db
|
|
96
|
+
.select(columns)
|
|
97
|
+
.from(communities)
|
|
98
|
+
.where(
|
|
99
|
+
and(
|
|
100
|
+
inArray(communities.followersUrl, addresses),
|
|
101
|
+
isNull(communities.deletedAt),
|
|
102
|
+
),
|
|
103
|
+
),
|
|
104
|
+
]);
|
|
105
|
+
const matches = new Map<string, InboundCommunityTarget>();
|
|
106
|
+
for (const community of [...byApId, ...byFollowersUrl]) {
|
|
107
|
+
matches.set(community.apId, community);
|
|
108
|
+
}
|
|
109
|
+
if (matches.size === 0) {
|
|
110
|
+
return { allowed: true, communityApId: null };
|
|
111
|
+
}
|
|
112
|
+
if (matches.size !== 1) {
|
|
113
|
+
return { allowed: false, communityApId: null };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const community = [...matches.values()][0]!;
|
|
117
|
+
if (!(await canActorPostToInboundCommunity(db, actorApId, community))) {
|
|
118
|
+
return { allowed: false, communityApId: null };
|
|
119
|
+
}
|
|
120
|
+
return { allowed: true, communityApId: community.apId };
|
|
121
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { isLocal, isSafeRemoteUrl } from "../../../federation-helpers.ts";
|
|
2
|
+
|
|
3
|
+
export const MAX_INBOUND_OBJECT_ID_LENGTH = 2048;
|
|
4
|
+
|
|
5
|
+
export type InboundObjectIdentityResult =
|
|
6
|
+
| { ok: true; objectId: string }
|
|
7
|
+
| {
|
|
8
|
+
ok: false;
|
|
9
|
+
reason:
|
|
10
|
+
| "missing"
|
|
11
|
+
| "too_long"
|
|
12
|
+
| "unsafe"
|
|
13
|
+
| "local_origin"
|
|
14
|
+
| "actor_origin_mismatch";
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Bind an inbound object's durable identity to the verified remote actor.
|
|
19
|
+
*
|
|
20
|
+
* Server-to-server Create needs a stable remote object IRI: minting a local IRI
|
|
21
|
+
* for an anonymous remote object would make this instance appear to own bytes
|
|
22
|
+
* controlled by another server and would generate a different row on retry.
|
|
23
|
+
* Exact URL origin (scheme + host + port), credential-free HTTP(S), the local
|
|
24
|
+
* origin exclusion, and a storage-safe length are all one ownership decision.
|
|
25
|
+
*/
|
|
26
|
+
export function validateInboundObjectIdentity(
|
|
27
|
+
objectId: unknown,
|
|
28
|
+
actorApId: string,
|
|
29
|
+
baseUrl: string,
|
|
30
|
+
): InboundObjectIdentityResult {
|
|
31
|
+
if (objectId === undefined || objectId === null || objectId === "") {
|
|
32
|
+
return { ok: false, reason: "missing" };
|
|
33
|
+
}
|
|
34
|
+
if (typeof objectId !== "string") {
|
|
35
|
+
return { ok: false, reason: "unsafe" };
|
|
36
|
+
}
|
|
37
|
+
if (objectId.length > MAX_INBOUND_OBJECT_ID_LENGTH) {
|
|
38
|
+
return { ok: false, reason: "too_long" };
|
|
39
|
+
}
|
|
40
|
+
if (!isSafeRemoteUrl(objectId)) {
|
|
41
|
+
return { ok: false, reason: "unsafe" };
|
|
42
|
+
}
|
|
43
|
+
if (isLocal(objectId, baseUrl)) {
|
|
44
|
+
return { ok: false, reason: "local_origin" };
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
if (new URL(objectId).origin !== new URL(actorApId).origin) {
|
|
48
|
+
return { ok: false, reason: "actor_origin_mismatch" };
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
return { ok: false, reason: "unsafe" };
|
|
52
|
+
}
|
|
53
|
+
return { ok: true, objectId };
|
|
54
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { isSafeRemoteUrl } from "../../../federation-helpers.ts";
|
|
2
|
+
|
|
3
|
+
export const MAX_INBOUND_REPLY_TARGET_LENGTH = 2048;
|
|
4
|
+
|
|
5
|
+
export type InboundReplyTargetResult =
|
|
6
|
+
| { ok: true; parentId: string | null | undefined }
|
|
7
|
+
| {
|
|
8
|
+
ok: false;
|
|
9
|
+
reason: "malformed" | "too_long" | "unsafe";
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Normalize one inbound Note reply edge before it reaches storage or SQL.
|
|
14
|
+
*
|
|
15
|
+
* Absence preserves partial-Update compatibility and explicit null clears an
|
|
16
|
+
* existing edge. A concrete parent must be one bounded, credential-free
|
|
17
|
+
* HTTP(S) IRI. Unlike remote object identity, the parent may be local or on a
|
|
18
|
+
* different remote origin: cross-origin replies are the normal federation
|
|
19
|
+
* case. Unknown parents remain valid links and are never fetched here.
|
|
20
|
+
*/
|
|
21
|
+
export function validateInboundReplyTarget(
|
|
22
|
+
value: unknown,
|
|
23
|
+
): InboundReplyTargetResult {
|
|
24
|
+
if (value === undefined) return { ok: true, parentId: undefined };
|
|
25
|
+
if (value === null) return { ok: true, parentId: null };
|
|
26
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
27
|
+
return { ok: false, reason: "malformed" };
|
|
28
|
+
}
|
|
29
|
+
if (value.length > MAX_INBOUND_REPLY_TARGET_LENGTH) {
|
|
30
|
+
return { ok: false, reason: "too_long" };
|
|
31
|
+
}
|
|
32
|
+
if (!isSafeRemoteUrl(value)) return { ok: false, reason: "unsafe" };
|
|
33
|
+
return { ok: true, parentId: value };
|
|
34
|
+
}
|