@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,865 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { actors, follows, objects } from "../../../db/index.ts";
|
|
3
|
+
import type { Database } from "../../../db/index.ts";
|
|
4
|
+
import { OBJECT_CONTEXT } from "../../lib/ap-context.ts";
|
|
5
|
+
import { and, desc, eq, gt, inArray, sql } from "drizzle-orm";
|
|
6
|
+
import type { Actor, Env, Variables } from "../../types.ts";
|
|
7
|
+
import {
|
|
8
|
+
activityApId,
|
|
9
|
+
formatUsername,
|
|
10
|
+
generateId,
|
|
11
|
+
isLocal,
|
|
12
|
+
isSafeRemoteUrl,
|
|
13
|
+
objectApId,
|
|
14
|
+
parseLimit,
|
|
15
|
+
safeJsonParse,
|
|
16
|
+
} from "../../federation-helpers.ts";
|
|
17
|
+
import {
|
|
18
|
+
formatPost,
|
|
19
|
+
MAX_POSTS_PAGE_LIMIT,
|
|
20
|
+
normalizeVisibility,
|
|
21
|
+
PostRow,
|
|
22
|
+
} from "./transformers.ts";
|
|
23
|
+
import {
|
|
24
|
+
AUTHOR_WITH,
|
|
25
|
+
buildAddressing,
|
|
26
|
+
buildCommunityObjectAddressing,
|
|
27
|
+
loadCachedAuthorMap,
|
|
28
|
+
loadInteractionFlags,
|
|
29
|
+
mergeCc,
|
|
30
|
+
persistActivity,
|
|
31
|
+
persistAndFanout,
|
|
32
|
+
persistAndFanoutToCommunity,
|
|
33
|
+
type PostDetailRow,
|
|
34
|
+
postWhereByIdOrApId,
|
|
35
|
+
type PostWithAuthor,
|
|
36
|
+
resolveAuthor,
|
|
37
|
+
resolveAuthorWithCache,
|
|
38
|
+
toPostRow,
|
|
39
|
+
} from "./queries.ts";
|
|
40
|
+
import { enqueueDeliveryToActor } from "../../lib/delivery/queue.ts";
|
|
41
|
+
import { deleteObjectCascade, purgeMediaBlobs } from "./delete-cascade.ts";
|
|
42
|
+
import {
|
|
43
|
+
checkCommunityPostPermission,
|
|
44
|
+
deriveContentTags,
|
|
45
|
+
insertPostAndHandleReply,
|
|
46
|
+
processMentions,
|
|
47
|
+
REPLY_TARGET_NOT_FOUND,
|
|
48
|
+
validateContentEdit,
|
|
49
|
+
validateCreatePostBody,
|
|
50
|
+
validateEditBody,
|
|
51
|
+
validateSummaryEdit,
|
|
52
|
+
} from "./post-helpers.ts";
|
|
53
|
+
import { requireActor } from "../actors-helpers.ts";
|
|
54
|
+
import { communityReadableApIds } from "../../lib/community-visibility.ts";
|
|
55
|
+
import { encodeFeedCursor, feedCursorWhere } from "../../lib/feed-cursor.ts";
|
|
56
|
+
import {
|
|
57
|
+
actorIsBlockedBy,
|
|
58
|
+
canViewerReadObjectFull,
|
|
59
|
+
} from "../../lib/post-visibility.ts";
|
|
60
|
+
import { toApAttachments } from "../../lib/activitypub-helpers.ts";
|
|
61
|
+
import { logger } from "../../lib/logger.ts";
|
|
62
|
+
|
|
63
|
+
const log = logger.child({ component: "posts.routes" });
|
|
64
|
+
|
|
65
|
+
// `.batch` lives only on the concrete D1/libsql subclasses, not the Database
|
|
66
|
+
// union; reach it through a narrow structural cast (matching the other routes).
|
|
67
|
+
type Batchable = { batch: (stmts: unknown[]) => Promise<unknown> };
|
|
68
|
+
|
|
69
|
+
const posts = new Hono<{ Bindings: Env; Variables: Variables }>();
|
|
70
|
+
|
|
71
|
+
const PUBLIC_COLLECTION = "https://www.w3.org/ns/activitystreams#Public";
|
|
72
|
+
|
|
73
|
+
/** Reply row shape needed for the visibility gate (subset of the object row). */
|
|
74
|
+
type ReplyVisibilityRow = {
|
|
75
|
+
apId: string;
|
|
76
|
+
attributedTo: string;
|
|
77
|
+
visibility: string;
|
|
78
|
+
toJson?: string | null;
|
|
79
|
+
audienceJson?: string | null;
|
|
80
|
+
communityApId?: string | null;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Apply the SAME per-post visibility gate that GET /:id uses, to a LIST of
|
|
85
|
+
* replies, so a follower-only or direct reply is never returned to a viewer
|
|
86
|
+
* who is not its author / an accepted follower / an addressed recipient.
|
|
87
|
+
*
|
|
88
|
+
* `public` and `unlisted` replies are always visible. The accepted-follow
|
|
89
|
+
* edges the viewer needs across all follower-only reply authors are resolved
|
|
90
|
+
* in a single batched query to avoid an N+1.
|
|
91
|
+
*/
|
|
92
|
+
async function filterVisibleReplies<T extends ReplyVisibilityRow>(
|
|
93
|
+
db: Database,
|
|
94
|
+
currentActor: Actor | null | undefined,
|
|
95
|
+
replies: T[],
|
|
96
|
+
): Promise<T[]> {
|
|
97
|
+
const viewerApId = currentActor?.ap_id;
|
|
98
|
+
|
|
99
|
+
// Authors of follower-only replies the viewer does not own — these are the
|
|
100
|
+
// only authors we need an accepted-follow edge for.
|
|
101
|
+
const followerGateAuthors = new Set<string>();
|
|
102
|
+
for (const reply of replies) {
|
|
103
|
+
if (reply.visibility === "followers" && reply.attributedTo !== viewerApId) {
|
|
104
|
+
followerGateAuthors.add(reply.attributedTo);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let acceptedFollowing = new Set<string>();
|
|
109
|
+
if (viewerApId && followerGateAuthors.size > 0) {
|
|
110
|
+
const rows = await db
|
|
111
|
+
.select({ followingApId: follows.followingApId })
|
|
112
|
+
.from(follows)
|
|
113
|
+
.where(
|
|
114
|
+
and(
|
|
115
|
+
eq(follows.followerApId, viewerApId),
|
|
116
|
+
inArray(follows.followingApId, [...followerGateAuthors]),
|
|
117
|
+
eq(follows.status, "accepted"),
|
|
118
|
+
),
|
|
119
|
+
);
|
|
120
|
+
acceptedFollowing = new Set(rows.map((r) => r.followingApId));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Pre-compute the community read-gate for every reply: a community-scoped
|
|
124
|
+
// reply is stored "public" but carries an audience, so the per-visibility
|
|
125
|
+
// checks below would let it through. Resolving membership here (rather than
|
|
126
|
+
// inside the synchronous .filter) lets the predicate stay synchronous.
|
|
127
|
+
// Batched community read-gate for the whole page (2 queries, not 1-2 per
|
|
128
|
+
// reply). Same semantics as canViewerReadObject.
|
|
129
|
+
const communityReadable = await communityReadableApIds(
|
|
130
|
+
db,
|
|
131
|
+
replies,
|
|
132
|
+
viewerApId,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
return replies.filter((reply) => {
|
|
136
|
+
// A private-community reply is hidden from anyone who is not an accepted
|
|
137
|
+
// member, regardless of the (stored "public") visibility.
|
|
138
|
+
if (!communityReadable.has(reply.apId)) return false;
|
|
139
|
+
if (reply.visibility === "followers") {
|
|
140
|
+
if (!viewerApId) return false;
|
|
141
|
+
if (reply.attributedTo === viewerApId) return true;
|
|
142
|
+
return acceptedFollowing.has(reply.attributedTo);
|
|
143
|
+
}
|
|
144
|
+
if (reply.visibility === "direct") {
|
|
145
|
+
if (!viewerApId) return false;
|
|
146
|
+
if (reply.attributedTo === viewerApId) return true;
|
|
147
|
+
const recipients = safeJsonParse<string[]>(reply.toJson, []);
|
|
148
|
+
return recipients.includes(viewerApId);
|
|
149
|
+
}
|
|
150
|
+
return true;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// --- Route handlers ---
|
|
155
|
+
|
|
156
|
+
// Create post
|
|
157
|
+
posts.post("/", async (c) => {
|
|
158
|
+
const actor = requireActor(c);
|
|
159
|
+
if (actor instanceof Response) return actor;
|
|
160
|
+
|
|
161
|
+
const validation = await validateCreatePostBody(c);
|
|
162
|
+
if (!validation.ok) {
|
|
163
|
+
return c.json(
|
|
164
|
+
{
|
|
165
|
+
error: validation.error,
|
|
166
|
+
...(validation.code ? { code: validation.code } : {}),
|
|
167
|
+
},
|
|
168
|
+
400,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
const { body, content, summary } = validation;
|
|
172
|
+
|
|
173
|
+
const db = c.get("db");
|
|
174
|
+
const visibility = normalizeVisibility(body.visibility);
|
|
175
|
+
|
|
176
|
+
const communityCheck = await checkCommunityPostPermission(
|
|
177
|
+
db,
|
|
178
|
+
actor.ap_id,
|
|
179
|
+
body.community_ap_id,
|
|
180
|
+
);
|
|
181
|
+
if (!communityCheck.allowed) {
|
|
182
|
+
return c.json({ error: communityCheck.error }, communityCheck.status);
|
|
183
|
+
}
|
|
184
|
+
const communityId = communityCheck.communityId;
|
|
185
|
+
const community = communityCheck.community;
|
|
186
|
+
|
|
187
|
+
// Reply read-gate: a reply may only target a parent the replier can actually
|
|
188
|
+
// READ. Without this, anyone who learns a followers-only / direct /
|
|
189
|
+
// private-community post's apId could reply to it — inflating the author's
|
|
190
|
+
// replyCount, sending them a reply notification, and publishing a public reply
|
|
191
|
+
// whose inReplyTo discloses the restricted parent's existence (and bypassing a
|
|
192
|
+
// block). Mirror the like/repost gates; 404 to avoid leaking existence.
|
|
193
|
+
if (body.in_reply_to) {
|
|
194
|
+
const parent = await db
|
|
195
|
+
.select({
|
|
196
|
+
visibility: objects.visibility,
|
|
197
|
+
attributedTo: objects.attributedTo,
|
|
198
|
+
toJson: objects.toJson,
|
|
199
|
+
ccJson: objects.ccJson,
|
|
200
|
+
audienceJson: objects.audienceJson,
|
|
201
|
+
communityApId: objects.communityApId,
|
|
202
|
+
type: objects.type,
|
|
203
|
+
endTime: objects.endTime,
|
|
204
|
+
})
|
|
205
|
+
.from(objects)
|
|
206
|
+
.where(eq(objects.apId, body.in_reply_to))
|
|
207
|
+
.get();
|
|
208
|
+
if (
|
|
209
|
+
!parent ||
|
|
210
|
+
!(await canViewerReadObjectFull(db, parent, actor.ap_id)) ||
|
|
211
|
+
(await actorIsBlockedBy(db, parent.attributedTo, actor.ap_id))
|
|
212
|
+
) {
|
|
213
|
+
return c.json({ error: "Post not found" }, 404);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const baseUrl = c.env.APP_URL;
|
|
218
|
+
const postId = generateId();
|
|
219
|
+
const apId = objectApId(baseUrl, postId);
|
|
220
|
+
const now = new Date().toISOString();
|
|
221
|
+
|
|
222
|
+
let parentAuthor: string | null = null;
|
|
223
|
+
try {
|
|
224
|
+
parentAuthor = await insertPostAndHandleReply(db, {
|
|
225
|
+
apId,
|
|
226
|
+
actorApId: actor.ap_id,
|
|
227
|
+
content,
|
|
228
|
+
summary: summary || null,
|
|
229
|
+
attachments: body.attachments,
|
|
230
|
+
inReplyTo: body.in_reply_to || null,
|
|
231
|
+
visibility,
|
|
232
|
+
communityId,
|
|
233
|
+
community,
|
|
234
|
+
baseUrl,
|
|
235
|
+
now,
|
|
236
|
+
});
|
|
237
|
+
} catch (e) {
|
|
238
|
+
if (e instanceof Error && e.message === REPLY_TARGET_NOT_FOUND) {
|
|
239
|
+
return c.json({ error: "Reply target not found" }, 404);
|
|
240
|
+
}
|
|
241
|
+
log.error("Failed to create post transaction", {
|
|
242
|
+
event: "posts.create.transaction_failed",
|
|
243
|
+
actor: actor.ap_id,
|
|
244
|
+
communityId,
|
|
245
|
+
error: e,
|
|
246
|
+
});
|
|
247
|
+
return c.json({ error: "Failed to create post" }, 500);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Process mentions: resolve @mentions (local + remote), build `Mention`
|
|
251
|
+
// tags, create local notifications, and collect the resolved recipient IRIs.
|
|
252
|
+
const {
|
|
253
|
+
failures: mentionFailures,
|
|
254
|
+
tags: mentionTags,
|
|
255
|
+
mentionedActorApIds,
|
|
256
|
+
remoteMentionedActorApIds,
|
|
257
|
+
} = await processMentions(db, {
|
|
258
|
+
content,
|
|
259
|
+
postApId: apId,
|
|
260
|
+
actorApId: actor.ap_id,
|
|
261
|
+
parentAuthor,
|
|
262
|
+
baseUrl,
|
|
263
|
+
now,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// A reply must reach the post it replies to. processMentions only addresses
|
|
267
|
+
// actors EXPLICITLY @-mentioned in the body, so a reply to a remote post that
|
|
268
|
+
// doesn't manually @-mention its author was delivered to the replier's own
|
|
269
|
+
// followers but NEVER to the upstream instance — it never landed in the
|
|
270
|
+
// original thread (whereas Like/Undo-repost already reach the remote object
|
|
271
|
+
// author). Auto-address the parent author of a NON-direct reply: add it to cc
|
|
272
|
+
// and a `Mention` tag (so the reply threads + notifies on the receiving
|
|
273
|
+
// server) and, when remote, deliver the Create to its inbox. Direct replies
|
|
274
|
+
// keep mentions-only addressing (no implicit parent disclosure). The local
|
|
275
|
+
// parent author is already notified by the reply path, so this only augments
|
|
276
|
+
// addressing/delivery, never a duplicate local notification.
|
|
277
|
+
const replyRecipients = [...mentionedActorApIds];
|
|
278
|
+
const replyRemoteRecipients = [...remoteMentionedActorApIds];
|
|
279
|
+
const replyTags = [...mentionTags];
|
|
280
|
+
if (
|
|
281
|
+
body.in_reply_to &&
|
|
282
|
+
parentAuthor &&
|
|
283
|
+
parentAuthor !== actor.ap_id &&
|
|
284
|
+
visibility !== "direct" &&
|
|
285
|
+
!replyRecipients.includes(parentAuthor)
|
|
286
|
+
) {
|
|
287
|
+
replyRecipients.push(parentAuthor);
|
|
288
|
+
replyTags.push({
|
|
289
|
+
type: "Mention",
|
|
290
|
+
href: parentAuthor,
|
|
291
|
+
name: `@${formatUsername(parentAuthor)}`,
|
|
292
|
+
});
|
|
293
|
+
if (!isLocal(parentAuthor, baseUrl)) {
|
|
294
|
+
replyRemoteRecipients.push(parentAuthor);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Federate when the post has follower/public/community reach OR when it has
|
|
299
|
+
// resolved mentions (a mention is an explicit recipient, so even a "direct"
|
|
300
|
+
// post must federate the Create to its remote mentioned actors).
|
|
301
|
+
//
|
|
302
|
+
// Community-scoped posts have reach == community: address the Create toward
|
|
303
|
+
// the community Group actor + its followers collection (NOT the author's
|
|
304
|
+
// personal followers), record the community in `audience`, and fan out to
|
|
305
|
+
// the community's members/followers. Non-community posts keep the existing
|
|
306
|
+
// author-follower addressing and fan-out. Mentioned actors are always added
|
|
307
|
+
// to `cc` so the post is addressed to them on the receiving server.
|
|
308
|
+
if (visibility !== "direct" || mentionedActorApIds.length > 0) {
|
|
309
|
+
let to: string[];
|
|
310
|
+
let cc: string[];
|
|
311
|
+
let audience: string[] | undefined;
|
|
312
|
+
|
|
313
|
+
if (visibility === "direct") {
|
|
314
|
+
// Direct post with mentions: no follower/public reach, only the
|
|
315
|
+
// mentioned actors are recipients.
|
|
316
|
+
to = [];
|
|
317
|
+
cc = [];
|
|
318
|
+
} else if (community) {
|
|
319
|
+
const objectAddressing = buildCommunityObjectAddressing(
|
|
320
|
+
visibility,
|
|
321
|
+
community,
|
|
322
|
+
);
|
|
323
|
+
to = objectAddressing.to;
|
|
324
|
+
cc = objectAddressing.cc;
|
|
325
|
+
audience = objectAddressing.audience;
|
|
326
|
+
} else {
|
|
327
|
+
const followersUrl = `${actor.ap_id}/followers`;
|
|
328
|
+
({ to, cc } = buildAddressing(visibility, followersUrl));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Add every mentioned actor IRI — plus an auto-addressed reply parent — to
|
|
332
|
+
// cc (de-duplicated).
|
|
333
|
+
cc = mergeCc(cc, replyRecipients);
|
|
334
|
+
|
|
335
|
+
const tag = replyTags.length > 0 ? replyTags : undefined;
|
|
336
|
+
|
|
337
|
+
const createActivity = {
|
|
338
|
+
"@context": OBJECT_CONTEXT,
|
|
339
|
+
id: activityApId(baseUrl, generateId()),
|
|
340
|
+
type: "Create",
|
|
341
|
+
actor: actor.ap_id,
|
|
342
|
+
published: now,
|
|
343
|
+
to,
|
|
344
|
+
cc,
|
|
345
|
+
...(audience ? { audience } : {}),
|
|
346
|
+
...(tag ? { tag } : {}),
|
|
347
|
+
object: {
|
|
348
|
+
"@context": OBJECT_CONTEXT,
|
|
349
|
+
id: apId,
|
|
350
|
+
type: "Note",
|
|
351
|
+
attributedTo: actor.ap_id,
|
|
352
|
+
content,
|
|
353
|
+
summary: summary || null,
|
|
354
|
+
// A non-empty summary is a content warning; Mastodon-compatible peers
|
|
355
|
+
// gate rendering on BOTH `summary` (the CW text) and `sensitive`. The
|
|
356
|
+
// served object doc (routes/activitypub/outbox.ts) already sets this, so
|
|
357
|
+
// the delivered Create must match or the CW federates inconsistently.
|
|
358
|
+
...(summary ? { sensitive: true } : {}),
|
|
359
|
+
// Media is stored as an app-relative /media path; absolutize for the
|
|
360
|
+
// federated copy so remote servers can fetch the image.
|
|
361
|
+
attachment: toApAttachments(body.attachments || [], baseUrl),
|
|
362
|
+
inReplyTo: body.in_reply_to || null,
|
|
363
|
+
published: now,
|
|
364
|
+
to,
|
|
365
|
+
cc,
|
|
366
|
+
...(audience ? { audience } : {}),
|
|
367
|
+
...(tag ? { tag } : {}),
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
if (visibility === "direct") {
|
|
372
|
+
// No follower/community fanout for a direct post — persist only, then
|
|
373
|
+
// direct-deliver to the remote mentioned actors below.
|
|
374
|
+
await persistActivity(db, createActivity, apId);
|
|
375
|
+
} else if (community) {
|
|
376
|
+
await persistAndFanoutToCommunity(
|
|
377
|
+
db,
|
|
378
|
+
c.env,
|
|
379
|
+
createActivity,
|
|
380
|
+
apId,
|
|
381
|
+
community.apId,
|
|
382
|
+
);
|
|
383
|
+
} else {
|
|
384
|
+
await persistAndFanout(db, c.env, createActivity, apId);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Deliver the Create directly to each remote mentioned actor's inbox — plus
|
|
388
|
+
// the remote parent author of a reply (auto-addressed above). Community/
|
|
389
|
+
// follower fanout does not include arbitrary remote actors, so this is the
|
|
390
|
+
// only path that reaches a remote @user@domain mention or reply target.
|
|
391
|
+
for (const recipient of replyRemoteRecipients) {
|
|
392
|
+
try {
|
|
393
|
+
await enqueueDeliveryToActor(c.env, createActivity.id, recipient);
|
|
394
|
+
} catch (err) {
|
|
395
|
+
log.error("Failed to enqueue mention delivery", {
|
|
396
|
+
event: "posts.mention.delivery_enqueue_failed",
|
|
397
|
+
activityId: createActivity.id,
|
|
398
|
+
recipient,
|
|
399
|
+
error: err,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const createdPost = {
|
|
406
|
+
ap_id: apId,
|
|
407
|
+
type: "Note",
|
|
408
|
+
author: {
|
|
409
|
+
ap_id: actor.ap_id,
|
|
410
|
+
username: formatUsername(actor.ap_id),
|
|
411
|
+
preferred_username: actor.preferred_username,
|
|
412
|
+
name: actor.name,
|
|
413
|
+
icon_url: actor.icon_url,
|
|
414
|
+
},
|
|
415
|
+
content,
|
|
416
|
+
summary: summary || null,
|
|
417
|
+
attachments: body.attachments || [],
|
|
418
|
+
visibility,
|
|
419
|
+
published: now,
|
|
420
|
+
like_count: 0,
|
|
421
|
+
reply_count: 0,
|
|
422
|
+
announce_count: 0,
|
|
423
|
+
liked: false,
|
|
424
|
+
bookmarked: false,
|
|
425
|
+
...(mentionFailures.length > 0
|
|
426
|
+
? {
|
|
427
|
+
mention_processing: {
|
|
428
|
+
failed_count: mentionFailures.length,
|
|
429
|
+
failures: mentionFailures,
|
|
430
|
+
},
|
|
431
|
+
}
|
|
432
|
+
: {}),
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
return c.json({
|
|
436
|
+
...createdPost,
|
|
437
|
+
post: createdPost,
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
// Get single post
|
|
442
|
+
posts.get("/:id", async (c) => {
|
|
443
|
+
const currentActor = c.get("actor");
|
|
444
|
+
const postId = c.req.param("id");
|
|
445
|
+
const baseUrl = c.env.APP_URL;
|
|
446
|
+
const db = c.get("db");
|
|
447
|
+
|
|
448
|
+
const post = await db.query.objects.findFirst({
|
|
449
|
+
where: postWhereByIdOrApId(baseUrl, postId),
|
|
450
|
+
with: AUTHOR_WITH,
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
if (!post) return c.json({ error: "Post not found" }, 404);
|
|
454
|
+
|
|
455
|
+
// Resolve author and interaction flags in parallel
|
|
456
|
+
const [author, { likedIds, bookmarkedIds }] = await Promise.all([
|
|
457
|
+
resolveAuthorWithCache(post.author, post.attributedTo, db),
|
|
458
|
+
loadInteractionFlags(db, currentActor?.ap_id, [post.apId]),
|
|
459
|
+
]);
|
|
460
|
+
const liked = likedIds.has(post.apId);
|
|
461
|
+
const bookmarked = bookmarkedIds.has(post.apId);
|
|
462
|
+
|
|
463
|
+
// Single canonical read-gate: community membership + per-post visibility
|
|
464
|
+
// (public / unlisted / followers / direct, honoring an explicit to/cc mention)
|
|
465
|
+
// + the Story reach rule (a Story is stored "public" / empty-audience but is
|
|
466
|
+
// followers-/member-only and is revoked at endTime — without the Story branch
|
|
467
|
+
// its full caption/poll/media payload leaked here to any caller with the apId).
|
|
468
|
+
// `post` (a full objects row) carries type + endTime so the Story branch fires.
|
|
469
|
+
if (!(await canViewerReadObjectFull(db, post, currentActor?.ap_id))) {
|
|
470
|
+
return c.json({ error: "Post not found" }, 404);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const postRow: PostDetailRow = toPostRow(post, author, { liked, bookmarked });
|
|
474
|
+
|
|
475
|
+
return c.json({ post: formatPost(postRow, currentActor?.ap_id) });
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// Get post replies
|
|
479
|
+
posts.get("/:id/replies", async (c) => {
|
|
480
|
+
const currentActor = c.get("actor");
|
|
481
|
+
const postId = c.req.param("id");
|
|
482
|
+
const baseUrl = c.env.APP_URL;
|
|
483
|
+
const limit = parseLimit(c.req.query("limit"), 20, MAX_POSTS_PAGE_LIMIT);
|
|
484
|
+
const before = c.req.query("before");
|
|
485
|
+
const db = c.get("db");
|
|
486
|
+
|
|
487
|
+
const parentPost = await db
|
|
488
|
+
.select({
|
|
489
|
+
apId: objects.apId,
|
|
490
|
+
visibility: objects.visibility,
|
|
491
|
+
attributedTo: objects.attributedTo,
|
|
492
|
+
toJson: objects.toJson,
|
|
493
|
+
ccJson: objects.ccJson,
|
|
494
|
+
audienceJson: objects.audienceJson,
|
|
495
|
+
communityApId: objects.communityApId,
|
|
496
|
+
type: objects.type,
|
|
497
|
+
endTime: objects.endTime,
|
|
498
|
+
})
|
|
499
|
+
.from(objects)
|
|
500
|
+
.where(postWhereByIdOrApId(baseUrl, postId)!)
|
|
501
|
+
.get();
|
|
502
|
+
|
|
503
|
+
if (!parentPost) return c.json({ error: "Post not found" }, 404);
|
|
504
|
+
|
|
505
|
+
// Gate the parent with the FULL read-gate (community membership AND the
|
|
506
|
+
// followers/direct per-post visibility), mirroring GET /:id and GET
|
|
507
|
+
// /ap/objects/:id. Gating only the community dimension let anyone enumerate a
|
|
508
|
+
// followers-only / direct parent's public replies and confirm the restricted
|
|
509
|
+
// parent exists — an existence/metadata oracle the other surfaces deny.
|
|
510
|
+
if (!(await canViewerReadObjectFull(db, parentPost, currentActor?.ap_id))) {
|
|
511
|
+
return c.json({ error: "Post not found" }, 404);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Composite (published, apId) cursor so replies sharing a published
|
|
515
|
+
// millisecond aren't skipped at a page boundary (see lib/feed-cursor.ts).
|
|
516
|
+
const cursorPredicate = feedCursorWhere(
|
|
517
|
+
objects.published,
|
|
518
|
+
objects.apId,
|
|
519
|
+
before,
|
|
520
|
+
);
|
|
521
|
+
|
|
522
|
+
// Fetch limit+1 to compute has_more, then SLICE before the per-reply
|
|
523
|
+
// visibility filter. Advancing the cursor by the last SCANNED row (not the
|
|
524
|
+
// last readable one) means unreadable replies are skipped without ever
|
|
525
|
+
// skipping a readable one — so load-more reaches every readable reply, and
|
|
526
|
+
// the gate dropping rows can only make a page short, never lose a reply.
|
|
527
|
+
const scanned = await db.query.objects.findMany({
|
|
528
|
+
where: cursorPredicate
|
|
529
|
+
? and(eq(objects.inReplyTo, parentPost.apId), cursorPredicate)
|
|
530
|
+
: eq(objects.inReplyTo, parentPost.apId),
|
|
531
|
+
with: AUTHOR_WITH,
|
|
532
|
+
orderBy: [desc(objects.published), desc(objects.apId)],
|
|
533
|
+
limit: limit + 1,
|
|
534
|
+
});
|
|
535
|
+
const hasMore = scanned.length > limit;
|
|
536
|
+
const page = hasMore ? scanned.slice(0, limit) : scanned;
|
|
537
|
+
const lastScanned = page[page.length - 1];
|
|
538
|
+
const nextCursor =
|
|
539
|
+
hasMore && lastScanned
|
|
540
|
+
? encodeFeedCursor(lastScanned.published, lastScanned.apId)
|
|
541
|
+
: null;
|
|
542
|
+
|
|
543
|
+
// Apply the SAME visibility gate as GET /:id, per-reply: a follower-only or
|
|
544
|
+
// direct reply must not leak to a viewer who is not its author / an accepted
|
|
545
|
+
// follower / an addressed recipient. Resolve the accepted-follow edges the
|
|
546
|
+
// viewer needs in a single batched query to avoid an N+1.
|
|
547
|
+
const replies = await filterVisibleReplies(db, currentActor, page);
|
|
548
|
+
|
|
549
|
+
// Batch load cached authors and interaction flags in parallel
|
|
550
|
+
const replyApIds = replies.map((r) => r.apId);
|
|
551
|
+
const [cachedAuthorMap, { likedIds }] = await Promise.all([
|
|
552
|
+
loadCachedAuthorMap(db, replies as PostWithAuthor[]),
|
|
553
|
+
loadInteractionFlags(db, currentActor?.ap_id, replyApIds),
|
|
554
|
+
]);
|
|
555
|
+
|
|
556
|
+
const result = replies.map((reply) => {
|
|
557
|
+
const author = resolveAuthor(
|
|
558
|
+
reply.author,
|
|
559
|
+
reply.attributedTo,
|
|
560
|
+
cachedAuthorMap,
|
|
561
|
+
);
|
|
562
|
+
const postRow = toPostRow(reply as PostWithAuthor, author, {
|
|
563
|
+
liked: likedIds.has(reply.apId),
|
|
564
|
+
});
|
|
565
|
+
return formatPost(postRow, currentActor?.ap_id);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
return c.json({
|
|
569
|
+
replies: result,
|
|
570
|
+
has_more: hasMore,
|
|
571
|
+
next_cursor: nextCursor,
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
// Edit post
|
|
576
|
+
posts.patch("/:id", async (c) => {
|
|
577
|
+
const actor = requireActor(c);
|
|
578
|
+
if (actor instanceof Response) return actor;
|
|
579
|
+
|
|
580
|
+
const postId = c.req.param("id");
|
|
581
|
+
const baseUrl = c.env.APP_URL;
|
|
582
|
+
|
|
583
|
+
const editValidation = await validateEditBody(c);
|
|
584
|
+
if (!editValidation.ok) {
|
|
585
|
+
return c.json(
|
|
586
|
+
{
|
|
587
|
+
error: editValidation.error,
|
|
588
|
+
...(editValidation.code ? { code: editValidation.code } : {}),
|
|
589
|
+
},
|
|
590
|
+
400,
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
const { body } = editValidation;
|
|
594
|
+
|
|
595
|
+
const db = c.get("db");
|
|
596
|
+
|
|
597
|
+
const post = await db.query.objects.findFirst({
|
|
598
|
+
where: postWhereByIdOrApId(baseUrl, postId),
|
|
599
|
+
});
|
|
600
|
+
if (!post) return c.json({ error: "Post not found" }, 404);
|
|
601
|
+
if (post.attributedTo !== actor.ap_id) {
|
|
602
|
+
return c.json({ error: "Forbidden" }, 403);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Validate content
|
|
606
|
+
const contentCheck = validateContentEdit(body.content);
|
|
607
|
+
if (!contentCheck.ok) return c.json({ error: contentCheck.error }, 400);
|
|
608
|
+
const trimmedContent = contentCheck.ok ? contentCheck.trimmed : undefined;
|
|
609
|
+
|
|
610
|
+
// Validate summary
|
|
611
|
+
const summaryCheck = validateSummaryEdit(body.summary);
|
|
612
|
+
if (!summaryCheck.ok) return c.json({ error: summaryCheck.error }, 400);
|
|
613
|
+
const trimmedSummary = summaryCheck.ok ? summaryCheck.trimmed : undefined;
|
|
614
|
+
|
|
615
|
+
const nextContent =
|
|
616
|
+
body.content !== undefined ? (trimmedContent as string) : post.content;
|
|
617
|
+
const nextSummary =
|
|
618
|
+
body.summary !== undefined ? trimmedSummary || null : post.summary;
|
|
619
|
+
const now = new Date().toISOString();
|
|
620
|
+
|
|
621
|
+
const updateData: {
|
|
622
|
+
content?: string;
|
|
623
|
+
summary?: string | null;
|
|
624
|
+
tagsJson?: string;
|
|
625
|
+
updated: string;
|
|
626
|
+
} = { updated: now };
|
|
627
|
+
|
|
628
|
+
if (body.content !== undefined) updateData.content = trimmedContent;
|
|
629
|
+
if (body.summary !== undefined) updateData.summary = trimmedSummary || null;
|
|
630
|
+
|
|
631
|
+
if (Object.keys(updateData).length === 1) {
|
|
632
|
+
return c.json({ error: "No changes provided" }, 400);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Re-derive the post's AS2 tags (Hashtag + Mention) from the next content so
|
|
636
|
+
// the served object doc and the Update(Note) below carry the same tags a
|
|
637
|
+
// fresh post would — otherwise editing a #hashtag / @mention post would strip
|
|
638
|
+
// those tags from remote copies. Side-effect-free (no re-notification);
|
|
639
|
+
// persist tagsJson only when the content actually changed.
|
|
640
|
+
const nextTags = await deriveContentTags(
|
|
641
|
+
db,
|
|
642
|
+
nextContent,
|
|
643
|
+
baseUrl,
|
|
644
|
+
actor.ap_id,
|
|
645
|
+
);
|
|
646
|
+
if (body.content !== undefined) {
|
|
647
|
+
updateData.tagsJson = JSON.stringify(nextTags);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
await db.update(objects).set(updateData).where(eq(objects.apId, post.apId));
|
|
651
|
+
|
|
652
|
+
// Mirror the stored post's addressing onto the Update so its audience matches
|
|
653
|
+
// the ORIGINAL post (like the Delete path). Without this the Update carried no
|
|
654
|
+
// to/cc/audience and — combined with the community branch below — fanned out
|
|
655
|
+
// to the wrong graph.
|
|
656
|
+
const updateTo = safeJsonParse<string[]>(post.toJson, []);
|
|
657
|
+
const updateCc = safeJsonParse<string[]>(post.ccJson, []);
|
|
658
|
+
const updateAudience = safeJsonParse<string[]>(post.audienceJson, []);
|
|
659
|
+
|
|
660
|
+
const updateActivity = {
|
|
661
|
+
"@context": "https://www.w3.org/ns/activitystreams",
|
|
662
|
+
id: activityApId(baseUrl, generateId()),
|
|
663
|
+
type: "Update",
|
|
664
|
+
actor: actor.ap_id,
|
|
665
|
+
to: updateTo,
|
|
666
|
+
cc: updateCc,
|
|
667
|
+
...(updateAudience.length > 0 ? { audience: updateAudience } : {}),
|
|
668
|
+
object: {
|
|
669
|
+
id: post.apId,
|
|
670
|
+
type: "Note",
|
|
671
|
+
attributedTo: actor.ap_id,
|
|
672
|
+
content: nextContent,
|
|
673
|
+
summary: nextSummary,
|
|
674
|
+
// Keep the CW's `sensitive` flag in sync on edit. Unlike the create path
|
|
675
|
+
// this is always a boolean (not omitted) so that REMOVING a content
|
|
676
|
+
// warning pushes `sensitive: false` and clears it on followers who act on
|
|
677
|
+
// the Update without re-fetching the object.
|
|
678
|
+
sensitive: Boolean(nextSummary),
|
|
679
|
+
// Carry the re-derived tags so a receiver updating the Note keeps its
|
|
680
|
+
// Hashtag/Mention tags instead of dropping them on edit.
|
|
681
|
+
...(nextTags.length > 0 ? { tag: nextTags } : {}),
|
|
682
|
+
to: updateTo,
|
|
683
|
+
cc: updateCc,
|
|
684
|
+
...(updateAudience.length > 0 ? { audience: updateAudience } : {}),
|
|
685
|
+
updated: now,
|
|
686
|
+
},
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
// A community-scoped post's Update must reach the COMMUNITY (the members who
|
|
690
|
+
// got the Create), NOT the author's personal followers — who never received
|
|
691
|
+
// the Create. Mirror the create path's community-vs-personal fan-out branch.
|
|
692
|
+
if (post.communityApId) {
|
|
693
|
+
await persistAndFanoutToCommunity(
|
|
694
|
+
db,
|
|
695
|
+
c.env,
|
|
696
|
+
updateActivity,
|
|
697
|
+
post.apId,
|
|
698
|
+
post.communityApId,
|
|
699
|
+
);
|
|
700
|
+
} else {
|
|
701
|
+
await persistAndFanout(db, c.env, updateActivity, post.apId);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
return c.json({
|
|
705
|
+
success: true,
|
|
706
|
+
post: {
|
|
707
|
+
ap_id: post.apId,
|
|
708
|
+
content: nextContent,
|
|
709
|
+
summary: nextSummary,
|
|
710
|
+
updated_at: now,
|
|
711
|
+
},
|
|
712
|
+
});
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
// Delete post
|
|
716
|
+
posts.delete("/:id", async (c) => {
|
|
717
|
+
const actor = requireActor(c);
|
|
718
|
+
if (actor instanceof Response) return actor;
|
|
719
|
+
|
|
720
|
+
const postId = c.req.param("id");
|
|
721
|
+
const baseUrl = c.env.APP_URL;
|
|
722
|
+
const db = c.get("db");
|
|
723
|
+
|
|
724
|
+
const post = await db.query.objects.findFirst({
|
|
725
|
+
where: postWhereByIdOrApId(baseUrl, postId),
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
if (!post) return c.json({ error: "Post not found" }, 404);
|
|
729
|
+
if (post.attributedTo !== actor.ap_id) {
|
|
730
|
+
return c.json({ error: "Forbidden" }, 403);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// D1 doesn't support interactive transactions; use sequential operations.
|
|
734
|
+
// FK ON DELETE CASCADE is not reliably enforced (PRAGMA foreign_keys is not
|
|
735
|
+
// guaranteed on every runtime/connection, and D1 ignores it), so delete the
|
|
736
|
+
// object's child rows explicitly before the object row to avoid orphans.
|
|
737
|
+
const mediaKeys = await deleteObjectCascade(db, post.apId, c.env.MEDIA);
|
|
738
|
+
|
|
739
|
+
// Co-commit the object delete + author postCount-- + parent replyCount in ONE
|
|
740
|
+
// batch (mirrors the federated handleDelete): a crash between separate
|
|
741
|
+
// autocommits would otherwise leave the row gone with an un-decremented
|
|
742
|
+
// postCount (permanent over-count, no recovery). postCount-- is guarded by
|
|
743
|
+
// gt>0 (underflow) + EXISTS(object) so it fires exactly once; the parent
|
|
744
|
+
// replyCount is RECOMPUTED from COUNT(*) after the delete — exact + idempotent.
|
|
745
|
+
const objectExists = sql`EXISTS (SELECT 1 FROM ${objects} WHERE ${objects.apId} = ${post.apId})`;
|
|
746
|
+
const decPostCount = db
|
|
747
|
+
.update(actors)
|
|
748
|
+
.set({ postCount: sql`${actors.postCount} - 1` })
|
|
749
|
+
.where(
|
|
750
|
+
and(eq(actors.apId, actor.ap_id), gt(actors.postCount, 0), objectExists),
|
|
751
|
+
);
|
|
752
|
+
const deleteObject = db.delete(objects).where(eq(objects.apId, post.apId));
|
|
753
|
+
// DM notes (`visibility="direct"`, created by createDmNote) are NOT counted in
|
|
754
|
+
// postCount on send, so deleting one here must NOT decrement it — otherwise a
|
|
755
|
+
// DM deleted through this generic endpoint (the dedicated DELETE
|
|
756
|
+
// /dm/messages/:id correctly skips the count) under-counts the author's
|
|
757
|
+
// postCount (floored at 0). Keep create/delete symmetric: only regular posts
|
|
758
|
+
// (which incremented) decrement.
|
|
759
|
+
const ops: unknown[] = [];
|
|
760
|
+
if (post.visibility !== "direct") ops.push(decPostCount);
|
|
761
|
+
ops.push(deleteObject);
|
|
762
|
+
if (post.inReplyTo) {
|
|
763
|
+
const parentId = post.inReplyTo;
|
|
764
|
+
ops.push(
|
|
765
|
+
db
|
|
766
|
+
.update(objects)
|
|
767
|
+
.set({
|
|
768
|
+
replyCount: sql`(SELECT COUNT(*) FROM ${objects} WHERE ${objects.inReplyTo} = ${parentId})`,
|
|
769
|
+
})
|
|
770
|
+
.where(eq(objects.apId, parentId)),
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
await (db as unknown as Batchable).batch(ops);
|
|
774
|
+
|
|
775
|
+
// Irreversible R2 purge LAST — only now that the objects row is gone. A
|
|
776
|
+
// failure here degrades to a leaked blob, not a live post with a deleted blob.
|
|
777
|
+
await purgeMediaBlobs(c.env.MEDIA, mediaKeys);
|
|
778
|
+
|
|
779
|
+
// The Delete must reach everyone the original object reached, not just the
|
|
780
|
+
// author's current followers: mirror the object's stored to/cc, and emit a
|
|
781
|
+
// Tombstone object (per AP) instead of a bare IRI so receivers can render
|
|
782
|
+
// the deletion correctly.
|
|
783
|
+
const originalTo = safeJsonParse<string[]>(post.toJson, []);
|
|
784
|
+
const originalCc = safeJsonParse<string[]>(post.ccJson, []);
|
|
785
|
+
|
|
786
|
+
// For a reply, the parent author's instance must also be told (it counts the
|
|
787
|
+
// reply); for a direct post, the DM recipients are exactly the addressed
|
|
788
|
+
// actors. Collect explicit (actor-IRI) recipients for direct per-actor
|
|
789
|
+
// delivery — anything that is not a Public/collection IRI is treated as an
|
|
790
|
+
// actor inbox target if it is a safe remote URL.
|
|
791
|
+
const explicitRecipients = new Set<string>();
|
|
792
|
+
for (const iri of [...originalTo, ...originalCc]) {
|
|
793
|
+
if (
|
|
794
|
+
iri &&
|
|
795
|
+
iri !== PUBLIC_COLLECTION &&
|
|
796
|
+
!iri.endsWith("/followers") &&
|
|
797
|
+
isLocal(iri, baseUrl) === false &&
|
|
798
|
+
isSafeRemoteUrl(iri)
|
|
799
|
+
) {
|
|
800
|
+
explicitRecipients.add(iri);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// Parent author's instance (replies): ensure the reply deletion propagates
|
|
805
|
+
// to the thread root's host even if it was not in the object's to/cc.
|
|
806
|
+
if (post.inReplyTo) {
|
|
807
|
+
const parent = await db
|
|
808
|
+
.select({ attributedTo: objects.attributedTo })
|
|
809
|
+
.from(objects)
|
|
810
|
+
.where(eq(objects.apId, post.inReplyTo))
|
|
811
|
+
.get();
|
|
812
|
+
if (
|
|
813
|
+
parent?.attributedTo &&
|
|
814
|
+
!isLocal(parent.attributedTo, baseUrl) &&
|
|
815
|
+
isSafeRemoteUrl(parent.attributedTo)
|
|
816
|
+
) {
|
|
817
|
+
explicitRecipients.add(parent.attributedTo);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const deleteActivity = {
|
|
822
|
+
"@context": "https://www.w3.org/ns/activitystreams",
|
|
823
|
+
id: activityApId(baseUrl, generateId()),
|
|
824
|
+
type: "Delete",
|
|
825
|
+
actor: actor.ap_id,
|
|
826
|
+
to: originalTo,
|
|
827
|
+
cc: originalCc,
|
|
828
|
+
object: {
|
|
829
|
+
id: post.apId,
|
|
830
|
+
type: "Tombstone",
|
|
831
|
+
},
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
// Fan out matching the original create reach (community → the community, not
|
|
835
|
+
// the author's personal followers) and additionally deliver directly to each
|
|
836
|
+
// explicitly-addressed remote actor.
|
|
837
|
+
if (post.communityApId) {
|
|
838
|
+
await persistAndFanoutToCommunity(
|
|
839
|
+
db,
|
|
840
|
+
c.env,
|
|
841
|
+
deleteActivity,
|
|
842
|
+
post.apId,
|
|
843
|
+
post.communityApId,
|
|
844
|
+
);
|
|
845
|
+
} else {
|
|
846
|
+
await persistAndFanout(db, c.env, deleteActivity, post.apId);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
for (const recipient of explicitRecipients) {
|
|
850
|
+
try {
|
|
851
|
+
await enqueueDeliveryToActor(c.env, deleteActivity.id, recipient);
|
|
852
|
+
} catch (err) {
|
|
853
|
+
log.error("Failed to enqueue delete delivery", {
|
|
854
|
+
event: "posts.delete.delivery_enqueue_failed",
|
|
855
|
+
activityId: deleteActivity.id,
|
|
856
|
+
recipient,
|
|
857
|
+
error: err,
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
return c.json({ success: true });
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
export default posts;
|