@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,755 @@
1
+ // Timeline routes for Yurucommu backend
2
+ import { Hono } from "hono";
3
+ import type { Context } from "hono";
4
+ import { and, desc, eq, inArray, isNull, lt, ne, or } from "drizzle-orm";
5
+ import type { SQL } from "drizzle-orm";
6
+ import type { Database } from "../../db/index.ts";
7
+ import {
8
+ actorCache,
9
+ actors,
10
+ announces,
11
+ bookmarks,
12
+ communities,
13
+ communityMembers,
14
+ follows,
15
+ likes,
16
+ objects,
17
+ } from "../../db/index.ts";
18
+ import type { Env, Variables } from "../types.ts";
19
+ import { communityRequiresMembership } from "../lib/community-visibility.ts";
20
+ import {
21
+ formatUsername,
22
+ parseLimit,
23
+ parseOffset,
24
+ safeJsonParse,
25
+ } from "../federation-helpers.ts";
26
+ import { CacheTags, CacheTTL, withCache } from "../middleware/cache.ts";
27
+ import { excludeBlockedMutedAuthors } from "../lib/feed-exclude.ts";
28
+ import { chunkForInClause } from "../lib/chunk.ts";
29
+
30
+ // Match every other feed's page cap. Clamping to 90 (not 100) leaves headroom
31
+ // under D1's 100-bound-parameter ceiling for the `+1 eq` in the interaction
32
+ // batch below (see lib/chunk.ts); the batch is also chunked defensively.
33
+ const MAX_FEED_LIMIT = 90;
34
+ // Cap on offset-based deep paging. Offset pagination forces SQLite to
35
+ // materialize + discard every skipped row of the (OR-of-subqueries) feed
36
+ // predicate per request, so a large offset is an unindexed deep scan. The keyset
37
+ // `before` cursor is the intended path for deep paging (indexed, constant-cost);
38
+ // offset stays as a shallow fallback, clamped well below the old 10000 ceiling.
39
+ const MAX_FEED_OFFSET = 1000;
40
+
41
+ const timeline = new Hono<{ Bindings: Env; Variables: Variables }>();
42
+
43
+ // Explicit column projection for timeline feeds. Selecting `*` pulls the large
44
+ // `raw_json` blob (plus other unused columns like to_json/cc_json/audience_json/
45
+ // conversation/end_time) on every row only for `formatPost` to discard them.
46
+ // This list is exactly the set of fields `formatPost` reads — keep them in sync.
47
+ const POST_FEED_COLUMNS = {
48
+ apId: objects.apId,
49
+ type: objects.type,
50
+ attributedTo: objects.attributedTo,
51
+ content: objects.content,
52
+ summary: objects.summary,
53
+ attachmentsJson: objects.attachmentsJson,
54
+ inReplyTo: objects.inReplyTo,
55
+ visibility: objects.visibility,
56
+ communityApId: objects.communityApId,
57
+ likeCount: objects.likeCount,
58
+ replyCount: objects.replyCount,
59
+ announceCount: objects.announceCount,
60
+ published: objects.published,
61
+ updated: objects.updated,
62
+ } as const;
63
+
64
+ type Attachment = {
65
+ type?: string;
66
+ mediaType?: string;
67
+ url?: string;
68
+ [key: string]: unknown;
69
+ };
70
+
71
+ type AuthorInfo = {
72
+ preferredUsername: string | null;
73
+ name: string | null;
74
+ iconUrl: string | null;
75
+ };
76
+ const NULL_AUTHOR: AuthorInfo = {
77
+ preferredUsername: null,
78
+ name: null,
79
+ iconUrl: null,
80
+ };
81
+
82
+ // Batch helper to get author info from either local actors or actor cache
83
+ // This avoids N+1 queries by fetching all authors at once
84
+ async function batchGetAuthorInfo(
85
+ db: Database,
86
+ apIds: string[],
87
+ ): Promise<Map<string, AuthorInfo>> {
88
+ if (apIds.length === 0) return new Map();
89
+
90
+ const uniqueApIds = [...new Set(apIds)];
91
+
92
+ const [localActors, cachedActors] = await Promise.all([
93
+ db
94
+ .select({
95
+ apId: actors.apId,
96
+ preferredUsername: actors.preferredUsername,
97
+ name: actors.name,
98
+ iconUrl: actors.iconUrl,
99
+ })
100
+ .from(actors)
101
+ .where(inArray(actors.apId, uniqueApIds)),
102
+ db
103
+ .select({
104
+ apId: actorCache.apId,
105
+ preferredUsername: actorCache.preferredUsername,
106
+ name: actorCache.name,
107
+ iconUrl: actorCache.iconUrl,
108
+ })
109
+ .from(actorCache)
110
+ .where(inArray(actorCache.apId, uniqueApIds)),
111
+ ]);
112
+
113
+ const result = new Map<string, AuthorInfo>();
114
+
115
+ // Cached actors first; local actors override
116
+ for (const a of cachedActors) {
117
+ result.set(a.apId, {
118
+ preferredUsername: a.preferredUsername,
119
+ name: a.name,
120
+ iconUrl: a.iconUrl,
121
+ });
122
+ }
123
+ for (const a of localActors) {
124
+ result.set(a.apId, {
125
+ preferredUsername: a.preferredUsername,
126
+ name: a.name,
127
+ iconUrl: a.iconUrl,
128
+ });
129
+ }
130
+
131
+ return result;
132
+ }
133
+
134
+ // Batch helper to check interaction status for multiple objects
135
+ // This avoids N+1 queries by fetching all interactions at once
136
+ async function batchGetInteractionStatus(
137
+ db: Database,
138
+ viewerApId: string,
139
+ objectApIds: string[],
140
+ ): Promise<{
141
+ likedSet: Set<string>;
142
+ bookmarkedSet: Set<string>;
143
+ repostedSet: Set<string>;
144
+ }> {
145
+ if (!viewerApId || objectApIds.length === 0) {
146
+ return {
147
+ likedSet: new Set(),
148
+ bookmarkedSet: new Set(),
149
+ repostedSet: new Set(),
150
+ };
151
+ }
152
+
153
+ // Chunk the IN() lists: `eq(actorApId) + inArray(objectApIds)` is 1 + N bound
154
+ // params, and a full page of N=100 ids would exceed D1's 100-param ceiling
155
+ // (libsql tests don't enforce it, so this only 500'd in prod). Chunking by 90
156
+ // makes the batch robust to any page size, independent of the caller's clamp.
157
+ const collect = async (
158
+ sel: (ids: string[]) => Promise<{ objectApId: string }[]>,
159
+ ): Promise<Set<string>> => {
160
+ const out = new Set<string>();
161
+ for (const chunk of chunkForInClause(objectApIds)) {
162
+ for (const r of await sel(chunk)) out.add(r.objectApId);
163
+ }
164
+ return out;
165
+ };
166
+
167
+ const [likedSet, bookmarkedSet, repostedSet] = await Promise.all([
168
+ collect((ids) =>
169
+ db
170
+ .select({ objectApId: likes.objectApId })
171
+ .from(likes)
172
+ .where(
173
+ and(eq(likes.actorApId, viewerApId), inArray(likes.objectApId, ids)),
174
+ ),
175
+ ),
176
+ collect((ids) =>
177
+ db
178
+ .select({ objectApId: bookmarks.objectApId })
179
+ .from(bookmarks)
180
+ .where(
181
+ and(
182
+ eq(bookmarks.actorApId, viewerApId),
183
+ inArray(bookmarks.objectApId, ids),
184
+ ),
185
+ ),
186
+ ),
187
+ collect((ids) =>
188
+ db
189
+ .select({ objectApId: announces.objectApId })
190
+ .from(announces)
191
+ .where(
192
+ and(
193
+ eq(announces.actorApId, viewerApId),
194
+ inArray(announces.objectApId, ids),
195
+ ),
196
+ ),
197
+ ),
198
+ ]);
199
+
200
+ return { likedSet, bookmarkedSet, repostedSet };
201
+ }
202
+
203
+ // Paginate a fetched-with-extra-1 result set and determine has_more
204
+ function paginateResults<T>(
205
+ rows: T[],
206
+ limit: number,
207
+ ): { results: T[]; has_more: boolean } {
208
+ const has_more = rows.length > limit;
209
+ return { results: has_more ? rows.slice(0, limit) : rows, has_more };
210
+ }
211
+
212
+ // Feeds order by `published` desc, but `published` is not unique: several posts
213
+ // can share the same millisecond. An exclusive cursor on `published` alone would
214
+ // skip the rows on either side of the page boundary that share the cursor's
215
+ // millisecond. To avoid that, the cursor is a composite of (published, apId) and
216
+ // pagination uses a tuple predicate:
217
+ // published < c.published OR (published = c.published AND apId < c.apId)
218
+ // `apId` is unique, so the (published desc, apId desc) ordering is total and the
219
+ // boundary is unambiguous. The two parts are encoded into the opaque `before`
220
+ // cursor string with a NUL separator (NUL cannot appear in an ISO timestamp or
221
+ // an http(s) ap_id URL).
222
+ const CURSOR_SEP = "\u0000";
223
+
224
+ type FeedCursor = { published: string; apId: string };
225
+
226
+ // Decode a `before` cursor. For backward compatibility a legacy cursor that
227
+ // carries only a `published` value (no separator) is still accepted: it falls
228
+ // back to a published-only predicate, which is never wider than the composite
229
+ // form (it can only skip same-ms rows on re-paginated legacy clients, never
230
+ // leak extra rows).
231
+ function decodeFeedCursor(before: string): FeedCursor | { published: string } {
232
+ const idx = before.indexOf(CURSOR_SEP);
233
+ if (idx === -1) return { published: before };
234
+ return {
235
+ published: before.slice(0, idx),
236
+ apId: before.slice(idx + 1),
237
+ };
238
+ }
239
+
240
+ // Encode the composite cursor for the last row of a page so the next request
241
+ // resumes strictly after it.
242
+ function encodeFeedCursor(row: {
243
+ published: string | null;
244
+ apId: string;
245
+ }): string | null {
246
+ if (row.published === null) return null;
247
+ return `${row.published}${CURSOR_SEP}${row.apId}`;
248
+ }
249
+
250
+ // Build the exclusive tuple predicate for a decoded cursor. Always returns a
251
+ // concrete predicate (its operands are defined), so the result is a non-null
252
+ // `SQL` suitable for the feed `conditions` array.
253
+ function feedCursorPredicate(cursor: FeedCursor | { published: string }): SQL {
254
+ if (!("apId" in cursor)) {
255
+ // Legacy published-only cursor.
256
+ return lt(objects.published, cursor.published);
257
+ }
258
+ return or(
259
+ lt(objects.published, cursor.published),
260
+ and(eq(objects.published, cursor.published), lt(objects.apId, cursor.apId)),
261
+ )!;
262
+ }
263
+
264
+ // Compute the `next_cursor` for a (possibly paginated) result page: the encoded
265
+ // composite cursor of the last returned row, or null when there are no more.
266
+ function nextFeedCursor(
267
+ results: Array<{ published: string | null; apId: string }>,
268
+ has_more: boolean,
269
+ ): string | null {
270
+ if (!has_more || results.length === 0) return null;
271
+ return encodeFeedCursor(results[results.length - 1]);
272
+ }
273
+
274
+ // Format a post row and its resolved author/interaction data into the API response shape
275
+ function formatPost(
276
+ p: {
277
+ apId: string;
278
+ type: string;
279
+ attributedTo: string;
280
+ content: string;
281
+ summary: string | null;
282
+ attachmentsJson: string | null;
283
+ inReplyTo: string | null;
284
+ visibility: string;
285
+ communityApId: string | null;
286
+ likeCount: number;
287
+ replyCount: number;
288
+ announceCount: number;
289
+ published: string | null;
290
+ updated?: string | null;
291
+ },
292
+ authorMap: Map<string, AuthorInfo>,
293
+ interactions: {
294
+ likedSet: Set<string>;
295
+ bookmarkedSet: Set<string>;
296
+ repostedSet: Set<string>;
297
+ },
298
+ ): Record<string, unknown> {
299
+ const author = authorMap.get(p.attributedTo) || NULL_AUTHOR;
300
+ return {
301
+ ap_id: p.apId,
302
+ type: p.type,
303
+ author: {
304
+ ap_id: p.attributedTo,
305
+ username: formatUsername(p.attributedTo),
306
+ preferred_username: author.preferredUsername,
307
+ name: author.name,
308
+ icon_url: author.iconUrl,
309
+ },
310
+ content: p.content,
311
+ summary: p.summary,
312
+ attachments: safeJsonParse<Attachment[]>(p.attachmentsJson, []),
313
+ in_reply_to: p.inReplyTo,
314
+ visibility: p.visibility,
315
+ community_ap_id: p.communityApId,
316
+ like_count: p.likeCount,
317
+ reply_count: p.replyCount,
318
+ announce_count: p.announceCount,
319
+ published: p.published,
320
+ // `edited_at` is the post's `updated` timestamp, surfaced only when it
321
+ // differs from `published` (create leaves `updated` NULL; an edit sets it).
322
+ // The client renders an "編集済み" marker, matching the `updated` that
323
+ // federation peers already receive on the Update(Note).
324
+ edited_at: p.updated && p.updated !== p.published ? p.updated : null,
325
+ liked: interactions.likedSet.has(p.apId),
326
+ bookmarked: interactions.bookmarkedSet.has(p.apId),
327
+ reposted: interactions.repostedSet.has(p.apId),
328
+ };
329
+ }
330
+
331
+ // Batch-resolve authors and interactions, then format posts for API response
332
+ async function resolveAndFormatPosts(
333
+ db: Database,
334
+ posts: Array<Parameters<typeof formatPost>[0]>,
335
+ viewerApId: string,
336
+ ): Promise<Record<string, unknown>[]> {
337
+ const authorApIds = posts.map((p) => p.attributedTo);
338
+ const postApIds = posts.map((p) => p.apId);
339
+
340
+ const [authorMap, interactions] = await Promise.all([
341
+ batchGetAuthorInfo(db, authorApIds),
342
+ batchGetInteractionStatus(db, viewerApId, postApIds),
343
+ ]);
344
+
345
+ return posts.map((p) => formatPost(p, authorMap, interactions));
346
+ }
347
+
348
+ // Resolve a community by its ap_id (or preferred username) and the viewer's
349
+ // accepted membership, enforcing read access by community visibility.
350
+ //
351
+ // Read access policy (mirrors GET /communities/:id/messages):
352
+ // - public community -> readable by anyone (member or not, authed or not)
353
+ // - non-public -> requires an accepted membership row
354
+ // Returns:
355
+ // - { gate: "not_found" } community does not exist / soft-deleted
356
+ // - { gate: "forbidden" } non-public community, viewer not a member
357
+ // - { gate: "ok", community } viewer may read this community's feed
358
+ async function resolveCommunityRead(
359
+ db: Database,
360
+ communityParam: string,
361
+ viewerApId: string,
362
+ ): Promise<
363
+ | { gate: "not_found" }
364
+ | { gate: "forbidden" }
365
+ | { gate: "ok"; community: { apId: string; visibility: string } }
366
+ > {
367
+ const community = await db
368
+ .select({
369
+ apId: communities.apId,
370
+ visibility: communities.visibility,
371
+ })
372
+ .from(communities)
373
+ .where(
374
+ and(
375
+ or(
376
+ eq(communities.apId, communityParam),
377
+ eq(communities.preferredUsername, communityParam),
378
+ ),
379
+ isNull(communities.deletedAt),
380
+ ),
381
+ )
382
+ .get();
383
+
384
+ if (!community) return { gate: "not_found" };
385
+
386
+ if (!communityRequiresMembership(community.visibility)) {
387
+ return { gate: "ok", community };
388
+ }
389
+
390
+ // Non-public community: an accepted membership row is required. Anonymous
391
+ // viewers (no ap_id) can never satisfy this, so do not leak the feed.
392
+ if (!viewerApId) return { gate: "forbidden" };
393
+
394
+ const membership = await db
395
+ .select({ actorApId: communityMembers.actorApId })
396
+ .from(communityMembers)
397
+ .where(
398
+ and(
399
+ eq(communityMembers.communityApId, community.apId),
400
+ eq(communityMembers.actorApId, viewerApId),
401
+ ),
402
+ )
403
+ .get();
404
+
405
+ if (!membership) return { gate: "forbidden" };
406
+ return { gate: "ok", community };
407
+ }
408
+
409
+ /**
410
+ * Visibility gate for COMMUNITY-NARROWED posts (objects.communityApId set) in a
411
+ * feed leg. A community post still honors per-post visibility, matching the
412
+ * canonical single-object gate (canViewerReadObjectFull): public/unlisted are
413
+ * readable by anyone with community access; a `followers` post is readable ONLY
414
+ * by the author's accepted followers (and the author); `direct` is excluded at
415
+ * the feed base. Without this, the community-filter leg (and the home community
416
+ * branch) surfaced followers-only community posts to non-followers / anonymous
417
+ * viewers — because for a PUBLIC community "community access" is granted to
418
+ * everyone, so the old "members see every post" assumption leaks.
419
+ */
420
+ // Non-correlated subquery of the actors the viewer accepted-follows. Used as
421
+ // `inArray(objects.attributedTo, acceptedFollowingSubquery(db, me))` so the
422
+ // membership test compiles to `attributed_to IN (SELECT ...)`. This keeps the
423
+ // home / following / community feeds LOSSLESS for any follow count AND avoids
424
+ // splicing thousands of follow ids into the query as bound parameters (SQLite's
425
+ // variable ceiling) — it replaces an earlier defensive row-cap that silently
426
+ // dropped authors beyond ~1000 follows.
427
+ function acceptedFollowingSubquery(db: Database, viewerApId: string) {
428
+ return db
429
+ .select({ id: follows.followingApId })
430
+ .from(follows)
431
+ .where(
432
+ and(eq(follows.followerApId, viewerApId), eq(follows.status, "accepted")),
433
+ );
434
+ }
435
+
436
+ function communityPostVisibilityGate(
437
+ db: Database,
438
+ viewerApId: string,
439
+ ): SQL | undefined {
440
+ return or(
441
+ inArray(objects.visibility, ["public", "unlisted"]),
442
+ viewerApId ? eq(objects.attributedTo, viewerApId) : undefined,
443
+ viewerApId
444
+ ? and(
445
+ eq(objects.visibility, "followers"),
446
+ inArray(
447
+ objects.attributedTo,
448
+ acceptedFollowingSubquery(db, viewerApId),
449
+ ),
450
+ )
451
+ : undefined,
452
+ );
453
+ }
454
+
455
+ // Community-scoped read: returns the community's feed to viewers permitted by
456
+ // `resolveCommunityRead`. Per-post visibility is still honored via
457
+ // `communityPostVisibilityGate` (a followers-only post stays follow-gated even
458
+ // inside the community), gated additionally by community access.
459
+ async function handleCommunityTimeline(
460
+ c: Context<{ Bindings: Env; Variables: Variables }>,
461
+ communityParam: string,
462
+ ): Promise<Response> {
463
+ const actor = c.get("actor");
464
+ const db = c.get("db");
465
+ const limit = parseLimit(c.req.query("limit"), 20, MAX_FEED_LIMIT);
466
+ const offset = parseOffset(c.req.query("offset"), 0, MAX_FEED_OFFSET);
467
+ const before = c.req.query("before");
468
+ const viewerApId = actor?.ap_id || "";
469
+
470
+ const gate = await resolveCommunityRead(db, communityParam, viewerApId);
471
+ if (gate.gate === "not_found") {
472
+ return c.json({ error: "Community not found" }, 404);
473
+ }
474
+ if (gate.gate === "forbidden") {
475
+ return c.json({ error: "Not a community member" }, 403);
476
+ }
477
+
478
+ const excludeAuthors = excludeBlockedMutedAuthors(db, viewerApId);
479
+
480
+ // This is the home "narrow to a community" filter: it shows the slice of the
481
+ // viewer's reach that belongs to this community — (1) posts deliberately
482
+ // narrowed to it (communityApId), plus (2) the general public/unlisted posts
483
+ // of its members (a community is a named slice of people, not a content silo).
484
+ // Membership tests as subqueries (IN (SELECT ...)) so a community with many
485
+ // members — or a viewer with many follows — is matched losslessly without a
486
+ // row-cap or thousands of bound parameters.
487
+ const memberSubquery = db
488
+ .select({ id: communityMembers.actorApId })
489
+ .from(communityMembers)
490
+ .where(eq(communityMembers.communityApId, gate.community.apId));
491
+
492
+ const conditions = [
493
+ eq(objects.type, "Note"),
494
+ isNull(objects.inReplyTo),
495
+ isNull(objects.deletedAt),
496
+ // A direct post narrowed to this community must never surface here — direct
497
+ // belongs in /dm. The communityApId leg below has no visibility filter, so
498
+ // gate it at the base.
499
+ ne(objects.visibility, "direct"),
500
+ ];
501
+ if (excludeAuthors) {
502
+ conditions.push(excludeAuthors);
503
+ }
504
+ if (before) conditions.push(feedCursorPredicate(decodeFeedCursor(before)));
505
+
506
+ const memberFeed = and(
507
+ eq(objects.audienceJson, "[]"),
508
+ inArray(objects.attributedTo, memberSubquery),
509
+ inArray(objects.visibility, ["public", "unlisted"]),
510
+ );
511
+ const source = or(
512
+ and(
513
+ eq(objects.communityApId, gate.community.apId),
514
+ communityPostVisibilityGate(db, viewerApId),
515
+ ),
516
+ memberFeed,
517
+ );
518
+
519
+ const posts = await db
520
+ .select(POST_FEED_COLUMNS)
521
+ .from(objects)
522
+ .where(and(...conditions, source))
523
+ .orderBy(desc(objects.published), desc(objects.apId))
524
+ .limit(limit + 1)
525
+ .offset(offset);
526
+
527
+ const { results, has_more } = paginateResults(posts, limit);
528
+ const result = await resolveAndFormatPosts(db, results, viewerApId);
529
+
530
+ return c.json({
531
+ posts: result,
532
+ limit,
533
+ offset,
534
+ has_more,
535
+ next_cursor: nextFeedCursor(results, has_more),
536
+ });
537
+ }
538
+
539
+ // Get public timeline
540
+ // Supports both cursor (before) and offset pagination
541
+ // Returns: posts, limit, offset (if used), has_more
542
+ // Cached for 2 minutes for unauthenticated users.
543
+ //
544
+ // `community` is handled by a dedicated, per-viewer code path (membership +
545
+ // visibility gate). The withCache wrapper already bypasses the shared cache
546
+ // whenever an authenticated actor is present (varyByActor is false), so a
547
+ // member's community read is never served from another user's cached copy.
548
+ timeline.get(
549
+ "/",
550
+ withCache({
551
+ ttl: CacheTTL.PUBLIC_TIMELINE,
552
+ cacheTag: CacheTags.TIMELINE,
553
+ queryParamsToInclude: ["limit", "offset", "before", "community"],
554
+ }),
555
+ async (c) => {
556
+ const communityParam = c.req.query("community");
557
+ if (communityParam) {
558
+ return handleCommunityTimeline(c, communityParam);
559
+ }
560
+
561
+ const actor = c.get("actor");
562
+ const db = c.get("db");
563
+ const limit = parseLimit(c.req.query("limit"), 20, MAX_FEED_LIMIT);
564
+ const offset = parseOffset(c.req.query("offset"), 0, MAX_FEED_OFFSET);
565
+ const before = c.req.query("before");
566
+ const viewerApId = actor?.ap_id || "";
567
+
568
+ const excludeAuthors = excludeBlockedMutedAuthors(db, viewerApId);
569
+
570
+ const base = [
571
+ eq(objects.type, "Note"),
572
+ isNull(objects.inReplyTo),
573
+ isNull(objects.deletedAt),
574
+ // Direct posts (DMs) are stored as audienceJson="[]" too, so they would
575
+ // otherwise slip into the ununion — the own-author branch leg has no
576
+ // visibility filter, and the community branch has none either. They belong
577
+ // only in /dm, never in any timeline feed; exclude them at the base.
578
+ ne(objects.visibility, "direct"),
579
+ ];
580
+ if (excludeAuthors) {
581
+ base.push(excludeAuthors);
582
+ }
583
+ if (before) base.push(feedCursorPredicate(decodeFeedCursor(before)));
584
+
585
+ // The "source" predicate selects WHICH posts belong in this feed.
586
+ let sourcePredicate;
587
+ if (!viewerApId) {
588
+ // Anonymous visitor: the instance's public timeline — top-level public
589
+ // posts with no extra audience (community / addressed posts excluded).
590
+ sourcePredicate = and(
591
+ eq(objects.visibility, "public"),
592
+ eq(objects.audienceJson, "[]"),
593
+ );
594
+ } else {
595
+ // Authenticated home: the unified "everything I can see" feed = my whole
596
+ // reach. A post belongs to ME, not to a single community, so this merges:
597
+ // A1 self + accepted follows (own = any non-direct; follows = public/
598
+ // unlisted/followers),
599
+ // A2 co-members (authors who share a community with me) — their public/
600
+ // unlisted posts only (followers-only stays follow-gated, no leak),
601
+ // B posts deliberately narrowed to a community I belong to.
602
+ // Author fan-in is expressed as `attributed_to IN (SELECT ...)` subqueries
603
+ // (follows / co-members), so the feed stays lossless for any follow or
604
+ // community-member count without splicing thousands of ids into the query
605
+ // as bound parameters (SQLite's variable ceiling). Only the viewer's own
606
+ // community list is materialized — it is naturally small (the communities
607
+ // one has joined).
608
+ const myCommunityRows = await db
609
+ .select({ communityApId: communityMembers.communityApId })
610
+ .from(communityMembers)
611
+ .where(eq(communityMembers.actorApId, viewerApId));
612
+ const myCommunityApIds = myCommunityRows.map((r) => r.communityApId);
613
+ // The membership set re-expressed as a subquery for the feed predicate, so
614
+ // a viewer in many communities never splices community ids into the query
615
+ // as bound parameters (D1 caps a query at 100). The materialised list above
616
+ // is used only for the cheap `length` guard, never bound.
617
+ const myCommunitySubquery = db
618
+ .select({ id: communityMembers.communityApId })
619
+ .from(communityMembers)
620
+ .where(eq(communityMembers.actorApId, viewerApId));
621
+
622
+ const branches = [
623
+ and(
624
+ eq(objects.audienceJson, "[]"),
625
+ or(
626
+ eq(objects.attributedTo, viewerApId),
627
+ inArray(
628
+ objects.attributedTo,
629
+ acceptedFollowingSubquery(db, viewerApId),
630
+ ),
631
+ ),
632
+ or(
633
+ eq(objects.attributedTo, viewerApId),
634
+ inArray(objects.visibility, ["public", "unlisted", "followers"]),
635
+ ),
636
+ ),
637
+ ];
638
+ if (myCommunityApIds.length > 0) {
639
+ // Co-members = authors who share a community with me. A subquery against
640
+ // my community set (empty → matches nothing, harmless).
641
+ const coMemberSubquery = db
642
+ .select({ id: communityMembers.actorApId })
643
+ .from(communityMembers)
644
+ .where(
645
+ and(
646
+ inArray(communityMembers.communityApId, myCommunitySubquery),
647
+ ne(communityMembers.actorApId, viewerApId),
648
+ ),
649
+ );
650
+ branches.push(
651
+ and(
652
+ eq(objects.audienceJson, "[]"),
653
+ inArray(objects.attributedTo, coMemberSubquery),
654
+ inArray(objects.visibility, ["public", "unlisted"]),
655
+ ),
656
+ );
657
+ // Honor per-post visibility on community-narrowed posts too — a
658
+ // followers-only post stays follow-gated even inside a community I'm in
659
+ // (matches the canonical single-object gate; otherwise a co-member who
660
+ // doesn't follow the author would see their followers-only post).
661
+ branches.push(
662
+ and(
663
+ inArray(objects.communityApId, myCommunitySubquery),
664
+ communityPostVisibilityGate(db, viewerApId),
665
+ ),
666
+ );
667
+ }
668
+ sourcePredicate = or(...branches);
669
+ }
670
+
671
+ const posts = await db
672
+ .select(POST_FEED_COLUMNS)
673
+ .from(objects)
674
+ .where(and(...base, sourcePredicate))
675
+ .orderBy(desc(objects.published), desc(objects.apId))
676
+ .limit(limit + 1)
677
+ .offset(offset);
678
+
679
+ const { results, has_more } = paginateResults(posts, limit);
680
+ const result = await resolveAndFormatPosts(db, results, viewerApId);
681
+
682
+ return c.json({
683
+ posts: result,
684
+ limit,
685
+ offset,
686
+ has_more,
687
+ next_cursor: nextFeedCursor(results, has_more),
688
+ });
689
+ },
690
+ );
691
+
692
+ // Get following timeline
693
+ // Supports both cursor (before) and offset pagination
694
+ // Returns: posts, limit, offset (if used), has_more
695
+ timeline.get("/following", async (c) => {
696
+ const actor = c.get("actor");
697
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
698
+
699
+ const db = c.get("db");
700
+ const limit = parseLimit(c.req.query("limit"), 20, MAX_FEED_LIMIT);
701
+ const offset = parseOffset(c.req.query("offset"), 0, MAX_FEED_OFFSET);
702
+ const before = c.req.query("before");
703
+ const viewerApId = actor.ap_id;
704
+
705
+ const excludeAuthors = excludeBlockedMutedAuthors(db, viewerApId);
706
+
707
+ // Own posts: all visibilities except direct
708
+ // Followed users' posts: public, unlisted, or followers visibility
709
+ // Membership = me OR accepted-follow (subquery, lossless for any follow count).
710
+ const conditions = [
711
+ eq(objects.type, "Note"),
712
+ isNull(objects.inReplyTo),
713
+ eq(objects.audienceJson, "[]"),
714
+ or(
715
+ eq(objects.attributedTo, viewerApId),
716
+ inArray(objects.attributedTo, acceptedFollowingSubquery(db, viewerApId)),
717
+ ),
718
+ isNull(objects.deletedAt),
719
+ // Exclude directs: the own-author leg below is unconditional, so a DM the
720
+ // viewer sent would otherwise appear in their own following feed.
721
+ ne(objects.visibility, "direct"),
722
+ or(
723
+ eq(objects.attributedTo, viewerApId),
724
+ and(
725
+ ne(objects.attributedTo, viewerApId),
726
+ inArray(objects.visibility, ["public", "unlisted", "followers"]),
727
+ ),
728
+ ),
729
+ ];
730
+ if (excludeAuthors) {
731
+ conditions.push(excludeAuthors);
732
+ }
733
+ if (before) conditions.push(feedCursorPredicate(decodeFeedCursor(before)));
734
+
735
+ const posts = await db
736
+ .select(POST_FEED_COLUMNS)
737
+ .from(objects)
738
+ .where(and(...conditions))
739
+ .orderBy(desc(objects.published), desc(objects.apId))
740
+ .limit(limit + 1)
741
+ .offset(offset);
742
+
743
+ const { results, has_more } = paginateResults(posts, limit);
744
+ const result = await resolveAndFormatPosts(db, results, viewerApId);
745
+
746
+ return c.json({
747
+ posts: result,
748
+ limit,
749
+ offset,
750
+ has_more,
751
+ next_cursor: nextFeedCursor(results, has_more),
752
+ });
753
+ });
754
+
755
+ export default timeline;