@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,497 @@
|
|
|
1
|
+
import { and, eq, gt, sql } from "drizzle-orm";
|
|
2
|
+
import type { BatchItem } from "drizzle-orm/batch";
|
|
3
|
+
import {
|
|
4
|
+
actors,
|
|
5
|
+
announces,
|
|
6
|
+
follows,
|
|
7
|
+
likes,
|
|
8
|
+
objects,
|
|
9
|
+
reports,
|
|
10
|
+
} from "../../../../db/index.ts";
|
|
11
|
+
import type { Database } from "../../../../db/index.ts";
|
|
12
|
+
import {
|
|
13
|
+
activityApId,
|
|
14
|
+
generateId,
|
|
15
|
+
getDomain,
|
|
16
|
+
} from "../../../federation-helpers.ts";
|
|
17
|
+
import {
|
|
18
|
+
type Activity,
|
|
19
|
+
type ActivityContext,
|
|
20
|
+
getActivityObjectId,
|
|
21
|
+
} from "../inbox-types.ts";
|
|
22
|
+
import { notifyLocalObjectOwner } from "./inbox-shared-helpers.ts";
|
|
23
|
+
import { isLocal } from "../../../lib/ap-ids.ts";
|
|
24
|
+
import {
|
|
25
|
+
actorIsBlockedBy,
|
|
26
|
+
canViewerReadObjectFull,
|
|
27
|
+
} from "../../../lib/post-visibility.ts";
|
|
28
|
+
import { logger } from "../../../lib/logger.ts";
|
|
29
|
+
|
|
30
|
+
type ActorRow = typeof actors.$inferSelect;
|
|
31
|
+
|
|
32
|
+
const log = logger.child({ component: "activitypub.inbox.interaction" });
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Atomic multi-statement commit (mirrors posts/interactions.ts `runBatch`)
|
|
36
|
+
//
|
|
37
|
+
// D1 has no interactive transactions, but both the D1 and libsql drivers
|
|
38
|
+
// expose `db.batch([...])`, which commits a list of prepared statements
|
|
39
|
+
// atomically. The shared `Database` union aliases the abstract
|
|
40
|
+
// `BaseSQLiteDatabase` base (which does not surface `batch`), so we narrow to
|
|
41
|
+
// the concrete batch surface here rather than weakening the shared type.
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
type BatchStatement = BatchItem<"sqlite">;
|
|
45
|
+
interface BatchableDb {
|
|
46
|
+
batch(
|
|
47
|
+
statements: readonly [BatchStatement, ...BatchStatement[]],
|
|
48
|
+
): Promise<unknown>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function runBatch(
|
|
52
|
+
db: Database,
|
|
53
|
+
statements: readonly [BatchStatement, ...BatchStatement[]],
|
|
54
|
+
): Promise<void> {
|
|
55
|
+
await (db as unknown as BatchableDb).batch(statements);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Interaction table / count-field mapping
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
type InteractionKind = "like" | "announce";
|
|
63
|
+
|
|
64
|
+
const INTERACTION_CONFIG = {
|
|
65
|
+
like: {
|
|
66
|
+
table: likes,
|
|
67
|
+
countField: "likeCount" as const,
|
|
68
|
+
activityType: "Like",
|
|
69
|
+
},
|
|
70
|
+
announce: {
|
|
71
|
+
table: announces,
|
|
72
|
+
countField: "announceCount" as const,
|
|
73
|
+
activityType: "Announce",
|
|
74
|
+
},
|
|
75
|
+
} as const;
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Generic interaction handler (shared by Like & Announce)
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
async function handleInteraction(
|
|
82
|
+
kind: InteractionKind,
|
|
83
|
+
c: ActivityContext,
|
|
84
|
+
activity: Activity,
|
|
85
|
+
actor: string,
|
|
86
|
+
baseUrl: string,
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
const db = c.get("db");
|
|
89
|
+
const objectId = getActivityObjectId(activity);
|
|
90
|
+
if (!objectId) return;
|
|
91
|
+
|
|
92
|
+
// Block + read gate for a LOCAL target. Every LOCAL interaction path (like /
|
|
93
|
+
// repost / reply / story like-vote-share) refuses an actor the owner has
|
|
94
|
+
// blocked or who cannot read the object; the inbound federated Like/Announce
|
|
95
|
+
// path did neither, so a personally-blocked remote could still bump the
|
|
96
|
+
// owner's like/boost counter AND deliver a "X liked your post" notification
|
|
97
|
+
// (defeating the block as a harassment remedy), and a remote that cannot read a
|
|
98
|
+
// restricted post could still interact with it. Mirror the local guards.
|
|
99
|
+
const target = await db
|
|
100
|
+
.select({
|
|
101
|
+
attributedTo: objects.attributedTo,
|
|
102
|
+
visibility: objects.visibility,
|
|
103
|
+
toJson: objects.toJson,
|
|
104
|
+
ccJson: objects.ccJson,
|
|
105
|
+
audienceJson: objects.audienceJson,
|
|
106
|
+
communityApId: objects.communityApId,
|
|
107
|
+
type: objects.type,
|
|
108
|
+
endTime: objects.endTime,
|
|
109
|
+
})
|
|
110
|
+
.from(objects)
|
|
111
|
+
.where(eq(objects.apId, objectId))
|
|
112
|
+
.get();
|
|
113
|
+
if (target && isLocal(target.attributedTo, baseUrl)) {
|
|
114
|
+
if (await actorIsBlockedBy(db, target.attributedTo, actor)) return;
|
|
115
|
+
if (!(await canViewerReadObjectFull(db, target, actor))) return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const { table, countField, activityType } = INTERACTION_CONFIG[kind];
|
|
119
|
+
const activityId = activity.id || activityApId(baseUrl, generateId());
|
|
120
|
+
|
|
121
|
+
// Was the edge already present BEFORE this dispatch? This decides whether the
|
|
122
|
+
// dispatch represents a genuinely new interaction (gate for the one-shot
|
|
123
|
+
// owner notification below) — it does NOT gate the counter, which is derived
|
|
124
|
+
// atomically from the edge table state at commit (see below).
|
|
125
|
+
const existingEdge = await db
|
|
126
|
+
.select({ actorApId: table.actorApId })
|
|
127
|
+
.from(table)
|
|
128
|
+
.where(and(eq(table.actorApId, actor), eq(table.objectApId, objectId)))
|
|
129
|
+
.get();
|
|
130
|
+
|
|
131
|
+
// #7 (atomicity + idempotency): the edge insert and the counter maintenance
|
|
132
|
+
// MUST commit together. Previously the edge was inserted (onConflictDoNothing)
|
|
133
|
+
// and the counter was bumped in a SEPARATE statement; under the claim/processed
|
|
134
|
+
// re-dispatch model an interruption between the two left the edge present but
|
|
135
|
+
// the counter un-bumped, and a retry's no-op insert SKIPPED the bump → a
|
|
136
|
+
// permanent under-count. Group both into one atomic `db.batch`, and derive the
|
|
137
|
+
// counter from `COUNT(*)` of the edge table rather than a blind `+ 1`: the
|
|
138
|
+
// recompute is exact and idempotent, so a retry after a mid-write crash
|
|
139
|
+
// converges to the correct value and a genuine duplicate can never double-count.
|
|
140
|
+
await runBatch(db, [
|
|
141
|
+
db
|
|
142
|
+
.insert(table)
|
|
143
|
+
.values({
|
|
144
|
+
actorApId: actor,
|
|
145
|
+
objectApId: objectId,
|
|
146
|
+
activityApId: activityId,
|
|
147
|
+
})
|
|
148
|
+
.onConflictDoNothing(),
|
|
149
|
+
db
|
|
150
|
+
.update(objects)
|
|
151
|
+
.set({
|
|
152
|
+
[countField]: sql`(SELECT COUNT(*) FROM ${table} WHERE ${table.objectApId} = ${objectId})`,
|
|
153
|
+
})
|
|
154
|
+
.where(eq(objects.apId, objectId)),
|
|
155
|
+
]);
|
|
156
|
+
|
|
157
|
+
// Only notify the local owner for a genuinely new interaction. A duplicate
|
|
158
|
+
// (re)delivery — including a wave-8 re-dispatch of an already-applied edge —
|
|
159
|
+
// must not spam a second notification.
|
|
160
|
+
if (existingEdge) return;
|
|
161
|
+
|
|
162
|
+
await notifyLocalObjectOwner(
|
|
163
|
+
db,
|
|
164
|
+
objectId,
|
|
165
|
+
activityId,
|
|
166
|
+
activityType,
|
|
167
|
+
actor,
|
|
168
|
+
activity,
|
|
169
|
+
baseUrl,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// Like handler
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
export async function handleLike(
|
|
178
|
+
c: ActivityContext,
|
|
179
|
+
activity: Activity,
|
|
180
|
+
_recipient: ActorRow,
|
|
181
|
+
actor: string,
|
|
182
|
+
baseUrl: string,
|
|
183
|
+
) {
|
|
184
|
+
await handleInteraction("like", c, activity, actor, baseUrl);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// Announce handler (repost/boost)
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
export async function handleAnnounce(
|
|
192
|
+
c: ActivityContext,
|
|
193
|
+
activity: Activity,
|
|
194
|
+
_recipient: ActorRow,
|
|
195
|
+
actor: string,
|
|
196
|
+
baseUrl: string,
|
|
197
|
+
) {
|
|
198
|
+
await handleInteraction("announce", c, activity, actor, baseUrl);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// Add handler (collection add; used by some servers for membership)
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
export async function handleAdd(
|
|
206
|
+
c: ActivityContext,
|
|
207
|
+
activity: Activity,
|
|
208
|
+
recipient: ActorRow,
|
|
209
|
+
actor: string,
|
|
210
|
+
) {
|
|
211
|
+
const followingApId = resolveCollectionTarget(activity, recipient, actor);
|
|
212
|
+
if (!followingApId) return;
|
|
213
|
+
|
|
214
|
+
const db = c.get("db");
|
|
215
|
+
const now = new Date().toISOString();
|
|
216
|
+
|
|
217
|
+
// SECURITY (consent — federated follow-graph forgery): an `Add <local user>
|
|
218
|
+
// to <remote>/followers` is the remote CONFIRMING the local user's OWN Follow
|
|
219
|
+
// (it is an alias of Accept), NOT a license to make the local user follow the
|
|
220
|
+
// sender. It must therefore only TRANSITION a PRE-EXISTING pending edge to
|
|
221
|
+
// accepted (mirroring handleAccept) — never CREATE an edge. The previous
|
|
222
|
+
// version inserted a fresh `accepted` edge + bumped both counters whenever the
|
|
223
|
+
// edge was absent, so a remote could sign an unsolicited `Add` naming any
|
|
224
|
+
// local user as `object` and forge an accepted follow `<victim> -> <sender>`,
|
|
225
|
+
// inflating the victim's followingCount and routing the sender's posts into
|
|
226
|
+
// the victim's home feed — all without the victim ever following anyone.
|
|
227
|
+
//
|
|
228
|
+
// #COUNTER-SYM: like handleAccept, the two +1s run BEFORE the flip, each
|
|
229
|
+
// guarded by a correlated `EXISTS(... status='pending')` subquery, and the
|
|
230
|
+
// flip's own `status='pending'` predicate makes a duplicate/already-accepted
|
|
231
|
+
// (or absent) edge a total no-op — so counters can neither double-bump,
|
|
232
|
+
// under-count on retry, nor bump for an edge that was never pending.
|
|
233
|
+
const pendingEdgeExists = sql`EXISTS (SELECT 1 FROM ${follows} WHERE ${follows.followerApId} = ${recipient.apId} AND ${follows.followingApId} = ${followingApId} AND ${follows.status} = 'pending')`;
|
|
234
|
+
await runBatch(db, [
|
|
235
|
+
db
|
|
236
|
+
.update(actors)
|
|
237
|
+
.set({ followingCount: sql`${actors.followingCount} + 1` })
|
|
238
|
+
.where(and(eq(actors.apId, recipient.apId), pendingEdgeExists)),
|
|
239
|
+
db
|
|
240
|
+
.update(actors)
|
|
241
|
+
.set({ followerCount: sql`${actors.followerCount} + 1` })
|
|
242
|
+
.where(and(eq(actors.apId, followingApId), pendingEdgeExists)),
|
|
243
|
+
db
|
|
244
|
+
.update(follows)
|
|
245
|
+
.set({ status: "accepted", acceptedAt: now })
|
|
246
|
+
.where(
|
|
247
|
+
and(
|
|
248
|
+
eq(follows.followerApId, recipient.apId),
|
|
249
|
+
eq(follows.followingApId, followingApId),
|
|
250
|
+
eq(follows.status, "pending"),
|
|
251
|
+
),
|
|
252
|
+
),
|
|
253
|
+
]);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// Remove handler (collection remove; used for expulsion/ban)
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
export async function handleRemove(
|
|
261
|
+
c: ActivityContext,
|
|
262
|
+
activity: Activity,
|
|
263
|
+
recipient: ActorRow,
|
|
264
|
+
actor: string,
|
|
265
|
+
) {
|
|
266
|
+
const followingApId = resolveCollectionTarget(activity, recipient, actor);
|
|
267
|
+
if (!followingApId) return;
|
|
268
|
+
|
|
269
|
+
const db = c.get("db");
|
|
270
|
+
|
|
271
|
+
// #COUNTER-SYM (crash-retry convergence): the edge delete and both -1s MUST
|
|
272
|
+
// commit together. Previously the delete committed first and the decrements
|
|
273
|
+
// were SEPARATE statements; a crash between them left the edge gone but the
|
|
274
|
+
// counts un-decremented, and the peer's retry matched 0 rows so the decrements
|
|
275
|
+
// were SKIPPED → a permanent OVER-count. Co-commit them in one atomic batch.
|
|
276
|
+
//
|
|
277
|
+
// The two decrements run BEFORE the delete and are each guarded by a
|
|
278
|
+
// correlated `EXISTS(... status='accepted')` subquery (so a pending /
|
|
279
|
+
// never-counted edge, a duplicate Remove, or an unknown edge does not drift
|
|
280
|
+
// the counts) plus a `count > 0` underflow guard (mirrors the local API delete
|
|
281
|
+
// paths in posts/interactions.ts which batch + guard both sides).
|
|
282
|
+
const acceptedEdgeExists = sql`EXISTS (SELECT 1 FROM ${follows} WHERE ${follows.followerApId} = ${recipient.apId} AND ${follows.followingApId} = ${followingApId} AND ${follows.status} = 'accepted')`;
|
|
283
|
+
await runBatch(db, [
|
|
284
|
+
db
|
|
285
|
+
.update(actors)
|
|
286
|
+
.set({ followingCount: sql`${actors.followingCount} - 1` })
|
|
287
|
+
.where(
|
|
288
|
+
and(
|
|
289
|
+
eq(actors.apId, recipient.apId),
|
|
290
|
+
gt(actors.followingCount, 0),
|
|
291
|
+
acceptedEdgeExists,
|
|
292
|
+
),
|
|
293
|
+
),
|
|
294
|
+
db
|
|
295
|
+
.update(actors)
|
|
296
|
+
.set({ followerCount: sql`${actors.followerCount} - 1` })
|
|
297
|
+
.where(
|
|
298
|
+
and(
|
|
299
|
+
eq(actors.apId, followingApId),
|
|
300
|
+
gt(actors.followerCount, 0),
|
|
301
|
+
acceptedEdgeExists,
|
|
302
|
+
),
|
|
303
|
+
),
|
|
304
|
+
db
|
|
305
|
+
.delete(follows)
|
|
306
|
+
.where(
|
|
307
|
+
and(
|
|
308
|
+
eq(follows.followerApId, recipient.apId),
|
|
309
|
+
eq(follows.followingApId, followingApId),
|
|
310
|
+
),
|
|
311
|
+
),
|
|
312
|
+
]);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
// Block handler (remote actor blocks the recipient)
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
export async function handleBlock(
|
|
320
|
+
c: ActivityContext,
|
|
321
|
+
activity: Activity,
|
|
322
|
+
recipient: ActorRow,
|
|
323
|
+
actor: string,
|
|
324
|
+
) {
|
|
325
|
+
const db = c.get("db");
|
|
326
|
+
const blockedId = getActivityObjectId(activity);
|
|
327
|
+
if (!blockedId) return;
|
|
328
|
+
|
|
329
|
+
// Only act when the recipient is being blocked.
|
|
330
|
+
if (blockedId !== recipient.apId) return;
|
|
331
|
+
|
|
332
|
+
// Best-effort: sever follow relations in both directions. #COUNTER-SYM
|
|
333
|
+
// (crash-retry convergence): handle each direction as its own atomic
|
|
334
|
+
// edge-delete + counter reconcile so a crash between the delete and the
|
|
335
|
+
// decrements cannot leave a counter permanently over-counted (the peer's
|
|
336
|
+
// retry would otherwise match 0 rows and skip the decrement).
|
|
337
|
+
await severFollowEdge(db, recipient.apId, actor); // recipient follows actor
|
|
338
|
+
await severFollowEdge(db, actor, recipient.apId); // actor follows recipient
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Atomically delete a (followerApId -> followingApId) follow edge and reconcile
|
|
343
|
+
* both denormalized counters in a single batch.
|
|
344
|
+
*
|
|
345
|
+
* #COUNTER-SYM: the decrements run BEFORE the delete, each guarded by a
|
|
346
|
+
* correlated `EXISTS(... status='accepted')` subquery (pending edges were never
|
|
347
|
+
* counted) plus a `count > 0` underflow guard, so a pending edge, a duplicate
|
|
348
|
+
* Block, a never-existing edge, or a crash-then-retry is a clean no-op rather
|
|
349
|
+
* than permanently over-counting.
|
|
350
|
+
*/
|
|
351
|
+
export async function severFollowEdge(
|
|
352
|
+
db: Database,
|
|
353
|
+
followerApId: string,
|
|
354
|
+
followingApId: string,
|
|
355
|
+
): Promise<void> {
|
|
356
|
+
const acceptedEdgeExists = sql`EXISTS (SELECT 1 FROM ${follows} WHERE ${follows.followerApId} = ${followerApId} AND ${follows.followingApId} = ${followingApId} AND ${follows.status} = 'accepted')`;
|
|
357
|
+
await runBatch(db, [
|
|
358
|
+
db
|
|
359
|
+
.update(actors)
|
|
360
|
+
.set({ followingCount: sql`${actors.followingCount} - 1` })
|
|
361
|
+
.where(
|
|
362
|
+
and(
|
|
363
|
+
eq(actors.apId, followerApId),
|
|
364
|
+
gt(actors.followingCount, 0),
|
|
365
|
+
acceptedEdgeExists,
|
|
366
|
+
),
|
|
367
|
+
),
|
|
368
|
+
db
|
|
369
|
+
.update(actors)
|
|
370
|
+
.set({ followerCount: sql`${actors.followerCount} - 1` })
|
|
371
|
+
.where(
|
|
372
|
+
and(
|
|
373
|
+
eq(actors.apId, followingApId),
|
|
374
|
+
gt(actors.followerCount, 0),
|
|
375
|
+
acceptedEdgeExists,
|
|
376
|
+
),
|
|
377
|
+
),
|
|
378
|
+
db
|
|
379
|
+
.delete(follows)
|
|
380
|
+
.where(
|
|
381
|
+
and(
|
|
382
|
+
eq(follows.followerApId, followerApId),
|
|
383
|
+
eq(follows.followingApId, followingApId),
|
|
384
|
+
),
|
|
385
|
+
),
|
|
386
|
+
]);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
// Flag handler (report)
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
export async function handleFlag(
|
|
394
|
+
c: ActivityContext,
|
|
395
|
+
activity: Activity,
|
|
396
|
+
actor: string,
|
|
397
|
+
) {
|
|
398
|
+
const objectId = getActivityObjectId(activity);
|
|
399
|
+
const targetId = getActivityTargetId(activity);
|
|
400
|
+
// Flag activities carry a free-text reason in `content` (not part of the
|
|
401
|
+
// narrowed Activity type, so read it defensively).
|
|
402
|
+
const rawContent = (activity as { content?: unknown }).content;
|
|
403
|
+
// Cap the inbound reason length at ingest. The Flag `content` is fully
|
|
404
|
+
// attacker-controlled free text, so without a bound the reports table grows
|
|
405
|
+
// unbounded under report spam. 2000 chars is ample for a moderation reason.
|
|
406
|
+
const content =
|
|
407
|
+
typeof rawContent === "string" ? rawContent.slice(0, 2000) : null;
|
|
408
|
+
|
|
409
|
+
// The report target is the flagged object (preferred) or the activity target.
|
|
410
|
+
const reportTarget = objectId ?? targetId ?? null;
|
|
411
|
+
|
|
412
|
+
let instance: string | null = null;
|
|
413
|
+
try {
|
|
414
|
+
instance = getDomain(actor);
|
|
415
|
+
} catch {
|
|
416
|
+
instance = null;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Persist the report so operators can triage it via the moderation API.
|
|
420
|
+
// Best-effort: never let a storage error 5xx the inbox (which would make
|
|
421
|
+
// the sender retry on a backoff). Log the failure for the operator.
|
|
422
|
+
try {
|
|
423
|
+
const db = c.get("db");
|
|
424
|
+
await db.insert(reports).values({
|
|
425
|
+
id: generateId(),
|
|
426
|
+
reporterApId: actor,
|
|
427
|
+
targetApId: reportTarget,
|
|
428
|
+
content,
|
|
429
|
+
instance,
|
|
430
|
+
});
|
|
431
|
+
} catch (err) {
|
|
432
|
+
log.warn("Failed to persist Flag report", {
|
|
433
|
+
event: "ap.flag.persist_failed",
|
|
434
|
+
actor,
|
|
435
|
+
object: objectId,
|
|
436
|
+
target: targetId,
|
|
437
|
+
error: err,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
log.warn("Flag received", {
|
|
442
|
+
event: "ap.flag.received",
|
|
443
|
+
actor,
|
|
444
|
+
object: objectId,
|
|
445
|
+
target: targetId,
|
|
446
|
+
activityId: activity.id || null,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
// Internal helpers
|
|
452
|
+
// ---------------------------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
function getActivityTargetId(activity: Activity): string | null {
|
|
455
|
+
const target = activity.target;
|
|
456
|
+
if (!target) return null;
|
|
457
|
+
if (typeof target === "string") return target;
|
|
458
|
+
return target.id || null;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function normalizeCollectionTarget(targetId: string): string {
|
|
462
|
+
if (targetId.endsWith("/followers")) {
|
|
463
|
+
return targetId.slice(0, -"/followers".length);
|
|
464
|
+
}
|
|
465
|
+
return targetId;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Resolve the collection target for Add/Remove activities.
|
|
470
|
+
* Returns the normalized followingApId, or null if the activity should be ignored
|
|
471
|
+
* (missing object, object does not target recipient, or missing target).
|
|
472
|
+
*/
|
|
473
|
+
function resolveCollectionTarget(
|
|
474
|
+
activity: Activity,
|
|
475
|
+
recipient: ActorRow,
|
|
476
|
+
actor: string,
|
|
477
|
+
): string | null {
|
|
478
|
+
const objectId = getActivityObjectId(activity);
|
|
479
|
+
if (!objectId || objectId !== recipient.apId) return null;
|
|
480
|
+
|
|
481
|
+
const targetId = getActivityTargetId(activity);
|
|
482
|
+
const followingApId = normalizeCollectionTarget(targetId || actor) || null;
|
|
483
|
+
if (!followingApId) return null;
|
|
484
|
+
|
|
485
|
+
// SECURITY (federated follow-graph forgery): `activity.target` is
|
|
486
|
+
// attacker-controlled and only the signing actor is authenticated, NOT the
|
|
487
|
+
// target. Without this check a signed Add/Remove could forge or delete a local
|
|
488
|
+
// user's follow edge to an ARBITRARY third party (followingApId on any host).
|
|
489
|
+
// Constrain the resolved target to the signing actor's own origin, so an
|
|
490
|
+
// Add/Remove can only affect a relationship involving the sending actor.
|
|
491
|
+
try {
|
|
492
|
+
if (getDomain(followingApId) !== getDomain(actor)) return null;
|
|
493
|
+
} catch {
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
return followingApId;
|
|
497
|
+
}
|