@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,588 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { and, desc, eq, gt, inArray, sql } from "drizzle-orm";
|
|
3
|
+
import type { Env, Variables } from "../types.ts";
|
|
4
|
+
import {
|
|
5
|
+
activities,
|
|
6
|
+
actorCache,
|
|
7
|
+
actors,
|
|
8
|
+
follows,
|
|
9
|
+
inbox,
|
|
10
|
+
} from "../../db/index.ts";
|
|
11
|
+
import {
|
|
12
|
+
activityApId,
|
|
13
|
+
formatUsername,
|
|
14
|
+
generateId,
|
|
15
|
+
isLocal,
|
|
16
|
+
isSafeRemoteUrl,
|
|
17
|
+
parseLimit,
|
|
18
|
+
parseOffset,
|
|
19
|
+
} from "../federation-helpers.ts";
|
|
20
|
+
import { enqueueDeliveryToActor } from "../lib/delivery/queue.ts";
|
|
21
|
+
import {
|
|
22
|
+
buildApActivity,
|
|
23
|
+
createAndDeliverActivity,
|
|
24
|
+
deliverResponseIfRemote,
|
|
25
|
+
findPendingFollow,
|
|
26
|
+
handleLocalFollow,
|
|
27
|
+
handleRemoteFollow,
|
|
28
|
+
isResponse,
|
|
29
|
+
parseNonEmptyString,
|
|
30
|
+
parseStringArray,
|
|
31
|
+
requireActorAndBody,
|
|
32
|
+
} from "./follow-helpers.ts";
|
|
33
|
+
import { logger } from "../lib/logger.ts";
|
|
34
|
+
|
|
35
|
+
const log = logger.child({ component: "follow" });
|
|
36
|
+
|
|
37
|
+
// `.batch` lives only on the concrete D1/libsql subclasses, not the Database
|
|
38
|
+
// union; reach it through a narrow structural cast (matching the other routes).
|
|
39
|
+
type Batchable = { batch: (stmts: unknown[]) => Promise<unknown> };
|
|
40
|
+
|
|
41
|
+
// Capped at 90 (not 100): the accepted ids are re-queried via
|
|
42
|
+
// `inArray(follows.followerApId, requesterApIds)` and Cloudflare D1 allows at
|
|
43
|
+
// most 100 bound parameters per query (libsql, used by the tests, allows ~32k).
|
|
44
|
+
const MAX_BATCH_ACCEPT_SIZE = 90;
|
|
45
|
+
|
|
46
|
+
const follow = new Hono<{ Bindings: Env; Variables: Variables }>();
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// POST / -- Follow an actor (local or remote)
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
follow.post("/", async (c) => {
|
|
53
|
+
const ctx = await requireActorAndBody(c);
|
|
54
|
+
if (isResponse(ctx)) return ctx;
|
|
55
|
+
const { actor, body, baseUrl, db } = ctx;
|
|
56
|
+
|
|
57
|
+
const targetApId = parseNonEmptyString(body.target_ap_id);
|
|
58
|
+
if (!targetApId) {
|
|
59
|
+
return c.json({ error: "target_ap_id required", code: "BAD_REQUEST" }, 400);
|
|
60
|
+
}
|
|
61
|
+
if (targetApId === actor.ap_id) {
|
|
62
|
+
return c.json({ error: "Cannot follow yourself" }, 400);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const existing = await db
|
|
66
|
+
.select()
|
|
67
|
+
.from(follows)
|
|
68
|
+
.where(
|
|
69
|
+
and(
|
|
70
|
+
eq(follows.followerApId, actor.ap_id),
|
|
71
|
+
eq(follows.followingApId, targetApId),
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
.get();
|
|
75
|
+
if (existing) {
|
|
76
|
+
// A leftover 'rejected' edge (from before reject switched to deleting the
|
|
77
|
+
// row) must not permanently block a re-follow — clear it and fall through to
|
|
78
|
+
// a fresh request. A live pending/accepted edge still blocks.
|
|
79
|
+
if (existing.status === "rejected") {
|
|
80
|
+
await db
|
|
81
|
+
.delete(follows)
|
|
82
|
+
.where(
|
|
83
|
+
and(
|
|
84
|
+
eq(follows.followerApId, actor.ap_id),
|
|
85
|
+
eq(follows.followingApId, targetApId),
|
|
86
|
+
),
|
|
87
|
+
);
|
|
88
|
+
} else {
|
|
89
|
+
return c.json({ error: "Already following or pending" }, 400);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (isLocal(targetApId, baseUrl)) {
|
|
94
|
+
return handleLocalFollow(c, db, baseUrl, actor, targetApId);
|
|
95
|
+
}
|
|
96
|
+
return handleRemoteFollow(c, db, baseUrl, actor, targetApId);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// DELETE / -- Unfollow
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
follow.delete("/", async (c) => {
|
|
104
|
+
const ctx = await requireActorAndBody(c);
|
|
105
|
+
if (isResponse(ctx)) return ctx;
|
|
106
|
+
const { actor, body, baseUrl, db } = ctx;
|
|
107
|
+
|
|
108
|
+
const targetApId = parseNonEmptyString(body.target_ap_id);
|
|
109
|
+
if (!targetApId) {
|
|
110
|
+
return c.json({ error: "target_ap_id required", code: "BAD_REQUEST" }, 400);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const existingFollow = await db
|
|
114
|
+
.select()
|
|
115
|
+
.from(follows)
|
|
116
|
+
.where(
|
|
117
|
+
and(
|
|
118
|
+
eq(follows.followerApId, actor.ap_id),
|
|
119
|
+
eq(follows.followingApId, targetApId),
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
.get();
|
|
123
|
+
if (!existingFollow) return c.json({ error: "Not following" }, 400);
|
|
124
|
+
|
|
125
|
+
const wasAccepted = existingFollow.status === "accepted";
|
|
126
|
+
const targetIsLocal = isLocal(targetApId, baseUrl);
|
|
127
|
+
|
|
128
|
+
const deleteEdge = db
|
|
129
|
+
.delete(follows)
|
|
130
|
+
.where(
|
|
131
|
+
and(
|
|
132
|
+
eq(follows.followerApId, actor.ap_id),
|
|
133
|
+
eq(follows.followingApId, targetApId),
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
if (wasAccepted) {
|
|
138
|
+
// Co-commit the decrements + delete in ONE batch so a crash between them
|
|
139
|
+
// can't leave the edge gone with un-decremented counts (permanent over-
|
|
140
|
+
// count). Decrements run BEFORE the delete, guarded by EXISTS(accepted edge)
|
|
141
|
+
// + count>0 (underflow) — mirrors the federated undoFollowEdge.
|
|
142
|
+
const acceptedEdgeExists = sql`EXISTS (SELECT 1 FROM ${follows} WHERE ${follows.followerApId} = ${actor.ap_id} AND ${follows.followingApId} = ${targetApId} AND ${follows.status} = 'accepted')`;
|
|
143
|
+
const stmts: unknown[] = [
|
|
144
|
+
db
|
|
145
|
+
.update(actors)
|
|
146
|
+
.set({ followingCount: sql`${actors.followingCount} - 1` })
|
|
147
|
+
.where(
|
|
148
|
+
and(
|
|
149
|
+
eq(actors.apId, actor.ap_id),
|
|
150
|
+
gt(actors.followingCount, 0),
|
|
151
|
+
acceptedEdgeExists,
|
|
152
|
+
),
|
|
153
|
+
),
|
|
154
|
+
];
|
|
155
|
+
if (targetIsLocal) {
|
|
156
|
+
stmts.push(
|
|
157
|
+
db
|
|
158
|
+
.update(actors)
|
|
159
|
+
.set({ followerCount: sql`${actors.followerCount} - 1` })
|
|
160
|
+
.where(
|
|
161
|
+
and(
|
|
162
|
+
eq(actors.apId, targetApId),
|
|
163
|
+
gt(actors.followerCount, 0),
|
|
164
|
+
acceptedEdgeExists,
|
|
165
|
+
),
|
|
166
|
+
),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
stmts.push(deleteEdge);
|
|
170
|
+
await (db as unknown as Batchable).batch(stmts);
|
|
171
|
+
} else {
|
|
172
|
+
await deleteEdge;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (!targetIsLocal) {
|
|
176
|
+
const undoObject = {
|
|
177
|
+
type: "Follow",
|
|
178
|
+
actor: actor.ap_id,
|
|
179
|
+
object: targetApId,
|
|
180
|
+
};
|
|
181
|
+
await createAndDeliverActivity(
|
|
182
|
+
c.env,
|
|
183
|
+
db,
|
|
184
|
+
baseUrl,
|
|
185
|
+
"Undo",
|
|
186
|
+
actor.ap_id,
|
|
187
|
+
undoObject,
|
|
188
|
+
targetApId,
|
|
189
|
+
targetApId,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return c.json({ success: true });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// POST /accept -- Accept a single follow request
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
follow.post("/accept", async (c) => {
|
|
201
|
+
const ctx = await requireActorAndBody(c);
|
|
202
|
+
if (isResponse(ctx)) return ctx;
|
|
203
|
+
const { actor, body, baseUrl, db } = ctx;
|
|
204
|
+
|
|
205
|
+
const requesterApId = parseNonEmptyString(body.requester_ap_id);
|
|
206
|
+
if (!requesterApId) {
|
|
207
|
+
return c.json(
|
|
208
|
+
{ error: "requester_ap_id required", code: "BAD_REQUEST" },
|
|
209
|
+
400,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let pendingFollow: Awaited<ReturnType<typeof findPendingFollow>>;
|
|
214
|
+
try {
|
|
215
|
+
// Read the pending edge first (we need its activityApId for the Accept
|
|
216
|
+
// delivery below), then co-commit the flip + both increments in ONE batch.
|
|
217
|
+
// Previously the flip committed and the +1s were SEPARATE autocommits: a
|
|
218
|
+
// crash between them left the edge accepted with un-bumped counts, and the
|
|
219
|
+
// retry's conditional flip matched 0 rows so the increment was permanently
|
|
220
|
+
// SKIPPED (under-count). The increments are guarded by EXISTS(... pending) —
|
|
221
|
+
// evaluated before the in-batch flip — and the flip keeps its `status=
|
|
222
|
+
// 'pending'` predicate, so a concurrent duplicate accept can neither double-
|
|
223
|
+
// count nor under-count. Mirrors the federated handleAccept.
|
|
224
|
+
const pending = await findPendingFollow(db, requesterApId, actor.ap_id);
|
|
225
|
+
if (!pending) {
|
|
226
|
+
pendingFollow = undefined;
|
|
227
|
+
} else {
|
|
228
|
+
const pendingExists = sql`EXISTS (SELECT 1 FROM ${follows} WHERE ${follows.followerApId} = ${requesterApId} AND ${follows.followingApId} = ${actor.ap_id} AND ${follows.status} = 'pending')`;
|
|
229
|
+
const stmts: unknown[] = [
|
|
230
|
+
db
|
|
231
|
+
.update(actors)
|
|
232
|
+
.set({ followerCount: sql`${actors.followerCount} + 1` })
|
|
233
|
+
.where(and(eq(actors.apId, actor.ap_id), pendingExists)),
|
|
234
|
+
];
|
|
235
|
+
if (isLocal(requesterApId, baseUrl)) {
|
|
236
|
+
stmts.push(
|
|
237
|
+
db
|
|
238
|
+
.update(actors)
|
|
239
|
+
.set({ followingCount: sql`${actors.followingCount} + 1` })
|
|
240
|
+
.where(and(eq(actors.apId, requesterApId), pendingExists)),
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
stmts.push(
|
|
244
|
+
db
|
|
245
|
+
.update(follows)
|
|
246
|
+
.set({ status: "accepted", acceptedAt: new Date().toISOString() })
|
|
247
|
+
.where(
|
|
248
|
+
and(
|
|
249
|
+
eq(follows.followerApId, requesterApId),
|
|
250
|
+
eq(follows.followingApId, actor.ap_id),
|
|
251
|
+
eq(follows.status, "pending"),
|
|
252
|
+
),
|
|
253
|
+
),
|
|
254
|
+
);
|
|
255
|
+
await (db as unknown as Batchable).batch(stmts);
|
|
256
|
+
pendingFollow = pending;
|
|
257
|
+
}
|
|
258
|
+
} catch (e) {
|
|
259
|
+
log.error("Error in accept", {
|
|
260
|
+
event: "follow.accept.error",
|
|
261
|
+
error: e,
|
|
262
|
+
});
|
|
263
|
+
return c.json({ error: "Internal error" }, 500);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (!pendingFollow) {
|
|
267
|
+
return c.json({ error: "No pending follow request" }, 404);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
await deliverResponseIfRemote(
|
|
271
|
+
c.env,
|
|
272
|
+
db,
|
|
273
|
+
baseUrl,
|
|
274
|
+
"Accept",
|
|
275
|
+
actor.ap_id,
|
|
276
|
+
requesterApId,
|
|
277
|
+
pendingFollow.activityApId,
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
return c.json({ success: true });
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
// POST /accept/batch -- Batch accept follow requests
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
follow.post("/accept/batch", async (c) => {
|
|
288
|
+
const ctx = await requireActorAndBody(c);
|
|
289
|
+
if (isResponse(ctx)) return ctx;
|
|
290
|
+
const { actor, body, baseUrl, db } = ctx;
|
|
291
|
+
|
|
292
|
+
const requesterApIds = parseStringArray(body.requester_ap_ids);
|
|
293
|
+
if (!requesterApIds || requesterApIds.length === 0) {
|
|
294
|
+
return c.json(
|
|
295
|
+
{
|
|
296
|
+
error: "requester_ap_ids array required",
|
|
297
|
+
code: "BAD_REQUEST",
|
|
298
|
+
},
|
|
299
|
+
400,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
if (requesterApIds.length > MAX_BATCH_ACCEPT_SIZE) {
|
|
303
|
+
return c.json(
|
|
304
|
+
{
|
|
305
|
+
error: `Batch size exceeds maximum of ${MAX_BATCH_ACCEPT_SIZE}`,
|
|
306
|
+
},
|
|
307
|
+
400,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const pendingFollows = await db
|
|
312
|
+
.select()
|
|
313
|
+
.from(follows)
|
|
314
|
+
.where(
|
|
315
|
+
and(
|
|
316
|
+
inArray(follows.followerApId, requesterApIds),
|
|
317
|
+
eq(follows.followingApId, actor.ap_id),
|
|
318
|
+
eq(follows.status, "pending"),
|
|
319
|
+
),
|
|
320
|
+
);
|
|
321
|
+
const pendingFollowMap = new Map(
|
|
322
|
+
pendingFollows.map((f) => [f.followerApId, f]),
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
const results: { ap_id: string; success: boolean; error?: string }[] = [];
|
|
326
|
+
const activitiesToCreate: Array<{
|
|
327
|
+
apId: string;
|
|
328
|
+
type: string;
|
|
329
|
+
actorApId: string;
|
|
330
|
+
objectApId: string | undefined;
|
|
331
|
+
rawJson: string;
|
|
332
|
+
direction: string;
|
|
333
|
+
}> = [];
|
|
334
|
+
const remoteEnqueues: Array<{ activityId: string; recipientApId: string }> =
|
|
335
|
+
[];
|
|
336
|
+
|
|
337
|
+
for (const requesterApId of requesterApIds) {
|
|
338
|
+
const pendingFollow = pendingFollowMap.get(requesterApId);
|
|
339
|
+
if (!pendingFollow) {
|
|
340
|
+
results.push({
|
|
341
|
+
ap_id: requesterApId,
|
|
342
|
+
success: false,
|
|
343
|
+
error: "No pending follow request",
|
|
344
|
+
});
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
try {
|
|
349
|
+
// Co-commit the flip + increments per request in ONE batch (mirrors the
|
|
350
|
+
// single accept). Previously each flip committed in the loop and the
|
|
351
|
+
// counts were bumped ONCE after the loop, so a crash mid-loop lost the
|
|
352
|
+
// accumulated increments for already-flipped rows. Increments guarded by
|
|
353
|
+
// EXISTS(pending) before the flip; the flip keeps its pending predicate.
|
|
354
|
+
const requesterIsLocal = isLocal(requesterApId, baseUrl);
|
|
355
|
+
const pendingExists = sql`EXISTS (SELECT 1 FROM ${follows} WHERE ${follows.followerApId} = ${requesterApId} AND ${follows.followingApId} = ${actor.ap_id} AND ${follows.status} = 'pending')`;
|
|
356
|
+
const stmts: unknown[] = [
|
|
357
|
+
db
|
|
358
|
+
.update(actors)
|
|
359
|
+
.set({ followerCount: sql`${actors.followerCount} + 1` })
|
|
360
|
+
.where(and(eq(actors.apId, actor.ap_id), pendingExists)),
|
|
361
|
+
];
|
|
362
|
+
if (requesterIsLocal) {
|
|
363
|
+
stmts.push(
|
|
364
|
+
db
|
|
365
|
+
.update(actors)
|
|
366
|
+
.set({ followingCount: sql`${actors.followingCount} + 1` })
|
|
367
|
+
.where(and(eq(actors.apId, requesterApId), pendingExists)),
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
stmts.push(
|
|
371
|
+
db
|
|
372
|
+
.update(follows)
|
|
373
|
+
.set({ status: "accepted", acceptedAt: new Date().toISOString() })
|
|
374
|
+
.where(
|
|
375
|
+
and(
|
|
376
|
+
eq(follows.followerApId, requesterApId),
|
|
377
|
+
eq(follows.followingApId, actor.ap_id),
|
|
378
|
+
eq(follows.status, "pending"),
|
|
379
|
+
),
|
|
380
|
+
),
|
|
381
|
+
);
|
|
382
|
+
await (db as unknown as Batchable).batch(stmts);
|
|
383
|
+
|
|
384
|
+
if (requesterIsLocal) {
|
|
385
|
+
// local follower's followingCount already bumped in the batch above
|
|
386
|
+
} else if (isSafeRemoteUrl(requesterApId)) {
|
|
387
|
+
const id = activityApId(baseUrl, generateId());
|
|
388
|
+
const activity = buildApActivity(
|
|
389
|
+
"Accept",
|
|
390
|
+
actor.ap_id,
|
|
391
|
+
pendingFollow.activityApId,
|
|
392
|
+
id,
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
activitiesToCreate.push({
|
|
396
|
+
apId: id,
|
|
397
|
+
type: "Accept",
|
|
398
|
+
actorApId: actor.ap_id,
|
|
399
|
+
objectApId: pendingFollow.activityApId || undefined,
|
|
400
|
+
rawJson: JSON.stringify(activity),
|
|
401
|
+
direction: "outbound",
|
|
402
|
+
});
|
|
403
|
+
remoteEnqueues.push({ activityId: id, recipientApId: requesterApId });
|
|
404
|
+
} else {
|
|
405
|
+
log.warn("Blocked unsafe remote actor", {
|
|
406
|
+
event: "follow.accept.unsafe_remote_actor",
|
|
407
|
+
actor: requesterApId,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
results.push({ ap_id: requesterApId, success: true });
|
|
412
|
+
} catch {
|
|
413
|
+
results.push({
|
|
414
|
+
ap_id: requesterApId,
|
|
415
|
+
success: false,
|
|
416
|
+
error: "Internal error",
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// (Counts are bumped per-request inside the loop's batch — no aggregate
|
|
422
|
+
// post-loop increment, which could be lost on a mid-loop crash.)
|
|
423
|
+
|
|
424
|
+
if (activitiesToCreate.length > 0) {
|
|
425
|
+
await db.insert(activities).values(activitiesToCreate);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (remoteEnqueues.length > 0) {
|
|
429
|
+
await Promise.allSettled(
|
|
430
|
+
remoteEnqueues.map((e) =>
|
|
431
|
+
enqueueDeliveryToActor(c.env, e.activityId, e.recipientApId),
|
|
432
|
+
),
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return c.json({
|
|
437
|
+
results,
|
|
438
|
+
accepted_count: results.filter((r) => r.success).length,
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// ---------------------------------------------------------------------------
|
|
443
|
+
// POST /reject -- Reject a follow request
|
|
444
|
+
// ---------------------------------------------------------------------------
|
|
445
|
+
|
|
446
|
+
follow.post("/reject", async (c) => {
|
|
447
|
+
const ctx = await requireActorAndBody(c);
|
|
448
|
+
if (isResponse(ctx)) return ctx;
|
|
449
|
+
const { actor, body, baseUrl, db } = ctx;
|
|
450
|
+
|
|
451
|
+
const requesterApId = parseNonEmptyString(body.requester_ap_id);
|
|
452
|
+
if (!requesterApId) {
|
|
453
|
+
return c.json(
|
|
454
|
+
{ error: "requester_ap_id required", code: "BAD_REQUEST" },
|
|
455
|
+
400,
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const pendingFollow = await findPendingFollow(db, requesterApId, actor.ap_id);
|
|
460
|
+
if (!pendingFollow) {
|
|
461
|
+
return c.json({ error: "No pending follow request" }, 404);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// DELETE the edge rather than parking it at status='rejected'. A 'rejected'
|
|
465
|
+
// row is a permanent dead state: the create path blocks on ANY existing edge
|
|
466
|
+
// ("Already following or pending") and the inbound handleFollow early-returns
|
|
467
|
+
// on a present edge, so the requester could NEVER re-follow. Deleting matches
|
|
468
|
+
// the INBOUND reject path (handleReject -> deleteFollowByCompoundKey) and the
|
|
469
|
+
// community join-request re-pend behaviour, letting a fresh Follow start clean.
|
|
470
|
+
// A pending edge was never counted, so no counter reconcile is needed.
|
|
471
|
+
await db
|
|
472
|
+
.delete(follows)
|
|
473
|
+
.where(
|
|
474
|
+
and(
|
|
475
|
+
eq(follows.followerApId, requesterApId),
|
|
476
|
+
eq(follows.followingApId, actor.ap_id),
|
|
477
|
+
),
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
if (pendingFollow.activityApId) {
|
|
481
|
+
await db
|
|
482
|
+
.update(inbox)
|
|
483
|
+
.set({ read: 1 })
|
|
484
|
+
.where(
|
|
485
|
+
and(
|
|
486
|
+
eq(inbox.actorApId, actor.ap_id),
|
|
487
|
+
eq(inbox.activityApId, pendingFollow.activityApId),
|
|
488
|
+
),
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
await deliverResponseIfRemote(
|
|
493
|
+
c.env,
|
|
494
|
+
db,
|
|
495
|
+
baseUrl,
|
|
496
|
+
"Reject",
|
|
497
|
+
actor.ap_id,
|
|
498
|
+
requesterApId,
|
|
499
|
+
pendingFollow.activityApId,
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
return c.json({ success: true });
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
// ---------------------------------------------------------------------------
|
|
506
|
+
// GET /requests -- Pending follow requests
|
|
507
|
+
// ---------------------------------------------------------------------------
|
|
508
|
+
|
|
509
|
+
follow.get("/requests", async (c) => {
|
|
510
|
+
const actor = c.get("actor");
|
|
511
|
+
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
512
|
+
|
|
513
|
+
const db = c.get("db");
|
|
514
|
+
// Capped at 90: this page's followerApIds are re-queried via `inArray` below,
|
|
515
|
+
// and Cloudflare D1 allows at most 100 bound parameters per query. The
|
|
516
|
+
// unclamped fallback (parseLimit returns it verbatim when no param is given)
|
|
517
|
+
// must also be <=90, hence 90/90. Offset paginates the rest.
|
|
518
|
+
const limit = parseLimit(c.req.query("limit"), 90, 90);
|
|
519
|
+
const offset = parseOffset(c.req.query("offset"), 0, 10000);
|
|
520
|
+
|
|
521
|
+
const followRows = await db
|
|
522
|
+
.select()
|
|
523
|
+
.from(follows)
|
|
524
|
+
.where(
|
|
525
|
+
and(
|
|
526
|
+
eq(follows.followingApId, actor.ap_id),
|
|
527
|
+
eq(follows.status, "pending"),
|
|
528
|
+
),
|
|
529
|
+
)
|
|
530
|
+
// followerApId is the PK discriminator (followingApId is fixed = me); add it
|
|
531
|
+
// as a tiebreaker so same-millisecond pending requests page deterministically.
|
|
532
|
+
.orderBy(desc(follows.createdAt), desc(follows.followerApId))
|
|
533
|
+
.limit(limit)
|
|
534
|
+
.offset(offset);
|
|
535
|
+
|
|
536
|
+
const followerApIds = followRows.map((f) => f.followerApId);
|
|
537
|
+
const [localActors, cachedActors] = await Promise.all([
|
|
538
|
+
followerApIds.length > 0
|
|
539
|
+
? db
|
|
540
|
+
.select({
|
|
541
|
+
apId: actors.apId,
|
|
542
|
+
preferredUsername: actors.preferredUsername,
|
|
543
|
+
name: actors.name,
|
|
544
|
+
iconUrl: actors.iconUrl,
|
|
545
|
+
})
|
|
546
|
+
.from(actors)
|
|
547
|
+
.where(inArray(actors.apId, followerApIds))
|
|
548
|
+
: Promise.resolve([]),
|
|
549
|
+
followerApIds.length > 0
|
|
550
|
+
? db
|
|
551
|
+
.select({
|
|
552
|
+
apId: actorCache.apId,
|
|
553
|
+
preferredUsername: actorCache.preferredUsername,
|
|
554
|
+
name: actorCache.name,
|
|
555
|
+
iconUrl: actorCache.iconUrl,
|
|
556
|
+
})
|
|
557
|
+
.from(actorCache)
|
|
558
|
+
.where(inArray(actorCache.apId, followerApIds))
|
|
559
|
+
: Promise.resolve([]),
|
|
560
|
+
]);
|
|
561
|
+
|
|
562
|
+
const actorInfoMap = new Map<
|
|
563
|
+
string,
|
|
564
|
+
{
|
|
565
|
+
preferredUsername: string | null;
|
|
566
|
+
name: string | null;
|
|
567
|
+
iconUrl: string | null;
|
|
568
|
+
}
|
|
569
|
+
>();
|
|
570
|
+
for (const a of cachedActors) actorInfoMap.set(a.apId, a);
|
|
571
|
+
for (const a of localActors) actorInfoMap.set(a.apId, a);
|
|
572
|
+
|
|
573
|
+
const result = followRows.map((f) => {
|
|
574
|
+
const actorInfo = actorInfoMap.get(f.followerApId);
|
|
575
|
+
return {
|
|
576
|
+
ap_id: f.followerApId,
|
|
577
|
+
username: formatUsername(f.followerApId),
|
|
578
|
+
preferred_username: actorInfo?.preferredUsername || null,
|
|
579
|
+
name: actorInfo?.name || null,
|
|
580
|
+
icon_url: actorInfo?.iconUrl || null,
|
|
581
|
+
created_at: f.createdAt,
|
|
582
|
+
};
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
return c.json({ requests: result });
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
export default follow;
|