@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
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
follows,
|
|
12
12
|
inbox as inboxTable,
|
|
13
13
|
likes,
|
|
14
|
+
objectRecipients,
|
|
14
15
|
objects,
|
|
15
16
|
} from "../../../db/index.ts";
|
|
16
17
|
import { and, desc, eq, gt, inArray, or, sql } from "drizzle-orm";
|
|
@@ -38,6 +39,11 @@ import {
|
|
|
38
39
|
passesPostVisibilitySync,
|
|
39
40
|
} from "../../lib/post-visibility.ts";
|
|
40
41
|
import { logger } from "../../lib/logger.ts";
|
|
42
|
+
import { excludeModeratedActors } from "../../lib/feed-exclude.ts";
|
|
43
|
+
import { isActorBlockedStrict } from "../../lib/blocklist.ts";
|
|
44
|
+
import { activityDeleteCascadeStatements } from "../../lib/activity-delete-cascade.ts";
|
|
45
|
+
import { setPostLike } from "./like-mutation.ts";
|
|
46
|
+
import { prepareDeliveryFanoutJob } from "../../lib/delivery/fanout-outbox.ts";
|
|
41
47
|
|
|
42
48
|
const log = logger.child({ component: "posts.interactions" });
|
|
43
49
|
|
|
@@ -113,6 +119,29 @@ async function deliverToRemote(
|
|
|
113
119
|
}
|
|
114
120
|
}
|
|
115
121
|
|
|
122
|
+
async function publishDurableFollowerFanoutWakeup(
|
|
123
|
+
env: Env,
|
|
124
|
+
activityId: string,
|
|
125
|
+
actorApId: string,
|
|
126
|
+
operation: "repost" | "unrepost",
|
|
127
|
+
): Promise<void> {
|
|
128
|
+
try {
|
|
129
|
+
await enqueueFanoutToFollowers(env, activityId, actorApId);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
// The route has already committed the interaction, Activity, and fanout
|
|
132
|
+
// intent atomically. A Queue RPC is only a wakeup; leave the row pending so
|
|
133
|
+
// scheduled/startup recovery can publish it without turning a successful
|
|
134
|
+
// user mutation into a misleading HTTP 500.
|
|
135
|
+
log.error("Failed to publish durable repost fanout wakeup", {
|
|
136
|
+
event: "posts.repost.fanout_wakeup_failed",
|
|
137
|
+
operation,
|
|
138
|
+
activityId,
|
|
139
|
+
actor: actorApId,
|
|
140
|
+
error,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
116
145
|
/**
|
|
117
146
|
* Atomic multi-statement commit.
|
|
118
147
|
*
|
|
@@ -148,7 +177,6 @@ posts.post("/:id/like", async (c) => {
|
|
|
148
177
|
if (!post) return c.json({ error: "Post not found" }, 404);
|
|
149
178
|
|
|
150
179
|
const db = c.get("db");
|
|
151
|
-
const baseUrl = c.env.APP_URL;
|
|
152
180
|
|
|
153
181
|
// Only a post the actor can actually read may be liked: without this an authed
|
|
154
182
|
// user who learns a post's apId could "like" a followers-only / direct /
|
|
@@ -165,77 +193,14 @@ posts.post("/:id/like", async (c) => {
|
|
|
165
193
|
return c.json({ error: "Post not found" }, 404);
|
|
166
194
|
}
|
|
167
195
|
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
.
|
|
171
|
-
.
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
if (
|
|
176
|
-
|
|
177
|
-
const likeId = generateId();
|
|
178
|
-
const likeActivityId = activityApId(baseUrl, likeId);
|
|
179
|
-
const likeActivityRaw = {
|
|
180
|
-
"@context": "https://www.w3.org/ns/activitystreams",
|
|
181
|
-
id: likeActivityId,
|
|
182
|
-
type: "Like",
|
|
183
|
-
actor: actor.ap_id,
|
|
184
|
-
object: post.apId,
|
|
185
|
-
};
|
|
186
|
-
const now = new Date().toISOString();
|
|
187
|
-
const shouldNotifyLocal =
|
|
188
|
-
post.attributedTo !== actor.ap_id && isLocal(post.attributedTo, baseUrl);
|
|
189
|
-
|
|
190
|
-
// D1 has no interactive transactions; group the child-row insert, counter
|
|
191
|
-
// bump, activity row, and (optional) inbox row into a single atomic batch so
|
|
192
|
-
// the like row and likeCount can never diverge on a mid-request failure.
|
|
193
|
-
const likeStatements: BatchStatement[] = [
|
|
194
|
-
db.insert(likes).values({
|
|
195
|
-
actorApId: actor.ap_id,
|
|
196
|
-
objectApId: post.apId,
|
|
197
|
-
activityApId: likeActivityId,
|
|
198
|
-
}),
|
|
199
|
-
db
|
|
200
|
-
.update(objects)
|
|
201
|
-
.set({ likeCount: sql`${objects.likeCount} + 1` })
|
|
202
|
-
.where(eq(objects.apId, post.apId)),
|
|
203
|
-
db.insert(activities).values({
|
|
204
|
-
apId: likeActivityId,
|
|
205
|
-
type: "Like",
|
|
206
|
-
actorApId: actor.ap_id,
|
|
207
|
-
objectApId: post.apId,
|
|
208
|
-
rawJson: JSON.stringify(likeActivityRaw),
|
|
209
|
-
createdAt: now,
|
|
210
|
-
}),
|
|
211
|
-
];
|
|
212
|
-
|
|
213
|
-
if (shouldNotifyLocal) {
|
|
214
|
-
likeStatements.push(
|
|
215
|
-
db.insert(inboxTable).values({
|
|
216
|
-
actorApId: post.attributedTo,
|
|
217
|
-
activityApId: likeActivityId,
|
|
218
|
-
read: 0,
|
|
219
|
-
createdAt: now,
|
|
220
|
-
}),
|
|
221
|
-
);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
try {
|
|
225
|
-
await runBatch(db, likeStatements as [BatchStatement, ...BatchStatement[]]);
|
|
226
|
-
} catch (e) {
|
|
227
|
-
// Two concurrent likes (two tabs / a retried slow request) both pass the
|
|
228
|
-
// SELECT above; the loser's composite-PK insert hits a UNIQUE constraint.
|
|
229
|
-
// That is the idempotent "already liked" case, not a 500.
|
|
230
|
-
if (isUniqueConstraintError(e)) {
|
|
231
|
-
return c.json({ error: "Already liked" }, 400);
|
|
232
|
-
}
|
|
233
|
-
throw e;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
if (!isLocal(post.apId, baseUrl)) {
|
|
237
|
-
await deliverToRemote(c.env, likeActivityId, post.attributedTo);
|
|
238
|
-
}
|
|
196
|
+
const result = await setPostLike({
|
|
197
|
+
db,
|
|
198
|
+
env: c.env,
|
|
199
|
+
actorApId: actor.ap_id,
|
|
200
|
+
post,
|
|
201
|
+
active: true,
|
|
202
|
+
});
|
|
203
|
+
if (!result.changed) return c.json({ error: "Already liked" }, 400);
|
|
239
204
|
|
|
240
205
|
return c.json({ success: true, liked: true });
|
|
241
206
|
});
|
|
@@ -248,76 +213,15 @@ posts.delete("/:id/like", async (c) => {
|
|
|
248
213
|
if (!post) return c.json({ error: "Post not found" }, 404);
|
|
249
214
|
|
|
250
215
|
const db = c.get("db");
|
|
251
|
-
const baseUrl = c.env.APP_URL;
|
|
252
|
-
|
|
253
|
-
const like = await db
|
|
254
|
-
.select({
|
|
255
|
-
actorApId: likes.actorApId,
|
|
256
|
-
activityApId: likes.activityApId,
|
|
257
|
-
})
|
|
258
|
-
.from(likes)
|
|
259
|
-
.where(
|
|
260
|
-
and(eq(likes.actorApId, actor.ap_id), eq(likes.objectApId, post.apId)),
|
|
261
|
-
)
|
|
262
|
-
.get();
|
|
263
|
-
if (!like) return c.json({ error: "Not liked" }, 400);
|
|
264
|
-
|
|
265
|
-
// D1 has no interactive transactions; group the like-row delete and the
|
|
266
|
-
// counter decrement into a single atomic batch so they cannot diverge. Also
|
|
267
|
-
// reap the original like's notification: the like mints a FRESH activity id
|
|
268
|
-
// each time and the dedup guard only checks the `likes` edge, so without this
|
|
269
|
-
// a like→unlike→like cycle would leave the first notification behind and add a
|
|
270
|
-
// second — duplicate "X liked your post" rows (and a phantom unread +1). The
|
|
271
|
-
// inbound-federated path already gates its notify on the existing edge; this
|
|
272
|
-
// gives the local path the same idempotency.
|
|
273
|
-
const reapLikeNotification: BatchStatement[] = like.activityApId
|
|
274
|
-
? [
|
|
275
|
-
db
|
|
276
|
-
.delete(inboxTable)
|
|
277
|
-
.where(eq(inboxTable.activityApId, like.activityApId)),
|
|
278
|
-
db.delete(activities).where(eq(activities.apId, like.activityApId)),
|
|
279
|
-
]
|
|
280
|
-
: [];
|
|
281
|
-
await runBatch(db, [
|
|
282
|
-
db
|
|
283
|
-
.delete(likes)
|
|
284
|
-
.where(
|
|
285
|
-
and(eq(likes.actorApId, actor.ap_id), eq(likes.objectApId, post.apId)),
|
|
286
|
-
),
|
|
287
|
-
db
|
|
288
|
-
.update(objects)
|
|
289
|
-
.set({ likeCount: sql`${objects.likeCount} - 1` })
|
|
290
|
-
.where(and(eq(objects.apId, post.apId), gt(objects.likeCount, 0))),
|
|
291
|
-
...reapLikeNotification,
|
|
292
|
-
]);
|
|
293
|
-
|
|
294
|
-
if (!isLocal(post.apId, baseUrl)) {
|
|
295
|
-
const undoObject = like.activityApId
|
|
296
|
-
? like.activityApId
|
|
297
|
-
: { type: "Like", actor: actor.ap_id, object: post.apId };
|
|
298
|
-
|
|
299
|
-
const undoActivity = {
|
|
300
|
-
"@context": "https://www.w3.org/ns/activitystreams",
|
|
301
|
-
id: activityApId(baseUrl, generateId()),
|
|
302
|
-
type: "Undo",
|
|
303
|
-
actor: actor.ap_id,
|
|
304
|
-
object: undoObject,
|
|
305
|
-
};
|
|
306
216
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
direction: "outbound",
|
|
316
|
-
})
|
|
317
|
-
.onConflictDoNothing();
|
|
318
|
-
|
|
319
|
-
await deliverToRemote(c.env, undoActivity.id, post.attributedTo);
|
|
320
|
-
}
|
|
217
|
+
const result = await setPostLike({
|
|
218
|
+
db,
|
|
219
|
+
env: c.env,
|
|
220
|
+
actorApId: actor.ap_id,
|
|
221
|
+
post,
|
|
222
|
+
active: false,
|
|
223
|
+
});
|
|
224
|
+
if (!result.changed) return c.json({ error: "Not liked" }, 400);
|
|
321
225
|
|
|
322
226
|
return c.json({ success: true, liked: false });
|
|
323
227
|
});
|
|
@@ -336,6 +240,13 @@ posts.post("/:id/repost", async (c) => {
|
|
|
336
240
|
const db = c.get("db");
|
|
337
241
|
const baseUrl = c.env.APP_URL;
|
|
338
242
|
|
|
243
|
+
// Repost has a public-reach gate rather than the canonical read gate below,
|
|
244
|
+
// so enforce instance moderation explicitly before it can persist/fan out an
|
|
245
|
+
// Announce for a retained object from a defederated actor/domain.
|
|
246
|
+
if (await isActorBlockedStrict(db, post.attributedTo)) {
|
|
247
|
+
return c.json({ error: "Post not found" }, 404);
|
|
248
|
+
}
|
|
249
|
+
|
|
339
250
|
// A repost is always an Announce addressed to Public + the booster's
|
|
340
251
|
// followers (see the activity built below), so it re-broadcasts the object to
|
|
341
252
|
// a wider audience than the original. Only a post whose reach is ALREADY
|
|
@@ -387,6 +298,11 @@ posts.post("/:id/repost", async (c) => {
|
|
|
387
298
|
const now = new Date().toISOString();
|
|
388
299
|
const shouldNotifyLocal =
|
|
389
300
|
post.attributedTo !== actor.ap_id && isLocal(post.attributedTo, baseUrl);
|
|
301
|
+
const preparedFanout = await prepareDeliveryFanoutJob(db, {
|
|
302
|
+
kind: "followers",
|
|
303
|
+
activityId: announceActivityId,
|
|
304
|
+
targetApId: actor.ap_id,
|
|
305
|
+
});
|
|
390
306
|
|
|
391
307
|
// D1 has no interactive transactions; group the child-row insert, counter
|
|
392
308
|
// bump, activity row, and (optional) inbox row into a single atomic batch so
|
|
@@ -409,6 +325,7 @@ posts.post("/:id/repost", async (c) => {
|
|
|
409
325
|
rawJson: JSON.stringify(announceActivityRaw),
|
|
410
326
|
createdAt: now,
|
|
411
327
|
}),
|
|
328
|
+
preparedFanout.statement as BatchStatement,
|
|
412
329
|
];
|
|
413
330
|
|
|
414
331
|
if (shouldNotifyLocal) {
|
|
@@ -438,7 +355,12 @@ posts.post("/:id/repost", async (c) => {
|
|
|
438
355
|
// The Announce is cc'd to the booster's own followers collection, so fan it
|
|
439
356
|
// out to them — otherwise a repost only ever reached the original author's
|
|
440
357
|
// inbox and was invisible to the booster's remote followers.
|
|
441
|
-
await
|
|
358
|
+
await publishDurableFollowerFanoutWakeup(
|
|
359
|
+
c.env,
|
|
360
|
+
announceActivityId,
|
|
361
|
+
actor.ap_id,
|
|
362
|
+
"repost",
|
|
363
|
+
);
|
|
442
364
|
|
|
443
365
|
if (!isLocal(post.apId, baseUrl)) {
|
|
444
366
|
await deliverToRemote(c.env, announceActivityId, post.attributedTo);
|
|
@@ -472,17 +394,33 @@ posts.delete("/:id/repost", async (c) => {
|
|
|
472
394
|
.get();
|
|
473
395
|
if (!announce) return c.json({ error: "Not reposted" }, 400);
|
|
474
396
|
|
|
397
|
+
const undoActivity = {
|
|
398
|
+
"@context": "https://www.w3.org/ns/activitystreams",
|
|
399
|
+
id: activityApId(baseUrl, generateId()),
|
|
400
|
+
type: "Undo",
|
|
401
|
+
actor: actor.ap_id,
|
|
402
|
+
to: ["https://www.w3.org/ns/activitystreams#Public"],
|
|
403
|
+
cc: [actor.ap_id + "/followers"],
|
|
404
|
+
object: { type: "Announce", actor: actor.ap_id, object: post.apId },
|
|
405
|
+
};
|
|
406
|
+
const preparedFanout = await prepareDeliveryFanoutJob(db, {
|
|
407
|
+
kind: "followers",
|
|
408
|
+
activityId: undoActivity.id,
|
|
409
|
+
targetApId: actor.ap_id,
|
|
410
|
+
});
|
|
411
|
+
|
|
475
412
|
// D1 has no interactive transactions; group the announce-row delete and the
|
|
476
413
|
// counter decrement into a single atomic batch so they cannot diverge. Also
|
|
477
|
-
// reap the original repost
|
|
478
|
-
// as unlike: each repost mints a fresh Announce
|
|
479
|
-
// checks the edge, so
|
|
414
|
+
// reap the original repost activity plus all durable projections (same
|
|
415
|
+
// duplicate-notification issue as unlike: each repost mints a fresh Announce
|
|
416
|
+
// activity id and the guard only checks the edge, so
|
|
417
|
+
// repost→unrepost→repost would stack notifications and queue residue).
|
|
480
418
|
const reapRepostNotification: BatchStatement[] = announce.activityApId
|
|
481
419
|
? [
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
420
|
+
...activityDeleteCascadeStatements(
|
|
421
|
+
db,
|
|
422
|
+
eq(activities.apId, announce.activityApId),
|
|
423
|
+
),
|
|
486
424
|
]
|
|
487
425
|
: [];
|
|
488
426
|
await runBatch(db, [
|
|
@@ -499,33 +437,28 @@ posts.delete("/:id/repost", async (c) => {
|
|
|
499
437
|
.set({ announceCount: sql`${objects.announceCount} - 1` })
|
|
500
438
|
.where(and(eq(objects.apId, post.apId), gt(objects.announceCount, 0))),
|
|
501
439
|
...reapRepostNotification,
|
|
440
|
+
db
|
|
441
|
+
.insert(activities)
|
|
442
|
+
.values({
|
|
443
|
+
apId: undoActivity.id,
|
|
444
|
+
type: "Undo",
|
|
445
|
+
actorApId: actor.ap_id,
|
|
446
|
+
objectApId: post.apId,
|
|
447
|
+
rawJson: JSON.stringify(undoActivity),
|
|
448
|
+
direction: "outbound",
|
|
449
|
+
})
|
|
450
|
+
.onConflictDoNothing(),
|
|
451
|
+
preparedFanout.statement as BatchStatement,
|
|
502
452
|
]);
|
|
503
453
|
|
|
504
|
-
const undoActivity = {
|
|
505
|
-
"@context": "https://www.w3.org/ns/activitystreams",
|
|
506
|
-
id: activityApId(baseUrl, generateId()),
|
|
507
|
-
type: "Undo",
|
|
508
|
-
actor: actor.ap_id,
|
|
509
|
-
to: ["https://www.w3.org/ns/activitystreams#Public"],
|
|
510
|
-
cc: [actor.ap_id + "/followers"],
|
|
511
|
-
object: { type: "Announce", actor: actor.ap_id, object: post.apId },
|
|
512
|
-
};
|
|
513
|
-
|
|
514
|
-
await db
|
|
515
|
-
.insert(activities)
|
|
516
|
-
.values({
|
|
517
|
-
apId: undoActivity.id,
|
|
518
|
-
type: "Undo",
|
|
519
|
-
actorApId: actor.ap_id,
|
|
520
|
-
objectApId: post.apId,
|
|
521
|
-
rawJson: JSON.stringify(undoActivity),
|
|
522
|
-
direction: "outbound",
|
|
523
|
-
})
|
|
524
|
-
.onConflictDoNothing();
|
|
525
|
-
|
|
526
454
|
// Mirror the Announce reach: the Undo must reach the booster's followers (who
|
|
527
455
|
// saw the repost) regardless of whether the boosted post was local or remote.
|
|
528
|
-
await
|
|
456
|
+
await publishDurableFollowerFanoutWakeup(
|
|
457
|
+
c.env,
|
|
458
|
+
undoActivity.id,
|
|
459
|
+
actor.ap_id,
|
|
460
|
+
"unrepost",
|
|
461
|
+
);
|
|
529
462
|
|
|
530
463
|
// The boosted post's author instance is only an extra recipient for a remote post.
|
|
531
464
|
if (!isLocal(post.apId, baseUrl)) {
|
|
@@ -640,20 +573,28 @@ posts.get("/bookmarks", async (c) => {
|
|
|
640
573
|
// Fetch limit+1 and advance the cursor by the last SCANNED row, so the
|
|
641
574
|
// visibility gate dropping rows can only shorten a page, never make a readable
|
|
642
575
|
// bookmark permanently unreachable (load-more continues past the dropped ones).
|
|
643
|
-
const scanned = await db
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
576
|
+
const scanned = await db
|
|
577
|
+
.select({ bookmark: bookmarks, object: objects })
|
|
578
|
+
.from(bookmarks)
|
|
579
|
+
.innerJoin(objects, eq(bookmarks.objectApId, objects.apId))
|
|
580
|
+
.where(
|
|
581
|
+
and(
|
|
582
|
+
eq(bookmarks.actorApId, actor.ap_id),
|
|
583
|
+
cursorPredicate,
|
|
584
|
+
excludeModeratedActors(actor.ap_id),
|
|
585
|
+
),
|
|
586
|
+
)
|
|
587
|
+
.orderBy(desc(bookmarks.createdAt), desc(bookmarks.objectApId))
|
|
588
|
+
.limit(limit + 1);
|
|
651
589
|
const hasMore = scanned.length > limit;
|
|
652
590
|
const allBookmarkRows = hasMore ? scanned.slice(0, limit) : scanned;
|
|
653
591
|
const lastScanned = allBookmarkRows[allBookmarkRows.length - 1];
|
|
654
592
|
const nextCursor =
|
|
655
593
|
hasMore && lastScanned
|
|
656
|
-
? encodeFeedCursor(
|
|
594
|
+
? encodeFeedCursor(
|
|
595
|
+
lastScanned.bookmark.createdAt,
|
|
596
|
+
lastScanned.bookmark.objectApId,
|
|
597
|
+
)
|
|
657
598
|
: null;
|
|
658
599
|
|
|
659
600
|
// Re-check read-access at read time so a bookmark can never resurface a post
|
|
@@ -700,11 +641,43 @@ posts.get("/bookmarks", async (c) => {
|
|
|
700
641
|
)
|
|
701
642
|
: new Set<string>();
|
|
702
643
|
|
|
644
|
+
// Hidden bto/bcc identities cannot live in to_json/cc_json. Resolve the
|
|
645
|
+
// indexed recipient projection once for this page so a hidden recipient who
|
|
646
|
+
// successfully bookmarked a readable post does not lose it on list refresh.
|
|
647
|
+
const projectedCandidateIds = allBookmarkRows
|
|
648
|
+
.filter(
|
|
649
|
+
(b) =>
|
|
650
|
+
b.object.attributedTo !== actor.ap_id &&
|
|
651
|
+
(b.object.visibility === "direct" ||
|
|
652
|
+
b.object.visibility === "followers"),
|
|
653
|
+
)
|
|
654
|
+
.map((b) => b.object.apId);
|
|
655
|
+
const projectedRecipientIds =
|
|
656
|
+
projectedCandidateIds.length > 0
|
|
657
|
+
? new Set(
|
|
658
|
+
(
|
|
659
|
+
await db
|
|
660
|
+
.select({ objectApId: objectRecipients.objectApId })
|
|
661
|
+
.from(objectRecipients)
|
|
662
|
+
.where(
|
|
663
|
+
and(
|
|
664
|
+
eq(objectRecipients.recipientApId, actor.ap_id),
|
|
665
|
+
eq(objectRecipients.type, "to"),
|
|
666
|
+
inArray(objectRecipients.objectApId, projectedCandidateIds),
|
|
667
|
+
),
|
|
668
|
+
)
|
|
669
|
+
).map((row) => row.objectApId),
|
|
670
|
+
)
|
|
671
|
+
: new Set<string>();
|
|
672
|
+
|
|
703
673
|
// Sync visibility-gate first, then ONE batched community read-gate over the
|
|
704
674
|
// survivors (2 queries instead of 1-2 per bookmarked post).
|
|
705
675
|
const visibilityOk = allBookmarkRows.filter((b) =>
|
|
706
|
-
passesPostVisibilitySync(
|
|
707
|
-
|
|
676
|
+
passesPostVisibilitySync(
|
|
677
|
+
b.object,
|
|
678
|
+
actor.ap_id,
|
|
679
|
+
(a) => followedAuthors.has(a),
|
|
680
|
+
(objectApId) => projectedRecipientIds.has(objectApId),
|
|
708
681
|
),
|
|
709
682
|
);
|
|
710
683
|
const communityReadable = await communityReadableApIds(
|
|
@@ -768,7 +741,10 @@ posts.get("/bookmarks", async (c) => {
|
|
|
768
741
|
type: obj.type,
|
|
769
742
|
author: {
|
|
770
743
|
ap_id: obj.attributedTo,
|
|
771
|
-
username: formatUsername(
|
|
744
|
+
username: formatUsername(
|
|
745
|
+
obj.attributedTo,
|
|
746
|
+
authorInfo?.preferredUsername,
|
|
747
|
+
),
|
|
772
748
|
preferred_username: authorInfo?.preferredUsername ?? null,
|
|
773
749
|
name: authorInfo?.name ?? null,
|
|
774
750
|
icon_url: authorInfo?.iconUrl ?? null,
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { and, count, eq, sql } from "drizzle-orm";
|
|
2
|
+
import type { BatchItem } from "drizzle-orm/batch";
|
|
3
|
+
import type { Database } from "../../../db/index.ts";
|
|
4
|
+
import { activities, inbox, likes, objects } from "../../../db/index.ts";
|
|
5
|
+
import type { Env } from "../../types.ts";
|
|
6
|
+
import { activityApId, generateId, isLocal } from "../../federation-helpers.ts";
|
|
7
|
+
import { activityDeleteCascadeStatements } from "../../lib/activity-delete-cascade.ts";
|
|
8
|
+
import { enqueueDeliveryToActor } from "../../lib/delivery/queue.ts";
|
|
9
|
+
import { logger } from "../../lib/logger.ts";
|
|
10
|
+
import { isUniqueConstraintError } from "../../lib/parse-helpers.ts";
|
|
11
|
+
|
|
12
|
+
const log = logger.child({ component: "posts.like_mutation" });
|
|
13
|
+
|
|
14
|
+
type BatchStatement = BatchItem<"sqlite">;
|
|
15
|
+
type BatchableDb = {
|
|
16
|
+
batch(
|
|
17
|
+
statements: readonly [BatchStatement, ...BatchStatement[]],
|
|
18
|
+
): Promise<unknown>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type LikeTarget = {
|
|
22
|
+
apId: string;
|
|
23
|
+
attributedTo: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type PostLikeMutationResult = {
|
|
27
|
+
active: boolean;
|
|
28
|
+
changed: boolean;
|
|
29
|
+
likeCount: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
async function readLikeCount(
|
|
33
|
+
db: Database,
|
|
34
|
+
objectApId: string,
|
|
35
|
+
): Promise<number> {
|
|
36
|
+
const row = await db
|
|
37
|
+
.select({ count: count() })
|
|
38
|
+
.from(likes)
|
|
39
|
+
.where(eq(likes.objectApId, objectApId))
|
|
40
|
+
.get();
|
|
41
|
+
return row?.count ?? 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function enqueueRemoteInteraction(
|
|
45
|
+
env: Env,
|
|
46
|
+
activityId: string,
|
|
47
|
+
recipientApId: string,
|
|
48
|
+
type: "Like" | "Undo",
|
|
49
|
+
): Promise<void> {
|
|
50
|
+
try {
|
|
51
|
+
await enqueueDeliveryToActor(env, activityId, recipientApId);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
log.error("Failed to enqueue post interaction", {
|
|
54
|
+
event: "posts.like_mutation.enqueue_failed",
|
|
55
|
+
type,
|
|
56
|
+
activityId,
|
|
57
|
+
recipient: recipientApId,
|
|
58
|
+
error,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Set one actor's Like edge and all of its durable Activity/notification state.
|
|
65
|
+
*
|
|
66
|
+
* Web and agent callers deliberately keep their own response semantics: this
|
|
67
|
+
* owner reports whether the requested state changed, so HTTP may reject a
|
|
68
|
+
* duplicate while the Takos tool remains safely idempotent.
|
|
69
|
+
*/
|
|
70
|
+
export async function setPostLike(input: {
|
|
71
|
+
db: Database;
|
|
72
|
+
env: Env;
|
|
73
|
+
actorApId: string;
|
|
74
|
+
post: LikeTarget;
|
|
75
|
+
active: boolean;
|
|
76
|
+
}): Promise<PostLikeMutationResult> {
|
|
77
|
+
const { db, env, actorApId, post, active } = input;
|
|
78
|
+
const existing = await db
|
|
79
|
+
.select({ activityApId: likes.activityApId })
|
|
80
|
+
.from(likes)
|
|
81
|
+
.where(and(eq(likes.actorApId, actorApId), eq(likes.objectApId, post.apId)))
|
|
82
|
+
.get();
|
|
83
|
+
|
|
84
|
+
if (active) {
|
|
85
|
+
if (existing) {
|
|
86
|
+
return {
|
|
87
|
+
active: true,
|
|
88
|
+
changed: false,
|
|
89
|
+
likeCount: await readLikeCount(db, post.apId),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const now = new Date().toISOString();
|
|
94
|
+
const likeActivityId = activityApId(env.APP_URL, generateId());
|
|
95
|
+
const raw = {
|
|
96
|
+
"@context": "https://www.w3.org/ns/activitystreams",
|
|
97
|
+
id: likeActivityId,
|
|
98
|
+
type: "Like",
|
|
99
|
+
actor: actorApId,
|
|
100
|
+
object: post.apId,
|
|
101
|
+
};
|
|
102
|
+
const statements: BatchStatement[] = [
|
|
103
|
+
db.insert(likes).values({
|
|
104
|
+
actorApId,
|
|
105
|
+
objectApId: post.apId,
|
|
106
|
+
activityApId: likeActivityId,
|
|
107
|
+
createdAt: now,
|
|
108
|
+
}),
|
|
109
|
+
db
|
|
110
|
+
.update(objects)
|
|
111
|
+
.set({
|
|
112
|
+
likeCount: sql`(SELECT COUNT(*) FROM ${likes} WHERE ${likes.objectApId} = ${post.apId})`,
|
|
113
|
+
})
|
|
114
|
+
.where(eq(objects.apId, post.apId)),
|
|
115
|
+
db.insert(activities).values({
|
|
116
|
+
apId: likeActivityId,
|
|
117
|
+
type: "Like",
|
|
118
|
+
actorApId,
|
|
119
|
+
objectApId: post.apId,
|
|
120
|
+
rawJson: JSON.stringify(raw),
|
|
121
|
+
direction: "outbound",
|
|
122
|
+
createdAt: now,
|
|
123
|
+
}),
|
|
124
|
+
];
|
|
125
|
+
if (
|
|
126
|
+
post.attributedTo !== actorApId &&
|
|
127
|
+
isLocal(post.attributedTo, env.APP_URL)
|
|
128
|
+
) {
|
|
129
|
+
statements.push(
|
|
130
|
+
db.insert(inbox).values({
|
|
131
|
+
actorApId: post.attributedTo,
|
|
132
|
+
activityApId: likeActivityId,
|
|
133
|
+
read: 0,
|
|
134
|
+
createdAt: now,
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
await (db as unknown as BatchableDb).batch(
|
|
141
|
+
statements as [BatchStatement, ...BatchStatement[]],
|
|
142
|
+
);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (!isUniqueConstraintError(error)) throw error;
|
|
145
|
+
return {
|
|
146
|
+
active: true,
|
|
147
|
+
changed: false,
|
|
148
|
+
likeCount: await readLikeCount(db, post.apId),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!isLocal(post.apId, env.APP_URL)) {
|
|
153
|
+
await enqueueRemoteInteraction(
|
|
154
|
+
env,
|
|
155
|
+
likeActivityId,
|
|
156
|
+
post.attributedTo,
|
|
157
|
+
"Like",
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
active: true,
|
|
162
|
+
changed: true,
|
|
163
|
+
likeCount: await readLikeCount(db, post.apId),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!existing) {
|
|
168
|
+
return {
|
|
169
|
+
active: false,
|
|
170
|
+
changed: false,
|
|
171
|
+
likeCount: await readLikeCount(db, post.apId),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const remote = !isLocal(post.apId, env.APP_URL);
|
|
176
|
+
const undoActivityId = remote
|
|
177
|
+
? activityApId(env.APP_URL, generateId())
|
|
178
|
+
: null;
|
|
179
|
+
const undoObject = existing.activityApId
|
|
180
|
+
? existing.activityApId
|
|
181
|
+
: { type: "Like", actor: actorApId, object: post.apId };
|
|
182
|
+
const undoRaw = undoActivityId
|
|
183
|
+
? {
|
|
184
|
+
"@context": "https://www.w3.org/ns/activitystreams",
|
|
185
|
+
id: undoActivityId,
|
|
186
|
+
type: "Undo",
|
|
187
|
+
actor: actorApId,
|
|
188
|
+
object: undoObject,
|
|
189
|
+
}
|
|
190
|
+
: null;
|
|
191
|
+
const reapOriginal = existing.activityApId
|
|
192
|
+
? activityDeleteCascadeStatements(
|
|
193
|
+
db,
|
|
194
|
+
eq(activities.apId, existing.activityApId),
|
|
195
|
+
)
|
|
196
|
+
: [];
|
|
197
|
+
const statements: BatchStatement[] = [
|
|
198
|
+
db
|
|
199
|
+
.delete(likes)
|
|
200
|
+
.where(
|
|
201
|
+
and(eq(likes.actorApId, actorApId), eq(likes.objectApId, post.apId)),
|
|
202
|
+
),
|
|
203
|
+
db
|
|
204
|
+
.update(objects)
|
|
205
|
+
.set({
|
|
206
|
+
likeCount: sql`(SELECT COUNT(*) FROM ${likes} WHERE ${likes.objectApId} = ${post.apId})`,
|
|
207
|
+
})
|
|
208
|
+
.where(eq(objects.apId, post.apId)),
|
|
209
|
+
...reapOriginal,
|
|
210
|
+
];
|
|
211
|
+
if (undoActivityId && undoRaw) {
|
|
212
|
+
statements.push(
|
|
213
|
+
db.insert(activities).values({
|
|
214
|
+
apId: undoActivityId,
|
|
215
|
+
type: "Undo",
|
|
216
|
+
actorApId,
|
|
217
|
+
objectApId: post.apId,
|
|
218
|
+
rawJson: JSON.stringify(undoRaw),
|
|
219
|
+
direction: "outbound",
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
await (db as unknown as BatchableDb).batch(
|
|
224
|
+
statements as [BatchStatement, ...BatchStatement[]],
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
if (undoActivityId) {
|
|
228
|
+
await enqueueRemoteInteraction(
|
|
229
|
+
env,
|
|
230
|
+
undoActivityId,
|
|
231
|
+
post.attributedTo,
|
|
232
|
+
"Undo",
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
active: false,
|
|
237
|
+
changed: true,
|
|
238
|
+
likeCount: await readLikeCount(db, post.apId),
|
|
239
|
+
};
|
|
240
|
+
}
|