@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,795 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { Context } from "hono";
|
|
3
|
+
import type { Env, Variables } from "../../types.ts";
|
|
4
|
+
import type { Database } from "../../../db/index.ts";
|
|
5
|
+
import {
|
|
6
|
+
activities,
|
|
7
|
+
actorCache,
|
|
8
|
+
actors,
|
|
9
|
+
announces,
|
|
10
|
+
bookmarks,
|
|
11
|
+
follows,
|
|
12
|
+
inbox as inboxTable,
|
|
13
|
+
likes,
|
|
14
|
+
objects,
|
|
15
|
+
} from "../../../db/index.ts";
|
|
16
|
+
import { and, desc, eq, gt, inArray, or, sql } from "drizzle-orm";
|
|
17
|
+
import { isUniqueConstraintError } from "../../lib/parse-helpers.ts";
|
|
18
|
+
import type { BatchItem } from "drizzle-orm/batch";
|
|
19
|
+
import {
|
|
20
|
+
activityApId,
|
|
21
|
+
formatUsername,
|
|
22
|
+
generateId,
|
|
23
|
+
isLocal,
|
|
24
|
+
objectApId,
|
|
25
|
+
parseLimit,
|
|
26
|
+
safeJsonParse,
|
|
27
|
+
} from "../../federation-helpers.ts";
|
|
28
|
+
import { MAX_POSTS_PAGE_LIMIT } from "./transformers.ts";
|
|
29
|
+
import {
|
|
30
|
+
enqueueDeliveryToActor,
|
|
31
|
+
enqueueFanoutToFollowers,
|
|
32
|
+
} from "../../lib/delivery/queue.ts";
|
|
33
|
+
import { communityReadableApIds } from "../../lib/community-visibility.ts";
|
|
34
|
+
import { encodeFeedCursor, feedCursorWhere } from "../../lib/feed-cursor.ts";
|
|
35
|
+
import {
|
|
36
|
+
actorIsBlockedBy,
|
|
37
|
+
canViewerReadObjectFull,
|
|
38
|
+
passesPostVisibilitySync,
|
|
39
|
+
} from "../../lib/post-visibility.ts";
|
|
40
|
+
import { logger } from "../../lib/logger.ts";
|
|
41
|
+
|
|
42
|
+
const log = logger.child({ component: "posts.interactions" });
|
|
43
|
+
|
|
44
|
+
type AppContext = Context<{ Bindings: Env; Variables: Variables }>;
|
|
45
|
+
|
|
46
|
+
const posts = new Hono<{ Bindings: Env; Variables: Variables }>();
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Shared helpers (file-local)
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
/** Look up a post by local ID or full AP ID. Returns null when not found. */
|
|
53
|
+
async function findPost(
|
|
54
|
+
c: AppContext,
|
|
55
|
+
): Promise<typeof objects.$inferSelect | null>;
|
|
56
|
+
async function findPost(
|
|
57
|
+
c: AppContext,
|
|
58
|
+
selectFields: "apIdOnly",
|
|
59
|
+
): Promise<{ apId: string; attributedTo: string } | null>;
|
|
60
|
+
async function findPost(
|
|
61
|
+
c: AppContext,
|
|
62
|
+
selectFields?: "apIdOnly",
|
|
63
|
+
): Promise<
|
|
64
|
+
| typeof objects.$inferSelect
|
|
65
|
+
| { apId: string; attributedTo: string }
|
|
66
|
+
| null
|
|
67
|
+
| undefined
|
|
68
|
+
> {
|
|
69
|
+
const db = c.get("db");
|
|
70
|
+
const postId = c.req.param("id")!;
|
|
71
|
+
const baseUrl = c.env.APP_URL;
|
|
72
|
+
|
|
73
|
+
const whereCondition = or(
|
|
74
|
+
eq(objects.apId, objectApId(baseUrl, postId)),
|
|
75
|
+
eq(objects.apId, postId),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
if (selectFields === "apIdOnly") {
|
|
79
|
+
return (
|
|
80
|
+
db
|
|
81
|
+
.select({ apId: objects.apId, attributedTo: objects.attributedTo })
|
|
82
|
+
.from(objects)
|
|
83
|
+
.where(whereCondition)
|
|
84
|
+
.get() ?? null
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
db.query.objects.findFirst({
|
|
90
|
+
where: whereCondition,
|
|
91
|
+
}) ?? null
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Best-effort delivery of an activity to a remote actor.
|
|
97
|
+
* Errors are logged but never propagated.
|
|
98
|
+
*/
|
|
99
|
+
async function deliverToRemote(
|
|
100
|
+
env: Env,
|
|
101
|
+
activityId: string,
|
|
102
|
+
recipientApId: string,
|
|
103
|
+
): Promise<void> {
|
|
104
|
+
try {
|
|
105
|
+
await enqueueDeliveryToActor(env, activityId, recipientApId);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
log.error("Failed to enqueue delivery", {
|
|
108
|
+
event: "posts.delivery.enqueue_failed",
|
|
109
|
+
activityId,
|
|
110
|
+
recipient: recipientApId,
|
|
111
|
+
error: err,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Atomic multi-statement commit.
|
|
118
|
+
*
|
|
119
|
+
* D1 has no interactive transactions, but both the D1 and libsql drivers
|
|
120
|
+
* expose `db.batch([...])`, which commits a list of prepared statements
|
|
121
|
+
* atomically. The shared `Database` union aliases the abstract
|
|
122
|
+
* `BaseSQLiteDatabase` base (which does not surface `batch`), so we narrow to
|
|
123
|
+
* the concrete batch surface here rather than weakening the shared type.
|
|
124
|
+
*/
|
|
125
|
+
type BatchStatement = BatchItem<"sqlite">;
|
|
126
|
+
interface BatchableDb {
|
|
127
|
+
batch(
|
|
128
|
+
statements: readonly [BatchStatement, ...BatchStatement[]],
|
|
129
|
+
): Promise<unknown>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function runBatch(
|
|
133
|
+
db: Database,
|
|
134
|
+
statements: readonly [BatchStatement, ...BatchStatement[]],
|
|
135
|
+
): Promise<void> {
|
|
136
|
+
await (db as unknown as BatchableDb).batch(statements);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Like / Unlike
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
posts.post("/:id/like", async (c) => {
|
|
144
|
+
const actor = c.get("actor");
|
|
145
|
+
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
146
|
+
|
|
147
|
+
const post = await findPost(c);
|
|
148
|
+
if (!post) return c.json({ error: "Post not found" }, 404);
|
|
149
|
+
|
|
150
|
+
const db = c.get("db");
|
|
151
|
+
const baseUrl = c.env.APP_URL;
|
|
152
|
+
|
|
153
|
+
// Only a post the actor can actually read may be liked: without this an authed
|
|
154
|
+
// user who learns a post's apId could "like" a followers-only / direct /
|
|
155
|
+
// private-community post they were never shown, notifying the author and
|
|
156
|
+
// bumping the count from an unentitled actor. 404 (not 403) so existence is
|
|
157
|
+
// not revealed, mirroring the post-detail gate. `post` is the full row loaded
|
|
158
|
+
// above, so the read-gate runs against it directly.
|
|
159
|
+
if (!(await canViewerReadObjectFull(db, post, actor.ap_id))) {
|
|
160
|
+
return c.json({ error: "Post not found" }, 404);
|
|
161
|
+
}
|
|
162
|
+
// A blocked actor must not be able to like (bump likeCount + notify) the author
|
|
163
|
+
// who blocked them — mirror the story-like / DM block guard (404, not 403).
|
|
164
|
+
if (await actorIsBlockedBy(db, post.attributedTo, actor.ap_id)) {
|
|
165
|
+
return c.json({ error: "Post not found" }, 404);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const existingLike = await db
|
|
169
|
+
.select({ actorApId: likes.actorApId })
|
|
170
|
+
.from(likes)
|
|
171
|
+
.where(
|
|
172
|
+
and(eq(likes.actorApId, actor.ap_id), eq(likes.objectApId, post.apId)),
|
|
173
|
+
)
|
|
174
|
+
.get();
|
|
175
|
+
if (existingLike) return c.json({ error: "Already liked" }, 400);
|
|
176
|
+
|
|
177
|
+
const likeId = generateId();
|
|
178
|
+
const likeActivityId = activityApId(baseUrl, likeId);
|
|
179
|
+
const likeActivityRaw = {
|
|
180
|
+
"@context": "https://www.w3.org/ns/activitystreams",
|
|
181
|
+
id: likeActivityId,
|
|
182
|
+
type: "Like",
|
|
183
|
+
actor: actor.ap_id,
|
|
184
|
+
object: post.apId,
|
|
185
|
+
};
|
|
186
|
+
const now = new Date().toISOString();
|
|
187
|
+
const shouldNotifyLocal =
|
|
188
|
+
post.attributedTo !== actor.ap_id && isLocal(post.attributedTo, baseUrl);
|
|
189
|
+
|
|
190
|
+
// D1 has no interactive transactions; group the child-row insert, counter
|
|
191
|
+
// bump, activity row, and (optional) inbox row into a single atomic batch so
|
|
192
|
+
// the like row and likeCount can never diverge on a mid-request failure.
|
|
193
|
+
const likeStatements: BatchStatement[] = [
|
|
194
|
+
db.insert(likes).values({
|
|
195
|
+
actorApId: actor.ap_id,
|
|
196
|
+
objectApId: post.apId,
|
|
197
|
+
activityApId: likeActivityId,
|
|
198
|
+
}),
|
|
199
|
+
db
|
|
200
|
+
.update(objects)
|
|
201
|
+
.set({ likeCount: sql`${objects.likeCount} + 1` })
|
|
202
|
+
.where(eq(objects.apId, post.apId)),
|
|
203
|
+
db.insert(activities).values({
|
|
204
|
+
apId: likeActivityId,
|
|
205
|
+
type: "Like",
|
|
206
|
+
actorApId: actor.ap_id,
|
|
207
|
+
objectApId: post.apId,
|
|
208
|
+
rawJson: JSON.stringify(likeActivityRaw),
|
|
209
|
+
createdAt: now,
|
|
210
|
+
}),
|
|
211
|
+
];
|
|
212
|
+
|
|
213
|
+
if (shouldNotifyLocal) {
|
|
214
|
+
likeStatements.push(
|
|
215
|
+
db.insert(inboxTable).values({
|
|
216
|
+
actorApId: post.attributedTo,
|
|
217
|
+
activityApId: likeActivityId,
|
|
218
|
+
read: 0,
|
|
219
|
+
createdAt: now,
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
await runBatch(db, likeStatements as [BatchStatement, ...BatchStatement[]]);
|
|
226
|
+
} catch (e) {
|
|
227
|
+
// Two concurrent likes (two tabs / a retried slow request) both pass the
|
|
228
|
+
// SELECT above; the loser's composite-PK insert hits a UNIQUE constraint.
|
|
229
|
+
// That is the idempotent "already liked" case, not a 500.
|
|
230
|
+
if (isUniqueConstraintError(e)) {
|
|
231
|
+
return c.json({ error: "Already liked" }, 400);
|
|
232
|
+
}
|
|
233
|
+
throw e;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (!isLocal(post.apId, baseUrl)) {
|
|
237
|
+
await deliverToRemote(c.env, likeActivityId, post.attributedTo);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return c.json({ success: true, liked: true });
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
posts.delete("/:id/like", async (c) => {
|
|
244
|
+
const actor = c.get("actor");
|
|
245
|
+
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
246
|
+
|
|
247
|
+
const post = await findPost(c);
|
|
248
|
+
if (!post) return c.json({ error: "Post not found" }, 404);
|
|
249
|
+
|
|
250
|
+
const db = c.get("db");
|
|
251
|
+
const baseUrl = c.env.APP_URL;
|
|
252
|
+
|
|
253
|
+
const like = await db
|
|
254
|
+
.select({
|
|
255
|
+
actorApId: likes.actorApId,
|
|
256
|
+
activityApId: likes.activityApId,
|
|
257
|
+
})
|
|
258
|
+
.from(likes)
|
|
259
|
+
.where(
|
|
260
|
+
and(eq(likes.actorApId, actor.ap_id), eq(likes.objectApId, post.apId)),
|
|
261
|
+
)
|
|
262
|
+
.get();
|
|
263
|
+
if (!like) return c.json({ error: "Not liked" }, 400);
|
|
264
|
+
|
|
265
|
+
// D1 has no interactive transactions; group the like-row delete and the
|
|
266
|
+
// counter decrement into a single atomic batch so they cannot diverge. Also
|
|
267
|
+
// reap the original like's notification: the like mints a FRESH activity id
|
|
268
|
+
// each time and the dedup guard only checks the `likes` edge, so without this
|
|
269
|
+
// a like→unlike→like cycle would leave the first notification behind and add a
|
|
270
|
+
// second — duplicate "X liked your post" rows (and a phantom unread +1). The
|
|
271
|
+
// inbound-federated path already gates its notify on the existing edge; this
|
|
272
|
+
// gives the local path the same idempotency.
|
|
273
|
+
const reapLikeNotification: BatchStatement[] = like.activityApId
|
|
274
|
+
? [
|
|
275
|
+
db
|
|
276
|
+
.delete(inboxTable)
|
|
277
|
+
.where(eq(inboxTable.activityApId, like.activityApId)),
|
|
278
|
+
db.delete(activities).where(eq(activities.apId, like.activityApId)),
|
|
279
|
+
]
|
|
280
|
+
: [];
|
|
281
|
+
await runBatch(db, [
|
|
282
|
+
db
|
|
283
|
+
.delete(likes)
|
|
284
|
+
.where(
|
|
285
|
+
and(eq(likes.actorApId, actor.ap_id), eq(likes.objectApId, post.apId)),
|
|
286
|
+
),
|
|
287
|
+
db
|
|
288
|
+
.update(objects)
|
|
289
|
+
.set({ likeCount: sql`${objects.likeCount} - 1` })
|
|
290
|
+
.where(and(eq(objects.apId, post.apId), gt(objects.likeCount, 0))),
|
|
291
|
+
...reapLikeNotification,
|
|
292
|
+
]);
|
|
293
|
+
|
|
294
|
+
if (!isLocal(post.apId, baseUrl)) {
|
|
295
|
+
const undoObject = like.activityApId
|
|
296
|
+
? like.activityApId
|
|
297
|
+
: { type: "Like", actor: actor.ap_id, object: post.apId };
|
|
298
|
+
|
|
299
|
+
const undoActivity = {
|
|
300
|
+
"@context": "https://www.w3.org/ns/activitystreams",
|
|
301
|
+
id: activityApId(baseUrl, generateId()),
|
|
302
|
+
type: "Undo",
|
|
303
|
+
actor: actor.ap_id,
|
|
304
|
+
object: undoObject,
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
await db
|
|
308
|
+
.insert(activities)
|
|
309
|
+
.values({
|
|
310
|
+
apId: undoActivity.id,
|
|
311
|
+
type: "Undo",
|
|
312
|
+
actorApId: actor.ap_id,
|
|
313
|
+
objectApId: post.apId,
|
|
314
|
+
rawJson: JSON.stringify(undoActivity),
|
|
315
|
+
direction: "outbound",
|
|
316
|
+
})
|
|
317
|
+
.onConflictDoNothing();
|
|
318
|
+
|
|
319
|
+
await deliverToRemote(c.env, undoActivity.id, post.attributedTo);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return c.json({ success: true, liked: false });
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
// Repost (Announce) / Unrepost (Undo Announce)
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
posts.post("/:id/repost", async (c) => {
|
|
330
|
+
const actor = c.get("actor");
|
|
331
|
+
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
332
|
+
|
|
333
|
+
const post = await findPost(c);
|
|
334
|
+
if (!post) return c.json({ error: "Post not found" }, 404);
|
|
335
|
+
|
|
336
|
+
const db = c.get("db");
|
|
337
|
+
const baseUrl = c.env.APP_URL;
|
|
338
|
+
|
|
339
|
+
// A repost is always an Announce addressed to Public + the booster's
|
|
340
|
+
// followers (see the activity built below), so it re-broadcasts the object to
|
|
341
|
+
// a wider audience than the original. Only a post whose reach is ALREADY
|
|
342
|
+
// public may be boosted: reposting a followers-only / direct / community-
|
|
343
|
+
// scoped post would leak restricted content out to the public (Mastodon
|
|
344
|
+
// likewise forbids boosting followers-only and direct posts). "Truly public"
|
|
345
|
+
// = visibility public/unlisted AND no community/addressed audience (an empty
|
|
346
|
+
// audienceJson — community feed + chat posts both carry a non-empty audience).
|
|
347
|
+
// `post` is the full row loaded above, so reach is read from it directly.
|
|
348
|
+
const boostable =
|
|
349
|
+
// A Story is stored visibility="public"/audienceJson="[]" but its reach is
|
|
350
|
+
// followers-only and it is ephemeral — boosting it would re-broadcast
|
|
351
|
+
// follower-scoped content to the booster's public audience past its lifetime.
|
|
352
|
+
post.type !== "Story" &&
|
|
353
|
+
(post.visibility === "public" || post.visibility === "unlisted") &&
|
|
354
|
+
post.audienceJson === "[]";
|
|
355
|
+
if (!boostable) {
|
|
356
|
+
return c.json({ error: "This post cannot be reposted" }, 403);
|
|
357
|
+
}
|
|
358
|
+
// A blocked actor must not be able to repost (bump announceCount + notify) the
|
|
359
|
+
// author who blocked them — mirror the story-like / DM block guard (404).
|
|
360
|
+
if (await actorIsBlockedBy(db, post.attributedTo, actor.ap_id)) {
|
|
361
|
+
return c.json({ error: "Post not found" }, 404);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const existingRepost = await db
|
|
365
|
+
.select({ actorApId: announces.actorApId })
|
|
366
|
+
.from(announces)
|
|
367
|
+
.where(
|
|
368
|
+
and(
|
|
369
|
+
eq(announces.actorApId, actor.ap_id),
|
|
370
|
+
eq(announces.objectApId, post.apId),
|
|
371
|
+
),
|
|
372
|
+
)
|
|
373
|
+
.get();
|
|
374
|
+
if (existingRepost) return c.json({ error: "Already reposted" }, 400);
|
|
375
|
+
|
|
376
|
+
const announceId = generateId();
|
|
377
|
+
const announceActivityId = activityApId(baseUrl, announceId);
|
|
378
|
+
const announceActivityRaw = {
|
|
379
|
+
"@context": "https://www.w3.org/ns/activitystreams",
|
|
380
|
+
id: announceActivityId,
|
|
381
|
+
type: "Announce",
|
|
382
|
+
actor: actor.ap_id,
|
|
383
|
+
object: post.apId,
|
|
384
|
+
to: ["https://www.w3.org/ns/activitystreams#Public"],
|
|
385
|
+
cc: [actor.ap_id + "/followers"],
|
|
386
|
+
};
|
|
387
|
+
const now = new Date().toISOString();
|
|
388
|
+
const shouldNotifyLocal =
|
|
389
|
+
post.attributedTo !== actor.ap_id && isLocal(post.attributedTo, baseUrl);
|
|
390
|
+
|
|
391
|
+
// D1 has no interactive transactions; group the child-row insert, counter
|
|
392
|
+
// bump, activity row, and (optional) inbox row into a single atomic batch so
|
|
393
|
+
// the announce row and announceCount can never diverge.
|
|
394
|
+
const repostStatements: BatchStatement[] = [
|
|
395
|
+
db.insert(announces).values({
|
|
396
|
+
actorApId: actor.ap_id,
|
|
397
|
+
objectApId: post.apId,
|
|
398
|
+
activityApId: announceActivityId,
|
|
399
|
+
}),
|
|
400
|
+
db
|
|
401
|
+
.update(objects)
|
|
402
|
+
.set({ announceCount: sql`${objects.announceCount} + 1` })
|
|
403
|
+
.where(eq(objects.apId, post.apId)),
|
|
404
|
+
db.insert(activities).values({
|
|
405
|
+
apId: announceActivityId,
|
|
406
|
+
type: "Announce",
|
|
407
|
+
actorApId: actor.ap_id,
|
|
408
|
+
objectApId: post.apId,
|
|
409
|
+
rawJson: JSON.stringify(announceActivityRaw),
|
|
410
|
+
createdAt: now,
|
|
411
|
+
}),
|
|
412
|
+
];
|
|
413
|
+
|
|
414
|
+
if (shouldNotifyLocal) {
|
|
415
|
+
repostStatements.push(
|
|
416
|
+
db.insert(inboxTable).values({
|
|
417
|
+
actorApId: post.attributedTo,
|
|
418
|
+
activityApId: announceActivityId,
|
|
419
|
+
read: 0,
|
|
420
|
+
createdAt: now,
|
|
421
|
+
}),
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
try {
|
|
426
|
+
await runBatch(
|
|
427
|
+
db,
|
|
428
|
+
repostStatements as [BatchStatement, ...BatchStatement[]],
|
|
429
|
+
);
|
|
430
|
+
} catch (e) {
|
|
431
|
+
// Concurrent duplicate repost → composite-PK UNIQUE → idempotent 400.
|
|
432
|
+
if (isUniqueConstraintError(e)) {
|
|
433
|
+
return c.json({ error: "Already reposted" }, 400);
|
|
434
|
+
}
|
|
435
|
+
throw e;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// The Announce is cc'd to the booster's own followers collection, so fan it
|
|
439
|
+
// out to them — otherwise a repost only ever reached the original author's
|
|
440
|
+
// inbox and was invisible to the booster's remote followers.
|
|
441
|
+
await enqueueFanoutToFollowers(c.env, announceActivityId, actor.ap_id);
|
|
442
|
+
|
|
443
|
+
if (!isLocal(post.apId, baseUrl)) {
|
|
444
|
+
await deliverToRemote(c.env, announceActivityId, post.attributedTo);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return c.json({ success: true, reposted: true });
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
posts.delete("/:id/repost", async (c) => {
|
|
451
|
+
const actor = c.get("actor");
|
|
452
|
+
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
453
|
+
|
|
454
|
+
const post = await findPost(c);
|
|
455
|
+
if (!post) return c.json({ error: "Post not found" }, 404);
|
|
456
|
+
|
|
457
|
+
const db = c.get("db");
|
|
458
|
+
const baseUrl = c.env.APP_URL;
|
|
459
|
+
|
|
460
|
+
const announce = await db
|
|
461
|
+
.select({
|
|
462
|
+
actorApId: announces.actorApId,
|
|
463
|
+
activityApId: announces.activityApId,
|
|
464
|
+
})
|
|
465
|
+
.from(announces)
|
|
466
|
+
.where(
|
|
467
|
+
and(
|
|
468
|
+
eq(announces.actorApId, actor.ap_id),
|
|
469
|
+
eq(announces.objectApId, post.apId),
|
|
470
|
+
),
|
|
471
|
+
)
|
|
472
|
+
.get();
|
|
473
|
+
if (!announce) return c.json({ error: "Not reposted" }, 400);
|
|
474
|
+
|
|
475
|
+
// D1 has no interactive transactions; group the announce-row delete and the
|
|
476
|
+
// counter decrement into a single atomic batch so they cannot diverge. Also
|
|
477
|
+
// reap the original repost's notification (same duplicate-notification issue
|
|
478
|
+
// as unlike: each repost mints a fresh Announce activity id and the guard only
|
|
479
|
+
// checks the edge, so repost→unrepost→repost would stack notifications).
|
|
480
|
+
const reapRepostNotification: BatchStatement[] = announce.activityApId
|
|
481
|
+
? [
|
|
482
|
+
db
|
|
483
|
+
.delete(inboxTable)
|
|
484
|
+
.where(eq(inboxTable.activityApId, announce.activityApId)),
|
|
485
|
+
db.delete(activities).where(eq(activities.apId, announce.activityApId)),
|
|
486
|
+
]
|
|
487
|
+
: [];
|
|
488
|
+
await runBatch(db, [
|
|
489
|
+
db
|
|
490
|
+
.delete(announces)
|
|
491
|
+
.where(
|
|
492
|
+
and(
|
|
493
|
+
eq(announces.actorApId, actor.ap_id),
|
|
494
|
+
eq(announces.objectApId, post.apId),
|
|
495
|
+
),
|
|
496
|
+
),
|
|
497
|
+
db
|
|
498
|
+
.update(objects)
|
|
499
|
+
.set({ announceCount: sql`${objects.announceCount} - 1` })
|
|
500
|
+
.where(and(eq(objects.apId, post.apId), gt(objects.announceCount, 0))),
|
|
501
|
+
...reapRepostNotification,
|
|
502
|
+
]);
|
|
503
|
+
|
|
504
|
+
const undoActivity = {
|
|
505
|
+
"@context": "https://www.w3.org/ns/activitystreams",
|
|
506
|
+
id: activityApId(baseUrl, generateId()),
|
|
507
|
+
type: "Undo",
|
|
508
|
+
actor: actor.ap_id,
|
|
509
|
+
to: ["https://www.w3.org/ns/activitystreams#Public"],
|
|
510
|
+
cc: [actor.ap_id + "/followers"],
|
|
511
|
+
object: { type: "Announce", actor: actor.ap_id, object: post.apId },
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
await db
|
|
515
|
+
.insert(activities)
|
|
516
|
+
.values({
|
|
517
|
+
apId: undoActivity.id,
|
|
518
|
+
type: "Undo",
|
|
519
|
+
actorApId: actor.ap_id,
|
|
520
|
+
objectApId: post.apId,
|
|
521
|
+
rawJson: JSON.stringify(undoActivity),
|
|
522
|
+
direction: "outbound",
|
|
523
|
+
})
|
|
524
|
+
.onConflictDoNothing();
|
|
525
|
+
|
|
526
|
+
// Mirror the Announce reach: the Undo must reach the booster's followers (who
|
|
527
|
+
// saw the repost) regardless of whether the boosted post was local or remote.
|
|
528
|
+
await enqueueFanoutToFollowers(c.env, undoActivity.id, actor.ap_id);
|
|
529
|
+
|
|
530
|
+
// The boosted post's author instance is only an extra recipient for a remote post.
|
|
531
|
+
if (!isLocal(post.apId, baseUrl)) {
|
|
532
|
+
await deliverToRemote(c.env, undoActivity.id, post.attributedTo);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return c.json({ success: true, reposted: false });
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
// ---------------------------------------------------------------------------
|
|
539
|
+
// Bookmark / Unbookmark / List bookmarks
|
|
540
|
+
// ---------------------------------------------------------------------------
|
|
541
|
+
|
|
542
|
+
posts.post("/:id/bookmark", async (c) => {
|
|
543
|
+
const actor = c.get("actor");
|
|
544
|
+
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
545
|
+
|
|
546
|
+
// Load the FULL row (not apId-only) so the read-gate can run: previously this
|
|
547
|
+
// resolved by apId with NO visibility/block check, so any authed user could
|
|
548
|
+
// bookmark any object by id — including a followers-only / direct / private-
|
|
549
|
+
// community post or a personal Story they were never shown — which then leaked
|
|
550
|
+
// via GET /bookmarks. Gate it like /like and /repost (#3).
|
|
551
|
+
const post = await findPost(c);
|
|
552
|
+
if (!post) return c.json({ error: "Post not found" }, 404);
|
|
553
|
+
|
|
554
|
+
const db = c.get("db");
|
|
555
|
+
|
|
556
|
+
// 404 (not 403) so existence is not revealed, mirroring the like/repost gate.
|
|
557
|
+
if (!(await canViewerReadObjectFull(db, post, actor.ap_id))) {
|
|
558
|
+
return c.json({ error: "Post not found" }, 404);
|
|
559
|
+
}
|
|
560
|
+
if (await actorIsBlockedBy(db, post.attributedTo, actor.ap_id)) {
|
|
561
|
+
return c.json({ error: "Post not found" }, 404);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const existing = await db
|
|
565
|
+
.select({ actorApId: bookmarks.actorApId })
|
|
566
|
+
.from(bookmarks)
|
|
567
|
+
.where(
|
|
568
|
+
and(
|
|
569
|
+
eq(bookmarks.actorApId, actor.ap_id),
|
|
570
|
+
eq(bookmarks.objectApId, post.apId),
|
|
571
|
+
),
|
|
572
|
+
)
|
|
573
|
+
.get();
|
|
574
|
+
if (existing) return c.json({ error: "Already bookmarked" }, 400);
|
|
575
|
+
|
|
576
|
+
// onConflictDoNothing so two concurrent bookmarks of the same post (two tabs /
|
|
577
|
+
// a retried slow request) that both pass the existence check don't 500 the
|
|
578
|
+
// loser on the (actorApId, objectApId) composite PK — the edge is idempotent.
|
|
579
|
+
await db
|
|
580
|
+
.insert(bookmarks)
|
|
581
|
+
.values({
|
|
582
|
+
actorApId: actor.ap_id,
|
|
583
|
+
objectApId: post.apId,
|
|
584
|
+
})
|
|
585
|
+
.onConflictDoNothing();
|
|
586
|
+
|
|
587
|
+
return c.json({ success: true, bookmarked: true });
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
posts.delete("/:id/bookmark", async (c) => {
|
|
591
|
+
const actor = c.get("actor");
|
|
592
|
+
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
593
|
+
|
|
594
|
+
const post = await findPost(c, "apIdOnly");
|
|
595
|
+
if (!post) return c.json({ error: "Post not found" }, 404);
|
|
596
|
+
|
|
597
|
+
const db = c.get("db");
|
|
598
|
+
|
|
599
|
+
const bookmark = await db
|
|
600
|
+
.select({ actorApId: bookmarks.actorApId })
|
|
601
|
+
.from(bookmarks)
|
|
602
|
+
.where(
|
|
603
|
+
and(
|
|
604
|
+
eq(bookmarks.actorApId, actor.ap_id),
|
|
605
|
+
eq(bookmarks.objectApId, post.apId),
|
|
606
|
+
),
|
|
607
|
+
)
|
|
608
|
+
.get();
|
|
609
|
+
if (!bookmark) return c.json({ error: "Not bookmarked" }, 400);
|
|
610
|
+
|
|
611
|
+
await db
|
|
612
|
+
.delete(bookmarks)
|
|
613
|
+
.where(
|
|
614
|
+
and(
|
|
615
|
+
eq(bookmarks.actorApId, actor.ap_id),
|
|
616
|
+
eq(bookmarks.objectApId, post.apId),
|
|
617
|
+
),
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
return c.json({ success: true, bookmarked: false });
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
posts.get("/bookmarks", async (c) => {
|
|
624
|
+
const actor = c.get("actor");
|
|
625
|
+
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
626
|
+
|
|
627
|
+
const db = c.get("db");
|
|
628
|
+
const limit = parseLimit(c.req.query("limit"), 20, MAX_POSTS_PAGE_LIMIT);
|
|
629
|
+
const before = c.req.query("before");
|
|
630
|
+
|
|
631
|
+
// Composite (createdAt, objectApId) cursor — createdAt is not unique, so a
|
|
632
|
+
// bare cursor would skip same-timestamp bookmarks at a boundary; objectApId is
|
|
633
|
+
// the unique tiebreaker (see lib/feed-cursor.ts).
|
|
634
|
+
const cursorPredicate = feedCursorWhere(
|
|
635
|
+
bookmarks.createdAt,
|
|
636
|
+
bookmarks.objectApId,
|
|
637
|
+
before,
|
|
638
|
+
);
|
|
639
|
+
|
|
640
|
+
// Fetch limit+1 and advance the cursor by the last SCANNED row, so the
|
|
641
|
+
// visibility gate dropping rows can only shorten a page, never make a readable
|
|
642
|
+
// bookmark permanently unreachable (load-more continues past the dropped ones).
|
|
643
|
+
const scanned = await db.query.bookmarks.findMany({
|
|
644
|
+
where: cursorPredicate
|
|
645
|
+
? and(eq(bookmarks.actorApId, actor.ap_id), cursorPredicate)
|
|
646
|
+
: eq(bookmarks.actorApId, actor.ap_id),
|
|
647
|
+
with: { object: true },
|
|
648
|
+
orderBy: [desc(bookmarks.createdAt), desc(bookmarks.objectApId)],
|
|
649
|
+
limit: limit + 1,
|
|
650
|
+
});
|
|
651
|
+
const hasMore = scanned.length > limit;
|
|
652
|
+
const allBookmarkRows = hasMore ? scanned.slice(0, limit) : scanned;
|
|
653
|
+
const lastScanned = allBookmarkRows[allBookmarkRows.length - 1];
|
|
654
|
+
const nextCursor =
|
|
655
|
+
hasMore && lastScanned
|
|
656
|
+
? encodeFeedCursor(lastScanned.createdAt, lastScanned.objectApId)
|
|
657
|
+
: null;
|
|
658
|
+
|
|
659
|
+
// Re-check read-access at read time so a bookmark can never resurface a post
|
|
660
|
+
// the viewer can no longer read. The per-post visibility decision is delegated
|
|
661
|
+
// to the canonical `passesPostVisibilitySync` (the SAME predicate the
|
|
662
|
+
// single-object gate uses) so the to/cc explicit-recipient and Story branches
|
|
663
|
+
// cannot drift here — the hand-rolled gate this replaces dropped the cc check
|
|
664
|
+
// AND had no Story reach/expiry branch, leaking a personal (followers-only)
|
|
665
|
+
// Story's caption/overlay/attachment metadata to a non-follower and past
|
|
666
|
+
// expiry (#3). The private-community membership gate is applied separately by
|
|
667
|
+
// the batched `communityReadableApIds` below.
|
|
668
|
+
//
|
|
669
|
+
// Batch the follower lookup: an accepted follow could matter for a
|
|
670
|
+
// followers-only post OR a personal (non-community) story, so collect those
|
|
671
|
+
// authors and resolve them in one query, then feed the Set to the predicate.
|
|
672
|
+
const followerGateAuthors = [
|
|
673
|
+
...new Set(
|
|
674
|
+
allBookmarkRows
|
|
675
|
+
.filter(
|
|
676
|
+
(b) =>
|
|
677
|
+
b.object.attributedTo !== actor.ap_id &&
|
|
678
|
+
((b.object.type === "Story" && !b.object.communityApId) ||
|
|
679
|
+
(b.object.type !== "Story" &&
|
|
680
|
+
b.object.visibility === "followers")),
|
|
681
|
+
)
|
|
682
|
+
.map((b) => b.object.attributedTo),
|
|
683
|
+
),
|
|
684
|
+
];
|
|
685
|
+
const followedAuthors =
|
|
686
|
+
followerGateAuthors.length > 0
|
|
687
|
+
? new Set(
|
|
688
|
+
(
|
|
689
|
+
await db
|
|
690
|
+
.select({ followingApId: follows.followingApId })
|
|
691
|
+
.from(follows)
|
|
692
|
+
.where(
|
|
693
|
+
and(
|
|
694
|
+
eq(follows.followerApId, actor.ap_id),
|
|
695
|
+
inArray(follows.followingApId, followerGateAuthors),
|
|
696
|
+
eq(follows.status, "accepted"),
|
|
697
|
+
),
|
|
698
|
+
)
|
|
699
|
+
).map((r) => r.followingApId),
|
|
700
|
+
)
|
|
701
|
+
: new Set<string>();
|
|
702
|
+
|
|
703
|
+
// Sync visibility-gate first, then ONE batched community read-gate over the
|
|
704
|
+
// survivors (2 queries instead of 1-2 per bookmarked post).
|
|
705
|
+
const visibilityOk = allBookmarkRows.filter((b) =>
|
|
706
|
+
passesPostVisibilitySync(b.object, actor.ap_id, (a) =>
|
|
707
|
+
followedAuthors.has(a),
|
|
708
|
+
),
|
|
709
|
+
);
|
|
710
|
+
const communityReadable = await communityReadableApIds(
|
|
711
|
+
db,
|
|
712
|
+
visibilityOk.map((b) => b.object),
|
|
713
|
+
actor.ap_id,
|
|
714
|
+
);
|
|
715
|
+
const bookmarkRows = visibilityOk.filter((b) =>
|
|
716
|
+
communityReadable.has(b.object.apId),
|
|
717
|
+
);
|
|
718
|
+
|
|
719
|
+
// Batch-load author info to avoid N+1 queries
|
|
720
|
+
const authorApIds = [
|
|
721
|
+
...new Set(bookmarkRows.map((b) => b.object.attributedTo)),
|
|
722
|
+
];
|
|
723
|
+
const [localActors, cachedActors] = await Promise.all([
|
|
724
|
+
db
|
|
725
|
+
.select({
|
|
726
|
+
apId: actors.apId,
|
|
727
|
+
preferredUsername: actors.preferredUsername,
|
|
728
|
+
name: actors.name,
|
|
729
|
+
iconUrl: actors.iconUrl,
|
|
730
|
+
})
|
|
731
|
+
.from(actors)
|
|
732
|
+
.where(inArray(actors.apId, authorApIds)),
|
|
733
|
+
db
|
|
734
|
+
.select({
|
|
735
|
+
apId: actorCache.apId,
|
|
736
|
+
preferredUsername: actorCache.preferredUsername,
|
|
737
|
+
name: actorCache.name,
|
|
738
|
+
iconUrl: actorCache.iconUrl,
|
|
739
|
+
})
|
|
740
|
+
.from(actorCache)
|
|
741
|
+
.where(inArray(actorCache.apId, authorApIds)),
|
|
742
|
+
]);
|
|
743
|
+
|
|
744
|
+
const actorMap = new Map([
|
|
745
|
+
...cachedActors.map((a) => [a.apId, a] as const),
|
|
746
|
+
...localActors.map((a) => [a.apId, a] as const),
|
|
747
|
+
]);
|
|
748
|
+
|
|
749
|
+
// Batch-load likes for all bookmarked posts
|
|
750
|
+
const postApIds = bookmarkRows.map((b) => b.object.apId);
|
|
751
|
+
const likeRows = await db
|
|
752
|
+
.select({ objectApId: likes.objectApId })
|
|
753
|
+
.from(likes)
|
|
754
|
+
.where(
|
|
755
|
+
and(
|
|
756
|
+
eq(likes.actorApId, actor.ap_id),
|
|
757
|
+
inArray(likes.objectApId, postApIds),
|
|
758
|
+
),
|
|
759
|
+
);
|
|
760
|
+
const likedPostIds = new Set(likeRows.map((l) => l.objectApId));
|
|
761
|
+
|
|
762
|
+
const result = bookmarkRows.map((b) => {
|
|
763
|
+
const obj = b.object;
|
|
764
|
+
const authorInfo = actorMap.get(obj.attributedTo);
|
|
765
|
+
|
|
766
|
+
return {
|
|
767
|
+
ap_id: obj.apId,
|
|
768
|
+
type: obj.type,
|
|
769
|
+
author: {
|
|
770
|
+
ap_id: obj.attributedTo,
|
|
771
|
+
username: formatUsername(obj.attributedTo),
|
|
772
|
+
preferred_username: authorInfo?.preferredUsername ?? null,
|
|
773
|
+
name: authorInfo?.name ?? null,
|
|
774
|
+
icon_url: authorInfo?.iconUrl ?? null,
|
|
775
|
+
},
|
|
776
|
+
content: obj.content,
|
|
777
|
+
summary: obj.summary,
|
|
778
|
+
attachments: safeJsonParse(obj.attachmentsJson, []),
|
|
779
|
+
in_reply_to: obj.inReplyTo,
|
|
780
|
+
visibility: obj.visibility,
|
|
781
|
+
community_ap_id: obj.communityApId,
|
|
782
|
+
like_count: obj.likeCount,
|
|
783
|
+
reply_count: obj.replyCount,
|
|
784
|
+
announce_count: obj.announceCount,
|
|
785
|
+
published: obj.published,
|
|
786
|
+
liked: likedPostIds.has(obj.apId),
|
|
787
|
+
bookmarked: true,
|
|
788
|
+
reposted: false,
|
|
789
|
+
};
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
return c.json({ posts: result, has_more: hasMore, next_cursor: nextCursor });
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
export default posts;
|