@takosjp/yurucommu-core 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +16 -0
- package/README.md +82 -0
- package/migrations/0001_init.sql +495 -0
- package/migrations/0002_social_remote_actor_edges.sql +92 -0
- package/migrations/0003_activity_remote_object_edges.sql +68 -0
- package/migrations/0004_blocklist.sql +26 -0
- package/migrations/0005_story_community_scope.sql +13 -0
- package/migrations/0006_dm_community_read_status.sql +19 -0
- package/migrations/0007_moderation_reports.sql +22 -0
- package/migrations/0008_actor_fields_aka.sql +18 -0
- package/migrations/0009_object_tags.sql +13 -0
- package/migrations/0010_object_recipients_drop_actor_fk.sql +34 -0
- package/migrations/0011_drop_remote_actor_fks.sql +205 -0
- package/migrations/0012_objects_content_fts.sql +39 -0
- package/migrations/0013_efficiency_indexes.sql +13 -0
- package/migrations/0014_inbox_actor_created_idx.sql +15 -0
- package/migrations/0015_community_bans.sql +16 -0
- package/migrations/0016_namespace_takos_oidc_subject.sql +19 -0
- package/migrations/0017_mobile_push_registrations.sql +22 -0
- package/migrations/README.md +122 -0
- package/package.json +75 -0
- package/packages/api/LICENSE +16 -0
- package/packages/api/package.json +30 -0
- package/packages/api/src/index.ts +4 -0
- package/packages/api/src/lib/api/account.ts +20 -0
- package/packages/api/src/lib/api/actors.ts +149 -0
- package/packages/api/src/lib/api/auth.ts +46 -0
- package/packages/api/src/lib/api/communities.ts +329 -0
- package/packages/api/src/lib/api/dm.test.ts +67 -0
- package/packages/api/src/lib/api/dm.ts +236 -0
- package/packages/api/src/lib/api/fetch.ts +111 -0
- package/packages/api/src/lib/api/follow.ts +30 -0
- package/packages/api/src/lib/api/media.ts +100 -0
- package/packages/api/src/lib/api/moderation.ts +98 -0
- package/packages/api/src/lib/api/normalize.ts +71 -0
- package/packages/api/src/lib/api/notifications.test.ts +63 -0
- package/packages/api/src/lib/api/notifications.ts +61 -0
- package/packages/api/src/lib/api/posts.test.ts +110 -0
- package/packages/api/src/lib/api/posts.ts +181 -0
- package/packages/api/src/lib/api/recommendations.ts +22 -0
- package/packages/api/src/lib/api/search.ts +88 -0
- package/packages/api/src/lib/api/stories.ts +80 -0
- package/packages/api/src/lib/api.ts +15 -0
- package/packages/api/src/lib/fetch-with-timeout.ts +42 -0
- package/packages/api/src/lib/transport.ts +40 -0
- package/packages/api/src/social-server.ts +47 -0
- package/packages/api/src/types/index.ts +185 -0
- package/scripts/apply-takosumi-migrations.ts +621 -0
- package/src/backend/federation-helpers.ts +36 -0
- package/src/backend/index.ts +872 -0
- package/src/backend/lib/account-migration.ts +106 -0
- package/src/backend/lib/activitypub-actor-cache.ts +238 -0
- package/src/backend/lib/activitypub-helpers.ts +131 -0
- package/src/backend/lib/activitypub-validators.ts +323 -0
- package/src/backend/lib/ap-context.ts +16 -0
- package/src/backend/lib/ap-ids.ts +101 -0
- package/src/backend/lib/ap-response.ts +30 -0
- package/src/backend/lib/ap-signing.ts +87 -0
- package/src/backend/lib/ap-verify.ts +670 -0
- package/src/backend/lib/auth-lockout.ts +230 -0
- package/src/backend/lib/backend-paths.ts +34 -0
- package/src/backend/lib/base64.ts +30 -0
- package/src/backend/lib/blocklist-purge.ts +109 -0
- package/src/backend/lib/blocklist.ts +279 -0
- package/src/backend/lib/chunk.ts +33 -0
- package/src/backend/lib/client-ip.ts +169 -0
- package/src/backend/lib/community-visibility.ts +230 -0
- package/src/backend/lib/crypto.ts +424 -0
- package/src/backend/lib/delivery/circuit.ts +265 -0
- package/src/backend/lib/delivery/metrics.ts +30 -0
- package/src/backend/lib/delivery/planner.ts +190 -0
- package/src/backend/lib/delivery/queue-batching.ts +626 -0
- package/src/backend/lib/delivery/queue-delivery.ts +641 -0
- package/src/backend/lib/delivery/queue.ts +576 -0
- package/src/backend/lib/delivery/transformers.ts +56 -0
- package/src/backend/lib/delivery/types.ts +139 -0
- package/src/backend/lib/errors.ts +114 -0
- package/src/backend/lib/federation-fetch.ts +296 -0
- package/src/backend/lib/feed-cursor.ts +57 -0
- package/src/backend/lib/feed-exclude.ts +48 -0
- package/src/backend/lib/hex.ts +8 -0
- package/src/backend/lib/log-mask.ts +213 -0
- package/src/backend/lib/logger.ts +285 -0
- package/src/backend/lib/mobile-contract.ts +137 -0
- package/src/backend/lib/oauth-providers.ts +324 -0
- package/src/backend/lib/oauth-utils.ts +148 -0
- package/src/backend/lib/oidc-id-token.ts +151 -0
- package/src/backend/lib/parse-helpers.ts +31 -0
- package/src/backend/lib/post-visibility.ts +190 -0
- package/src/backend/lib/session-actor.ts +61 -0
- package/src/backend/lib/ssrf.ts +428 -0
- package/src/backend/lib/strip-image-metadata.ts +191 -0
- package/src/backend/middleware/bearer-auth.ts +70 -0
- package/src/backend/middleware/body-limit.ts +212 -0
- package/src/backend/middleware/cache.ts +429 -0
- package/src/backend/middleware/csrf.ts +130 -0
- package/src/backend/middleware/error-handler.ts +77 -0
- package/src/backend/middleware/rate-limit.ts +308 -0
- package/src/backend/public.ts +21 -0
- package/src/backend/routes/account-teardown.ts +430 -0
- package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +354 -0
- package/src/backend/routes/activitypub/handlers/inbound-timestamp.ts +29 -0
- package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1634 -0
- package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +547 -0
- package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +497 -0
- package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +262 -0
- package/src/backend/routes/activitypub/handlers/user-inbox-handlers.ts +35 -0
- package/src/backend/routes/activitypub/inbox-types.ts +74 -0
- package/src/backend/routes/activitypub/inbox.ts +1191 -0
- package/src/backend/routes/activitypub/outbox.ts +0 -0
- package/src/backend/routes/activitypub/query-helpers.ts +227 -0
- package/src/backend/routes/activitypub.ts +616 -0
- package/src/backend/routes/actors-helpers.ts +487 -0
- package/src/backend/routes/actors.ts +1311 -0
- package/src/backend/routes/apps.ts +313 -0
- package/src/backend/routes/auth-helpers.ts +566 -0
- package/src/backend/routes/auth.ts +615 -0
- package/src/backend/routes/communities/membership-invites.ts +208 -0
- package/src/backend/routes/communities/membership-join.ts +335 -0
- package/src/backend/routes/communities/membership-members.ts +539 -0
- package/src/backend/routes/communities/membership-requests.ts +296 -0
- package/src/backend/routes/communities/membership-shared.ts +364 -0
- package/src/backend/routes/communities/messages.ts +479 -0
- package/src/backend/routes/communities/routes.ts +624 -0
- package/src/backend/routes/communities.ts +21 -0
- package/src/backend/routes/dm/contacts.ts +525 -0
- package/src/backend/routes/dm/conversations-helpers.ts +197 -0
- package/src/backend/routes/dm/conversations.ts +25 -0
- package/src/backend/routes/dm/messages.ts +658 -0
- package/src/backend/routes/dm/query-helpers.ts +85 -0
- package/src/backend/routes/dm/read-archive.ts +228 -0
- package/src/backend/routes/dm/requests.ts +222 -0
- package/src/backend/routes/dm/typing.ts +81 -0
- package/src/backend/routes/dm.ts +15 -0
- package/src/backend/routes/follow-helpers.ts +370 -0
- package/src/backend/routes/follow.ts +588 -0
- package/src/backend/routes/media.ts +692 -0
- package/src/backend/routes/mobile.ts +159 -0
- package/src/backend/routes/moderation.ts +373 -0
- package/src/backend/routes/notifications.ts +757 -0
- package/src/backend/routes/posts/delete-cascade.ts +330 -0
- package/src/backend/routes/posts/interactions.ts +795 -0
- package/src/backend/routes/posts/post-helpers.ts +847 -0
- package/src/backend/routes/posts/queries.ts +537 -0
- package/src/backend/routes/posts/routes.ts +865 -0
- package/src/backend/routes/posts/transformers.ts +161 -0
- package/src/backend/routes/posts.ts +17 -0
- package/src/backend/routes/recommendations.ts +88 -0
- package/src/backend/routes/search.ts +730 -0
- package/src/backend/routes/stories/interactions.ts +576 -0
- package/src/backend/routes/stories/query-helpers.ts +482 -0
- package/src/backend/routes/stories/routes.ts +906 -0
- package/src/backend/routes/stories.ts +13 -0
- package/src/backend/routes/takos-tools/dm.ts +249 -0
- package/src/backend/routes/takos-tools/follows.ts +225 -0
- package/src/backend/routes/takos-tools/posts.ts +292 -0
- package/src/backend/routes/takos-tools/search.ts +228 -0
- package/src/backend/routes/takos-tools/timeline.ts +132 -0
- package/src/backend/routes/takos-tools/types.ts +10 -0
- package/src/backend/routes/takos-tools-response.ts +178 -0
- package/src/backend/routes/takos-tools.ts +153 -0
- package/src/backend/routes/timeline.ts +755 -0
- package/src/backend/runtime/bun.ts +620 -0
- package/src/backend/runtime/cloudflare.ts +202 -0
- package/src/backend/runtime/compat-bun/types.ts +44 -0
- package/src/backend/runtime/memory-kv.ts +104 -0
- package/src/backend/runtime/shared.ts +142 -0
- package/src/backend/runtime/types.ts +205 -0
- package/src/backend/server.ts +636 -0
- package/src/backend/types.ts +143 -0
- package/src/db/index.ts +97 -0
- package/src/db/schema/actors.ts +129 -0
- package/src/db/schema/communities.ts +133 -0
- package/src/db/schema/date-utils.ts +17 -0
- package/src/db/schema/index.ts +17 -0
- package/src/db/schema/messaging.ts +241 -0
- package/src/db/schema/mobile.ts +37 -0
- package/src/db/schema/posts.ts +150 -0
- package/src/db/schema/relations.ts +266 -0
- package/src/db/schema/reports.ts +33 -0
- package/src/db/schema/social.ts +106 -0
- package/src/db/schema/stories.ts +70 -0
- package/src/db/schema.ts +15 -0
- package/src/plugin/public.ts +7 -0
- package/src/runtime/site-worker.ts +10 -0
|
@@ -0,0 +1,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;
|