@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,847 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post route helper functions
|
|
3
|
+
*
|
|
4
|
+
* Extracted from base.ts to reduce file size. Contains:
|
|
5
|
+
* - validateCreatePostBody: full validation for POST / body
|
|
6
|
+
* - checkCommunityPostPermission: community policy enforcement
|
|
7
|
+
* - processMentions: mention extraction, resolution, and notification
|
|
8
|
+
* - validateEditFields: content/summary validation for PATCH
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
activities,
|
|
13
|
+
actorCache,
|
|
14
|
+
actors,
|
|
15
|
+
communities,
|
|
16
|
+
communityMembers,
|
|
17
|
+
inbox as inboxTable,
|
|
18
|
+
objects,
|
|
19
|
+
} from "../../../db/index.ts";
|
|
20
|
+
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
|
21
|
+
import type { Database } from "../../../db/index.ts";
|
|
22
|
+
import type { Env } from "../../types.ts";
|
|
23
|
+
import {
|
|
24
|
+
activityApId,
|
|
25
|
+
formatUsername,
|
|
26
|
+
generateId,
|
|
27
|
+
isLocal,
|
|
28
|
+
} from "../../federation-helpers.ts";
|
|
29
|
+
import {
|
|
30
|
+
extractHashtags,
|
|
31
|
+
extractMentions,
|
|
32
|
+
MAX_ATTACHMENTS,
|
|
33
|
+
MAX_ATTACHMENTS_JSON_LENGTH,
|
|
34
|
+
MAX_POST_CONTENT_LENGTH,
|
|
35
|
+
MAX_POST_SUMMARY_LENGTH,
|
|
36
|
+
} from "./transformers.ts";
|
|
37
|
+
import {
|
|
38
|
+
buildCommunityObjectAddressing,
|
|
39
|
+
type CreatePostBody,
|
|
40
|
+
isRecord,
|
|
41
|
+
type MentionFailure,
|
|
42
|
+
type PostTag,
|
|
43
|
+
parseJsonObject,
|
|
44
|
+
type PostAttachment,
|
|
45
|
+
type ProcessMentionsResult,
|
|
46
|
+
validateOptionalString,
|
|
47
|
+
} from "./queries.ts";
|
|
48
|
+
import { logger } from "../../lib/logger.ts";
|
|
49
|
+
import { chunkForInClause } from "../../lib/chunk.ts";
|
|
50
|
+
|
|
51
|
+
const log = logger.child({ component: "posts.helpers" });
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Validation helpers
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
export type CreatePostValidationResult =
|
|
58
|
+
| {
|
|
59
|
+
ok: true;
|
|
60
|
+
body: CreatePostBody;
|
|
61
|
+
content: string;
|
|
62
|
+
summary: string | undefined;
|
|
63
|
+
}
|
|
64
|
+
| { ok: false; error: string; code?: string };
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Parse and validate the raw request body for creating a post.
|
|
68
|
+
* Returns a discriminated union: ok with parsed body, or error details.
|
|
69
|
+
*/
|
|
70
|
+
export async function validateCreatePostBody(c: {
|
|
71
|
+
req: { json: () => Promise<unknown> };
|
|
72
|
+
}): Promise<CreatePostValidationResult> {
|
|
73
|
+
const rawBody = await parseJsonObject(c);
|
|
74
|
+
if (!rawBody) {
|
|
75
|
+
return { ok: false, error: "Invalid request body", code: "BAD_REQUEST" };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (typeof rawBody.content !== "string") {
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
error: "content must be a string",
|
|
82
|
+
code: "BAD_REQUEST",
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const field of [
|
|
87
|
+
"summary",
|
|
88
|
+
"visibility",
|
|
89
|
+
"in_reply_to",
|
|
90
|
+
"community_ap_id",
|
|
91
|
+
] as const) {
|
|
92
|
+
const err = validateOptionalString(rawBody, field);
|
|
93
|
+
if (err) return { ok: false, error: err, code: "BAD_REQUEST" };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (
|
|
97
|
+
rawBody.attachments !== undefined &&
|
|
98
|
+
!Array.isArray(rawBody.attachments)
|
|
99
|
+
) {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
error: "attachments must be an array",
|
|
103
|
+
code: "BAD_REQUEST",
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (
|
|
107
|
+
Array.isArray(rawBody.attachments) &&
|
|
108
|
+
rawBody.attachments.some((a) => !isRecord(a))
|
|
109
|
+
) {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
error: "attachments must be objects",
|
|
113
|
+
code: "BAD_REQUEST",
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
// Bound the attachments payload (count + serialized size). content/summary are
|
|
117
|
+
// length-capped above; without this an attachments blob could carry up to the
|
|
118
|
+
// global 1 MiB body cap into the stored row and every federated delivery.
|
|
119
|
+
if (Array.isArray(rawBody.attachments)) {
|
|
120
|
+
if (rawBody.attachments.length > MAX_ATTACHMENTS) {
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
error: `Too many attachments (max ${MAX_ATTACHMENTS})`,
|
|
124
|
+
code: "BAD_REQUEST",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (
|
|
128
|
+
JSON.stringify(rawBody.attachments).length > MAX_ATTACHMENTS_JSON_LENGTH
|
|
129
|
+
) {
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
error: "attachments payload too large",
|
|
133
|
+
code: "BAD_REQUEST",
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const body: CreatePostBody = {
|
|
139
|
+
content: rawBody.content,
|
|
140
|
+
summary: typeof rawBody.summary === "string" ? rawBody.summary : undefined,
|
|
141
|
+
attachments: Array.isArray(rawBody.attachments)
|
|
142
|
+
? (rawBody.attachments as PostAttachment[])
|
|
143
|
+
: undefined,
|
|
144
|
+
in_reply_to:
|
|
145
|
+
typeof rawBody.in_reply_to === "string" ? rawBody.in_reply_to : undefined,
|
|
146
|
+
visibility:
|
|
147
|
+
typeof rawBody.visibility === "string" ? rawBody.visibility : undefined,
|
|
148
|
+
community_ap_id:
|
|
149
|
+
typeof rawBody.community_ap_id === "string"
|
|
150
|
+
? rawBody.community_ap_id
|
|
151
|
+
: undefined,
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const content = body.content.trim();
|
|
155
|
+
const summary = body.summary?.trim();
|
|
156
|
+
|
|
157
|
+
if (!content) {
|
|
158
|
+
return { ok: false, error: "Content required" };
|
|
159
|
+
}
|
|
160
|
+
if (content.length > MAX_POST_CONTENT_LENGTH) {
|
|
161
|
+
return {
|
|
162
|
+
ok: false,
|
|
163
|
+
error: `Content too long (max ${MAX_POST_CONTENT_LENGTH} chars)`,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
if (summary && summary.length > MAX_POST_SUMMARY_LENGTH) {
|
|
167
|
+
return {
|
|
168
|
+
ok: false,
|
|
169
|
+
error: `Summary too long (max ${MAX_POST_SUMMARY_LENGTH} chars)`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { ok: true, body, content, summary };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// Community policy check
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
export type CommunityTarget = {
|
|
181
|
+
apId: string;
|
|
182
|
+
followersUrl: string;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
export type CommunityCheckResult =
|
|
186
|
+
| {
|
|
187
|
+
allowed: true;
|
|
188
|
+
communityId: string | null;
|
|
189
|
+
community: CommunityTarget | null;
|
|
190
|
+
}
|
|
191
|
+
| { allowed: false; error: string; status: 403 | 404 };
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Check whether the actor is allowed to post in the given community.
|
|
195
|
+
* If `communityApId` is undefined, returns { allowed: true, communityId: null }.
|
|
196
|
+
*/
|
|
197
|
+
export async function checkCommunityPostPermission(
|
|
198
|
+
db: Database,
|
|
199
|
+
actorApId: string,
|
|
200
|
+
communityApId: string | undefined,
|
|
201
|
+
): Promise<CommunityCheckResult> {
|
|
202
|
+
if (!communityApId) {
|
|
203
|
+
return { allowed: true, communityId: null, community: null };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const community = await db
|
|
207
|
+
.select({
|
|
208
|
+
apId: communities.apId,
|
|
209
|
+
followersUrl: communities.followersUrl,
|
|
210
|
+
postPolicy: communities.postPolicy,
|
|
211
|
+
visibility: communities.visibility,
|
|
212
|
+
})
|
|
213
|
+
.from(communities)
|
|
214
|
+
.where(
|
|
215
|
+
and(
|
|
216
|
+
or(
|
|
217
|
+
eq(communities.apId, communityApId),
|
|
218
|
+
eq(communities.preferredUsername, communityApId),
|
|
219
|
+
),
|
|
220
|
+
isNull(communities.deletedAt),
|
|
221
|
+
),
|
|
222
|
+
)
|
|
223
|
+
.get();
|
|
224
|
+
|
|
225
|
+
if (!community) {
|
|
226
|
+
return { allowed: false, error: "Community not found", status: 404 };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const membership = await db
|
|
230
|
+
.select({
|
|
231
|
+
role: communityMembers.role,
|
|
232
|
+
})
|
|
233
|
+
.from(communityMembers)
|
|
234
|
+
.where(
|
|
235
|
+
and(
|
|
236
|
+
eq(communityMembers.communityApId, community.apId),
|
|
237
|
+
eq(communityMembers.actorApId, actorApId),
|
|
238
|
+
),
|
|
239
|
+
)
|
|
240
|
+
.get();
|
|
241
|
+
|
|
242
|
+
const policy = community.postPolicy || "members";
|
|
243
|
+
const role = membership?.role as "owner" | "moderator" | "member" | undefined;
|
|
244
|
+
const isManager = role === "owner" || role === "moderator";
|
|
245
|
+
|
|
246
|
+
// A non-public community requires membership to WRITE regardless of
|
|
247
|
+
// post_policy. Read access is membership-gated (canViewerReadObject /
|
|
248
|
+
// checkReadAccess), so without this a private community with
|
|
249
|
+
// post_policy="anyone" would let a non-member who CANNOT read it inject posts.
|
|
250
|
+
if ((community.visibility ?? "public") !== "public" && !membership) {
|
|
251
|
+
return { allowed: false, error: "Not a community member", status: 403 };
|
|
252
|
+
}
|
|
253
|
+
if (policy !== "anyone" && !membership) {
|
|
254
|
+
return { allowed: false, error: "Not a community member", status: 403 };
|
|
255
|
+
}
|
|
256
|
+
if (policy === "mods" && !isManager) {
|
|
257
|
+
return { allowed: false, error: "Moderator role required", status: 403 };
|
|
258
|
+
}
|
|
259
|
+
if (policy === "owners" && role !== "owner") {
|
|
260
|
+
return { allowed: false, error: "Owner role required", status: 403 };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
allowed: true,
|
|
265
|
+
communityId: community.apId,
|
|
266
|
+
community: {
|
|
267
|
+
apId: community.apId,
|
|
268
|
+
followersUrl: community.followersUrl,
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// Reply handling
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
export const REPLY_TARGET_NOT_FOUND = "REPLY_TARGET_NOT_FOUND";
|
|
278
|
+
|
|
279
|
+
// `.batch` lives only on the concrete D1/libsql subclasses, not the Database
|
|
280
|
+
// union; reach it through a narrow structural cast (matching the other routes).
|
|
281
|
+
type Batchable = { batch: (stmts: unknown[]) => Promise<unknown> };
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Insert the post object, increment author post count, and handle reply-chain
|
|
285
|
+
* updates (parent reply count bump + notification to local parent author).
|
|
286
|
+
*
|
|
287
|
+
* Throws Error(REPLY_TARGET_NOT_FOUND) if in_reply_to references a missing post.
|
|
288
|
+
* Returns the parentAuthor apId (or null if not a reply).
|
|
289
|
+
*/
|
|
290
|
+
export async function insertPostAndHandleReply(
|
|
291
|
+
db: Database,
|
|
292
|
+
params: {
|
|
293
|
+
apId: string;
|
|
294
|
+
actorApId: string;
|
|
295
|
+
content: string;
|
|
296
|
+
summary: string | null;
|
|
297
|
+
attachments: PostAttachment[] | undefined;
|
|
298
|
+
inReplyTo: string | null;
|
|
299
|
+
visibility: string;
|
|
300
|
+
communityId: string | null;
|
|
301
|
+
community: CommunityTarget | null;
|
|
302
|
+
baseUrl: string;
|
|
303
|
+
now: string;
|
|
304
|
+
},
|
|
305
|
+
): Promise<string | null> {
|
|
306
|
+
let parentAuthor: string | null = null;
|
|
307
|
+
|
|
308
|
+
// Community-scoped posts are ADDRESSED to the community Group actor: the
|
|
309
|
+
// community (and its followers collection) goes into to/audience. A non-"[]"
|
|
310
|
+
// audienceJson is exactly what excludes the post from the public/home feed,
|
|
311
|
+
// so reach is the community — not the open public timeline.
|
|
312
|
+
const addressing = buildCommunityObjectAddressing(
|
|
313
|
+
params.visibility,
|
|
314
|
+
params.community,
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
// Look up + validate the reply parent BEFORE the write batch — we need its
|
|
318
|
+
// author both as the replyCount target and for the reply notification.
|
|
319
|
+
if (params.inReplyTo) {
|
|
320
|
+
const parentPost = await db
|
|
321
|
+
.select({ attributedTo: objects.attributedTo })
|
|
322
|
+
.from(objects)
|
|
323
|
+
.where(eq(objects.apId, params.inReplyTo))
|
|
324
|
+
.get();
|
|
325
|
+
if (!parentPost) throw new Error(REPLY_TARGET_NOT_FOUND);
|
|
326
|
+
parentAuthor = parentPost.attributedTo;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Co-commit the object insert + author postCount++ + parent replyCount recompute
|
|
330
|
+
// in ONE batch (mirrors the federated handleCreate): a crash between separate
|
|
331
|
+
// autocommits would otherwise leave the object inserted with an un-bumped
|
|
332
|
+
// postCount (permanent under-count). postCount++ is guarded NOT EXISTS(object)
|
|
333
|
+
// so a retry can't double-count; the parent replyCount is RECOMPUTED from
|
|
334
|
+
// COUNT(*) of the reply edge set — exact and idempotent.
|
|
335
|
+
const objectAbsent = sql`NOT EXISTS (SELECT 1 FROM ${objects} WHERE ${objects.apId} = ${params.apId})`;
|
|
336
|
+
const insertObject = db.insert(objects).values({
|
|
337
|
+
apId: params.apId,
|
|
338
|
+
type: "Note",
|
|
339
|
+
attributedTo: params.actorApId,
|
|
340
|
+
content: params.content,
|
|
341
|
+
summary: params.summary,
|
|
342
|
+
attachmentsJson: JSON.stringify(params.attachments || []),
|
|
343
|
+
inReplyTo: params.inReplyTo,
|
|
344
|
+
visibility: params.visibility,
|
|
345
|
+
communityApId: params.communityId,
|
|
346
|
+
toJson: JSON.stringify(addressing.to),
|
|
347
|
+
ccJson: JSON.stringify(addressing.cc),
|
|
348
|
+
audienceJson: JSON.stringify(addressing.audience),
|
|
349
|
+
published: params.now,
|
|
350
|
+
isLocal: 1,
|
|
351
|
+
});
|
|
352
|
+
const bumpPostCount = db
|
|
353
|
+
.update(actors)
|
|
354
|
+
.set({ postCount: sql`${actors.postCount} + 1` })
|
|
355
|
+
.where(and(eq(actors.apId, params.actorApId), objectAbsent));
|
|
356
|
+
|
|
357
|
+
// Direct (DM) posts do NOT count toward postCount: the dedicated DM send path
|
|
358
|
+
// (createDmNote) never bumps it, and the generic DELETE skips the decrement for
|
|
359
|
+
// visibility==='direct'. A direct post created through the generic POST /posts
|
|
360
|
+
// must therefore skip the bump too — otherwise create/delete are asymmetric and
|
|
361
|
+
// postCount over-counts permanently.
|
|
362
|
+
const countStmts = params.visibility === "direct" ? [] : [bumpPostCount];
|
|
363
|
+
|
|
364
|
+
if (params.inReplyTo) {
|
|
365
|
+
const parentId = params.inReplyTo;
|
|
366
|
+
await (db as unknown as Batchable).batch([
|
|
367
|
+
...countStmts,
|
|
368
|
+
insertObject,
|
|
369
|
+
db
|
|
370
|
+
.update(objects)
|
|
371
|
+
.set({
|
|
372
|
+
replyCount: sql`(SELECT COUNT(*) FROM ${objects} WHERE ${objects.inReplyTo} = ${parentId})`,
|
|
373
|
+
})
|
|
374
|
+
.where(eq(objects.apId, parentId)),
|
|
375
|
+
] as Parameters<Batchable["batch"]>[0]);
|
|
376
|
+
} else {
|
|
377
|
+
await (db as unknown as Batchable).batch([
|
|
378
|
+
...countStmts,
|
|
379
|
+
insertObject,
|
|
380
|
+
] as Parameters<Batchable["batch"]>[0]);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (params.inReplyTo && parentAuthor) {
|
|
384
|
+
if (
|
|
385
|
+
parentAuthor !== params.actorApId &&
|
|
386
|
+
isLocal(parentAuthor, params.baseUrl)
|
|
387
|
+
) {
|
|
388
|
+
const replyActivityId = activityApId(params.baseUrl, generateId());
|
|
389
|
+
await db.insert(activities).values({
|
|
390
|
+
apId: replyActivityId,
|
|
391
|
+
type: "Create",
|
|
392
|
+
actorApId: params.actorApId,
|
|
393
|
+
objectApId: params.apId,
|
|
394
|
+
rawJson: JSON.stringify({
|
|
395
|
+
"@context": "https://www.w3.org/ns/activitystreams",
|
|
396
|
+
id: replyActivityId,
|
|
397
|
+
type: "Create",
|
|
398
|
+
actor: params.actorApId,
|
|
399
|
+
object: params.apId,
|
|
400
|
+
}),
|
|
401
|
+
createdAt: params.now,
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
await db.insert(inboxTable).values({
|
|
405
|
+
actorApId: parentAuthor,
|
|
406
|
+
activityApId: replyActivityId,
|
|
407
|
+
read: 0,
|
|
408
|
+
createdAt: params.now,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return parentAuthor;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
// Mention processing
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Extract @mentions from content, resolve them to actor AP IDs (local AND
|
|
422
|
+
* remote), build the `Mention` tag array for the outbound Note/Create, create
|
|
423
|
+
* notification activities for LOCAL mentioned actors, and return the resolved
|
|
424
|
+
* actor IRIs so the caller can address (`cc`) and deliver (remote inbox) the
|
|
425
|
+
* Create to every mentioned actor.
|
|
426
|
+
*
|
|
427
|
+
* Remote mentioned actors do not get a local inbox row — they are reached by
|
|
428
|
+
* federated delivery, which the caller enqueues via `enqueueDeliveryToActor`.
|
|
429
|
+
*/
|
|
430
|
+
|
|
431
|
+
// Match a cached actor's apId HOST against a mention's `@domain`. A substring
|
|
432
|
+
// test (`apId.includes(domain)`) would resolve a `@user@host.com` mention to a
|
|
433
|
+
// hostile actor whose apId merely CONTAINS the host (e.g.
|
|
434
|
+
// `https://host.com.attacker.test/users/user`), misdirecting the cc + the
|
|
435
|
+
// federated delivery (and, for a followers-only/direct post, handing read access
|
|
436
|
+
// to the wrong actor via isExplicitRecipient). Compare the parsed host exactly.
|
|
437
|
+
function actorHostMatches(apId: string, domain: string): boolean {
|
|
438
|
+
try {
|
|
439
|
+
return new URL(apId).host.toLowerCase() === domain.toLowerCase();
|
|
440
|
+
} catch {
|
|
441
|
+
return false;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
type MentionActorRow = { apId: string; preferredUsername: string | null };
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Resolve the local (`actors`) and cached-remote (`actor_cache`) rows for the
|
|
449
|
+
* mention tokens of a post. Both lookups are CHUNKED via chunkForInClause: post
|
|
450
|
+
* content allows >100 distinct `@token`s within MAX_POST_CONTENT_LENGTH, and an
|
|
451
|
+
* unchunked `inArray` over that list binds >100 params, exceeding Cloudflare
|
|
452
|
+
* D1's 100-bound-parameter ceiling ("too many SQL variables") — a prod-only
|
|
453
|
+
* failure invisible to the libsql/better-sqlite3 test driver. `remoteMentions`
|
|
454
|
+
* are `user@host` tokens; only their username part is matched here (the caller
|
|
455
|
+
* disambiguates the host).
|
|
456
|
+
*/
|
|
457
|
+
async function resolveMentionActorRows(
|
|
458
|
+
db: Database,
|
|
459
|
+
localMentions: string[],
|
|
460
|
+
remoteMentions: string[],
|
|
461
|
+
): Promise<{
|
|
462
|
+
localActors: MentionActorRow[];
|
|
463
|
+
cachedActors: MentionActorRow[];
|
|
464
|
+
}> {
|
|
465
|
+
const remoteUsernames = remoteMentions.map((m) => m.split("@")[0]);
|
|
466
|
+
const [localActors, cachedActors] = await Promise.all([
|
|
467
|
+
localMentions.length > 0
|
|
468
|
+
? Promise.all(
|
|
469
|
+
chunkForInClause(localMentions).map((chunk) =>
|
|
470
|
+
db
|
|
471
|
+
.select({
|
|
472
|
+
apId: actors.apId,
|
|
473
|
+
preferredUsername: actors.preferredUsername,
|
|
474
|
+
})
|
|
475
|
+
.from(actors)
|
|
476
|
+
.where(inArray(actors.preferredUsername, chunk)),
|
|
477
|
+
),
|
|
478
|
+
).then((rows) => rows.flat())
|
|
479
|
+
: [],
|
|
480
|
+
remoteUsernames.length > 0
|
|
481
|
+
? Promise.all(
|
|
482
|
+
chunkForInClause(remoteUsernames).map((chunk) =>
|
|
483
|
+
db
|
|
484
|
+
.select({
|
|
485
|
+
apId: actorCache.apId,
|
|
486
|
+
preferredUsername: actorCache.preferredUsername,
|
|
487
|
+
})
|
|
488
|
+
.from(actorCache)
|
|
489
|
+
.where(inArray(actorCache.preferredUsername, chunk)),
|
|
490
|
+
),
|
|
491
|
+
).then((rows) => rows.flat())
|
|
492
|
+
: [],
|
|
493
|
+
]);
|
|
494
|
+
return { localActors, cachedActors };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export async function processMentions(
|
|
498
|
+
db: Database,
|
|
499
|
+
params: {
|
|
500
|
+
content: string;
|
|
501
|
+
postApId: string;
|
|
502
|
+
actorApId: string;
|
|
503
|
+
parentAuthor: string | null;
|
|
504
|
+
baseUrl: string;
|
|
505
|
+
now: string;
|
|
506
|
+
},
|
|
507
|
+
): Promise<ProcessMentionsResult> {
|
|
508
|
+
const mentions = extractMentions(params.content);
|
|
509
|
+
const mentionFailures: MentionFailure[] = [];
|
|
510
|
+
const tags: PostTag[] = [];
|
|
511
|
+
const mentionedActorApIds: string[] = [];
|
|
512
|
+
const remoteMentionedActorApIds: string[] = [];
|
|
513
|
+
const seenMentioned = new Set<string>();
|
|
514
|
+
|
|
515
|
+
// Hashtags federate as standard AS2 `Hashtag` tags (independent of mention
|
|
516
|
+
// resolution) so remote servers can index the post. `href` points at this
|
|
517
|
+
// instance's tag search page (the same destination the web client links to).
|
|
518
|
+
const baseHref = params.baseUrl.replace(/\/+$/, "");
|
|
519
|
+
for (const tag of extractHashtags(params.content)) {
|
|
520
|
+
tags.push({
|
|
521
|
+
type: "Hashtag",
|
|
522
|
+
href: `${baseHref}/search?search=${encodeURIComponent(`#${tag}`)}`,
|
|
523
|
+
name: `#${tag}`,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Persist the computed tag array onto the object row so the served object at
|
|
528
|
+
// `GET /ap/objects/:id` emits the same `tag` the Create carries. The object
|
|
529
|
+
// was already inserted by `insertPostAndHandleReply`, so this is an UPDATE.
|
|
530
|
+
// Only write when there is at least one tag (the column defaults to "[]").
|
|
531
|
+
const persistTags = async () => {
|
|
532
|
+
if (tags.length === 0) return;
|
|
533
|
+
try {
|
|
534
|
+
await db
|
|
535
|
+
.update(objects)
|
|
536
|
+
.set({ tagsJson: JSON.stringify(tags) })
|
|
537
|
+
.where(eq(objects.apId, params.postApId));
|
|
538
|
+
} catch (e) {
|
|
539
|
+
log.error("Failed to persist object tags", {
|
|
540
|
+
event: "posts.mention.tags_persist_failed",
|
|
541
|
+
postApId: params.postApId,
|
|
542
|
+
error: e,
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
const emptyResult: ProcessMentionsResult = {
|
|
548
|
+
failures: mentionFailures,
|
|
549
|
+
tags,
|
|
550
|
+
mentionedActorApIds,
|
|
551
|
+
remoteMentionedActorApIds,
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
// No mentions to resolve — still persist any Hashtag tags before returning.
|
|
555
|
+
if (mentions.length === 0) {
|
|
556
|
+
await persistTags();
|
|
557
|
+
return emptyResult;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const localMentions = mentions.filter((m) => !m.includes("@"));
|
|
561
|
+
const remoteMentions = mentions.filter((m) => m.includes("@"));
|
|
562
|
+
|
|
563
|
+
const { localActors, cachedActors } = await resolveMentionActorRows(
|
|
564
|
+
db,
|
|
565
|
+
localMentions,
|
|
566
|
+
remoteMentions,
|
|
567
|
+
);
|
|
568
|
+
const localActorMap = new Map(
|
|
569
|
+
localActors.map((a) => [a.preferredUsername, a.apId]),
|
|
570
|
+
);
|
|
571
|
+
|
|
572
|
+
const remoteActorMap = new Map<string, string>();
|
|
573
|
+
for (const mention of remoteMentions) {
|
|
574
|
+
const [username, domain] = mention.split("@");
|
|
575
|
+
const matching = cachedActors.find(
|
|
576
|
+
(a) =>
|
|
577
|
+
a.preferredUsername === username && actorHostMatches(a.apId, domain),
|
|
578
|
+
);
|
|
579
|
+
if (matching) {
|
|
580
|
+
remoteActorMap.set(mention, matching.apId);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const activitiesToCreate: Array<{
|
|
585
|
+
apId: string;
|
|
586
|
+
type: string;
|
|
587
|
+
actorApId: string;
|
|
588
|
+
objectApId: string;
|
|
589
|
+
rawJson: string;
|
|
590
|
+
createdAt: string;
|
|
591
|
+
}> = [];
|
|
592
|
+
const inboxEntriesToCreate: Array<{
|
|
593
|
+
actorApId: string;
|
|
594
|
+
activityApId: string;
|
|
595
|
+
read: number;
|
|
596
|
+
createdAt: string;
|
|
597
|
+
}> = [];
|
|
598
|
+
|
|
599
|
+
for (const mention of mentions) {
|
|
600
|
+
try {
|
|
601
|
+
const mentionedActorApId = mention.includes("@")
|
|
602
|
+
? remoteActorMap.get(mention) || null
|
|
603
|
+
: localActorMap.get(mention) || null;
|
|
604
|
+
|
|
605
|
+
if (!mentionedActorApId || mentionedActorApId === params.actorApId) {
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const remote = !isLocal(mentionedActorApId, params.baseUrl);
|
|
610
|
+
|
|
611
|
+
// Every resolved mention (local + remote) gets a `Mention` tag and is
|
|
612
|
+
// recorded as a recipient so the caller can address (`cc`) and — for
|
|
613
|
+
// remote actors — deliver the Create to it. `name` uses the canonical
|
|
614
|
+
// `@user@host` acct form so receiving servers can render/notify.
|
|
615
|
+
if (!seenMentioned.has(mentionedActorApId)) {
|
|
616
|
+
seenMentioned.add(mentionedActorApId);
|
|
617
|
+
tags.push({
|
|
618
|
+
type: "Mention",
|
|
619
|
+
href: mentionedActorApId,
|
|
620
|
+
name: `@${formatUsername(mentionedActorApId)}`,
|
|
621
|
+
});
|
|
622
|
+
mentionedActorApIds.push(mentionedActorApId);
|
|
623
|
+
if (remote) remoteMentionedActorApIds.push(mentionedActorApId);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Local notification fan-in only. The parent author is already notified
|
|
627
|
+
// by the reply path, and remote actors are reached by federated delivery
|
|
628
|
+
// (no local inbox row), so skip both here.
|
|
629
|
+
if (params.parentAuthor === mentionedActorApId) continue;
|
|
630
|
+
if (remote) continue;
|
|
631
|
+
|
|
632
|
+
const mentionActivityId = activityApId(params.baseUrl, generateId());
|
|
633
|
+
activitiesToCreate.push({
|
|
634
|
+
apId: mentionActivityId,
|
|
635
|
+
type: "Create",
|
|
636
|
+
actorApId: params.actorApId,
|
|
637
|
+
objectApId: params.postApId,
|
|
638
|
+
rawJson: JSON.stringify({
|
|
639
|
+
"@context": "https://www.w3.org/ns/activitystreams",
|
|
640
|
+
id: mentionActivityId,
|
|
641
|
+
type: "Create",
|
|
642
|
+
actor: params.actorApId,
|
|
643
|
+
object: params.postApId,
|
|
644
|
+
}),
|
|
645
|
+
createdAt: params.now,
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
inboxEntriesToCreate.push({
|
|
649
|
+
actorApId: mentionedActorApId,
|
|
650
|
+
activityApId: mentionActivityId,
|
|
651
|
+
read: 0,
|
|
652
|
+
createdAt: params.now,
|
|
653
|
+
});
|
|
654
|
+
} catch (e) {
|
|
655
|
+
log.error("Failed to process mention", {
|
|
656
|
+
event: "posts.mention.processing_failed",
|
|
657
|
+
mention,
|
|
658
|
+
error: e,
|
|
659
|
+
});
|
|
660
|
+
mentionFailures.push({
|
|
661
|
+
mention,
|
|
662
|
+
stage: "resolve",
|
|
663
|
+
reason: "mention_processing_failed",
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (activitiesToCreate.length > 0) {
|
|
669
|
+
try {
|
|
670
|
+
await db.insert(activities).values(activitiesToCreate);
|
|
671
|
+
} catch (e) {
|
|
672
|
+
log.error("Failed to persist mention activities", {
|
|
673
|
+
event: "posts.mention.activity_persist_failed",
|
|
674
|
+
error: e,
|
|
675
|
+
});
|
|
676
|
+
mentionFailures.push({
|
|
677
|
+
mention: "__batch__",
|
|
678
|
+
stage: "persist_activity",
|
|
679
|
+
reason: "mention_activity_persist_failed",
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
if (inboxEntriesToCreate.length > 0) {
|
|
684
|
+
try {
|
|
685
|
+
await db.insert(inboxTable).values(inboxEntriesToCreate);
|
|
686
|
+
} catch (e) {
|
|
687
|
+
log.error("Failed to persist mention inbox entries", {
|
|
688
|
+
event: "posts.mention.inbox_persist_failed",
|
|
689
|
+
error: e,
|
|
690
|
+
});
|
|
691
|
+
mentionFailures.push({
|
|
692
|
+
mention: "__batch__",
|
|
693
|
+
stage: "persist_inbox",
|
|
694
|
+
reason: "mention_inbox_persist_failed",
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Persist Mention + Hashtag tags onto the object row (see persistTags above).
|
|
700
|
+
await persistTags();
|
|
701
|
+
|
|
702
|
+
return emptyResult;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Derive the AS2 `tag` array (Hashtag + Mention) for a post's content WITHOUT
|
|
707
|
+
* any notification / activity side effects. The EDIT path uses this so an
|
|
708
|
+
* edited post's served object + Update(Note) carry the SAME tags a fresh post
|
|
709
|
+
* would — re-running the full `processMentions` on every edit would re-notify
|
|
710
|
+
* every mention. Mirrors processMentions' tag-building (kept as a separate,
|
|
711
|
+
* side-effect-free function so the create/federation path stays untouched).
|
|
712
|
+
*/
|
|
713
|
+
export async function deriveContentTags(
|
|
714
|
+
db: Database,
|
|
715
|
+
content: string,
|
|
716
|
+
baseUrl: string,
|
|
717
|
+
actorApId: string,
|
|
718
|
+
): Promise<PostTag[]> {
|
|
719
|
+
const tags: PostTag[] = [];
|
|
720
|
+
|
|
721
|
+
const baseHref = baseUrl.replace(/\/+$/, "");
|
|
722
|
+
for (const tag of extractHashtags(content)) {
|
|
723
|
+
tags.push({
|
|
724
|
+
type: "Hashtag",
|
|
725
|
+
href: `${baseHref}/search?search=${encodeURIComponent(`#${tag}`)}`,
|
|
726
|
+
name: `#${tag}`,
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const mentions = extractMentions(content);
|
|
731
|
+
if (mentions.length === 0) return tags;
|
|
732
|
+
|
|
733
|
+
const localMentions = mentions.filter((m) => !m.includes("@"));
|
|
734
|
+
const remoteMentions = mentions.filter((m) => m.includes("@"));
|
|
735
|
+
|
|
736
|
+
const { localActors, cachedActors } = await resolveMentionActorRows(
|
|
737
|
+
db,
|
|
738
|
+
localMentions,
|
|
739
|
+
remoteMentions,
|
|
740
|
+
);
|
|
741
|
+
const localActorMap = new Map(
|
|
742
|
+
localActors.map((a) => [a.preferredUsername, a.apId]),
|
|
743
|
+
);
|
|
744
|
+
const remoteActorMap = new Map<string, string>();
|
|
745
|
+
for (const mention of remoteMentions) {
|
|
746
|
+
const [username, domain] = mention.split("@");
|
|
747
|
+
const matching = cachedActors.find(
|
|
748
|
+
(a) =>
|
|
749
|
+
a.preferredUsername === username && actorHostMatches(a.apId, domain),
|
|
750
|
+
);
|
|
751
|
+
if (matching) remoteActorMap.set(mention, matching.apId);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const seen = new Set<string>();
|
|
755
|
+
for (const mention of mentions) {
|
|
756
|
+
const apId = mention.includes("@")
|
|
757
|
+
? remoteActorMap.get(mention) || null
|
|
758
|
+
: localActorMap.get(mention) || null;
|
|
759
|
+
if (!apId || apId === actorApId || seen.has(apId)) continue;
|
|
760
|
+
seen.add(apId);
|
|
761
|
+
tags.push({
|
|
762
|
+
type: "Mention",
|
|
763
|
+
href: apId,
|
|
764
|
+
name: `@${formatUsername(apId)}`,
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
return tags;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// ---------------------------------------------------------------------------
|
|
771
|
+
// Edit validation
|
|
772
|
+
// ---------------------------------------------------------------------------
|
|
773
|
+
|
|
774
|
+
export type EditFieldsResult =
|
|
775
|
+
| {
|
|
776
|
+
ok: true;
|
|
777
|
+
rawBody: Record<string, unknown>;
|
|
778
|
+
body: { content?: string; summary?: string };
|
|
779
|
+
}
|
|
780
|
+
| { ok: false; error: string; code?: string };
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Parse and validate the request body for PATCH (edit post).
|
|
784
|
+
* Returns a discriminated union with the parsed body or error details.
|
|
785
|
+
*/
|
|
786
|
+
export async function validateEditBody(c: {
|
|
787
|
+
req: { json: () => Promise<unknown> };
|
|
788
|
+
}): Promise<EditFieldsResult> {
|
|
789
|
+
const rawBody = await parseJsonObject(c);
|
|
790
|
+
if (!rawBody) {
|
|
791
|
+
return { ok: false, error: "Invalid request body", code: "BAD_REQUEST" };
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
for (const field of ["content", "summary"] as const) {
|
|
795
|
+
const err = validateOptionalString(rawBody, field);
|
|
796
|
+
if (err) return { ok: false, error: err, code: "BAD_REQUEST" };
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const body: { content?: string; summary?: string } = {
|
|
800
|
+
content: typeof rawBody.content === "string" ? rawBody.content : undefined,
|
|
801
|
+
summary: typeof rawBody.summary === "string" ? rawBody.summary : undefined,
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
return { ok: true, rawBody, body };
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
type EditValidation =
|
|
808
|
+
{ ok: true; trimmed?: string } | { ok: false; error: string };
|
|
809
|
+
|
|
810
|
+
function validateTrimmedEdit(
|
|
811
|
+
value: string | undefined,
|
|
812
|
+
label: string,
|
|
813
|
+
maxLength: number,
|
|
814
|
+
allowEmpty: boolean,
|
|
815
|
+
): EditValidation {
|
|
816
|
+
if (value === undefined) return { ok: true };
|
|
817
|
+
const trimmed = value.trim();
|
|
818
|
+
if (!allowEmpty && trimmed.length === 0) {
|
|
819
|
+
return { ok: false, error: `${label} cannot be empty` };
|
|
820
|
+
}
|
|
821
|
+
if (trimmed.length > maxLength) {
|
|
822
|
+
return {
|
|
823
|
+
ok: false,
|
|
824
|
+
error: `${label} too long (max ${maxLength} chars)`,
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
return { ok: true, trimmed };
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/** Validate trimmed content length for editing. */
|
|
831
|
+
export function validateContentEdit(
|
|
832
|
+
content: string | undefined,
|
|
833
|
+
): EditValidation {
|
|
834
|
+
return validateTrimmedEdit(
|
|
835
|
+
content,
|
|
836
|
+
"Content",
|
|
837
|
+
MAX_POST_CONTENT_LENGTH,
|
|
838
|
+
false,
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/** Validate trimmed summary length for editing. */
|
|
843
|
+
export function validateSummaryEdit(
|
|
844
|
+
summary: string | undefined,
|
|
845
|
+
): EditValidation {
|
|
846
|
+
return validateTrimmedEdit(summary, "Summary", MAX_POST_SUMMARY_LENGTH, true);
|
|
847
|
+
}
|