@takosjp/yurucommu-core 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +16 -0
- package/README.md +82 -0
- package/migrations/0001_init.sql +495 -0
- package/migrations/0002_social_remote_actor_edges.sql +92 -0
- package/migrations/0003_activity_remote_object_edges.sql +68 -0
- package/migrations/0004_blocklist.sql +26 -0
- package/migrations/0005_story_community_scope.sql +13 -0
- package/migrations/0006_dm_community_read_status.sql +19 -0
- package/migrations/0007_moderation_reports.sql +22 -0
- package/migrations/0008_actor_fields_aka.sql +18 -0
- package/migrations/0009_object_tags.sql +13 -0
- package/migrations/0010_object_recipients_drop_actor_fk.sql +34 -0
- package/migrations/0011_drop_remote_actor_fks.sql +205 -0
- package/migrations/0012_objects_content_fts.sql +39 -0
- package/migrations/0013_efficiency_indexes.sql +13 -0
- package/migrations/0014_inbox_actor_created_idx.sql +15 -0
- package/migrations/0015_community_bans.sql +16 -0
- package/migrations/0016_namespace_takos_oidc_subject.sql +19 -0
- package/migrations/0017_mobile_push_registrations.sql +22 -0
- package/migrations/README.md +122 -0
- package/package.json +75 -0
- package/packages/api/LICENSE +16 -0
- package/packages/api/package.json +30 -0
- package/packages/api/src/index.ts +4 -0
- package/packages/api/src/lib/api/account.ts +20 -0
- package/packages/api/src/lib/api/actors.ts +149 -0
- package/packages/api/src/lib/api/auth.ts +46 -0
- package/packages/api/src/lib/api/communities.ts +329 -0
- package/packages/api/src/lib/api/dm.test.ts +67 -0
- package/packages/api/src/lib/api/dm.ts +236 -0
- package/packages/api/src/lib/api/fetch.ts +111 -0
- package/packages/api/src/lib/api/follow.ts +30 -0
- package/packages/api/src/lib/api/media.ts +100 -0
- package/packages/api/src/lib/api/moderation.ts +98 -0
- package/packages/api/src/lib/api/normalize.ts +71 -0
- package/packages/api/src/lib/api/notifications.test.ts +63 -0
- package/packages/api/src/lib/api/notifications.ts +61 -0
- package/packages/api/src/lib/api/posts.test.ts +110 -0
- package/packages/api/src/lib/api/posts.ts +181 -0
- package/packages/api/src/lib/api/recommendations.ts +22 -0
- package/packages/api/src/lib/api/search.ts +88 -0
- package/packages/api/src/lib/api/stories.ts +80 -0
- package/packages/api/src/lib/api.ts +15 -0
- package/packages/api/src/lib/fetch-with-timeout.ts +42 -0
- package/packages/api/src/lib/transport.ts +40 -0
- package/packages/api/src/social-server.ts +47 -0
- package/packages/api/src/types/index.ts +185 -0
- package/scripts/apply-takosumi-migrations.ts +621 -0
- package/src/backend/federation-helpers.ts +36 -0
- package/src/backend/index.ts +872 -0
- package/src/backend/lib/account-migration.ts +106 -0
- package/src/backend/lib/activitypub-actor-cache.ts +238 -0
- package/src/backend/lib/activitypub-helpers.ts +131 -0
- package/src/backend/lib/activitypub-validators.ts +323 -0
- package/src/backend/lib/ap-context.ts +16 -0
- package/src/backend/lib/ap-ids.ts +101 -0
- package/src/backend/lib/ap-response.ts +30 -0
- package/src/backend/lib/ap-signing.ts +87 -0
- package/src/backend/lib/ap-verify.ts +670 -0
- package/src/backend/lib/auth-lockout.ts +230 -0
- package/src/backend/lib/backend-paths.ts +34 -0
- package/src/backend/lib/base64.ts +30 -0
- package/src/backend/lib/blocklist-purge.ts +109 -0
- package/src/backend/lib/blocklist.ts +279 -0
- package/src/backend/lib/chunk.ts +33 -0
- package/src/backend/lib/client-ip.ts +169 -0
- package/src/backend/lib/community-visibility.ts +230 -0
- package/src/backend/lib/crypto.ts +424 -0
- package/src/backend/lib/delivery/circuit.ts +265 -0
- package/src/backend/lib/delivery/metrics.ts +30 -0
- package/src/backend/lib/delivery/planner.ts +190 -0
- package/src/backend/lib/delivery/queue-batching.ts +626 -0
- package/src/backend/lib/delivery/queue-delivery.ts +641 -0
- package/src/backend/lib/delivery/queue.ts +576 -0
- package/src/backend/lib/delivery/transformers.ts +56 -0
- package/src/backend/lib/delivery/types.ts +139 -0
- package/src/backend/lib/errors.ts +114 -0
- package/src/backend/lib/federation-fetch.ts +296 -0
- package/src/backend/lib/feed-cursor.ts +57 -0
- package/src/backend/lib/feed-exclude.ts +48 -0
- package/src/backend/lib/hex.ts +8 -0
- package/src/backend/lib/log-mask.ts +213 -0
- package/src/backend/lib/logger.ts +285 -0
- package/src/backend/lib/mobile-contract.ts +137 -0
- package/src/backend/lib/oauth-providers.ts +324 -0
- package/src/backend/lib/oauth-utils.ts +148 -0
- package/src/backend/lib/oidc-id-token.ts +151 -0
- package/src/backend/lib/parse-helpers.ts +31 -0
- package/src/backend/lib/post-visibility.ts +190 -0
- package/src/backend/lib/session-actor.ts +61 -0
- package/src/backend/lib/ssrf.ts +428 -0
- package/src/backend/lib/strip-image-metadata.ts +191 -0
- package/src/backend/middleware/bearer-auth.ts +70 -0
- package/src/backend/middleware/body-limit.ts +212 -0
- package/src/backend/middleware/cache.ts +429 -0
- package/src/backend/middleware/csrf.ts +130 -0
- package/src/backend/middleware/error-handler.ts +77 -0
- package/src/backend/middleware/rate-limit.ts +308 -0
- package/src/backend/public.ts +21 -0
- package/src/backend/routes/account-teardown.ts +430 -0
- package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +354 -0
- package/src/backend/routes/activitypub/handlers/inbound-timestamp.ts +29 -0
- package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1634 -0
- package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +547 -0
- package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +497 -0
- package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +262 -0
- package/src/backend/routes/activitypub/handlers/user-inbox-handlers.ts +35 -0
- package/src/backend/routes/activitypub/inbox-types.ts +74 -0
- package/src/backend/routes/activitypub/inbox.ts +1191 -0
- package/src/backend/routes/activitypub/outbox.ts +0 -0
- package/src/backend/routes/activitypub/query-helpers.ts +227 -0
- package/src/backend/routes/activitypub.ts +616 -0
- package/src/backend/routes/actors-helpers.ts +487 -0
- package/src/backend/routes/actors.ts +1311 -0
- package/src/backend/routes/apps.ts +313 -0
- package/src/backend/routes/auth-helpers.ts +566 -0
- package/src/backend/routes/auth.ts +615 -0
- package/src/backend/routes/communities/membership-invites.ts +208 -0
- package/src/backend/routes/communities/membership-join.ts +335 -0
- package/src/backend/routes/communities/membership-members.ts +539 -0
- package/src/backend/routes/communities/membership-requests.ts +296 -0
- package/src/backend/routes/communities/membership-shared.ts +364 -0
- package/src/backend/routes/communities/messages.ts +479 -0
- package/src/backend/routes/communities/routes.ts +624 -0
- package/src/backend/routes/communities.ts +21 -0
- package/src/backend/routes/dm/contacts.ts +525 -0
- package/src/backend/routes/dm/conversations-helpers.ts +197 -0
- package/src/backend/routes/dm/conversations.ts +25 -0
- package/src/backend/routes/dm/messages.ts +658 -0
- package/src/backend/routes/dm/query-helpers.ts +85 -0
- package/src/backend/routes/dm/read-archive.ts +228 -0
- package/src/backend/routes/dm/requests.ts +222 -0
- package/src/backend/routes/dm/typing.ts +81 -0
- package/src/backend/routes/dm.ts +15 -0
- package/src/backend/routes/follow-helpers.ts +370 -0
- package/src/backend/routes/follow.ts +588 -0
- package/src/backend/routes/media.ts +692 -0
- package/src/backend/routes/mobile.ts +159 -0
- package/src/backend/routes/moderation.ts +373 -0
- package/src/backend/routes/notifications.ts +757 -0
- package/src/backend/routes/posts/delete-cascade.ts +330 -0
- package/src/backend/routes/posts/interactions.ts +795 -0
- package/src/backend/routes/posts/post-helpers.ts +847 -0
- package/src/backend/routes/posts/queries.ts +537 -0
- package/src/backend/routes/posts/routes.ts +865 -0
- package/src/backend/routes/posts/transformers.ts +161 -0
- package/src/backend/routes/posts.ts +17 -0
- package/src/backend/routes/recommendations.ts +88 -0
- package/src/backend/routes/search.ts +730 -0
- package/src/backend/routes/stories/interactions.ts +576 -0
- package/src/backend/routes/stories/query-helpers.ts +482 -0
- package/src/backend/routes/stories/routes.ts +906 -0
- package/src/backend/routes/stories.ts +13 -0
- package/src/backend/routes/takos-tools/dm.ts +249 -0
- package/src/backend/routes/takos-tools/follows.ts +225 -0
- package/src/backend/routes/takos-tools/posts.ts +292 -0
- package/src/backend/routes/takos-tools/search.ts +228 -0
- package/src/backend/routes/takos-tools/timeline.ts +132 -0
- package/src/backend/routes/takos-tools/types.ts +10 -0
- package/src/backend/routes/takos-tools-response.ts +178 -0
- package/src/backend/routes/takos-tools.ts +153 -0
- package/src/backend/routes/timeline.ts +755 -0
- package/src/backend/runtime/bun.ts +620 -0
- package/src/backend/runtime/cloudflare.ts +202 -0
- package/src/backend/runtime/compat-bun/types.ts +44 -0
- package/src/backend/runtime/memory-kv.ts +104 -0
- package/src/backend/runtime/shared.ts +142 -0
- package/src/backend/runtime/types.ts +205 -0
- package/src/backend/server.ts +636 -0
- package/src/backend/types.ts +143 -0
- package/src/db/index.ts +97 -0
- package/src/db/schema/actors.ts +129 -0
- package/src/db/schema/communities.ts +133 -0
- package/src/db/schema/date-utils.ts +17 -0
- package/src/db/schema/index.ts +17 -0
- package/src/db/schema/messaging.ts +241 -0
- package/src/db/schema/mobile.ts +37 -0
- package/src/db/schema/posts.ts +150 -0
- package/src/db/schema/relations.ts +266 -0
- package/src/db/schema/reports.ts +33 -0
- package/src/db/schema/social.ts +106 -0
- package/src/db/schema/stories.ts +70 -0
- package/src/db/schema.ts +15 -0
- package/src/plugin/public.ts +7 -0
- package/src/runtime/site-worker.ts +10 -0
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
import type { Database } from "../../../../db/index.ts";
|
|
2
|
+
import { and, eq, gt, inArray, sql } from "drizzle-orm";
|
|
3
|
+
import {
|
|
4
|
+
activities,
|
|
5
|
+
actors,
|
|
6
|
+
follows,
|
|
7
|
+
likes,
|
|
8
|
+
objects,
|
|
9
|
+
} from "../../../../db/index.ts";
|
|
10
|
+
import {
|
|
11
|
+
activityApId,
|
|
12
|
+
generateId,
|
|
13
|
+
isLocal,
|
|
14
|
+
} from "../../../federation-helpers.ts";
|
|
15
|
+
import { enqueueDeliveryToActor } from "../../../lib/delivery/queue.ts";
|
|
16
|
+
import { actorIsBlockedBy } from "../../../lib/post-visibility.ts";
|
|
17
|
+
import { logger } from "../../../lib/logger.ts";
|
|
18
|
+
import {
|
|
19
|
+
type Activity,
|
|
20
|
+
type ActivityContext,
|
|
21
|
+
getActivityObject,
|
|
22
|
+
getActivityObjectId,
|
|
23
|
+
typeIncludes,
|
|
24
|
+
} from "../inbox-types.ts";
|
|
25
|
+
import {
|
|
26
|
+
findFollowByActivityId,
|
|
27
|
+
runBatch,
|
|
28
|
+
undoInteraction,
|
|
29
|
+
upsertActivityAndNotify,
|
|
30
|
+
} from "./inbox-shared-helpers.ts";
|
|
31
|
+
|
|
32
|
+
type ActorRow = typeof actors.$inferSelect;
|
|
33
|
+
|
|
34
|
+
const log = logger.child({ component: "activitypub.inbox.follow" });
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Follow handler
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
export async function handleFollow(
|
|
41
|
+
c: ActivityContext,
|
|
42
|
+
activity: Activity,
|
|
43
|
+
recipient: ActorRow,
|
|
44
|
+
actor: string,
|
|
45
|
+
baseUrl: string,
|
|
46
|
+
) {
|
|
47
|
+
const db = c.get("db");
|
|
48
|
+
|
|
49
|
+
// A Follow from an actor the recipient has personally BLOCKED must be dropped —
|
|
50
|
+
// mirror the local follow guard (handleLocalFollow's actorIsBlockedBy 404).
|
|
51
|
+
// Without this, a blocked remote could re-follow (auto-accepting on a public
|
|
52
|
+
// recipient → followerCount++ + a follow notification + an Accept, so the
|
|
53
|
+
// blocked actor resumes receiving fan-out), or surface in the owner's
|
|
54
|
+
// follow-request list. `actor` is the HTTP-signature-verified signer. The inbox
|
|
55
|
+
// already ACKs, so dropping here causes no retry storm and does not leak the
|
|
56
|
+
// block to the sender.
|
|
57
|
+
if (await actorIsBlockedBy(db, recipient.apId, actor)) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const activityId = activity.id || activityApId(baseUrl, generateId());
|
|
62
|
+
|
|
63
|
+
// Determine if we need to approve
|
|
64
|
+
const status = recipient.isPrivate ? "pending" : "accepted";
|
|
65
|
+
const now = new Date().toISOString();
|
|
66
|
+
|
|
67
|
+
// Was the edge already present BEFORE this dispatch? This gates the one-shot
|
|
68
|
+
// owner notification / Accept reply below so a duplicate (re)delivery does not
|
|
69
|
+
// spam them — it does NOT gate the counter, which is reconciled atomically in
|
|
70
|
+
// the batch below against the edge table's pre-insert state.
|
|
71
|
+
const existingEdge = await db
|
|
72
|
+
.select({ followerApId: follows.followerApId })
|
|
73
|
+
.from(follows)
|
|
74
|
+
.where(
|
|
75
|
+
and(
|
|
76
|
+
eq(follows.followerApId, actor),
|
|
77
|
+
eq(follows.followingApId, recipient.apId),
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
.get();
|
|
81
|
+
|
|
82
|
+
// #COUNTER-SYM (crash-retry convergence): the followers-edge insert and the
|
|
83
|
+
// recipient.followerCount +1 MUST commit together. Previously the edge was
|
|
84
|
+
// inserted (onConflictDoNothing) and, for an auto-accept recipient, the count
|
|
85
|
+
// was bumped in a SEPARATE statement gated on `isNewFollow`; under the
|
|
86
|
+
// claim/processed re-dispatch model a crash between the insert and the +1, then
|
|
87
|
+
// a peer retry (whose no-op insert sets isNewFollow=false → early return),
|
|
88
|
+
// permanently SKIPPED the increment → a follower UNDER-count. Co-commit the
|
|
89
|
+
// insert and the increment in one atomic batch (mirrors handleAccept / handleAdd).
|
|
90
|
+
//
|
|
91
|
+
// The increment runs BEFORE the insert, guarded by a correlated
|
|
92
|
+
// `NOT EXISTS(edge)` subquery, so it fires only when THIS batch is the one that
|
|
93
|
+
// creates the edge (the absent edge is observed in pre-insert state) AND only
|
|
94
|
+
// for an 'accepted' (non-private) recipient. A duplicate Follow, or a retry
|
|
95
|
+
// after the edge already existed, sees the edge present → the guard is false and
|
|
96
|
+
// the insert is a no-op, so the count can neither double-bump nor under-count.
|
|
97
|
+
// For a private recipient the edge inserts as 'pending' with no count change.
|
|
98
|
+
if (status === "accepted") {
|
|
99
|
+
const edgeAbsent = sql`NOT EXISTS (SELECT 1 FROM ${follows} WHERE ${follows.followerApId} = ${actor} AND ${follows.followingApId} = ${recipient.apId})`;
|
|
100
|
+
await runBatch(db, [
|
|
101
|
+
db
|
|
102
|
+
.update(actors)
|
|
103
|
+
.set({ followerCount: sql`${actors.followerCount} + 1` })
|
|
104
|
+
.where(and(eq(actors.apId, recipient.apId), edgeAbsent)),
|
|
105
|
+
db
|
|
106
|
+
.insert(follows)
|
|
107
|
+
.values({
|
|
108
|
+
followerApId: actor,
|
|
109
|
+
followingApId: recipient.apId,
|
|
110
|
+
status,
|
|
111
|
+
activityApId: activityId,
|
|
112
|
+
acceptedAt: now,
|
|
113
|
+
})
|
|
114
|
+
.onConflictDoNothing(),
|
|
115
|
+
]);
|
|
116
|
+
} else {
|
|
117
|
+
// Private recipient: follow stays pending, no count change.
|
|
118
|
+
await db
|
|
119
|
+
.insert(follows)
|
|
120
|
+
.values({
|
|
121
|
+
followerApId: actor,
|
|
122
|
+
followingApId: recipient.apId,
|
|
123
|
+
status,
|
|
124
|
+
activityApId: activityId,
|
|
125
|
+
acceptedAt: null,
|
|
126
|
+
})
|
|
127
|
+
.onConflictDoNothing();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// If the edge already existed, this is a duplicate (re)delivery: the counter is
|
|
131
|
+
// already correct (the batch's no-op insert kept the NOT-EXISTS guard false),
|
|
132
|
+
// so do not re-notify the owner or re-send an Accept.
|
|
133
|
+
if (existingEdge) return;
|
|
134
|
+
|
|
135
|
+
// Store activity and add to inbox (AP Native notification)
|
|
136
|
+
await upsertActivityAndNotify(
|
|
137
|
+
db,
|
|
138
|
+
activityId,
|
|
139
|
+
"Follow",
|
|
140
|
+
actor,
|
|
141
|
+
recipient.apId,
|
|
142
|
+
activity,
|
|
143
|
+
recipient.apId,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// Send Accept response
|
|
147
|
+
// If the recipient requires approval, do NOT auto-accept.
|
|
148
|
+
if (status === "accepted" && !isLocal(actor, baseUrl)) {
|
|
149
|
+
const acceptId = activityApId(baseUrl, generateId());
|
|
150
|
+
const acceptActivity = {
|
|
151
|
+
"@context": "https://www.w3.org/ns/activitystreams",
|
|
152
|
+
id: acceptId,
|
|
153
|
+
type: "Accept",
|
|
154
|
+
actor: recipient.apId,
|
|
155
|
+
object: activityId,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// Store accept activity before enqueue.
|
|
159
|
+
await db.insert(activities).values({
|
|
160
|
+
apId: acceptId,
|
|
161
|
+
type: "Accept",
|
|
162
|
+
actorApId: recipient.apId,
|
|
163
|
+
objectApId: activityId,
|
|
164
|
+
rawJson: JSON.stringify(acceptActivity),
|
|
165
|
+
direction: "outbound",
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Outbound delivery must be async (no remote POST in request path).
|
|
169
|
+
await enqueueDeliveryToActor(c.env, acceptId, actor);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// Accept handler
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
export async function handleAccept(
|
|
178
|
+
c: ActivityContext,
|
|
179
|
+
activity: Activity,
|
|
180
|
+
actor: string | null,
|
|
181
|
+
) {
|
|
182
|
+
const db = c.get("db");
|
|
183
|
+
const followId = getActivityObjectId(activity);
|
|
184
|
+
if (!followId) return;
|
|
185
|
+
|
|
186
|
+
const follow = await findFollowByActivityId(db, followId);
|
|
187
|
+
if (!follow || follow.status === "accepted") return;
|
|
188
|
+
|
|
189
|
+
// Only the followed party may Accept the follow. The signing actor is bound to
|
|
190
|
+
// its domain upstream, so without this a different-domain actor that learned
|
|
191
|
+
// the follow activity id could flip someone else's pending follow to accepted.
|
|
192
|
+
if (!actor || follow.followingApId !== actor) return;
|
|
193
|
+
|
|
194
|
+
const now = new Date().toISOString();
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
// #COUNTER-SYM (crash-retry convergence): the pending->accepted flip and
|
|
198
|
+
// both +1s MUST commit together. Previously the flip committed first and the
|
|
199
|
+
// increments were SEPARATE statements; a crash between them left the edge
|
|
200
|
+
// 'accepted' while the counts were un-bumped, and the peer's retry saw an
|
|
201
|
+
// already-accepted edge (the early-return above) so the increments were
|
|
202
|
+
// SKIPPED → a permanent UNDER-count. Co-commit the flip and the increments
|
|
203
|
+
// in one atomic batch so the whole transition is all-or-nothing.
|
|
204
|
+
//
|
|
205
|
+
// The two increments run BEFORE the flip and are each guarded by a
|
|
206
|
+
// correlated `EXISTS(... status='pending')` subquery, so they fire only when
|
|
207
|
+
// THIS batch is the one performing the transition (the still-pending edge is
|
|
208
|
+
// observed in pre-flip state). A concurrent duplicate Accept, or a retry
|
|
209
|
+
// after the flip already committed, sees a non-pending edge → both guards
|
|
210
|
+
// are false and the flip's `status='pending'` predicate is a no-op, so the
|
|
211
|
+
// counts can neither double-bump nor (on retry) under-count.
|
|
212
|
+
const pendingEdgeExists = sql`EXISTS (SELECT 1 FROM ${follows} WHERE ${follows.followerApId} = ${follow.followerApId} AND ${follows.followingApId} = ${follow.followingApId} AND ${follows.status} = 'pending')`;
|
|
213
|
+
await runBatch(db, [
|
|
214
|
+
db
|
|
215
|
+
.update(actors)
|
|
216
|
+
.set({ followingCount: sql`${actors.followingCount} + 1` })
|
|
217
|
+
.where(and(eq(actors.apId, follow.followerApId), pendingEdgeExists)),
|
|
218
|
+
db
|
|
219
|
+
.update(actors)
|
|
220
|
+
.set({ followerCount: sql`${actors.followerCount} + 1` })
|
|
221
|
+
.where(and(eq(actors.apId, follow.followingApId), pendingEdgeExists)),
|
|
222
|
+
db
|
|
223
|
+
.update(follows)
|
|
224
|
+
.set({ status: "accepted", acceptedAt: now })
|
|
225
|
+
.where(
|
|
226
|
+
and(
|
|
227
|
+
eq(follows.followerApId, follow.followerApId),
|
|
228
|
+
eq(follows.followingApId, follow.followingApId),
|
|
229
|
+
eq(follows.status, "pending"),
|
|
230
|
+
),
|
|
231
|
+
),
|
|
232
|
+
]);
|
|
233
|
+
} catch (e) {
|
|
234
|
+
log.error("Error in handleAccept", {
|
|
235
|
+
event: "ap.accept.handler_error",
|
|
236
|
+
error: e,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// Reject handler
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
export async function handleReject(
|
|
246
|
+
c: ActivityContext,
|
|
247
|
+
activity: Activity,
|
|
248
|
+
actor: string | null,
|
|
249
|
+
) {
|
|
250
|
+
const db = c.get("db");
|
|
251
|
+
const followId = getActivityObjectId(activity);
|
|
252
|
+
if (!followId) return;
|
|
253
|
+
|
|
254
|
+
const follow = await findFollowByActivityId(db, followId);
|
|
255
|
+
if (!follow) return;
|
|
256
|
+
|
|
257
|
+
// Only the followed party may Reject the follow (see handleAccept).
|
|
258
|
+
if (!actor || follow.followingApId !== actor) return;
|
|
259
|
+
|
|
260
|
+
// A remote followee can Reject an ALREADY-ACCEPTED follow to terminate it
|
|
261
|
+
// (Mastodon does this on lock + remove-follower). handleAccept incremented the
|
|
262
|
+
// local follower's followingCount when the edge became accepted, so a Reject of
|
|
263
|
+
// an accepted edge MUST decrement it — otherwise the follower's following_count
|
|
264
|
+
// stays permanently +1 over its true value (the edge IS correctly deleted, only
|
|
265
|
+
// the denormalized count drifts). Co-commit the accepted-gated decrement with
|
|
266
|
+
// the delete, mirroring undoFollowEdge: a pending (never-counted) reject and a
|
|
267
|
+
// duplicate are no-ops via the EXISTS(... status='accepted') + gt(>0) guards.
|
|
268
|
+
// (followingApId — the rejecting followee — is the remote signer with no local
|
|
269
|
+
// actors row, so only the local follower's followingCount is reconciled.)
|
|
270
|
+
const acceptedEdgeExists = sql`EXISTS (SELECT 1 FROM ${follows} WHERE ${follows.followerApId} = ${follow.followerApId} AND ${follows.followingApId} = ${follow.followingApId} AND ${follows.status} = 'accepted')`;
|
|
271
|
+
await runBatch(db, [
|
|
272
|
+
db
|
|
273
|
+
.update(actors)
|
|
274
|
+
.set({ followingCount: sql`${actors.followingCount} - 1` })
|
|
275
|
+
.where(
|
|
276
|
+
and(
|
|
277
|
+
eq(actors.apId, follow.followerApId),
|
|
278
|
+
gt(actors.followingCount, 0),
|
|
279
|
+
acceptedEdgeExists,
|
|
280
|
+
),
|
|
281
|
+
),
|
|
282
|
+
db
|
|
283
|
+
.delete(follows)
|
|
284
|
+
.where(
|
|
285
|
+
and(
|
|
286
|
+
eq(follows.followerApId, follow.followerApId),
|
|
287
|
+
eq(follows.followingApId, follow.followingApId),
|
|
288
|
+
),
|
|
289
|
+
),
|
|
290
|
+
]);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
// Undo handler
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
export async function handleUndo(
|
|
298
|
+
c: ActivityContext,
|
|
299
|
+
activity: Activity,
|
|
300
|
+
recipient: ActorRow,
|
|
301
|
+
actor: string,
|
|
302
|
+
_baseUrl: string,
|
|
303
|
+
) {
|
|
304
|
+
const db = c.get("db");
|
|
305
|
+
const activityObject = getActivityObject(activity);
|
|
306
|
+
const objectType = activityObject?.type;
|
|
307
|
+
const objectId = getActivityObjectId(activity);
|
|
308
|
+
|
|
309
|
+
// If object is just a string (activity ID), try to find the original activity
|
|
310
|
+
if (!objectType && objectId) {
|
|
311
|
+
const resolved = await resolveUndoByActivityId(
|
|
312
|
+
db,
|
|
313
|
+
objectId,
|
|
314
|
+
actor,
|
|
315
|
+
recipient,
|
|
316
|
+
);
|
|
317
|
+
if (resolved) return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (typeIncludes(objectType, "Follow")) {
|
|
321
|
+
await undoFollow(db, objectId, actor, recipient);
|
|
322
|
+
} else if (typeIncludes(objectType, "Like")) {
|
|
323
|
+
await undoLike(db, objectId, activityObject, actor, recipient);
|
|
324
|
+
} else if (typeIncludes(objectType, "Announce")) {
|
|
325
|
+
await undoAnnounce(db, objectId, activityObject, actor);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
// Undo sub-handlers (internal)
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* When the Undo object is a bare ID string, look up the original activity
|
|
335
|
+
* and undo it based on its stored type.
|
|
336
|
+
* Returns true if handled (caller should return), false otherwise.
|
|
337
|
+
*/
|
|
338
|
+
async function resolveUndoByActivityId(
|
|
339
|
+
db: Database,
|
|
340
|
+
objectId: string,
|
|
341
|
+
actor: string,
|
|
342
|
+
recipient: ActorRow,
|
|
343
|
+
): Promise<boolean> {
|
|
344
|
+
const originalActivity = await db
|
|
345
|
+
.select({
|
|
346
|
+
type: activities.type,
|
|
347
|
+
objectApId: activities.objectApId,
|
|
348
|
+
actorApId: activities.actorApId,
|
|
349
|
+
})
|
|
350
|
+
.from(activities)
|
|
351
|
+
.where(eq(activities.apId, objectId))
|
|
352
|
+
.get();
|
|
353
|
+
if (!originalActivity) return false;
|
|
354
|
+
|
|
355
|
+
if (originalActivity.actorApId && originalActivity.actorApId !== actor) {
|
|
356
|
+
log.warn("Undo actor mismatch", {
|
|
357
|
+
event: "ap.undo.actor_mismatch",
|
|
358
|
+
actor,
|
|
359
|
+
originalActor: originalActivity.actorApId,
|
|
360
|
+
activityId: objectId,
|
|
361
|
+
});
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (originalActivity.type === "Follow") {
|
|
366
|
+
const follow = await findFollowByActivityId(db, objectId);
|
|
367
|
+
if (follow) {
|
|
368
|
+
await undoFollowEdge(
|
|
369
|
+
db,
|
|
370
|
+
follow.followerApId,
|
|
371
|
+
follow.followingApId,
|
|
372
|
+
recipient.apId,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
return true;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (
|
|
379
|
+
(originalActivity.type === "Like" ||
|
|
380
|
+
originalActivity.type === "Announce") &&
|
|
381
|
+
originalActivity.objectApId
|
|
382
|
+
) {
|
|
383
|
+
const kind =
|
|
384
|
+
originalActivity.type === "Like"
|
|
385
|
+
? ("like" as const)
|
|
386
|
+
: ("announce" as const);
|
|
387
|
+
const countField =
|
|
388
|
+
kind === "like" ? ("likeCount" as const) : ("announceCount" as const);
|
|
389
|
+
// #COUNTER-SYM: delegate to `undoInteraction`'s activityId path, which now
|
|
390
|
+
// co-commits the edge delete and a COUNT(*) recompute in one atomic batch.
|
|
391
|
+
// A crash-then-retry converges (the recompute is idempotent against the true
|
|
392
|
+
// edge set) instead of permanently over-counting on the retry's no-op
|
|
393
|
+
// delete. The actor-mismatch guard above still constrains who may undo.
|
|
394
|
+
await undoInteraction(db, kind, countField, undefined, objectId, actor);
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return true;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Atomically remove a follow edge and reconcile `followerCount`.
|
|
403
|
+
*
|
|
404
|
+
* #COUNTER-SYM (crash-retry convergence): the edge delete and the -1 MUST
|
|
405
|
+
* commit together. Previously the delete committed first and the decrement was
|
|
406
|
+
* a SEPARATE statement; a crash between them left the edge gone but the count
|
|
407
|
+
* un-decremented, and the peer's retry matched 0 rows so the decrement was
|
|
408
|
+
* SKIPPED → a permanent OVER-count. Co-commit both in one batch. The decrement
|
|
409
|
+
* runs BEFORE the delete and is guarded by a correlated
|
|
410
|
+
* `EXISTS(... status='accepted')` subquery (so a pending/never-counted edge,
|
|
411
|
+
* a duplicate Undo, or an unknown edge does not drift the count) plus a
|
|
412
|
+
* `followerCount > 0` underflow guard (mirrors the local API delete paths in
|
|
413
|
+
* posts/interactions.ts which batch + guard both sides).
|
|
414
|
+
*/
|
|
415
|
+
async function undoFollowEdge(
|
|
416
|
+
db: Database,
|
|
417
|
+
followerApId: string,
|
|
418
|
+
followingApId: string,
|
|
419
|
+
recipientApId: string,
|
|
420
|
+
): Promise<void> {
|
|
421
|
+
const acceptedEdgeExists = sql`EXISTS (SELECT 1 FROM ${follows} WHERE ${follows.followerApId} = ${followerApId} AND ${follows.followingApId} = ${followingApId} AND ${follows.status} = 'accepted')`;
|
|
422
|
+
await runBatch(db, [
|
|
423
|
+
db
|
|
424
|
+
.update(actors)
|
|
425
|
+
.set({ followerCount: sql`${actors.followerCount} - 1` })
|
|
426
|
+
.where(
|
|
427
|
+
and(
|
|
428
|
+
eq(actors.apId, recipientApId),
|
|
429
|
+
gt(actors.followerCount, 0),
|
|
430
|
+
acceptedEdgeExists,
|
|
431
|
+
),
|
|
432
|
+
),
|
|
433
|
+
db
|
|
434
|
+
.delete(follows)
|
|
435
|
+
.where(
|
|
436
|
+
and(
|
|
437
|
+
eq(follows.followerApId, followerApId),
|
|
438
|
+
eq(follows.followingApId, followingApId),
|
|
439
|
+
),
|
|
440
|
+
),
|
|
441
|
+
]);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function undoFollow(
|
|
445
|
+
db: Database,
|
|
446
|
+
objectId: string | null,
|
|
447
|
+
actor: string,
|
|
448
|
+
recipient: ActorRow,
|
|
449
|
+
): Promise<void> {
|
|
450
|
+
const follow = objectId ? await findFollowByActivityId(db, objectId) : null;
|
|
451
|
+
|
|
452
|
+
// Bind the undo to the VERIFIED signer: an Undo(Follow) may only remove the
|
|
453
|
+
// SIGNER's own follow edge. The activity id is public (it appears in the
|
|
454
|
+
// originator's outbox), so a resolved edge whose follower != `actor` is a
|
|
455
|
+
// cross-actor forgery — an attacker severing a victim's follow + decrementing
|
|
456
|
+
// their followerCount. The bare-string Undo path already enforces this via
|
|
457
|
+
// resolveUndoByActivityId; the typed-object path must too.
|
|
458
|
+
if (follow && follow.followerApId !== actor) {
|
|
459
|
+
log.warn("Undo(Follow) actor mismatch", {
|
|
460
|
+
event: "ap.undo.follow.actor_mismatch",
|
|
461
|
+
actor,
|
|
462
|
+
followOwner: follow.followerApId,
|
|
463
|
+
activityId: objectId,
|
|
464
|
+
});
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// #COUNTER-SYM: co-commit the edge delete and the followerCount -1 atomically
|
|
469
|
+
// (see `undoFollowEdge`). `handleFollow` increments followerCount only for an
|
|
470
|
+
// 'accepted' follow, so the decrement is gated on the edge being accepted; a
|
|
471
|
+
// duplicate Undo, an Undo of a never-accepted (pending) follow, or an Undo of
|
|
472
|
+
// an unknown follow is a clean no-op and a crash-then-retry converges instead
|
|
473
|
+
// of permanently over-counting.
|
|
474
|
+
const followerApId = follow ? follow.followerApId : actor;
|
|
475
|
+
const followingApId = follow ? follow.followingApId : recipient.apId;
|
|
476
|
+
await undoFollowEdge(db, followerApId, followingApId, recipient.apId);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async function undoLike(
|
|
480
|
+
db: Database,
|
|
481
|
+
objectId: string | null,
|
|
482
|
+
activityObject: ReturnType<typeof getActivityObject>,
|
|
483
|
+
actor: string,
|
|
484
|
+
recipient: ActorRow,
|
|
485
|
+
): Promise<void> {
|
|
486
|
+
const handled = await undoInteraction(
|
|
487
|
+
db,
|
|
488
|
+
"like",
|
|
489
|
+
"likeCount",
|
|
490
|
+
activityObject?.object,
|
|
491
|
+
objectId,
|
|
492
|
+
actor,
|
|
493
|
+
);
|
|
494
|
+
if (handled) return;
|
|
495
|
+
|
|
496
|
+
// Last resort: delete any like from this actor for the recipient's objects.
|
|
497
|
+
// #COUNTER-SYM: co-commit the edge delete and a COUNT(*) recompute of likeCount
|
|
498
|
+
// in one atomic batch (mirrors the primary undoInteraction path). Previously
|
|
499
|
+
// the delete committed first and the count was decremented (`- 1`) in a
|
|
500
|
+
// SEPARATE statement; a crash between them, then a peer retry whose delete
|
|
501
|
+
// matches 0 rows, permanently SKIPPED the decrement → a like OVER-count.
|
|
502
|
+
// Deriving each affected object's likeCount from COUNT(*) of the remaining like
|
|
503
|
+
// rows makes the fallback idempotent so a crash-then-retry CONVERGES instead of
|
|
504
|
+
// drifting.
|
|
505
|
+
// The recipient (local user) can have authored thousands of objects; splicing
|
|
506
|
+
// every id into an `IN (...)` would blow D1's 100-bound-parameter ceiling.
|
|
507
|
+
// Express the affected set as a SUBQUERY (delete) and a direct predicate
|
|
508
|
+
// (update) so no per-object parameter is bound. An empty set is a harmless
|
|
509
|
+
// no-op batch, so the previous early-return is unnecessary.
|
|
510
|
+
const recipientObjectsSubquery = db
|
|
511
|
+
.select({ apId: objects.apId })
|
|
512
|
+
.from(objects)
|
|
513
|
+
.where(eq(objects.attributedTo, recipient.apId));
|
|
514
|
+
|
|
515
|
+
await runBatch(db, [
|
|
516
|
+
db
|
|
517
|
+
.delete(likes)
|
|
518
|
+
.where(
|
|
519
|
+
and(
|
|
520
|
+
eq(likes.actorApId, actor),
|
|
521
|
+
inArray(likes.objectApId, recipientObjectsSubquery),
|
|
522
|
+
),
|
|
523
|
+
),
|
|
524
|
+
db
|
|
525
|
+
.update(objects)
|
|
526
|
+
.set({
|
|
527
|
+
likeCount: sql`(SELECT COUNT(*) FROM ${likes} WHERE ${likes.objectApId} = ${objects.apId})`,
|
|
528
|
+
})
|
|
529
|
+
.where(eq(objects.attributedTo, recipient.apId)),
|
|
530
|
+
]);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async function undoAnnounce(
|
|
534
|
+
db: Database,
|
|
535
|
+
objectId: string | null,
|
|
536
|
+
activityObject: ReturnType<typeof getActivityObject>,
|
|
537
|
+
actor: string,
|
|
538
|
+
): Promise<void> {
|
|
539
|
+
await undoInteraction(
|
|
540
|
+
db,
|
|
541
|
+
"announce",
|
|
542
|
+
"announceCount",
|
|
543
|
+
activityObject?.object,
|
|
544
|
+
objectId,
|
|
545
|
+
actor,
|
|
546
|
+
);
|
|
547
|
+
}
|