@takosjp/yurucommu-core 3.3.0 → 3.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/migrations/0022_inbound_dispatch_claims.sql +17 -0
- package/package.json +3 -2
- package/packages/api/package.json +1 -1
- package/packages/api/src/index.ts +1 -0
- package/packages/api/src/lib/api/notifications.ts +1 -0
- package/packages/api/src/lib/api/posts.ts +1 -0
- package/packages/api/src/lib/rtc-client.ts +1 -3
- package/packages/api/src/types/call.ts +2 -10
- package/packages/api/src/types/index.ts +3 -0
- package/packages/api/src/types/realtime.ts +139 -0
- package/src/backend/index.ts +54 -12
- package/src/backend/lib/delivery/queue-batching.ts +53 -36
- package/src/backend/lib/delivery/queue-delivery.ts +3 -3
- package/src/backend/lib/delivery/queue.ts +17 -9
- package/src/backend/lib/delivery/types.ts +12 -0
- package/src/backend/lib/notification-push.ts +2 -2
- package/src/backend/lib/oauth-providers.ts +9 -0
- package/src/backend/lib/strip-image-metadata.ts +50 -30
- package/src/backend/lib/unread-counts.ts +63 -2
- package/src/backend/middleware/bearer-auth.ts +24 -9
- package/src/backend/public.ts +25 -1
- package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +4 -3
- package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +204 -5
- package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +78 -53
- package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +66 -2
- package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +35 -12
- package/src/backend/routes/activitypub/inbox-addressing.ts +236 -0
- package/src/backend/routes/activitypub/inbox-types.ts +8 -0
- package/src/backend/routes/activitypub/inbox.ts +410 -205
- package/src/backend/routes/activitypub/outbox.ts +0 -0
- package/src/backend/routes/auth.ts +2 -1
- package/src/backend/routes/communities/messages.ts +53 -17
- package/src/backend/routes/dm/messages.ts +47 -7
- package/src/backend/routes/dm/read-archive.ts +27 -0
- package/src/backend/routes/dm/typing.ts +16 -0
- package/src/backend/routes/notifications.ts +25 -0
- package/src/backend/routes/posts/post-helpers.ts +42 -23
- package/src/backend/routes/realtime/index.ts +67 -0
- package/src/backend/routes/rtc/index.ts +5 -1
- package/src/backend/runtime/call-hub-core.ts +13 -3
- package/src/backend/runtime/cloudflare.ts +63 -2
- package/src/backend/runtime/managed-relational.ts +197 -0
- package/src/backend/runtime/managed-runtime.ts +631 -0
- package/src/backend/runtime/queue.ts +40 -0
- package/src/backend/runtime/realtime-hub.ts +257 -0
- package/src/backend/runtime/realtime-stream-do.ts +323 -0
- package/src/backend/server.ts +15 -18
- package/src/backend/types.ts +13 -2
- package/src/db/d1-write.ts +270 -0
- package/src/db/index.ts +17 -0
- package/src/db/schema/federation.ts +19 -0
- package/src/db/schema/index.ts +1 -0
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import type { Database } from "../../../../db/index.ts";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
and,
|
|
4
|
+
count,
|
|
5
|
+
eq,
|
|
6
|
+
gt,
|
|
7
|
+
inArray,
|
|
8
|
+
isNotNull,
|
|
9
|
+
isNull,
|
|
10
|
+
or,
|
|
11
|
+
sql,
|
|
12
|
+
} from "drizzle-orm";
|
|
3
13
|
import type { BatchItem } from "drizzle-orm/batch";
|
|
4
14
|
import {
|
|
5
15
|
activities,
|
|
@@ -8,6 +18,7 @@ import {
|
|
|
8
18
|
announces,
|
|
9
19
|
blocks,
|
|
10
20
|
bookmarks,
|
|
21
|
+
communities,
|
|
11
22
|
follows,
|
|
12
23
|
inbox as inboxTable,
|
|
13
24
|
likes,
|
|
@@ -45,6 +56,8 @@ import {
|
|
|
45
56
|
fetchAndUpsertActorCache,
|
|
46
57
|
getInstanceFetchSignerByDb,
|
|
47
58
|
} from "../../../lib/activitypub-actor-cache.ts";
|
|
59
|
+
import { fetchWithTimeout } from "../../../lib/federation-fetch.ts";
|
|
60
|
+
import { signRequest } from "../../../lib/ap-signing.ts";
|
|
48
61
|
import { enqueueDeliveryToActor } from "../../../lib/delivery/queue.ts";
|
|
49
62
|
import { destinationDeclaresAlias } from "../../../lib/account-migration.ts";
|
|
50
63
|
import { chunkForInClause } from "../../../lib/chunk.ts";
|
|
@@ -132,13 +145,13 @@ function isActorTypeUpdate(type: string | string[] | undefined): boolean {
|
|
|
132
145
|
|
|
133
146
|
// The ActivityStreams public-collection magic value, including the legacy
|
|
134
147
|
// short forms some implementations still emit.
|
|
135
|
-
const PUBLIC_COLLECTION = new Set([
|
|
148
|
+
export const PUBLIC_COLLECTION = new Set([
|
|
136
149
|
"https://www.w3.org/ns/activitystreams#Public",
|
|
137
150
|
"as:Public",
|
|
138
151
|
"Public",
|
|
139
152
|
]);
|
|
140
153
|
|
|
141
|
-
function addressesPublic(addresses: string[]): boolean {
|
|
154
|
+
export function addressesPublic(addresses: string[]): boolean {
|
|
142
155
|
return addresses.some((a) => PUBLIC_COLLECTION.has(a));
|
|
143
156
|
}
|
|
144
157
|
|
|
@@ -146,7 +159,7 @@ function addressesPublic(addresses: string[]): boolean {
|
|
|
146
159
|
// and NOT to Public is a followers-only post. We match any `/followers`
|
|
147
160
|
// collection by suffix (mirrors isDirectNote), which covers the author's
|
|
148
161
|
// collection without needing to resolve it.
|
|
149
|
-
function addressesFollowers(addresses: string[]): boolean {
|
|
162
|
+
export function addressesFollowers(addresses: string[]): boolean {
|
|
150
163
|
return addresses.some((a) => a.endsWith("/followers"));
|
|
151
164
|
}
|
|
152
165
|
|
|
@@ -194,7 +207,7 @@ function isDirectShapedNote(object: { to?: string[]; cc?: string[] }): boolean {
|
|
|
194
207
|
// Cap persisted addressing arrays so a remote cannot bloat a row with a huge
|
|
195
208
|
// to/cc list; 64 entries is far beyond any real audience and keeps the explicit-
|
|
196
209
|
// recipient (mention) gate working.
|
|
197
|
-
const MAX_ADDRESS_ENTRIES = 64;
|
|
210
|
+
export const MAX_ADDRESS_ENTRIES = 64;
|
|
198
211
|
function boundAddressJson(addresses: string[] | undefined): string {
|
|
199
212
|
if (!Array.isArray(addresses) || addresses.length === 0) return "[]";
|
|
200
213
|
return JSON.stringify(
|
|
@@ -204,6 +217,21 @@ function boundAddressJson(addresses: string[] | undefined): string {
|
|
|
204
217
|
);
|
|
205
218
|
}
|
|
206
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Normalize an AS2 addressing field (`to` / `cc` / `audience`) to a bounded list
|
|
222
|
+
* of strings. The field may be absent, a bare string, or an array mixing
|
|
223
|
+
* strings and embedded objects; only the string forms are usable as a
|
|
224
|
+
* collection id, and the same 64-entry cap applies so a remote cannot force a
|
|
225
|
+
* huge `IN (...)` lookup.
|
|
226
|
+
*/
|
|
227
|
+
function addressList(value: unknown): string[] {
|
|
228
|
+
if (typeof value === "string") return [value];
|
|
229
|
+
if (!Array.isArray(value)) return [];
|
|
230
|
+
return value
|
|
231
|
+
.filter((a): a is string => typeof a === "string")
|
|
232
|
+
.slice(0, MAX_ADDRESS_ENTRIES);
|
|
233
|
+
}
|
|
234
|
+
|
|
207
235
|
/**
|
|
208
236
|
* Reject an inbound object whose `object.id` is asserted under a host the
|
|
209
237
|
* delivering actor does not control (object-ID squatting / cross-origin
|
|
@@ -888,6 +916,35 @@ export async function handleCreateStory(
|
|
|
888
916
|
storyDataJson = JSON.stringify({ ...attachmentData, overlays: undefined });
|
|
889
917
|
}
|
|
890
918
|
|
|
919
|
+
// Carry the community scope across the federation boundary. A story that
|
|
920
|
+
// arrived through community fanout is addressed to the community's followers
|
|
921
|
+
// collection, not the author's; storing it with no scope made the local read
|
|
922
|
+
// gate treat it as a personal story and serve it to every local follower of
|
|
923
|
+
// the author, member or not. Resolve the addressed collection against the
|
|
924
|
+
// communities this instance actually knows and only then mark the scope —
|
|
925
|
+
// an unresolvable audience is left unscoped rather than trusted, and the
|
|
926
|
+
// membership gate is still evaluated locally against `community_members`.
|
|
927
|
+
const addressedCollections = [
|
|
928
|
+
...addressList((activity as { audience?: unknown }).audience),
|
|
929
|
+
...addressList(object.to),
|
|
930
|
+
...addressList((object as { audience?: unknown }).audience),
|
|
931
|
+
];
|
|
932
|
+
const community = addressedCollections.length
|
|
933
|
+
? await db
|
|
934
|
+
.select({ apId: communities.apId })
|
|
935
|
+
.from(communities)
|
|
936
|
+
.where(
|
|
937
|
+
and(
|
|
938
|
+
or(
|
|
939
|
+
inArray(communities.followersUrl, addressedCollections),
|
|
940
|
+
inArray(communities.apId, addressedCollections),
|
|
941
|
+
),
|
|
942
|
+
isNull(communities.deletedAt),
|
|
943
|
+
),
|
|
944
|
+
)
|
|
945
|
+
.get()
|
|
946
|
+
: undefined;
|
|
947
|
+
|
|
891
948
|
const inserted = await db
|
|
892
949
|
.insert(objects)
|
|
893
950
|
.values({
|
|
@@ -896,6 +953,12 @@ export async function handleCreateStory(
|
|
|
896
953
|
attributedTo: actor,
|
|
897
954
|
content: "",
|
|
898
955
|
attachmentsJson: storyDataJson,
|
|
956
|
+
...(community
|
|
957
|
+
? {
|
|
958
|
+
communityApId: community.apId,
|
|
959
|
+
audienceJson: JSON.stringify([community.apId]),
|
|
960
|
+
}
|
|
961
|
+
: {}),
|
|
899
962
|
endTime,
|
|
900
963
|
published: publishedAt,
|
|
901
964
|
isLocal: 0,
|
|
@@ -907,6 +970,142 @@ export async function handleCreateStory(
|
|
|
907
970
|
if (!inserted) return; // duplicate
|
|
908
971
|
}
|
|
909
972
|
|
|
973
|
+
// ---------------------------------------------------------------------------
|
|
974
|
+
// Announce target resolution (fetch-and-store a boosted remote Note)
|
|
975
|
+
// ---------------------------------------------------------------------------
|
|
976
|
+
|
|
977
|
+
// Upper bound on the fetch of a boosted remote object. Mirrors the actor-cache
|
|
978
|
+
// fetch timeout; the body size is already capped by fetchWithTimeout's wrapper.
|
|
979
|
+
const ANNOUNCED_OBJECT_FETCH_TIMEOUT_MS = 15_000;
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* Resolve an inbound Announce whose target this instance has never seen: fetch
|
|
983
|
+
* the boosted object from its origin, validate it, and persist it as a remote
|
|
984
|
+
* Note so the announce edge recorded by handleAnnounce surfaces in feeds
|
|
985
|
+
* ("reposted by X") instead of dangling on an unknown ap_id.
|
|
986
|
+
*
|
|
987
|
+
* Reuses the SSRF-guarded federation fetch discipline end to end:
|
|
988
|
+
* `fetchWithTimeout` (resolver-pinned DNS validation, no redirects, capped +
|
|
989
|
+
* time-bounded body) with the GET signed as the instance actor so a
|
|
990
|
+
* secure-mode remote serves the document (mirrors fetchAndUpsertActorCache).
|
|
991
|
+
*
|
|
992
|
+
* Validation mirrors the inbound Create(Note) gates:
|
|
993
|
+
* - the document's `id` must equal the fetched URL (no id squatting);
|
|
994
|
+
* - `attributedTo` must be same-origin with the object id (a remote author
|
|
995
|
+
* may only own objects under its own host — mirrors
|
|
996
|
+
* isObjectIdOriginMismatch, and a local-origin object is never fetched);
|
|
997
|
+
* - type must include "Note"; direct/DM-shaped addressing is refused;
|
|
998
|
+
* - only world-readable classifications (public / unlisted) are persisted —
|
|
999
|
+
* a boost must never widen a followers-only/direct object's audience, and
|
|
1000
|
+
* this instance cannot verify a remote author's follower audience.
|
|
1001
|
+
*
|
|
1002
|
+
* Depth cap: the object's `inReplyTo` is stored verbatim but NEVER resolved —
|
|
1003
|
+
* a single Announce triggers at most one object fetch (plus a best-effort
|
|
1004
|
+
* author-profile cache fill), not a thread walk.
|
|
1005
|
+
*
|
|
1006
|
+
* Best-effort by contract: every failure returns false and the Announce is
|
|
1007
|
+
* dropped exactly as it was before this path existed.
|
|
1008
|
+
*/
|
|
1009
|
+
export async function fetchAndPersistAnnouncedNote(
|
|
1010
|
+
db: Database,
|
|
1011
|
+
objectId: string,
|
|
1012
|
+
baseUrl: string,
|
|
1013
|
+
): Promise<boolean> {
|
|
1014
|
+
// Never fetch a local id (a local object that does not exist is just gone)
|
|
1015
|
+
// and never fetch an unsafe URL (non-http(s), credentials, blocked host…).
|
|
1016
|
+
if (isLocal(objectId, baseUrl) || !isSafeRemoteUrl(objectId)) return false;
|
|
1017
|
+
|
|
1018
|
+
let note: ActivityObject & { attributedTo?: unknown };
|
|
1019
|
+
try {
|
|
1020
|
+
const headers: Record<string, string> = {
|
|
1021
|
+
Accept: "application/activity+json, application/ld+json",
|
|
1022
|
+
};
|
|
1023
|
+
const signer = await getInstanceFetchSignerByDb(db);
|
|
1024
|
+
if (signer) {
|
|
1025
|
+
Object.assign(
|
|
1026
|
+
headers,
|
|
1027
|
+
await signRequest(signer.privateKeyPem, signer.keyId, "GET", objectId),
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
const res = await fetchWithTimeout(objectId, {
|
|
1031
|
+
headers,
|
|
1032
|
+
timeout: ANNOUNCED_OBJECT_FETCH_TIMEOUT_MS,
|
|
1033
|
+
});
|
|
1034
|
+
if (!res.ok) return false;
|
|
1035
|
+
const raw: unknown = await res.json();
|
|
1036
|
+
if (!raw || typeof raw !== "object") return false;
|
|
1037
|
+
note = raw as ActivityObject & { attributedTo?: unknown };
|
|
1038
|
+
} catch {
|
|
1039
|
+
// Unresolvable / oversized / timed-out fetch: drop silently (the caller
|
|
1040
|
+
// logs at debug level), matching the pre-existing unknown-object behavior.
|
|
1041
|
+
return false;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
if (note.id !== objectId) return false;
|
|
1045
|
+
if (!typeIncludes(note.type, "Note")) return false;
|
|
1046
|
+
|
|
1047
|
+
const attributedTo =
|
|
1048
|
+
typeof note.attributedTo === "string" ? note.attributedTo : null;
|
|
1049
|
+
if (!attributedTo || !isSafeRemoteUrl(attributedTo)) return false;
|
|
1050
|
+
try {
|
|
1051
|
+
if (getDomain(attributedTo) !== getDomain(objectId)) return false;
|
|
1052
|
+
} catch {
|
|
1053
|
+
return false;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// Addressing gates: a DM-shaped object must never be stored world-readable,
|
|
1057
|
+
// and a non-public classification is refused outright (see doc comment).
|
|
1058
|
+
if (isDirectShapedNote(note)) return false;
|
|
1059
|
+
const visibility = classifyInboundNoteVisibility(note);
|
|
1060
|
+
if (visibility !== "public" && visibility !== "unlisted") return false;
|
|
1061
|
+
|
|
1062
|
+
// Best-effort author profile fill so the surfaced boost renders with the
|
|
1063
|
+
// author's name/icon. Cache-when-absent; a failure never blocks the persist.
|
|
1064
|
+
const cachedAuthor = await db
|
|
1065
|
+
.select({ apId: actorCache.apId })
|
|
1066
|
+
.from(actorCache)
|
|
1067
|
+
.where(eq(actorCache.apId, attributedTo))
|
|
1068
|
+
.get();
|
|
1069
|
+
if (!cachedAuthor) {
|
|
1070
|
+
try {
|
|
1071
|
+
await fetchAndUpsertActorCache(db, attributedTo, {
|
|
1072
|
+
timeout: ANNOUNCED_OBJECT_FETCH_TIMEOUT_MS,
|
|
1073
|
+
mode: "insert",
|
|
1074
|
+
signer: (await getInstanceFetchSignerByDb(db)) ?? undefined,
|
|
1075
|
+
});
|
|
1076
|
+
} catch {
|
|
1077
|
+
/* best-effort */
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
const attachments = note.attachment ? JSON.stringify(note.attachment) : "[]";
|
|
1082
|
+
await db
|
|
1083
|
+
.insert(objects)
|
|
1084
|
+
.values({
|
|
1085
|
+
apId: objectId,
|
|
1086
|
+
type: "Note",
|
|
1087
|
+
attributedTo,
|
|
1088
|
+
content: boundInboundContent(note.content),
|
|
1089
|
+
summary: boundInboundSummary(note.summary),
|
|
1090
|
+
attachmentsJson: boundAttachmentsJson(attachments),
|
|
1091
|
+
// Stored verbatim, never resolved (depth cap): a boosted reply keeps its
|
|
1092
|
+
// honest thread link even though the parent may stay unknown here.
|
|
1093
|
+
inReplyTo: note.inReplyTo || null,
|
|
1094
|
+
visibility,
|
|
1095
|
+
toJson: boundAddressJson(note.to),
|
|
1096
|
+
ccJson: boundAddressJson(note.cc),
|
|
1097
|
+
communityApId: null,
|
|
1098
|
+
published: normalizeInboundTimestamp(
|
|
1099
|
+
note.published,
|
|
1100
|
+
new Date().toISOString(),
|
|
1101
|
+
),
|
|
1102
|
+
isLocal: 0,
|
|
1103
|
+
})
|
|
1104
|
+
.onConflictDoNothing();
|
|
1105
|
+
|
|
1106
|
+
return true;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
910
1109
|
// ---------------------------------------------------------------------------
|
|
911
1110
|
// Delete handler
|
|
912
1111
|
// ---------------------------------------------------------------------------
|
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
import type { Database } from "../../../../db/index.ts";
|
|
2
|
-
import { and, eq, gt,
|
|
3
|
-
import {
|
|
4
|
-
activities,
|
|
5
|
-
actors,
|
|
6
|
-
follows,
|
|
7
|
-
likes,
|
|
8
|
-
objects,
|
|
9
|
-
} from "../../../../db/index.ts";
|
|
2
|
+
import { and, eq, gt, or, sql } from "drizzle-orm";
|
|
3
|
+
import { activities, actors, follows } from "../../../../db/index.ts";
|
|
10
4
|
import {
|
|
11
5
|
activityApId,
|
|
12
6
|
generateId,
|
|
@@ -43,6 +37,7 @@ export async function handleFollow(
|
|
|
43
37
|
recipient: ActorRow,
|
|
44
38
|
actor: string,
|
|
45
39
|
baseUrl: string,
|
|
40
|
+
sourceActivityId: string = activity.id || "",
|
|
46
41
|
) {
|
|
47
42
|
const db = c.get("db");
|
|
48
43
|
|
|
@@ -152,7 +147,9 @@ export async function handleFollow(
|
|
|
152
147
|
id: acceptId,
|
|
153
148
|
type: "Accept",
|
|
154
149
|
actor: recipient.apId,
|
|
155
|
-
|
|
150
|
+
// Echo the peer's bounded protocol id, not our origin-bound internal
|
|
151
|
+
// ledger id. Remote servers match Accept.object to their Follow id.
|
|
152
|
+
object: sourceActivityId || activityId,
|
|
156
153
|
};
|
|
157
154
|
|
|
158
155
|
// Store accept activity before enqueue.
|
|
@@ -160,7 +157,7 @@ export async function handleFollow(
|
|
|
160
157
|
apId: acceptId,
|
|
161
158
|
type: "Accept",
|
|
162
159
|
actorApId: recipient.apId,
|
|
163
|
-
objectApId: activityId,
|
|
160
|
+
objectApId: sourceActivityId || activityId,
|
|
164
161
|
rawJson: JSON.stringify(acceptActivity),
|
|
165
162
|
direction: "outbound",
|
|
166
163
|
});
|
|
@@ -294,10 +291,20 @@ export async function handleReject(
|
|
|
294
291
|
// Undo handler
|
|
295
292
|
// ---------------------------------------------------------------------------
|
|
296
293
|
|
|
294
|
+
/**
|
|
295
|
+
* `recipient` is null when the Undo arrived at the shared inbox as an
|
|
296
|
+
* INSTANCE-scoped activity — i.e. Undo(Like|Announce), which is actor-keyed
|
|
297
|
+
* and idempotent and names no local actor. Undo(Follow|Block) is object-actor
|
|
298
|
+
* scoped and always dispatched with its resolved target, so a null recipient
|
|
299
|
+
* on a follow branch means the target could not be resolved; that is logged
|
|
300
|
+
* and refused rather than guessed at. The previous code guessed: it fell back
|
|
301
|
+
* to deleting the signer's likes across EVERY object the "recipient" had
|
|
302
|
+
* authored and rewriting every one of that actor's `objects` rows.
|
|
303
|
+
*/
|
|
297
304
|
export async function handleUndo(
|
|
298
305
|
c: ActivityContext,
|
|
299
306
|
activity: Activity,
|
|
300
|
-
recipient: ActorRow,
|
|
307
|
+
recipient: ActorRow | null,
|
|
301
308
|
actor: string,
|
|
302
309
|
_baseUrl: string,
|
|
303
310
|
) {
|
|
@@ -318,9 +325,17 @@ export async function handleUndo(
|
|
|
318
325
|
}
|
|
319
326
|
|
|
320
327
|
if (typeIncludes(objectType, "Follow")) {
|
|
328
|
+
if (!recipient) {
|
|
329
|
+
log.warn("Undo(Follow) without a resolved local target", {
|
|
330
|
+
event: "ap.undo.follow.no_recipient",
|
|
331
|
+
actor,
|
|
332
|
+
activityId: activity.id,
|
|
333
|
+
});
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
321
336
|
await undoFollow(db, objectId, actor, recipient);
|
|
322
337
|
} else if (typeIncludes(objectType, "Like")) {
|
|
323
|
-
await undoLike(db, objectId, activityObject, actor
|
|
338
|
+
await undoLike(db, objectId, activityObject, actor);
|
|
324
339
|
} else if (typeIncludes(objectType, "Announce")) {
|
|
325
340
|
await undoAnnounce(db, objectId, activityObject, actor);
|
|
326
341
|
}
|
|
@@ -339,16 +354,25 @@ async function resolveUndoByActivityId(
|
|
|
339
354
|
db: Database,
|
|
340
355
|
objectId: string,
|
|
341
356
|
actor: string,
|
|
342
|
-
recipient: ActorRow,
|
|
357
|
+
recipient: ActorRow | null,
|
|
343
358
|
): Promise<boolean> {
|
|
344
359
|
const originalActivity = await db
|
|
345
360
|
.select({
|
|
361
|
+
apId: activities.apId,
|
|
346
362
|
type: activities.type,
|
|
347
363
|
objectApId: activities.objectApId,
|
|
348
364
|
actorApId: activities.actorApId,
|
|
349
365
|
})
|
|
350
366
|
.from(activities)
|
|
351
|
-
.where(
|
|
367
|
+
.where(
|
|
368
|
+
or(
|
|
369
|
+
eq(activities.apId, objectId),
|
|
370
|
+
and(
|
|
371
|
+
eq(activities.direction, "inbound"),
|
|
372
|
+
sql`json_extract(${activities.rawJson}, '$.id') = ${objectId}`,
|
|
373
|
+
),
|
|
374
|
+
),
|
|
375
|
+
)
|
|
352
376
|
.get();
|
|
353
377
|
if (!originalActivity) return false;
|
|
354
378
|
|
|
@@ -363,7 +387,17 @@ async function resolveUndoByActivityId(
|
|
|
363
387
|
}
|
|
364
388
|
|
|
365
389
|
if (originalActivity.type === "Follow") {
|
|
366
|
-
|
|
390
|
+
// The followerCount decrement is keyed on the followed actor, so an
|
|
391
|
+
// instance-dispatched Undo (no resolved local target) must not run it.
|
|
392
|
+
if (!recipient) {
|
|
393
|
+
log.warn("Undo(Follow) by activity id without a resolved local target", {
|
|
394
|
+
event: "ap.undo.follow.no_recipient",
|
|
395
|
+
actor,
|
|
396
|
+
activityId: objectId,
|
|
397
|
+
});
|
|
398
|
+
return true;
|
|
399
|
+
}
|
|
400
|
+
const follow = await findFollowByActivityId(db, originalActivity.apId);
|
|
367
401
|
if (follow) {
|
|
368
402
|
await undoFollowEdge(
|
|
369
403
|
db,
|
|
@@ -391,7 +425,14 @@ async function resolveUndoByActivityId(
|
|
|
391
425
|
// A crash-then-retry converges (the recompute is idempotent against the true
|
|
392
426
|
// edge set) instead of permanently over-counting on the retry's no-op
|
|
393
427
|
// delete. The actor-mismatch guard above still constrains who may undo.
|
|
394
|
-
await undoInteraction(
|
|
428
|
+
await undoInteraction(
|
|
429
|
+
db,
|
|
430
|
+
kind,
|
|
431
|
+
countField,
|
|
432
|
+
undefined,
|
|
433
|
+
originalActivity.apId,
|
|
434
|
+
actor,
|
|
435
|
+
);
|
|
395
436
|
return true;
|
|
396
437
|
}
|
|
397
438
|
|
|
@@ -476,12 +517,26 @@ async function undoFollow(
|
|
|
476
517
|
await undoFollowEdge(db, followerApId, followingApId, recipient.apId);
|
|
477
518
|
}
|
|
478
519
|
|
|
520
|
+
/**
|
|
521
|
+
* Undo(Like).
|
|
522
|
+
*
|
|
523
|
+
* If the like edge cannot be identified — from the inner object's `object`, or
|
|
524
|
+
* from the referenced Like activity id — there is NOTHING to undo and we say
|
|
525
|
+
* so. There used to be a "last resort" here that deleted every like the signer
|
|
526
|
+
* held on ANY object authored by the delivery recipient and then recomputed
|
|
527
|
+
* `likeCount` for EVERY one of that actor's `objects` rows. It was reachable
|
|
528
|
+
* from a malformed `{type:'Undo', object:{type:'Like', id:...}}` with no inner
|
|
529
|
+
* `object` (and from the actor-mismatch branch), and under the shared inbox it
|
|
530
|
+
* ran once per local follower, so a single non-conformant peer could both wipe
|
|
531
|
+
* a user's likes across an author's whole timeline and amplify a full-table
|
|
532
|
+
* UPDATE by the follower count. Nothing about the activity authorised that
|
|
533
|
+
* scope: the only thing it identified was the signer.
|
|
534
|
+
*/
|
|
479
535
|
async function undoLike(
|
|
480
536
|
db: Database,
|
|
481
537
|
objectId: string | null,
|
|
482
538
|
activityObject: ReturnType<typeof getActivityObject>,
|
|
483
539
|
actor: string,
|
|
484
|
-
recipient: ActorRow,
|
|
485
540
|
): Promise<void> {
|
|
486
541
|
const handled = await undoInteraction(
|
|
487
542
|
db,
|
|
@@ -493,43 +548,13 @@ async function undoLike(
|
|
|
493
548
|
);
|
|
494
549
|
if (handled) return;
|
|
495
550
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
]);
|
|
551
|
+
log.warn("Undo(Like) names no resolvable like edge; ignoring", {
|
|
552
|
+
event: "ap.undo.like.unresolved",
|
|
553
|
+
actor,
|
|
554
|
+
activityId: objectId,
|
|
555
|
+
object: activityObject?.object,
|
|
556
|
+
});
|
|
531
557
|
}
|
|
532
|
-
|
|
533
558
|
async function undoAnnounce(
|
|
534
559
|
db: Database,
|
|
535
560
|
objectId: string | null,
|
|
@@ -20,7 +20,9 @@ import {
|
|
|
20
20
|
getActivityObjectId,
|
|
21
21
|
} from "../inbox-types.ts";
|
|
22
22
|
import { notifyLocalObjectOwner } from "./inbox-shared-helpers.ts";
|
|
23
|
+
import { fetchAndPersistAnnouncedNote } from "./inbox-content-handlers.ts";
|
|
23
24
|
import { isLocal } from "../../../lib/ap-ids.ts";
|
|
25
|
+
import { notDeleted } from "../../../../db/index.ts";
|
|
24
26
|
import {
|
|
25
27
|
actorIsBlockedBy,
|
|
26
28
|
canViewerReadObjectFull,
|
|
@@ -174,10 +176,15 @@ async function handleInteraction(
|
|
|
174
176
|
// Like handler
|
|
175
177
|
// ---------------------------------------------------------------------------
|
|
176
178
|
|
|
179
|
+
/**
|
|
180
|
+
* A Like is INSTANCE-scoped: the affected object (and the local owner to
|
|
181
|
+
* notify) is resolved from `activity.object`, never from a delivery recipient.
|
|
182
|
+
* The recipient argument was already ignored; removing it is what stops the
|
|
183
|
+
* shared inbox from being able to run this once per local follower.
|
|
184
|
+
*/
|
|
177
185
|
export async function handleLike(
|
|
178
186
|
c: ActivityContext,
|
|
179
187
|
activity: Activity,
|
|
180
|
-
_recipient: ActorRow,
|
|
181
188
|
actor: string,
|
|
182
189
|
baseUrl: string,
|
|
183
190
|
) {
|
|
@@ -188,13 +195,70 @@ export async function handleLike(
|
|
|
188
195
|
// Announce handler (repost/boost)
|
|
189
196
|
// ---------------------------------------------------------------------------
|
|
190
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Does at least one (non-tombstoned) LOCAL actor have an accepted follow of
|
|
200
|
+
* `actorApId`? The fetch-and-store path below is gated on this so an arbitrary
|
|
201
|
+
* remote cannot use our inbox as an open relay: only a boost from someone a
|
|
202
|
+
* local user chose to follow may trigger an outbound object fetch.
|
|
203
|
+
*/
|
|
204
|
+
async function actorHasLocalFollowers(
|
|
205
|
+
db: Database,
|
|
206
|
+
actorApId: string,
|
|
207
|
+
): Promise<boolean> {
|
|
208
|
+
const row = await db
|
|
209
|
+
.select({ followerApId: follows.followerApId })
|
|
210
|
+
.from(follows)
|
|
211
|
+
.innerJoin(
|
|
212
|
+
actors,
|
|
213
|
+
and(eq(actors.apId, follows.followerApId), notDeleted(actors)),
|
|
214
|
+
)
|
|
215
|
+
.where(
|
|
216
|
+
and(eq(follows.followingApId, actorApId), eq(follows.status, "accepted")),
|
|
217
|
+
)
|
|
218
|
+
.limit(1)
|
|
219
|
+
.get();
|
|
220
|
+
return Boolean(row);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Instance-scoped, exactly like {@link handleLike}. */
|
|
191
224
|
export async function handleAnnounce(
|
|
192
225
|
c: ActivityContext,
|
|
193
226
|
activity: Activity,
|
|
194
|
-
_recipient: ActorRow,
|
|
195
227
|
actor: string,
|
|
196
228
|
baseUrl: string,
|
|
197
229
|
) {
|
|
230
|
+
// Fetch-and-store an UNKNOWN boosted remote object before recording the
|
|
231
|
+
// announce, so a followed remote's boost of a post this instance never saw
|
|
232
|
+
// still surfaces in feeds (previously the Announce only left a dangling
|
|
233
|
+
// announce edge + a counter no-op). Strictly gated: the target must be
|
|
234
|
+
// remote and unknown, and the booster must have at least one local follower
|
|
235
|
+
// (no open-relay amplification). On any fetch/validation failure the
|
|
236
|
+
// Announce degrades to exactly the old behavior — handleInteraction records
|
|
237
|
+
// the edge idempotently and the counter recompute no-ops on the absent row,
|
|
238
|
+
// so a later Create of the same object retroactively completes the boost.
|
|
239
|
+
const db = c.get("db");
|
|
240
|
+
const objectId = getActivityObjectId(activity);
|
|
241
|
+
if (objectId && !isLocal(objectId, baseUrl)) {
|
|
242
|
+
const known = await db
|
|
243
|
+
.select({ apId: objects.apId })
|
|
244
|
+
.from(objects)
|
|
245
|
+
.where(eq(objects.apId, objectId))
|
|
246
|
+
.get();
|
|
247
|
+
if (!known && (await actorHasLocalFollowers(db, actor))) {
|
|
248
|
+
const persisted = await fetchAndPersistAnnouncedNote(
|
|
249
|
+
db,
|
|
250
|
+
objectId,
|
|
251
|
+
baseUrl,
|
|
252
|
+
);
|
|
253
|
+
if (!persisted) {
|
|
254
|
+
log.debug("Announce target fetch skipped or failed", {
|
|
255
|
+
event: "ap.announce.object_fetch_failed",
|
|
256
|
+
actor,
|
|
257
|
+
objectId,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
198
262
|
await handleInteraction("announce", c, activity, actor, baseUrl);
|
|
199
263
|
}
|
|
200
264
|
|
|
@@ -23,18 +23,13 @@ import type { Activity } from "../inbox-types.ts";
|
|
|
23
23
|
// ---------------------------------------------------------------------------
|
|
24
24
|
|
|
25
25
|
type BatchStatement = BatchItem<"sqlite">;
|
|
26
|
-
interface BatchableDb {
|
|
27
|
-
batch(
|
|
28
|
-
statements: readonly [BatchStatement, ...BatchStatement[]],
|
|
29
|
-
): Promise<unknown>;
|
|
30
|
-
}
|
|
31
26
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
27
|
+
// The batch primitive lives with the other D1 write primitives (src/db/
|
|
28
|
+
// d1-write.ts) so the atomicity rule and the parameter-budget rule cannot
|
|
29
|
+
// drift apart. Re-exported here because the inbox handlers are its heaviest
|
|
30
|
+
// caller and this is the import path they already use.
|
|
31
|
+
export { runBatch } from "../../../../db/d1-write.ts";
|
|
32
|
+
import { runBatch } from "../../../../db/d1-write.ts";
|
|
38
33
|
|
|
39
34
|
// ---------------------------------------------------------------------------
|
|
40
35
|
// Shared helpers used by multiple inbox handler files
|
|
@@ -97,7 +92,7 @@ export async function findFollowByActivityId(
|
|
|
97
92
|
followingApId: string;
|
|
98
93
|
status: string;
|
|
99
94
|
} | null> {
|
|
100
|
-
|
|
95
|
+
let row = await db
|
|
101
96
|
.select({
|
|
102
97
|
followerApId: follows.followerApId,
|
|
103
98
|
followingApId: follows.followingApId,
|
|
@@ -106,6 +101,34 @@ export async function findFollowByActivityId(
|
|
|
106
101
|
.from(follows)
|
|
107
102
|
.where(eq(follows.activityApId, activityApIdValue))
|
|
108
103
|
.get();
|
|
104
|
+
if (!row) {
|
|
105
|
+
// Inbound envelopes use an origin-bound internal activities.apId. Undo
|
|
106
|
+
// carries the peer's protocol id, so resolve that bounded raw-json id back
|
|
107
|
+
// to the internal row before looking up the follow edge. The direct lookup
|
|
108
|
+
// above preserves compatibility with legacy rows.
|
|
109
|
+
const source = await db
|
|
110
|
+
.select({ apId: activities.apId })
|
|
111
|
+
.from(activities)
|
|
112
|
+
.where(
|
|
113
|
+
and(
|
|
114
|
+
eq(activities.direction, "inbound"),
|
|
115
|
+
sql`json_extract(${activities.rawJson}, '$.id') = ${activityApIdValue}`,
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
.limit(2);
|
|
119
|
+
for (const candidate of source) {
|
|
120
|
+
row = await db
|
|
121
|
+
.select({
|
|
122
|
+
followerApId: follows.followerApId,
|
|
123
|
+
followingApId: follows.followingApId,
|
|
124
|
+
status: follows.status,
|
|
125
|
+
})
|
|
126
|
+
.from(follows)
|
|
127
|
+
.where(eq(follows.activityApId, candidate.apId))
|
|
128
|
+
.get();
|
|
129
|
+
if (row) break;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
109
132
|
return row ?? null;
|
|
110
133
|
}
|
|
111
134
|
|