@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.
Files changed (185) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +82 -0
  3. package/migrations/0001_init.sql +495 -0
  4. package/migrations/0002_social_remote_actor_edges.sql +92 -0
  5. package/migrations/0003_activity_remote_object_edges.sql +68 -0
  6. package/migrations/0004_blocklist.sql +26 -0
  7. package/migrations/0005_story_community_scope.sql +13 -0
  8. package/migrations/0006_dm_community_read_status.sql +19 -0
  9. package/migrations/0007_moderation_reports.sql +22 -0
  10. package/migrations/0008_actor_fields_aka.sql +18 -0
  11. package/migrations/0009_object_tags.sql +13 -0
  12. package/migrations/0010_object_recipients_drop_actor_fk.sql +34 -0
  13. package/migrations/0011_drop_remote_actor_fks.sql +205 -0
  14. package/migrations/0012_objects_content_fts.sql +39 -0
  15. package/migrations/0013_efficiency_indexes.sql +13 -0
  16. package/migrations/0014_inbox_actor_created_idx.sql +15 -0
  17. package/migrations/0015_community_bans.sql +16 -0
  18. package/migrations/0016_namespace_takos_oidc_subject.sql +19 -0
  19. package/migrations/0017_mobile_push_registrations.sql +22 -0
  20. package/migrations/README.md +122 -0
  21. package/package.json +75 -0
  22. package/packages/api/LICENSE +16 -0
  23. package/packages/api/package.json +30 -0
  24. package/packages/api/src/index.ts +4 -0
  25. package/packages/api/src/lib/api/account.ts +20 -0
  26. package/packages/api/src/lib/api/actors.ts +149 -0
  27. package/packages/api/src/lib/api/auth.ts +46 -0
  28. package/packages/api/src/lib/api/communities.ts +329 -0
  29. package/packages/api/src/lib/api/dm.test.ts +67 -0
  30. package/packages/api/src/lib/api/dm.ts +236 -0
  31. package/packages/api/src/lib/api/fetch.ts +111 -0
  32. package/packages/api/src/lib/api/follow.ts +30 -0
  33. package/packages/api/src/lib/api/media.ts +100 -0
  34. package/packages/api/src/lib/api/moderation.ts +98 -0
  35. package/packages/api/src/lib/api/normalize.ts +71 -0
  36. package/packages/api/src/lib/api/notifications.test.ts +63 -0
  37. package/packages/api/src/lib/api/notifications.ts +61 -0
  38. package/packages/api/src/lib/api/posts.test.ts +110 -0
  39. package/packages/api/src/lib/api/posts.ts +181 -0
  40. package/packages/api/src/lib/api/recommendations.ts +22 -0
  41. package/packages/api/src/lib/api/search.ts +88 -0
  42. package/packages/api/src/lib/api/stories.ts +80 -0
  43. package/packages/api/src/lib/api.ts +15 -0
  44. package/packages/api/src/lib/fetch-with-timeout.ts +42 -0
  45. package/packages/api/src/lib/transport.ts +40 -0
  46. package/packages/api/src/social-server.ts +47 -0
  47. package/packages/api/src/types/index.ts +185 -0
  48. package/scripts/apply-takosumi-migrations.ts +621 -0
  49. package/src/backend/federation-helpers.ts +36 -0
  50. package/src/backend/index.ts +872 -0
  51. package/src/backend/lib/account-migration.ts +106 -0
  52. package/src/backend/lib/activitypub-actor-cache.ts +238 -0
  53. package/src/backend/lib/activitypub-helpers.ts +131 -0
  54. package/src/backend/lib/activitypub-validators.ts +323 -0
  55. package/src/backend/lib/ap-context.ts +16 -0
  56. package/src/backend/lib/ap-ids.ts +101 -0
  57. package/src/backend/lib/ap-response.ts +30 -0
  58. package/src/backend/lib/ap-signing.ts +87 -0
  59. package/src/backend/lib/ap-verify.ts +670 -0
  60. package/src/backend/lib/auth-lockout.ts +230 -0
  61. package/src/backend/lib/backend-paths.ts +34 -0
  62. package/src/backend/lib/base64.ts +30 -0
  63. package/src/backend/lib/blocklist-purge.ts +109 -0
  64. package/src/backend/lib/blocklist.ts +279 -0
  65. package/src/backend/lib/chunk.ts +33 -0
  66. package/src/backend/lib/client-ip.ts +169 -0
  67. package/src/backend/lib/community-visibility.ts +230 -0
  68. package/src/backend/lib/crypto.ts +424 -0
  69. package/src/backend/lib/delivery/circuit.ts +265 -0
  70. package/src/backend/lib/delivery/metrics.ts +30 -0
  71. package/src/backend/lib/delivery/planner.ts +190 -0
  72. package/src/backend/lib/delivery/queue-batching.ts +626 -0
  73. package/src/backend/lib/delivery/queue-delivery.ts +641 -0
  74. package/src/backend/lib/delivery/queue.ts +576 -0
  75. package/src/backend/lib/delivery/transformers.ts +56 -0
  76. package/src/backend/lib/delivery/types.ts +139 -0
  77. package/src/backend/lib/errors.ts +114 -0
  78. package/src/backend/lib/federation-fetch.ts +296 -0
  79. package/src/backend/lib/feed-cursor.ts +57 -0
  80. package/src/backend/lib/feed-exclude.ts +48 -0
  81. package/src/backend/lib/hex.ts +8 -0
  82. package/src/backend/lib/log-mask.ts +213 -0
  83. package/src/backend/lib/logger.ts +285 -0
  84. package/src/backend/lib/mobile-contract.ts +137 -0
  85. package/src/backend/lib/oauth-providers.ts +324 -0
  86. package/src/backend/lib/oauth-utils.ts +148 -0
  87. package/src/backend/lib/oidc-id-token.ts +151 -0
  88. package/src/backend/lib/parse-helpers.ts +31 -0
  89. package/src/backend/lib/post-visibility.ts +190 -0
  90. package/src/backend/lib/session-actor.ts +61 -0
  91. package/src/backend/lib/ssrf.ts +428 -0
  92. package/src/backend/lib/strip-image-metadata.ts +191 -0
  93. package/src/backend/middleware/bearer-auth.ts +70 -0
  94. package/src/backend/middleware/body-limit.ts +212 -0
  95. package/src/backend/middleware/cache.ts +429 -0
  96. package/src/backend/middleware/csrf.ts +130 -0
  97. package/src/backend/middleware/error-handler.ts +77 -0
  98. package/src/backend/middleware/rate-limit.ts +308 -0
  99. package/src/backend/public.ts +21 -0
  100. package/src/backend/routes/account-teardown.ts +430 -0
  101. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +354 -0
  102. package/src/backend/routes/activitypub/handlers/inbound-timestamp.ts +29 -0
  103. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1634 -0
  104. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +547 -0
  105. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +497 -0
  106. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +262 -0
  107. package/src/backend/routes/activitypub/handlers/user-inbox-handlers.ts +35 -0
  108. package/src/backend/routes/activitypub/inbox-types.ts +74 -0
  109. package/src/backend/routes/activitypub/inbox.ts +1191 -0
  110. package/src/backend/routes/activitypub/outbox.ts +0 -0
  111. package/src/backend/routes/activitypub/query-helpers.ts +227 -0
  112. package/src/backend/routes/activitypub.ts +616 -0
  113. package/src/backend/routes/actors-helpers.ts +487 -0
  114. package/src/backend/routes/actors.ts +1311 -0
  115. package/src/backend/routes/apps.ts +313 -0
  116. package/src/backend/routes/auth-helpers.ts +566 -0
  117. package/src/backend/routes/auth.ts +615 -0
  118. package/src/backend/routes/communities/membership-invites.ts +208 -0
  119. package/src/backend/routes/communities/membership-join.ts +335 -0
  120. package/src/backend/routes/communities/membership-members.ts +539 -0
  121. package/src/backend/routes/communities/membership-requests.ts +296 -0
  122. package/src/backend/routes/communities/membership-shared.ts +364 -0
  123. package/src/backend/routes/communities/messages.ts +479 -0
  124. package/src/backend/routes/communities/routes.ts +624 -0
  125. package/src/backend/routes/communities.ts +21 -0
  126. package/src/backend/routes/dm/contacts.ts +525 -0
  127. package/src/backend/routes/dm/conversations-helpers.ts +197 -0
  128. package/src/backend/routes/dm/conversations.ts +25 -0
  129. package/src/backend/routes/dm/messages.ts +658 -0
  130. package/src/backend/routes/dm/query-helpers.ts +85 -0
  131. package/src/backend/routes/dm/read-archive.ts +228 -0
  132. package/src/backend/routes/dm/requests.ts +222 -0
  133. package/src/backend/routes/dm/typing.ts +81 -0
  134. package/src/backend/routes/dm.ts +15 -0
  135. package/src/backend/routes/follow-helpers.ts +370 -0
  136. package/src/backend/routes/follow.ts +588 -0
  137. package/src/backend/routes/media.ts +692 -0
  138. package/src/backend/routes/mobile.ts +159 -0
  139. package/src/backend/routes/moderation.ts +373 -0
  140. package/src/backend/routes/notifications.ts +757 -0
  141. package/src/backend/routes/posts/delete-cascade.ts +330 -0
  142. package/src/backend/routes/posts/interactions.ts +795 -0
  143. package/src/backend/routes/posts/post-helpers.ts +847 -0
  144. package/src/backend/routes/posts/queries.ts +537 -0
  145. package/src/backend/routes/posts/routes.ts +865 -0
  146. package/src/backend/routes/posts/transformers.ts +161 -0
  147. package/src/backend/routes/posts.ts +17 -0
  148. package/src/backend/routes/recommendations.ts +88 -0
  149. package/src/backend/routes/search.ts +730 -0
  150. package/src/backend/routes/stories/interactions.ts +576 -0
  151. package/src/backend/routes/stories/query-helpers.ts +482 -0
  152. package/src/backend/routes/stories/routes.ts +906 -0
  153. package/src/backend/routes/stories.ts +13 -0
  154. package/src/backend/routes/takos-tools/dm.ts +249 -0
  155. package/src/backend/routes/takos-tools/follows.ts +225 -0
  156. package/src/backend/routes/takos-tools/posts.ts +292 -0
  157. package/src/backend/routes/takos-tools/search.ts +228 -0
  158. package/src/backend/routes/takos-tools/timeline.ts +132 -0
  159. package/src/backend/routes/takos-tools/types.ts +10 -0
  160. package/src/backend/routes/takos-tools-response.ts +178 -0
  161. package/src/backend/routes/takos-tools.ts +153 -0
  162. package/src/backend/routes/timeline.ts +755 -0
  163. package/src/backend/runtime/bun.ts +620 -0
  164. package/src/backend/runtime/cloudflare.ts +202 -0
  165. package/src/backend/runtime/compat-bun/types.ts +44 -0
  166. package/src/backend/runtime/memory-kv.ts +104 -0
  167. package/src/backend/runtime/shared.ts +142 -0
  168. package/src/backend/runtime/types.ts +205 -0
  169. package/src/backend/server.ts +636 -0
  170. package/src/backend/types.ts +143 -0
  171. package/src/db/index.ts +97 -0
  172. package/src/db/schema/actors.ts +129 -0
  173. package/src/db/schema/communities.ts +133 -0
  174. package/src/db/schema/date-utils.ts +17 -0
  175. package/src/db/schema/index.ts +17 -0
  176. package/src/db/schema/messaging.ts +241 -0
  177. package/src/db/schema/mobile.ts +37 -0
  178. package/src/db/schema/posts.ts +150 -0
  179. package/src/db/schema/relations.ts +266 -0
  180. package/src/db/schema/reports.ts +33 -0
  181. package/src/db/schema/social.ts +106 -0
  182. package/src/db/schema/stories.ts +70 -0
  183. package/src/db/schema.ts +15 -0
  184. package/src/plugin/public.ts +7 -0
  185. package/src/runtime/site-worker.ts +10 -0
@@ -0,0 +1,537 @@
1
+ /**
2
+ * Post query helpers and shared utilities
3
+ *
4
+ * Extracted from base.ts to reduce file size. Contains:
5
+ * - Type definitions
6
+ * - Inline helpers (validation, author resolution, interaction flags)
7
+ * - Addressing logic for ActivityPub delivery
8
+ */
9
+
10
+ import type { Database } from "../../../db/index.ts";
11
+ import {
12
+ activities,
13
+ actorCache,
14
+ bookmarks,
15
+ communities,
16
+ likes,
17
+ objects,
18
+ } from "../../../db/index.ts";
19
+ import { and, eq, inArray, or } from "drizzle-orm";
20
+ import type { Env } from "../../types.ts";
21
+ import {
22
+ activityApId,
23
+ formatUsername,
24
+ generateId,
25
+ objectApId,
26
+ } from "../../federation-helpers.ts";
27
+ import { formatPost, PostRow } from "./transformers.ts";
28
+ import {
29
+ enqueueFanoutToCommunity,
30
+ enqueueFanoutToFollowers,
31
+ } from "../../lib/delivery/queue.ts";
32
+ import { isRecord, parseJsonObject } from "../../lib/parse-helpers.ts";
33
+ import { logger } from "../../lib/logger.ts";
34
+
35
+ const log = logger.child({ component: "posts.queries" });
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Types
39
+ // ---------------------------------------------------------------------------
40
+
41
+ export type PostAttachment = {
42
+ type?: string;
43
+ mediaType?: string;
44
+ url?: string;
45
+ [key: string]: unknown;
46
+ };
47
+
48
+ export type CreatePostBody = {
49
+ content: string;
50
+ summary?: string;
51
+ attachments?: PostAttachment[];
52
+ in_reply_to?: string;
53
+ visibility?: string;
54
+ community_ap_id?: string;
55
+ };
56
+
57
+ export type PostDetailRow = PostRow & {
58
+ to_json?: string | null;
59
+ bookmarked?: number;
60
+ };
61
+
62
+ export type MentionFailure = {
63
+ mention: string;
64
+ stage: "resolve" | "persist_activity" | "persist_inbox";
65
+ reason: string;
66
+ };
67
+
68
+ /** An ActivityStreams `Mention` tag entry for an outbound Note/Create. */
69
+ export type MentionTag = {
70
+ type: "Mention";
71
+ href: string;
72
+ name: string;
73
+ };
74
+
75
+ /** An ActivityStreams `Hashtag` tag entry for an outbound Note/Create. */
76
+ export type HashtagTag = {
77
+ type: "Hashtag";
78
+ href: string;
79
+ name: string;
80
+ };
81
+
82
+ /** Any tag persisted to objects.tagsJson / emitted on a Note's `tag`. */
83
+ export type PostTag = MentionTag | HashtagTag;
84
+
85
+ /**
86
+ * Result of resolving the @mentions in a post: per-mention failures, the
87
+ * `Mention` tag array to attach to the outbound Note/Create, and the resolved
88
+ * actor IRIs (local + remote) that must be added to `cc` and — for remote
89
+ * actors — delivered to.
90
+ */
91
+ export type ProcessMentionsResult = {
92
+ failures: MentionFailure[];
93
+ tags: PostTag[];
94
+ /** All resolved mentioned actor IRIs (local + remote), de-duplicated. */
95
+ mentionedActorApIds: string[];
96
+ /** Resolved remote mentioned actor IRIs that need direct inbox delivery. */
97
+ remoteMentionedActorApIds: string[];
98
+ };
99
+
100
+ /** Merge extra recipient IRIs into a cc array, de-duplicating and dropping empties. */
101
+ export function mergeCc(cc: string[], extra: string[]): string[] {
102
+ const seen = new Set(cc);
103
+ const out = [...cc];
104
+ for (const iri of extra) {
105
+ if (iri && !seen.has(iri)) {
106
+ seen.add(iri);
107
+ out.push(iri);
108
+ }
109
+ }
110
+ return out;
111
+ }
112
+
113
+ export type AuthorInfo = {
114
+ preferredUsername: string | null;
115
+ name: string | null;
116
+ iconUrl: string | null;
117
+ };
118
+
119
+ /** Shape returned by Drizzle relational query with author. */
120
+ export type PostWithAuthor = {
121
+ apId: string;
122
+ type: string;
123
+ attributedTo: string;
124
+ content: string;
125
+ summary: string | null;
126
+ attachmentsJson: string | null;
127
+ inReplyTo: string | null;
128
+ visibility: string;
129
+ communityApId: string | null;
130
+ likeCount: number;
131
+ replyCount: number;
132
+ announceCount: number;
133
+ published: string;
134
+ toJson?: string | null;
135
+ author: AuthorInfo | null;
136
+ };
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Inline helpers
140
+ // ---------------------------------------------------------------------------
141
+
142
+ export { isRecord, parseJsonObject };
143
+
144
+ /**
145
+ * Validate that a raw field is either absent, null, or a string.
146
+ * Returns an error message string if invalid, or null if valid.
147
+ */
148
+ export function validateOptionalString(
149
+ raw: Record<string, unknown>,
150
+ field: string,
151
+ ): string | null {
152
+ const value = raw[field];
153
+ if (value !== undefined && value !== null && typeof value !== "string") {
154
+ return `${field} must be a string`;
155
+ }
156
+ return null;
157
+ }
158
+
159
+ /** Shared Drizzle relational `with` for loading a post's local author info. */
160
+ export const AUTHOR_WITH = {
161
+ author: {
162
+ columns: {
163
+ preferredUsername: true,
164
+ name: true,
165
+ iconUrl: true,
166
+ },
167
+ },
168
+ } as const;
169
+
170
+ /** Build an `or` condition that matches either the full apId or the raw postId. */
171
+ export function postWhereByIdOrApId(baseUrl: string, postId: string) {
172
+ return or(
173
+ eq(objects.apId, objectApId(baseUrl, postId)),
174
+ eq(objects.apId, postId),
175
+ );
176
+ }
177
+
178
+ /**
179
+ * Resolve author info from a Drizzle post's local author or a cached-author map.
180
+ * Returns { preferredUsername, name, iconUrl } with nulls as fallback.
181
+ */
182
+ export function resolveAuthor(
183
+ localAuthor: AuthorInfo | null | undefined,
184
+ attributedTo: string,
185
+ cachedAuthorMap?: Map<string, AuthorInfo>,
186
+ ): AuthorInfo {
187
+ if (localAuthor?.preferredUsername) return localAuthor;
188
+ const cached = cachedAuthorMap?.get(attributedTo);
189
+ if (cached) return cached;
190
+ return { preferredUsername: null, name: null, iconUrl: null };
191
+ }
192
+
193
+ /**
194
+ * Resolve author info with an async fallback to actorCache.
195
+ * Used for single-post lookups where a batch-loaded map is unavailable.
196
+ */
197
+ export async function resolveAuthorWithCache(
198
+ localAuthor: AuthorInfo | null | undefined,
199
+ attributedTo: string,
200
+ db: Database,
201
+ ): Promise<AuthorInfo> {
202
+ if (localAuthor?.preferredUsername) return localAuthor;
203
+ const cached = await db
204
+ .select({
205
+ preferredUsername: actorCache.preferredUsername,
206
+ name: actorCache.name,
207
+ iconUrl: actorCache.iconUrl,
208
+ })
209
+ .from(actorCache)
210
+ .where(eq(actorCache.apId, attributedTo))
211
+ .get();
212
+ return cached ?? { preferredUsername: null, name: null, iconUrl: null };
213
+ }
214
+
215
+ /** Convert a Drizzle object row + resolved author into a PostRow for formatPost. */
216
+ export function toPostRow(
217
+ post: {
218
+ apId: string;
219
+ type: string;
220
+ attributedTo: string;
221
+ content: string;
222
+ summary: string | null;
223
+ attachmentsJson: string | null;
224
+ inReplyTo: string | null;
225
+ visibility: string;
226
+ communityApId: string | null;
227
+ likeCount: number;
228
+ replyCount: number;
229
+ announceCount: number;
230
+ published: string;
231
+ updated?: string | null;
232
+ toJson?: string | null;
233
+ },
234
+ author: AuthorInfo,
235
+ flags: { liked: boolean; bookmarked?: boolean },
236
+ ): PostRow & { to_json?: string | null; bookmarked?: number } {
237
+ return {
238
+ ap_id: post.apId,
239
+ type: post.type,
240
+ attributed_to: post.attributedTo,
241
+ author_username: author.preferredUsername,
242
+ author_name: author.name,
243
+ author_icon_url: author.iconUrl,
244
+ content: post.content,
245
+ summary: post.summary,
246
+ attachments_json: post.attachmentsJson,
247
+ in_reply_to: post.inReplyTo,
248
+ visibility: post.visibility,
249
+ community_ap_id: post.communityApId,
250
+ like_count: post.likeCount,
251
+ reply_count: post.replyCount,
252
+ announce_count: post.announceCount,
253
+ published: post.published,
254
+ updated: post.updated ?? null,
255
+ liked: flags.liked ? 1 : 0,
256
+ ...(flags.bookmarked !== undefined
257
+ ? { bookmarked: flags.bookmarked ? 1 : 0 }
258
+ : {}),
259
+ ...(post.toJson !== undefined ? { to_json: post.toJson } : {}),
260
+ };
261
+ }
262
+
263
+ type Visibility = "public" | "unlisted" | "followers" | "direct";
264
+
265
+ function assertNever(x: never): never {
266
+ throw new Error(`Unhandled visibility: ${JSON.stringify(x)}`);
267
+ }
268
+
269
+ function classifyVisibility(value: string): Visibility {
270
+ switch (value) {
271
+ case "public":
272
+ case "unlisted":
273
+ case "followers":
274
+ case "direct":
275
+ return value;
276
+ default:
277
+ // Unknown visibility (e.g. from external AP server) — fail closed
278
+ // by treating as direct (no public delivery, equivalent to the
279
+ // previous `{to:[], cc:[]}` default).
280
+ return "direct";
281
+ }
282
+ }
283
+
284
+ const PUBLIC_URL = "https://www.w3.org/ns/activitystreams#Public";
285
+
286
+ /** Compute to/cc fields from visibility for ActivityPub delivery. */
287
+ export function buildAddressing(
288
+ visibility: string,
289
+ followersUrl: string,
290
+ ): { to: string[]; cc: string[] } {
291
+ const publicUrl = PUBLIC_URL;
292
+ const narrowed = classifyVisibility(visibility);
293
+ switch (narrowed) {
294
+ case "public":
295
+ return { to: [publicUrl], cc: [followersUrl] };
296
+ case "unlisted":
297
+ return { to: [followersUrl], cc: [publicUrl] };
298
+ case "followers":
299
+ return { to: [followersUrl], cc: [] };
300
+ case "direct":
301
+ return { to: [], cc: [] };
302
+ default:
303
+ return assertNever(narrowed);
304
+ }
305
+ }
306
+
307
+ export type CommunityAddressingTarget = {
308
+ apId: string;
309
+ followersUrl: string;
310
+ };
311
+
312
+ /**
313
+ * Compute the stored object addressing (to/cc/audience) for a post.
314
+ *
315
+ * For a community-scoped post the reach is the COMMUNITY, not the open public
316
+ * timeline: the community Group actor and its followers collection are placed
317
+ * in `to`, and the community is recorded in `audience`. Because the public /
318
+ * home feed filters on `audienceJson = "[]"`, a non-empty audience is exactly
319
+ * what keeps the post out of those feeds while keeping it visible in the
320
+ * community-scoped feed (which filters by `communityApId`). `#Public` is
321
+ * downgraded to `cc` (unlisted-style) so the post is not boosted into the
322
+ * federated public stream even when the post visibility is "public".
323
+ *
324
+ * For a non-community post this returns empty arrays, preserving the prior
325
+ * object defaults (the activity-level addressing in the Create still drives
326
+ * follower delivery).
327
+ */
328
+ export function buildCommunityObjectAddressing(
329
+ visibility: string,
330
+ community: CommunityAddressingTarget | null,
331
+ ): { to: string[]; cc: string[]; audience: string[] } {
332
+ if (!community) {
333
+ return { to: [], cc: [], audience: [] };
334
+ }
335
+ const narrowed = classifyVisibility(visibility);
336
+ const to = [community.apId, community.followersUrl];
337
+ const cc: string[] = [];
338
+ if (narrowed === "public" || narrowed === "unlisted") {
339
+ cc.push(PUBLIC_URL);
340
+ }
341
+ return { to, cc, audience: [community.apId] };
342
+ }
343
+
344
+ /** Batch-load liked and bookmarked object IDs for a set of posts. */
345
+ export async function loadInteractionFlags(
346
+ db: Database,
347
+ actorApId: string | undefined,
348
+ objectApIds: string[],
349
+ ): Promise<{ likedIds: Set<string>; bookmarkedIds: Set<string> }> {
350
+ if (!actorApId || objectApIds.length === 0) {
351
+ return { likedIds: new Set(), bookmarkedIds: new Set() };
352
+ }
353
+ const [likeRows, bookmarkRows] = await Promise.all([
354
+ db
355
+ .select({ objectApId: likes.objectApId })
356
+ .from(likes)
357
+ .where(
358
+ and(
359
+ eq(likes.actorApId, actorApId),
360
+ inArray(likes.objectApId, objectApIds),
361
+ ),
362
+ ),
363
+ db
364
+ .select({ objectApId: bookmarks.objectApId })
365
+ .from(bookmarks)
366
+ .where(
367
+ and(
368
+ eq(bookmarks.actorApId, actorApId),
369
+ inArray(bookmarks.objectApId, objectApIds),
370
+ ),
371
+ ),
372
+ ]);
373
+ return {
374
+ likedIds: new Set(likeRows.map((l) => l.objectApId)),
375
+ bookmarkedIds: new Set(bookmarkRows.map((b) => b.objectApId)),
376
+ };
377
+ }
378
+
379
+ /**
380
+ * Persist an outbound ActivityPub activity WITHOUT any follower/community
381
+ * fanout. Used when the only recipients are explicit (e.g. a "direct" post
382
+ * whose reach is exactly its mentioned actors), so the activity is on record
383
+ * for direct per-actor delivery but is never broadcast to the follower graph.
384
+ */
385
+ export async function persistActivity(
386
+ db: Database,
387
+ activity: { id: string; type: string; actor: string; [key: string]: unknown },
388
+ objectApIdValue: string,
389
+ ): Promise<void> {
390
+ await db.insert(activities).values({
391
+ apId: activity.id,
392
+ type: activity.type,
393
+ actorApId: activity.actor,
394
+ objectApId: objectApIdValue,
395
+ rawJson: JSON.stringify(activity),
396
+ direction: "outbound",
397
+ });
398
+ }
399
+
400
+ /** Persist an outbound ActivityPub activity and enqueue federation fanout. */
401
+ export async function persistAndFanout(
402
+ db: Database,
403
+ env: Env,
404
+ activity: { id: string; type: string; actor: string; [key: string]: unknown },
405
+ objectApIdValue: string,
406
+ ): Promise<void> {
407
+ await db.insert(activities).values({
408
+ apId: activity.id,
409
+ type: activity.type,
410
+ actorApId: activity.actor,
411
+ objectApId: objectApIdValue,
412
+ rawJson: JSON.stringify(activity),
413
+ direction: "outbound",
414
+ });
415
+
416
+ try {
417
+ await enqueueFanoutToFollowers(env, activity.id, activity.actor);
418
+ } catch (err) {
419
+ log.error("Failed to enqueue federation fanout", {
420
+ event: "posts.fanout.enqueue_failed",
421
+ activityType: activity.type,
422
+ activityId: activity.id,
423
+ actor: activity.actor,
424
+ error: err,
425
+ });
426
+ }
427
+ }
428
+
429
+ /**
430
+ * Persist an outbound activity and fan it out to a COMMUNITY's audience
431
+ * (members + community followers) instead of the author's personal followers.
432
+ * Used for community-scoped posts so reach == community.
433
+ */
434
+ export async function persistAndFanoutToCommunity(
435
+ db: Database,
436
+ env: Env,
437
+ activity: { id: string; type: string; actor: string; [key: string]: unknown },
438
+ objectApIdValue: string,
439
+ communityApId: string,
440
+ ): Promise<void> {
441
+ await db.insert(activities).values({
442
+ apId: activity.id,
443
+ type: activity.type,
444
+ actorApId: activity.actor,
445
+ objectApId: objectApIdValue,
446
+ rawJson: JSON.stringify(activity),
447
+ direction: "outbound",
448
+ });
449
+
450
+ // Announce-relay: for a new post (Create), the GROUP re-broadcasts it to its
451
+ // followers as its OWN Announce (Lemmy/Mobilizon convention) so the post is
452
+ // attributed to the community and reaches remote followers whose server
453
+ // cannot enumerate the group's follower collection. The Announce is signed by
454
+ // the community (its actor) at delivery time, appears in the community outbox
455
+ // (the outbox query keys on actorApId == group), and is delivered to REMOTE
456
+ // followers in place of the raw author Create; LOCAL members still get the
457
+ // Create. Edit/Delete (Update/Delete) relay the activity directly (no
458
+ // announce) for now.
459
+ // Only PUBLIC communities federate as Group actors (their actor/inbox/outbox
460
+ // are served + followable; a private community 404s). So only a public
461
+ // community gets a Group Announce — building one for a private community
462
+ // would mint a `cc: Public` activity that, while currently undelivered (a
463
+ // private community has no remote followers) and unserved (its outbox 404s),
464
+ // would leak into the outbox the moment the community is flipped public.
465
+ let isPublicCommunity = false;
466
+ if (activity.type === "Create") {
467
+ const row = await db
468
+ .select({ visibility: communities.visibility })
469
+ .from(communities)
470
+ .where(eq(communities.apId, communityApId))
471
+ .get();
472
+ isPublicCommunity = row?.visibility === "public";
473
+ }
474
+
475
+ let announceActivityId: string | undefined;
476
+ if (activity.type === "Create" && isPublicCommunity) {
477
+ const baseUrl = env.APP_URL;
478
+ announceActivityId = activityApId(baseUrl, generateId());
479
+ const announce = {
480
+ "@context": "https://www.w3.org/ns/activitystreams",
481
+ id: announceActivityId,
482
+ type: "Announce",
483
+ actor: communityApId,
484
+ object: objectApIdValue,
485
+ to: [`${communityApId}/followers`],
486
+ cc: [PUBLIC_URL],
487
+ published: new Date().toISOString(),
488
+ };
489
+ await db.insert(activities).values({
490
+ apId: announceActivityId,
491
+ type: "Announce",
492
+ actorApId: communityApId,
493
+ objectApId: objectApIdValue,
494
+ rawJson: JSON.stringify(announce),
495
+ direction: "outbound",
496
+ });
497
+ }
498
+
499
+ try {
500
+ await enqueueFanoutToCommunity(
501
+ env,
502
+ activity.id,
503
+ communityApId,
504
+ announceActivityId,
505
+ );
506
+ } catch (err) {
507
+ log.error("Failed to enqueue community federation fanout", {
508
+ event: "posts.fanout.community_enqueue_failed",
509
+ activityType: activity.type,
510
+ activityId: activity.id,
511
+ actor: activity.actor,
512
+ communityApId,
513
+ error: err,
514
+ });
515
+ }
516
+ }
517
+
518
+ /** Batch-load cached authors for posts without a local author join. */
519
+ export async function loadCachedAuthorMap(
520
+ db: Database,
521
+ posts: PostWithAuthor[],
522
+ ): Promise<Map<string, AuthorInfo>> {
523
+ const remoteAttributedTos = [
524
+ ...new Set(posts.filter((p) => !p.author).map((p) => p.attributedTo)),
525
+ ];
526
+ if (remoteAttributedTos.length === 0) return new Map();
527
+ const cachedAuthors = await db
528
+ .select({
529
+ apId: actorCache.apId,
530
+ preferredUsername: actorCache.preferredUsername,
531
+ name: actorCache.name,
532
+ iconUrl: actorCache.iconUrl,
533
+ })
534
+ .from(actorCache)
535
+ .where(inArray(actorCache.apId, remoteAttributedTos));
536
+ return new Map(cachedAuthors.map((a) => [a.apId, a]));
537
+ }