@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
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
actors,
|
|
4
|
+
follows,
|
|
5
|
+
objectRecipients,
|
|
6
|
+
objects,
|
|
7
|
+
runBatch,
|
|
8
|
+
type D1Statement,
|
|
9
|
+
} from "../../../db/index.ts";
|
|
3
10
|
import type { Database } from "../../../db/index.ts";
|
|
4
|
-
import { OBJECT_CONTEXT } from "../../lib/ap-context.ts";
|
|
5
11
|
import { and, desc, eq, gt, inArray, sql } from "drizzle-orm";
|
|
6
12
|
import type { Actor, Env, Variables } from "../../types.ts";
|
|
7
13
|
import {
|
|
8
14
|
activityApId,
|
|
9
15
|
formatUsername,
|
|
10
16
|
generateId,
|
|
11
|
-
isLocal,
|
|
12
|
-
isSafeRemoteUrl,
|
|
13
17
|
objectApId,
|
|
14
18
|
parseLimit,
|
|
15
19
|
safeJsonParse,
|
|
@@ -22,62 +26,51 @@ import {
|
|
|
22
26
|
} from "./transformers.ts";
|
|
23
27
|
import {
|
|
24
28
|
AUTHOR_WITH,
|
|
25
|
-
buildAddressing,
|
|
26
|
-
buildCommunityObjectAddressing,
|
|
27
|
-
loadCachedAuthorMap,
|
|
28
29
|
loadInteractionFlags,
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
persistAndFanout,
|
|
32
|
-
persistAndFanoutToCommunity,
|
|
30
|
+
preparePersistAndFanout,
|
|
31
|
+
preparePersistAndFanoutToCommunity,
|
|
33
32
|
type PostDetailRow,
|
|
34
33
|
postWhereByIdOrApId,
|
|
35
|
-
type PostWithAuthor,
|
|
36
34
|
resolveAuthor,
|
|
37
35
|
resolveAuthorWithCache,
|
|
38
36
|
toPostRow,
|
|
39
37
|
} from "./queries.ts";
|
|
40
|
-
import {
|
|
41
|
-
|
|
38
|
+
import {
|
|
39
|
+
prepareObjectDeleteCascade,
|
|
40
|
+
purgeMediaBlobs,
|
|
41
|
+
} from "./delete-cascade.ts";
|
|
42
42
|
import {
|
|
43
43
|
checkCommunityPostPermission,
|
|
44
44
|
deriveContentTags,
|
|
45
|
-
|
|
46
|
-
processMentions,
|
|
47
|
-
REPLY_TARGET_NOT_FOUND,
|
|
45
|
+
preparePostInsertStatements,
|
|
48
46
|
validateContentEdit,
|
|
49
47
|
validateCreatePostBody,
|
|
50
48
|
validateEditBody,
|
|
51
49
|
validateSummaryEdit,
|
|
52
50
|
} from "./post-helpers.ts";
|
|
53
|
-
import { requireActor } from "../actors-helpers.ts";
|
|
51
|
+
import { loadActorInfoMap, requireActor } from "../actors-helpers.ts";
|
|
54
52
|
import { communityReadableApIds } from "../../lib/community-visibility.ts";
|
|
55
53
|
import { encodeFeedCursor, feedCursorWhere } from "../../lib/feed-cursor.ts";
|
|
56
54
|
import {
|
|
57
55
|
actorIsBlockedBy,
|
|
58
56
|
canViewerReadObjectFull,
|
|
57
|
+
passesPostVisibilitySync,
|
|
58
|
+
type ReadGateObject,
|
|
59
59
|
} from "../../lib/post-visibility.ts";
|
|
60
|
-
import { toApAttachments } from "../../lib/activitypub-helpers.ts";
|
|
61
60
|
import { logger } from "../../lib/logger.ts";
|
|
61
|
+
import { excludeModeratedActors } from "../../lib/feed-exclude.ts";
|
|
62
|
+
import {
|
|
63
|
+
prepareCreatedPostFederation,
|
|
64
|
+
prepareDeletedPostFederation,
|
|
65
|
+
} from "./federation.ts";
|
|
62
66
|
|
|
63
67
|
const log = logger.child({ component: "posts.routes" });
|
|
64
68
|
|
|
65
|
-
// `.batch` lives only on the concrete D1/libsql subclasses, not the Database
|
|
66
|
-
// union; reach it through a narrow structural cast (matching the other routes).
|
|
67
|
-
type Batchable = { batch: (stmts: unknown[]) => Promise<unknown> };
|
|
68
|
-
|
|
69
69
|
const posts = new Hono<{ Bindings: Env; Variables: Variables }>();
|
|
70
70
|
|
|
71
|
-
const PUBLIC_COLLECTION = "https://www.w3.org/ns/activitystreams#Public";
|
|
72
|
-
|
|
73
71
|
/** Reply row shape needed for the visibility gate (subset of the object row). */
|
|
74
|
-
type ReplyVisibilityRow = {
|
|
72
|
+
type ReplyVisibilityRow = ReadGateObject & {
|
|
75
73
|
apId: string;
|
|
76
|
-
attributedTo: string;
|
|
77
|
-
visibility: string;
|
|
78
|
-
toJson?: string | null;
|
|
79
|
-
audienceJson?: string | null;
|
|
80
|
-
communityApId?: string | null;
|
|
81
74
|
};
|
|
82
75
|
|
|
83
76
|
/**
|
|
@@ -120,6 +113,27 @@ async function filterVisibleReplies<T extends ReplyVisibilityRow>(
|
|
|
120
113
|
acceptedFollowing = new Set(rows.map((r) => r.followingApId));
|
|
121
114
|
}
|
|
122
115
|
|
|
116
|
+
const projectedRecipientIds =
|
|
117
|
+
viewerApId && replies.length > 0
|
|
118
|
+
? new Set(
|
|
119
|
+
(
|
|
120
|
+
await db
|
|
121
|
+
.select({ objectApId: objectRecipients.objectApId })
|
|
122
|
+
.from(objectRecipients)
|
|
123
|
+
.where(
|
|
124
|
+
and(
|
|
125
|
+
eq(objectRecipients.recipientApId, viewerApId),
|
|
126
|
+
eq(objectRecipients.type, "to"),
|
|
127
|
+
inArray(
|
|
128
|
+
objectRecipients.objectApId,
|
|
129
|
+
replies.map((reply) => reply.apId),
|
|
130
|
+
),
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
).map((row) => row.objectApId),
|
|
134
|
+
)
|
|
135
|
+
: new Set<string>();
|
|
136
|
+
|
|
123
137
|
// Pre-compute the community read-gate for every reply: a community-scoped
|
|
124
138
|
// reply is stored "public" but carries an audience, so the per-visibility
|
|
125
139
|
// checks below would let it through. Resolving membership here (rather than
|
|
@@ -136,18 +150,12 @@ async function filterVisibleReplies<T extends ReplyVisibilityRow>(
|
|
|
136
150
|
// A private-community reply is hidden from anyone who is not an accepted
|
|
137
151
|
// member, regardless of the (stored "public") visibility.
|
|
138
152
|
if (!communityReadable.has(reply.apId)) return false;
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
if (!viewerApId) return false;
|
|
146
|
-
if (reply.attributedTo === viewerApId) return true;
|
|
147
|
-
const recipients = safeJsonParse<string[]>(reply.toJson, []);
|
|
148
|
-
return recipients.includes(viewerApId);
|
|
149
|
-
}
|
|
150
|
-
return true;
|
|
153
|
+
return passesPostVisibilitySync(
|
|
154
|
+
reply,
|
|
155
|
+
viewerApId,
|
|
156
|
+
(authorApId) => acceptedFollowing.has(authorApId),
|
|
157
|
+
(objectApId) => projectedRecipientIds.has(objectApId),
|
|
158
|
+
);
|
|
151
159
|
});
|
|
152
160
|
}
|
|
153
161
|
|
|
@@ -190,9 +198,11 @@ posts.post("/", async (c) => {
|
|
|
190
198
|
// replyCount, sending them a reply notification, and publishing a public reply
|
|
191
199
|
// whose inReplyTo discloses the restricted parent's existence (and bypassing a
|
|
192
200
|
// block). Mirror the like/repost gates; 404 to avoid leaking existence.
|
|
201
|
+
let parentAuthor: string | null = null;
|
|
193
202
|
if (body.in_reply_to) {
|
|
194
203
|
const parent = await db
|
|
195
204
|
.select({
|
|
205
|
+
apId: objects.apId,
|
|
196
206
|
visibility: objects.visibility,
|
|
197
207
|
attributedTo: objects.attributedTo,
|
|
198
208
|
toJson: objects.toJson,
|
|
@@ -212,6 +222,7 @@ posts.post("/", async (c) => {
|
|
|
212
222
|
) {
|
|
213
223
|
return c.json({ error: "Post not found" }, 404);
|
|
214
224
|
}
|
|
225
|
+
parentAuthor = parent.attributedTo;
|
|
215
226
|
}
|
|
216
227
|
|
|
217
228
|
const baseUrl = c.env.APP_URL;
|
|
@@ -219,9 +230,25 @@ posts.post("/", async (c) => {
|
|
|
219
230
|
const apId = objectApId(baseUrl, postId);
|
|
220
231
|
const now = new Date().toISOString();
|
|
221
232
|
|
|
222
|
-
let
|
|
233
|
+
let preparedFederation: Awaited<
|
|
234
|
+
ReturnType<typeof prepareCreatedPostFederation>
|
|
235
|
+
>;
|
|
223
236
|
try {
|
|
224
|
-
|
|
237
|
+
preparedFederation = await prepareCreatedPostFederation({
|
|
238
|
+
db,
|
|
239
|
+
env: c.env,
|
|
240
|
+
actorApId: actor.ap_id,
|
|
241
|
+
objectApId: apId,
|
|
242
|
+
content,
|
|
243
|
+
summary: summary || null,
|
|
244
|
+
attachments: body.attachments,
|
|
245
|
+
inReplyTo: body.in_reply_to || null,
|
|
246
|
+
parentAuthor,
|
|
247
|
+
visibility,
|
|
248
|
+
community,
|
|
249
|
+
published: now,
|
|
250
|
+
});
|
|
251
|
+
const localStatements = preparePostInsertStatements(db, {
|
|
225
252
|
apId,
|
|
226
253
|
actorApId: actor.ap_id,
|
|
227
254
|
content,
|
|
@@ -230,14 +257,16 @@ posts.post("/", async (c) => {
|
|
|
230
257
|
inReplyTo: body.in_reply_to || null,
|
|
231
258
|
visibility,
|
|
232
259
|
communityId,
|
|
233
|
-
|
|
260
|
+
to: preparedFederation.to,
|
|
261
|
+
cc: preparedFederation.cc,
|
|
262
|
+
audience: preparedFederation.audience,
|
|
263
|
+
tags: preparedFederation.tags,
|
|
264
|
+
parentAuthor,
|
|
234
265
|
baseUrl,
|
|
235
266
|
now,
|
|
236
267
|
});
|
|
268
|
+
await runBatch(db, [...localStatements, ...preparedFederation.statements]);
|
|
237
269
|
} catch (e) {
|
|
238
|
-
if (e instanceof Error && e.message === REPLY_TARGET_NOT_FOUND) {
|
|
239
|
-
return c.json({ error: "Reply target not found" }, 404);
|
|
240
|
-
}
|
|
241
270
|
log.error("Failed to create post transaction", {
|
|
242
271
|
event: "posts.create.transaction_failed",
|
|
243
272
|
actor: actor.ap_id,
|
|
@@ -247,167 +276,14 @@ posts.post("/", async (c) => {
|
|
|
247
276
|
return c.json({ error: "Failed to create post" }, 500);
|
|
248
277
|
}
|
|
249
278
|
|
|
250
|
-
|
|
251
|
-
// tags, create local notifications, and collect the resolved recipient IRIs.
|
|
252
|
-
const {
|
|
253
|
-
failures: mentionFailures,
|
|
254
|
-
tags: mentionTags,
|
|
255
|
-
mentionedActorApIds,
|
|
256
|
-
remoteMentionedActorApIds,
|
|
257
|
-
} = await processMentions(db, {
|
|
258
|
-
content,
|
|
259
|
-
postApId: apId,
|
|
260
|
-
actorApId: actor.ap_id,
|
|
261
|
-
parentAuthor,
|
|
262
|
-
baseUrl,
|
|
263
|
-
now,
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
// A reply must reach the post it replies to. processMentions only addresses
|
|
267
|
-
// actors EXPLICITLY @-mentioned in the body, so a reply to a remote post that
|
|
268
|
-
// doesn't manually @-mention its author was delivered to the replier's own
|
|
269
|
-
// followers but NEVER to the upstream instance — it never landed in the
|
|
270
|
-
// original thread (whereas Like/Undo-repost already reach the remote object
|
|
271
|
-
// author). Auto-address the parent author of a NON-direct reply: add it to cc
|
|
272
|
-
// and a `Mention` tag (so the reply threads + notifies on the receiving
|
|
273
|
-
// server) and, when remote, deliver the Create to its inbox. Direct replies
|
|
274
|
-
// keep mentions-only addressing (no implicit parent disclosure). The local
|
|
275
|
-
// parent author is already notified by the reply path, so this only augments
|
|
276
|
-
// addressing/delivery, never a duplicate local notification.
|
|
277
|
-
const replyRecipients = [...mentionedActorApIds];
|
|
278
|
-
const replyRemoteRecipients = [...remoteMentionedActorApIds];
|
|
279
|
-
const replyTags = [...mentionTags];
|
|
280
|
-
if (
|
|
281
|
-
body.in_reply_to &&
|
|
282
|
-
parentAuthor &&
|
|
283
|
-
parentAuthor !== actor.ap_id &&
|
|
284
|
-
visibility !== "direct" &&
|
|
285
|
-
!replyRecipients.includes(parentAuthor)
|
|
286
|
-
) {
|
|
287
|
-
replyRecipients.push(parentAuthor);
|
|
288
|
-
replyTags.push({
|
|
289
|
-
type: "Mention",
|
|
290
|
-
href: parentAuthor,
|
|
291
|
-
name: `@${formatUsername(parentAuthor)}`,
|
|
292
|
-
});
|
|
293
|
-
if (!isLocal(parentAuthor, baseUrl)) {
|
|
294
|
-
replyRemoteRecipients.push(parentAuthor);
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// Federate when the post has follower/public/community reach OR when it has
|
|
299
|
-
// resolved mentions (a mention is an explicit recipient, so even a "direct"
|
|
300
|
-
// post must federate the Create to its remote mentioned actors).
|
|
301
|
-
//
|
|
302
|
-
// Community-scoped posts have reach == community: address the Create toward
|
|
303
|
-
// the community Group actor + its followers collection (NOT the author's
|
|
304
|
-
// personal followers), record the community in `audience`, and fan out to
|
|
305
|
-
// the community's members/followers. Non-community posts keep the existing
|
|
306
|
-
// author-follower addressing and fan-out. Mentioned actors are always added
|
|
307
|
-
// to `cc` so the post is addressed to them on the receiving server.
|
|
308
|
-
if (visibility !== "direct" || mentionedActorApIds.length > 0) {
|
|
309
|
-
let to: string[];
|
|
310
|
-
let cc: string[];
|
|
311
|
-
let audience: string[] | undefined;
|
|
312
|
-
|
|
313
|
-
if (visibility === "direct") {
|
|
314
|
-
// Direct post with mentions: no follower/public reach, only the
|
|
315
|
-
// mentioned actors are recipients.
|
|
316
|
-
to = [];
|
|
317
|
-
cc = [];
|
|
318
|
-
} else if (community) {
|
|
319
|
-
const objectAddressing = buildCommunityObjectAddressing(
|
|
320
|
-
visibility,
|
|
321
|
-
community,
|
|
322
|
-
);
|
|
323
|
-
to = objectAddressing.to;
|
|
324
|
-
cc = objectAddressing.cc;
|
|
325
|
-
audience = objectAddressing.audience;
|
|
326
|
-
} else {
|
|
327
|
-
const followersUrl = `${actor.ap_id}/followers`;
|
|
328
|
-
({ to, cc } = buildAddressing(visibility, followersUrl));
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
// Add every mentioned actor IRI — plus an auto-addressed reply parent — to
|
|
332
|
-
// cc (de-duplicated).
|
|
333
|
-
cc = mergeCc(cc, replyRecipients);
|
|
334
|
-
|
|
335
|
-
const tag = replyTags.length > 0 ? replyTags : undefined;
|
|
336
|
-
|
|
337
|
-
const createActivity = {
|
|
338
|
-
"@context": OBJECT_CONTEXT,
|
|
339
|
-
id: activityApId(baseUrl, generateId()),
|
|
340
|
-
type: "Create",
|
|
341
|
-
actor: actor.ap_id,
|
|
342
|
-
published: now,
|
|
343
|
-
to,
|
|
344
|
-
cc,
|
|
345
|
-
...(audience ? { audience } : {}),
|
|
346
|
-
...(tag ? { tag } : {}),
|
|
347
|
-
object: {
|
|
348
|
-
"@context": OBJECT_CONTEXT,
|
|
349
|
-
id: apId,
|
|
350
|
-
type: "Note",
|
|
351
|
-
attributedTo: actor.ap_id,
|
|
352
|
-
content,
|
|
353
|
-
summary: summary || null,
|
|
354
|
-
// A non-empty summary is a content warning; Mastodon-compatible peers
|
|
355
|
-
// gate rendering on BOTH `summary` (the CW text) and `sensitive`. The
|
|
356
|
-
// served object doc (routes/activitypub/outbox.ts) already sets this, so
|
|
357
|
-
// the delivered Create must match or the CW federates inconsistently.
|
|
358
|
-
...(summary ? { sensitive: true } : {}),
|
|
359
|
-
// Media is stored as an app-relative /media path; absolutize for the
|
|
360
|
-
// federated copy so remote servers can fetch the image.
|
|
361
|
-
attachment: toApAttachments(body.attachments || [], baseUrl),
|
|
362
|
-
inReplyTo: body.in_reply_to || null,
|
|
363
|
-
published: now,
|
|
364
|
-
to,
|
|
365
|
-
cc,
|
|
366
|
-
...(audience ? { audience } : {}),
|
|
367
|
-
...(tag ? { tag } : {}),
|
|
368
|
-
},
|
|
369
|
-
};
|
|
370
|
-
|
|
371
|
-
if (visibility === "direct") {
|
|
372
|
-
// No follower/community fanout for a direct post — persist only, then
|
|
373
|
-
// direct-deliver to the remote mentioned actors below.
|
|
374
|
-
await persistActivity(db, createActivity, apId);
|
|
375
|
-
} else if (community) {
|
|
376
|
-
await persistAndFanoutToCommunity(
|
|
377
|
-
db,
|
|
378
|
-
c.env,
|
|
379
|
-
createActivity,
|
|
380
|
-
apId,
|
|
381
|
-
community.apId,
|
|
382
|
-
);
|
|
383
|
-
} else {
|
|
384
|
-
await persistAndFanout(db, c.env, createActivity, apId);
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
// Deliver the Create directly to each remote mentioned actor's inbox — plus
|
|
388
|
-
// the remote parent author of a reply (auto-addressed above). Community/
|
|
389
|
-
// follower fanout does not include arbitrary remote actors, so this is the
|
|
390
|
-
// only path that reaches a remote @user@domain mention or reply target.
|
|
391
|
-
for (const recipient of replyRemoteRecipients) {
|
|
392
|
-
try {
|
|
393
|
-
await enqueueDeliveryToActor(c.env, createActivity.id, recipient);
|
|
394
|
-
} catch (err) {
|
|
395
|
-
log.error("Failed to enqueue mention delivery", {
|
|
396
|
-
event: "posts.mention.delivery_enqueue_failed",
|
|
397
|
-
activityId: createActivity.id,
|
|
398
|
-
recipient,
|
|
399
|
-
error: err,
|
|
400
|
-
});
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
}
|
|
279
|
+
const { mentionFailures } = await preparedFederation.complete();
|
|
404
280
|
|
|
405
281
|
const createdPost = {
|
|
406
282
|
ap_id: apId,
|
|
407
283
|
type: "Note",
|
|
408
284
|
author: {
|
|
409
285
|
ap_id: actor.ap_id,
|
|
410
|
-
username: formatUsername(actor.ap_id),
|
|
286
|
+
username: formatUsername(actor.ap_id, actor.preferred_username),
|
|
411
287
|
preferred_username: actor.preferred_username,
|
|
412
288
|
name: actor.name,
|
|
413
289
|
icon_url: actor.icon_url,
|
|
@@ -451,6 +327,24 @@ posts.get("/:id", async (c) => {
|
|
|
451
327
|
});
|
|
452
328
|
|
|
453
329
|
if (!post) return c.json({ error: "Post not found" }, 404);
|
|
330
|
+
if (currentActor) {
|
|
331
|
+
// Drizzle's relational query aliases every embedded column reference to the
|
|
332
|
+
// outer table, so keep the moderation CTE in this ordinary select instead
|
|
333
|
+
// of injecting it into findFirst({ with: ... }).
|
|
334
|
+
const moderationReadable = await db
|
|
335
|
+
.select({ apId: objects.apId })
|
|
336
|
+
.from(objects)
|
|
337
|
+
.where(
|
|
338
|
+
and(
|
|
339
|
+
eq(objects.apId, post.apId),
|
|
340
|
+
excludeModeratedActors(currentActor.ap_id),
|
|
341
|
+
),
|
|
342
|
+
)
|
|
343
|
+
.get();
|
|
344
|
+
if (!moderationReadable) {
|
|
345
|
+
return c.json({ error: "Post not found" }, 404);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
454
348
|
|
|
455
349
|
// Resolve author and interaction flags in parallel
|
|
456
350
|
const [author, { likedIds, bookmarkedIds }] = await Promise.all([
|
|
@@ -497,7 +391,12 @@ posts.get("/:id/replies", async (c) => {
|
|
|
497
391
|
endTime: objects.endTime,
|
|
498
392
|
})
|
|
499
393
|
.from(objects)
|
|
500
|
-
.where(
|
|
394
|
+
.where(
|
|
395
|
+
and(
|
|
396
|
+
postWhereByIdOrApId(baseUrl, postId),
|
|
397
|
+
excludeModeratedActors(currentActor?.ap_id ?? ""),
|
|
398
|
+
),
|
|
399
|
+
)
|
|
501
400
|
.get();
|
|
502
401
|
|
|
503
402
|
if (!parentPost) return c.json({ error: "Post not found" }, 404);
|
|
@@ -524,14 +423,18 @@ posts.get("/:id/replies", async (c) => {
|
|
|
524
423
|
// last readable one) means unreadable replies are skipped without ever
|
|
525
424
|
// skipping a readable one — so load-more reaches every readable reply, and
|
|
526
425
|
// the gate dropping rows can only make a page short, never lose a reply.
|
|
527
|
-
const scanned = await db
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
426
|
+
const scanned = await db
|
|
427
|
+
.select()
|
|
428
|
+
.from(objects)
|
|
429
|
+
.where(
|
|
430
|
+
and(
|
|
431
|
+
eq(objects.inReplyTo, parentPost.apId),
|
|
432
|
+
cursorPredicate,
|
|
433
|
+
excludeModeratedActors(currentActor?.ap_id ?? ""),
|
|
434
|
+
),
|
|
435
|
+
)
|
|
436
|
+
.orderBy(desc(objects.published), desc(objects.apId))
|
|
437
|
+
.limit(limit + 1);
|
|
535
438
|
const hasMore = scanned.length > limit;
|
|
536
439
|
const page = hasMore ? scanned.slice(0, limit) : scanned;
|
|
537
440
|
const lastScanned = page[page.length - 1];
|
|
@@ -548,18 +451,18 @@ posts.get("/:id/replies", async (c) => {
|
|
|
548
451
|
|
|
549
452
|
// Batch load cached authors and interaction flags in parallel
|
|
550
453
|
const replyApIds = replies.map((r) => r.apId);
|
|
551
|
-
const [
|
|
552
|
-
|
|
454
|
+
const [authorMap, { likedIds }] = await Promise.all([
|
|
455
|
+
loadActorInfoMap(
|
|
456
|
+
db,
|
|
457
|
+
[...new Set(replies.map((reply) => reply.attributedTo))],
|
|
458
|
+
"author",
|
|
459
|
+
),
|
|
553
460
|
loadInteractionFlags(db, currentActor?.ap_id, replyApIds),
|
|
554
461
|
]);
|
|
555
462
|
|
|
556
463
|
const result = replies.map((reply) => {
|
|
557
|
-
const author = resolveAuthor(
|
|
558
|
-
|
|
559
|
-
reply.attributedTo,
|
|
560
|
-
cachedAuthorMap,
|
|
561
|
-
);
|
|
562
|
-
const postRow = toPostRow(reply as PostWithAuthor, author, {
|
|
464
|
+
const author = resolveAuthor(undefined, reply.attributedTo, authorMap);
|
|
465
|
+
const postRow = toPostRow(reply, author, {
|
|
563
466
|
liked: likedIds.has(reply.apId),
|
|
564
467
|
});
|
|
565
468
|
return formatPost(postRow, currentActor?.ap_id);
|
|
@@ -647,8 +550,6 @@ posts.patch("/:id", async (c) => {
|
|
|
647
550
|
updateData.tagsJson = JSON.stringify(nextTags);
|
|
648
551
|
}
|
|
649
552
|
|
|
650
|
-
await db.update(objects).set(updateData).where(eq(objects.apId, post.apId));
|
|
651
|
-
|
|
652
553
|
// Mirror the stored post's addressing onto the Update so its audience matches
|
|
653
554
|
// the ORIGINAL post (like the Delete path). Without this the Update carried no
|
|
654
555
|
// to/cc/audience and — combined with the community branch below — fanned out
|
|
@@ -678,7 +579,10 @@ posts.patch("/:id", async (c) => {
|
|
|
678
579
|
sensitive: Boolean(nextSummary),
|
|
679
580
|
// Carry the re-derived tags so a receiver updating the Note keeps its
|
|
680
581
|
// Hashtag/Mention tags instead of dropping them on edit.
|
|
681
|
-
|
|
582
|
+
// Always carry the projection. `[]` is an explicit clear; omission is
|
|
583
|
+
// reserved for partial third-party Updates that ask receivers to preserve
|
|
584
|
+
// their existing tag projection.
|
|
585
|
+
tag: nextTags,
|
|
682
586
|
to: updateTo,
|
|
683
587
|
cc: updateCc,
|
|
684
588
|
...(updateAudience.length > 0 ? { audience: updateAudience } : {}),
|
|
@@ -689,17 +593,28 @@ posts.patch("/:id", async (c) => {
|
|
|
689
593
|
// A community-scoped post's Update must reach the COMMUNITY (the members who
|
|
690
594
|
// got the Create), NOT the author's personal followers — who never received
|
|
691
595
|
// the Create. Mirror the create path's community-vs-personal fan-out branch.
|
|
692
|
-
|
|
693
|
-
await
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
596
|
+
const preparedFanout = post.communityApId
|
|
597
|
+
? await preparePersistAndFanoutToCommunity(
|
|
598
|
+
db,
|
|
599
|
+
c.env,
|
|
600
|
+
updateActivity,
|
|
601
|
+
post.apId,
|
|
602
|
+
post.communityApId,
|
|
603
|
+
)
|
|
604
|
+
: await preparePersistAndFanout(db, c.env, updateActivity, post.apId);
|
|
605
|
+
|
|
606
|
+
// The edited object and its outbound Update/fanout intent are one mutation.
|
|
607
|
+
// If the durable intent cannot be stored, the old content/tags stay visible
|
|
608
|
+
// and the client can safely retry instead of receiving 500 after a local-only
|
|
609
|
+
// edit already committed.
|
|
610
|
+
await runBatch(db, [
|
|
611
|
+
db
|
|
612
|
+
.update(objects)
|
|
613
|
+
.set(updateData)
|
|
614
|
+
.where(eq(objects.apId, post.apId)) as D1Statement,
|
|
615
|
+
...preparedFanout.statements,
|
|
616
|
+
]);
|
|
617
|
+
await preparedFanout.publish();
|
|
703
618
|
|
|
704
619
|
return c.json({
|
|
705
620
|
success: true,
|
|
@@ -730,11 +645,15 @@ posts.delete("/:id", async (c) => {
|
|
|
730
645
|
return c.json({ error: "Forbidden" }, 403);
|
|
731
646
|
}
|
|
732
647
|
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
648
|
+
const [cascade, preparedFederation] = await Promise.all([
|
|
649
|
+
prepareObjectDeleteCascade(db, post.apId, c.env.MEDIA),
|
|
650
|
+
prepareDeletedPostFederation({
|
|
651
|
+
db,
|
|
652
|
+
env: c.env,
|
|
653
|
+
actorApId: actor.ap_id,
|
|
654
|
+
post,
|
|
655
|
+
}),
|
|
656
|
+
]);
|
|
738
657
|
|
|
739
658
|
// Co-commit the object delete + author postCount-- + parent replyCount in ONE
|
|
740
659
|
// batch (mirrors the federated handleDelete): a crash between separate
|
|
@@ -748,16 +667,19 @@ posts.delete("/:id", async (c) => {
|
|
|
748
667
|
.set({ postCount: sql`${actors.postCount} - 1` })
|
|
749
668
|
.where(
|
|
750
669
|
and(eq(actors.apId, actor.ap_id), gt(actors.postCount, 0), objectExists),
|
|
751
|
-
);
|
|
752
|
-
const deleteObject = db
|
|
670
|
+
) as D1Statement;
|
|
671
|
+
const deleteObject = db
|
|
672
|
+
.delete(objects)
|
|
673
|
+
.where(eq(objects.apId, post.apId)) as D1Statement;
|
|
753
674
|
// DM notes (`visibility="direct"`, created by createDmNote) are NOT counted in
|
|
754
675
|
// postCount on send, so deleting one here must NOT decrement it — otherwise a
|
|
755
676
|
// DM deleted through this generic endpoint (the dedicated DELETE
|
|
756
677
|
// /dm/messages/:id correctly skips the count) under-counts the author's
|
|
757
678
|
// postCount (floored at 0). Keep create/delete symmetric: only regular posts
|
|
758
679
|
// (which incremented) decrement.
|
|
759
|
-
const ops:
|
|
680
|
+
const ops: D1Statement[] = [...cascade.statements];
|
|
760
681
|
if (post.visibility !== "direct") ops.push(decPostCount);
|
|
682
|
+
ops.push(...preparedFederation.statements);
|
|
761
683
|
ops.push(deleteObject);
|
|
762
684
|
if (post.inReplyTo) {
|
|
763
685
|
const parentId = post.inReplyTo;
|
|
@@ -767,97 +689,15 @@ posts.delete("/:id", async (c) => {
|
|
|
767
689
|
.set({
|
|
768
690
|
replyCount: sql`(SELECT COUNT(*) FROM ${objects} WHERE ${objects.inReplyTo} = ${parentId})`,
|
|
769
691
|
})
|
|
770
|
-
.where(eq(objects.apId, parentId)),
|
|
692
|
+
.where(eq(objects.apId, parentId)) as D1Statement,
|
|
771
693
|
);
|
|
772
694
|
}
|
|
773
|
-
await (db as
|
|
695
|
+
await runBatch(db, ops as [D1Statement, ...D1Statement[]]);
|
|
774
696
|
|
|
775
697
|
// Irreversible R2 purge LAST — only now that the objects row is gone. A
|
|
776
698
|
// failure here degrades to a leaked blob, not a live post with a deleted blob.
|
|
777
|
-
await purgeMediaBlobs(c.env.MEDIA, mediaKeys);
|
|
778
|
-
|
|
779
|
-
// The Delete must reach everyone the original object reached, not just the
|
|
780
|
-
// author's current followers: mirror the object's stored to/cc, and emit a
|
|
781
|
-
// Tombstone object (per AP) instead of a bare IRI so receivers can render
|
|
782
|
-
// the deletion correctly.
|
|
783
|
-
const originalTo = safeJsonParse<string[]>(post.toJson, []);
|
|
784
|
-
const originalCc = safeJsonParse<string[]>(post.ccJson, []);
|
|
785
|
-
|
|
786
|
-
// For a reply, the parent author's instance must also be told (it counts the
|
|
787
|
-
// reply); for a direct post, the DM recipients are exactly the addressed
|
|
788
|
-
// actors. Collect explicit (actor-IRI) recipients for direct per-actor
|
|
789
|
-
// delivery — anything that is not a Public/collection IRI is treated as an
|
|
790
|
-
// actor inbox target if it is a safe remote URL.
|
|
791
|
-
const explicitRecipients = new Set<string>();
|
|
792
|
-
for (const iri of [...originalTo, ...originalCc]) {
|
|
793
|
-
if (
|
|
794
|
-
iri &&
|
|
795
|
-
iri !== PUBLIC_COLLECTION &&
|
|
796
|
-
!iri.endsWith("/followers") &&
|
|
797
|
-
isLocal(iri, baseUrl) === false &&
|
|
798
|
-
isSafeRemoteUrl(iri)
|
|
799
|
-
) {
|
|
800
|
-
explicitRecipients.add(iri);
|
|
801
|
-
}
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
// Parent author's instance (replies): ensure the reply deletion propagates
|
|
805
|
-
// to the thread root's host even if it was not in the object's to/cc.
|
|
806
|
-
if (post.inReplyTo) {
|
|
807
|
-
const parent = await db
|
|
808
|
-
.select({ attributedTo: objects.attributedTo })
|
|
809
|
-
.from(objects)
|
|
810
|
-
.where(eq(objects.apId, post.inReplyTo))
|
|
811
|
-
.get();
|
|
812
|
-
if (
|
|
813
|
-
parent?.attributedTo &&
|
|
814
|
-
!isLocal(parent.attributedTo, baseUrl) &&
|
|
815
|
-
isSafeRemoteUrl(parent.attributedTo)
|
|
816
|
-
) {
|
|
817
|
-
explicitRecipients.add(parent.attributedTo);
|
|
818
|
-
}
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
const deleteActivity = {
|
|
822
|
-
"@context": "https://www.w3.org/ns/activitystreams",
|
|
823
|
-
id: activityApId(baseUrl, generateId()),
|
|
824
|
-
type: "Delete",
|
|
825
|
-
actor: actor.ap_id,
|
|
826
|
-
to: originalTo,
|
|
827
|
-
cc: originalCc,
|
|
828
|
-
object: {
|
|
829
|
-
id: post.apId,
|
|
830
|
-
type: "Tombstone",
|
|
831
|
-
},
|
|
832
|
-
};
|
|
833
|
-
|
|
834
|
-
// Fan out matching the original create reach (community → the community, not
|
|
835
|
-
// the author's personal followers) and additionally deliver directly to each
|
|
836
|
-
// explicitly-addressed remote actor.
|
|
837
|
-
if (post.communityApId) {
|
|
838
|
-
await persistAndFanoutToCommunity(
|
|
839
|
-
db,
|
|
840
|
-
c.env,
|
|
841
|
-
deleteActivity,
|
|
842
|
-
post.apId,
|
|
843
|
-
post.communityApId,
|
|
844
|
-
);
|
|
845
|
-
} else {
|
|
846
|
-
await persistAndFanout(db, c.env, deleteActivity, post.apId);
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
for (const recipient of explicitRecipients) {
|
|
850
|
-
try {
|
|
851
|
-
await enqueueDeliveryToActor(c.env, deleteActivity.id, recipient);
|
|
852
|
-
} catch (err) {
|
|
853
|
-
log.error("Failed to enqueue delete delivery", {
|
|
854
|
-
event: "posts.delete.delivery_enqueue_failed",
|
|
855
|
-
activityId: deleteActivity.id,
|
|
856
|
-
recipient,
|
|
857
|
-
error: err,
|
|
858
|
-
});
|
|
859
|
-
}
|
|
860
|
-
}
|
|
699
|
+
await purgeMediaBlobs(c.env.MEDIA, cascade.mediaKeys);
|
|
700
|
+
await preparedFederation.complete();
|
|
861
701
|
|
|
862
702
|
return c.json({ success: true });
|
|
863
703
|
});
|