@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,537 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post query helpers and shared utilities
|
|
3
|
+
*
|
|
4
|
+
* Extracted from base.ts to reduce file size. Contains:
|
|
5
|
+
* - Type definitions
|
|
6
|
+
* - Inline helpers (validation, author resolution, interaction flags)
|
|
7
|
+
* - Addressing logic for ActivityPub delivery
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Database } from "../../../db/index.ts";
|
|
11
|
+
import {
|
|
12
|
+
activities,
|
|
13
|
+
actorCache,
|
|
14
|
+
bookmarks,
|
|
15
|
+
communities,
|
|
16
|
+
likes,
|
|
17
|
+
objects,
|
|
18
|
+
} from "../../../db/index.ts";
|
|
19
|
+
import { and, eq, inArray, or } from "drizzle-orm";
|
|
20
|
+
import type { Env } from "../../types.ts";
|
|
21
|
+
import {
|
|
22
|
+
activityApId,
|
|
23
|
+
formatUsername,
|
|
24
|
+
generateId,
|
|
25
|
+
objectApId,
|
|
26
|
+
} from "../../federation-helpers.ts";
|
|
27
|
+
import { formatPost, PostRow } from "./transformers.ts";
|
|
28
|
+
import {
|
|
29
|
+
enqueueFanoutToCommunity,
|
|
30
|
+
enqueueFanoutToFollowers,
|
|
31
|
+
} from "../../lib/delivery/queue.ts";
|
|
32
|
+
import { isRecord, parseJsonObject } from "../../lib/parse-helpers.ts";
|
|
33
|
+
import { logger } from "../../lib/logger.ts";
|
|
34
|
+
|
|
35
|
+
const log = logger.child({ component: "posts.queries" });
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Types
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
export type PostAttachment = {
|
|
42
|
+
type?: string;
|
|
43
|
+
mediaType?: string;
|
|
44
|
+
url?: string;
|
|
45
|
+
[key: string]: unknown;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type CreatePostBody = {
|
|
49
|
+
content: string;
|
|
50
|
+
summary?: string;
|
|
51
|
+
attachments?: PostAttachment[];
|
|
52
|
+
in_reply_to?: string;
|
|
53
|
+
visibility?: string;
|
|
54
|
+
community_ap_id?: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type PostDetailRow = PostRow & {
|
|
58
|
+
to_json?: string | null;
|
|
59
|
+
bookmarked?: number;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export type MentionFailure = {
|
|
63
|
+
mention: string;
|
|
64
|
+
stage: "resolve" | "persist_activity" | "persist_inbox";
|
|
65
|
+
reason: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** An ActivityStreams `Mention` tag entry for an outbound Note/Create. */
|
|
69
|
+
export type MentionTag = {
|
|
70
|
+
type: "Mention";
|
|
71
|
+
href: string;
|
|
72
|
+
name: string;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** An ActivityStreams `Hashtag` tag entry for an outbound Note/Create. */
|
|
76
|
+
export type HashtagTag = {
|
|
77
|
+
type: "Hashtag";
|
|
78
|
+
href: string;
|
|
79
|
+
name: string;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Any tag persisted to objects.tagsJson / emitted on a Note's `tag`. */
|
|
83
|
+
export type PostTag = MentionTag | HashtagTag;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Result of resolving the @mentions in a post: per-mention failures, the
|
|
87
|
+
* `Mention` tag array to attach to the outbound Note/Create, and the resolved
|
|
88
|
+
* actor IRIs (local + remote) that must be added to `cc` and — for remote
|
|
89
|
+
* actors — delivered to.
|
|
90
|
+
*/
|
|
91
|
+
export type ProcessMentionsResult = {
|
|
92
|
+
failures: MentionFailure[];
|
|
93
|
+
tags: PostTag[];
|
|
94
|
+
/** All resolved mentioned actor IRIs (local + remote), de-duplicated. */
|
|
95
|
+
mentionedActorApIds: string[];
|
|
96
|
+
/** Resolved remote mentioned actor IRIs that need direct inbox delivery. */
|
|
97
|
+
remoteMentionedActorApIds: string[];
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** Merge extra recipient IRIs into a cc array, de-duplicating and dropping empties. */
|
|
101
|
+
export function mergeCc(cc: string[], extra: string[]): string[] {
|
|
102
|
+
const seen = new Set(cc);
|
|
103
|
+
const out = [...cc];
|
|
104
|
+
for (const iri of extra) {
|
|
105
|
+
if (iri && !seen.has(iri)) {
|
|
106
|
+
seen.add(iri);
|
|
107
|
+
out.push(iri);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type AuthorInfo = {
|
|
114
|
+
preferredUsername: string | null;
|
|
115
|
+
name: string | null;
|
|
116
|
+
iconUrl: string | null;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/** Shape returned by Drizzle relational query with author. */
|
|
120
|
+
export type PostWithAuthor = {
|
|
121
|
+
apId: string;
|
|
122
|
+
type: string;
|
|
123
|
+
attributedTo: string;
|
|
124
|
+
content: string;
|
|
125
|
+
summary: string | null;
|
|
126
|
+
attachmentsJson: string | null;
|
|
127
|
+
inReplyTo: string | null;
|
|
128
|
+
visibility: string;
|
|
129
|
+
communityApId: string | null;
|
|
130
|
+
likeCount: number;
|
|
131
|
+
replyCount: number;
|
|
132
|
+
announceCount: number;
|
|
133
|
+
published: string;
|
|
134
|
+
toJson?: string | null;
|
|
135
|
+
author: AuthorInfo | null;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Inline helpers
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
export { isRecord, parseJsonObject };
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Validate that a raw field is either absent, null, or a string.
|
|
146
|
+
* Returns an error message string if invalid, or null if valid.
|
|
147
|
+
*/
|
|
148
|
+
export function validateOptionalString(
|
|
149
|
+
raw: Record<string, unknown>,
|
|
150
|
+
field: string,
|
|
151
|
+
): string | null {
|
|
152
|
+
const value = raw[field];
|
|
153
|
+
if (value !== undefined && value !== null && typeof value !== "string") {
|
|
154
|
+
return `${field} must be a string`;
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Shared Drizzle relational `with` for loading a post's local author info. */
|
|
160
|
+
export const AUTHOR_WITH = {
|
|
161
|
+
author: {
|
|
162
|
+
columns: {
|
|
163
|
+
preferredUsername: true,
|
|
164
|
+
name: true,
|
|
165
|
+
iconUrl: true,
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
} as const;
|
|
169
|
+
|
|
170
|
+
/** Build an `or` condition that matches either the full apId or the raw postId. */
|
|
171
|
+
export function postWhereByIdOrApId(baseUrl: string, postId: string) {
|
|
172
|
+
return or(
|
|
173
|
+
eq(objects.apId, objectApId(baseUrl, postId)),
|
|
174
|
+
eq(objects.apId, postId),
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Resolve author info from a Drizzle post's local author or a cached-author map.
|
|
180
|
+
* Returns { preferredUsername, name, iconUrl } with nulls as fallback.
|
|
181
|
+
*/
|
|
182
|
+
export function resolveAuthor(
|
|
183
|
+
localAuthor: AuthorInfo | null | undefined,
|
|
184
|
+
attributedTo: string,
|
|
185
|
+
cachedAuthorMap?: Map<string, AuthorInfo>,
|
|
186
|
+
): AuthorInfo {
|
|
187
|
+
if (localAuthor?.preferredUsername) return localAuthor;
|
|
188
|
+
const cached = cachedAuthorMap?.get(attributedTo);
|
|
189
|
+
if (cached) return cached;
|
|
190
|
+
return { preferredUsername: null, name: null, iconUrl: null };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Resolve author info with an async fallback to actorCache.
|
|
195
|
+
* Used for single-post lookups where a batch-loaded map is unavailable.
|
|
196
|
+
*/
|
|
197
|
+
export async function resolveAuthorWithCache(
|
|
198
|
+
localAuthor: AuthorInfo | null | undefined,
|
|
199
|
+
attributedTo: string,
|
|
200
|
+
db: Database,
|
|
201
|
+
): Promise<AuthorInfo> {
|
|
202
|
+
if (localAuthor?.preferredUsername) return localAuthor;
|
|
203
|
+
const cached = await db
|
|
204
|
+
.select({
|
|
205
|
+
preferredUsername: actorCache.preferredUsername,
|
|
206
|
+
name: actorCache.name,
|
|
207
|
+
iconUrl: actorCache.iconUrl,
|
|
208
|
+
})
|
|
209
|
+
.from(actorCache)
|
|
210
|
+
.where(eq(actorCache.apId, attributedTo))
|
|
211
|
+
.get();
|
|
212
|
+
return cached ?? { preferredUsername: null, name: null, iconUrl: null };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Convert a Drizzle object row + resolved author into a PostRow for formatPost. */
|
|
216
|
+
export function toPostRow(
|
|
217
|
+
post: {
|
|
218
|
+
apId: string;
|
|
219
|
+
type: string;
|
|
220
|
+
attributedTo: string;
|
|
221
|
+
content: string;
|
|
222
|
+
summary: string | null;
|
|
223
|
+
attachmentsJson: string | null;
|
|
224
|
+
inReplyTo: string | null;
|
|
225
|
+
visibility: string;
|
|
226
|
+
communityApId: string | null;
|
|
227
|
+
likeCount: number;
|
|
228
|
+
replyCount: number;
|
|
229
|
+
announceCount: number;
|
|
230
|
+
published: string;
|
|
231
|
+
updated?: string | null;
|
|
232
|
+
toJson?: string | null;
|
|
233
|
+
},
|
|
234
|
+
author: AuthorInfo,
|
|
235
|
+
flags: { liked: boolean; bookmarked?: boolean },
|
|
236
|
+
): PostRow & { to_json?: string | null; bookmarked?: number } {
|
|
237
|
+
return {
|
|
238
|
+
ap_id: post.apId,
|
|
239
|
+
type: post.type,
|
|
240
|
+
attributed_to: post.attributedTo,
|
|
241
|
+
author_username: author.preferredUsername,
|
|
242
|
+
author_name: author.name,
|
|
243
|
+
author_icon_url: author.iconUrl,
|
|
244
|
+
content: post.content,
|
|
245
|
+
summary: post.summary,
|
|
246
|
+
attachments_json: post.attachmentsJson,
|
|
247
|
+
in_reply_to: post.inReplyTo,
|
|
248
|
+
visibility: post.visibility,
|
|
249
|
+
community_ap_id: post.communityApId,
|
|
250
|
+
like_count: post.likeCount,
|
|
251
|
+
reply_count: post.replyCount,
|
|
252
|
+
announce_count: post.announceCount,
|
|
253
|
+
published: post.published,
|
|
254
|
+
updated: post.updated ?? null,
|
|
255
|
+
liked: flags.liked ? 1 : 0,
|
|
256
|
+
...(flags.bookmarked !== undefined
|
|
257
|
+
? { bookmarked: flags.bookmarked ? 1 : 0 }
|
|
258
|
+
: {}),
|
|
259
|
+
...(post.toJson !== undefined ? { to_json: post.toJson } : {}),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
type Visibility = "public" | "unlisted" | "followers" | "direct";
|
|
264
|
+
|
|
265
|
+
function assertNever(x: never): never {
|
|
266
|
+
throw new Error(`Unhandled visibility: ${JSON.stringify(x)}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function classifyVisibility(value: string): Visibility {
|
|
270
|
+
switch (value) {
|
|
271
|
+
case "public":
|
|
272
|
+
case "unlisted":
|
|
273
|
+
case "followers":
|
|
274
|
+
case "direct":
|
|
275
|
+
return value;
|
|
276
|
+
default:
|
|
277
|
+
// Unknown visibility (e.g. from external AP server) — fail closed
|
|
278
|
+
// by treating as direct (no public delivery, equivalent to the
|
|
279
|
+
// previous `{to:[], cc:[]}` default).
|
|
280
|
+
return "direct";
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const PUBLIC_URL = "https://www.w3.org/ns/activitystreams#Public";
|
|
285
|
+
|
|
286
|
+
/** Compute to/cc fields from visibility for ActivityPub delivery. */
|
|
287
|
+
export function buildAddressing(
|
|
288
|
+
visibility: string,
|
|
289
|
+
followersUrl: string,
|
|
290
|
+
): { to: string[]; cc: string[] } {
|
|
291
|
+
const publicUrl = PUBLIC_URL;
|
|
292
|
+
const narrowed = classifyVisibility(visibility);
|
|
293
|
+
switch (narrowed) {
|
|
294
|
+
case "public":
|
|
295
|
+
return { to: [publicUrl], cc: [followersUrl] };
|
|
296
|
+
case "unlisted":
|
|
297
|
+
return { to: [followersUrl], cc: [publicUrl] };
|
|
298
|
+
case "followers":
|
|
299
|
+
return { to: [followersUrl], cc: [] };
|
|
300
|
+
case "direct":
|
|
301
|
+
return { to: [], cc: [] };
|
|
302
|
+
default:
|
|
303
|
+
return assertNever(narrowed);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export type CommunityAddressingTarget = {
|
|
308
|
+
apId: string;
|
|
309
|
+
followersUrl: string;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Compute the stored object addressing (to/cc/audience) for a post.
|
|
314
|
+
*
|
|
315
|
+
* For a community-scoped post the reach is the COMMUNITY, not the open public
|
|
316
|
+
* timeline: the community Group actor and its followers collection are placed
|
|
317
|
+
* in `to`, and the community is recorded in `audience`. Because the public /
|
|
318
|
+
* home feed filters on `audienceJson = "[]"`, a non-empty audience is exactly
|
|
319
|
+
* what keeps the post out of those feeds while keeping it visible in the
|
|
320
|
+
* community-scoped feed (which filters by `communityApId`). `#Public` is
|
|
321
|
+
* downgraded to `cc` (unlisted-style) so the post is not boosted into the
|
|
322
|
+
* federated public stream even when the post visibility is "public".
|
|
323
|
+
*
|
|
324
|
+
* For a non-community post this returns empty arrays, preserving the prior
|
|
325
|
+
* object defaults (the activity-level addressing in the Create still drives
|
|
326
|
+
* follower delivery).
|
|
327
|
+
*/
|
|
328
|
+
export function buildCommunityObjectAddressing(
|
|
329
|
+
visibility: string,
|
|
330
|
+
community: CommunityAddressingTarget | null,
|
|
331
|
+
): { to: string[]; cc: string[]; audience: string[] } {
|
|
332
|
+
if (!community) {
|
|
333
|
+
return { to: [], cc: [], audience: [] };
|
|
334
|
+
}
|
|
335
|
+
const narrowed = classifyVisibility(visibility);
|
|
336
|
+
const to = [community.apId, community.followersUrl];
|
|
337
|
+
const cc: string[] = [];
|
|
338
|
+
if (narrowed === "public" || narrowed === "unlisted") {
|
|
339
|
+
cc.push(PUBLIC_URL);
|
|
340
|
+
}
|
|
341
|
+
return { to, cc, audience: [community.apId] };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Batch-load liked and bookmarked object IDs for a set of posts. */
|
|
345
|
+
export async function loadInteractionFlags(
|
|
346
|
+
db: Database,
|
|
347
|
+
actorApId: string | undefined,
|
|
348
|
+
objectApIds: string[],
|
|
349
|
+
): Promise<{ likedIds: Set<string>; bookmarkedIds: Set<string> }> {
|
|
350
|
+
if (!actorApId || objectApIds.length === 0) {
|
|
351
|
+
return { likedIds: new Set(), bookmarkedIds: new Set() };
|
|
352
|
+
}
|
|
353
|
+
const [likeRows, bookmarkRows] = await Promise.all([
|
|
354
|
+
db
|
|
355
|
+
.select({ objectApId: likes.objectApId })
|
|
356
|
+
.from(likes)
|
|
357
|
+
.where(
|
|
358
|
+
and(
|
|
359
|
+
eq(likes.actorApId, actorApId),
|
|
360
|
+
inArray(likes.objectApId, objectApIds),
|
|
361
|
+
),
|
|
362
|
+
),
|
|
363
|
+
db
|
|
364
|
+
.select({ objectApId: bookmarks.objectApId })
|
|
365
|
+
.from(bookmarks)
|
|
366
|
+
.where(
|
|
367
|
+
and(
|
|
368
|
+
eq(bookmarks.actorApId, actorApId),
|
|
369
|
+
inArray(bookmarks.objectApId, objectApIds),
|
|
370
|
+
),
|
|
371
|
+
),
|
|
372
|
+
]);
|
|
373
|
+
return {
|
|
374
|
+
likedIds: new Set(likeRows.map((l) => l.objectApId)),
|
|
375
|
+
bookmarkedIds: new Set(bookmarkRows.map((b) => b.objectApId)),
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Persist an outbound ActivityPub activity WITHOUT any follower/community
|
|
381
|
+
* fanout. Used when the only recipients are explicit (e.g. a "direct" post
|
|
382
|
+
* whose reach is exactly its mentioned actors), so the activity is on record
|
|
383
|
+
* for direct per-actor delivery but is never broadcast to the follower graph.
|
|
384
|
+
*/
|
|
385
|
+
export async function persistActivity(
|
|
386
|
+
db: Database,
|
|
387
|
+
activity: { id: string; type: string; actor: string; [key: string]: unknown },
|
|
388
|
+
objectApIdValue: string,
|
|
389
|
+
): Promise<void> {
|
|
390
|
+
await db.insert(activities).values({
|
|
391
|
+
apId: activity.id,
|
|
392
|
+
type: activity.type,
|
|
393
|
+
actorApId: activity.actor,
|
|
394
|
+
objectApId: objectApIdValue,
|
|
395
|
+
rawJson: JSON.stringify(activity),
|
|
396
|
+
direction: "outbound",
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Persist an outbound ActivityPub activity and enqueue federation fanout. */
|
|
401
|
+
export async function persistAndFanout(
|
|
402
|
+
db: Database,
|
|
403
|
+
env: Env,
|
|
404
|
+
activity: { id: string; type: string; actor: string; [key: string]: unknown },
|
|
405
|
+
objectApIdValue: string,
|
|
406
|
+
): Promise<void> {
|
|
407
|
+
await db.insert(activities).values({
|
|
408
|
+
apId: activity.id,
|
|
409
|
+
type: activity.type,
|
|
410
|
+
actorApId: activity.actor,
|
|
411
|
+
objectApId: objectApIdValue,
|
|
412
|
+
rawJson: JSON.stringify(activity),
|
|
413
|
+
direction: "outbound",
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
try {
|
|
417
|
+
await enqueueFanoutToFollowers(env, activity.id, activity.actor);
|
|
418
|
+
} catch (err) {
|
|
419
|
+
log.error("Failed to enqueue federation fanout", {
|
|
420
|
+
event: "posts.fanout.enqueue_failed",
|
|
421
|
+
activityType: activity.type,
|
|
422
|
+
activityId: activity.id,
|
|
423
|
+
actor: activity.actor,
|
|
424
|
+
error: err,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Persist an outbound activity and fan it out to a COMMUNITY's audience
|
|
431
|
+
* (members + community followers) instead of the author's personal followers.
|
|
432
|
+
* Used for community-scoped posts so reach == community.
|
|
433
|
+
*/
|
|
434
|
+
export async function persistAndFanoutToCommunity(
|
|
435
|
+
db: Database,
|
|
436
|
+
env: Env,
|
|
437
|
+
activity: { id: string; type: string; actor: string; [key: string]: unknown },
|
|
438
|
+
objectApIdValue: string,
|
|
439
|
+
communityApId: string,
|
|
440
|
+
): Promise<void> {
|
|
441
|
+
await db.insert(activities).values({
|
|
442
|
+
apId: activity.id,
|
|
443
|
+
type: activity.type,
|
|
444
|
+
actorApId: activity.actor,
|
|
445
|
+
objectApId: objectApIdValue,
|
|
446
|
+
rawJson: JSON.stringify(activity),
|
|
447
|
+
direction: "outbound",
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
// Announce-relay: for a new post (Create), the GROUP re-broadcasts it to its
|
|
451
|
+
// followers as its OWN Announce (Lemmy/Mobilizon convention) so the post is
|
|
452
|
+
// attributed to the community and reaches remote followers whose server
|
|
453
|
+
// cannot enumerate the group's follower collection. The Announce is signed by
|
|
454
|
+
// the community (its actor) at delivery time, appears in the community outbox
|
|
455
|
+
// (the outbox query keys on actorApId == group), and is delivered to REMOTE
|
|
456
|
+
// followers in place of the raw author Create; LOCAL members still get the
|
|
457
|
+
// Create. Edit/Delete (Update/Delete) relay the activity directly (no
|
|
458
|
+
// announce) for now.
|
|
459
|
+
// Only PUBLIC communities federate as Group actors (their actor/inbox/outbox
|
|
460
|
+
// are served + followable; a private community 404s). So only a public
|
|
461
|
+
// community gets a Group Announce — building one for a private community
|
|
462
|
+
// would mint a `cc: Public` activity that, while currently undelivered (a
|
|
463
|
+
// private community has no remote followers) and unserved (its outbox 404s),
|
|
464
|
+
// would leak into the outbox the moment the community is flipped public.
|
|
465
|
+
let isPublicCommunity = false;
|
|
466
|
+
if (activity.type === "Create") {
|
|
467
|
+
const row = await db
|
|
468
|
+
.select({ visibility: communities.visibility })
|
|
469
|
+
.from(communities)
|
|
470
|
+
.where(eq(communities.apId, communityApId))
|
|
471
|
+
.get();
|
|
472
|
+
isPublicCommunity = row?.visibility === "public";
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
let announceActivityId: string | undefined;
|
|
476
|
+
if (activity.type === "Create" && isPublicCommunity) {
|
|
477
|
+
const baseUrl = env.APP_URL;
|
|
478
|
+
announceActivityId = activityApId(baseUrl, generateId());
|
|
479
|
+
const announce = {
|
|
480
|
+
"@context": "https://www.w3.org/ns/activitystreams",
|
|
481
|
+
id: announceActivityId,
|
|
482
|
+
type: "Announce",
|
|
483
|
+
actor: communityApId,
|
|
484
|
+
object: objectApIdValue,
|
|
485
|
+
to: [`${communityApId}/followers`],
|
|
486
|
+
cc: [PUBLIC_URL],
|
|
487
|
+
published: new Date().toISOString(),
|
|
488
|
+
};
|
|
489
|
+
await db.insert(activities).values({
|
|
490
|
+
apId: announceActivityId,
|
|
491
|
+
type: "Announce",
|
|
492
|
+
actorApId: communityApId,
|
|
493
|
+
objectApId: objectApIdValue,
|
|
494
|
+
rawJson: JSON.stringify(announce),
|
|
495
|
+
direction: "outbound",
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
try {
|
|
500
|
+
await enqueueFanoutToCommunity(
|
|
501
|
+
env,
|
|
502
|
+
activity.id,
|
|
503
|
+
communityApId,
|
|
504
|
+
announceActivityId,
|
|
505
|
+
);
|
|
506
|
+
} catch (err) {
|
|
507
|
+
log.error("Failed to enqueue community federation fanout", {
|
|
508
|
+
event: "posts.fanout.community_enqueue_failed",
|
|
509
|
+
activityType: activity.type,
|
|
510
|
+
activityId: activity.id,
|
|
511
|
+
actor: activity.actor,
|
|
512
|
+
communityApId,
|
|
513
|
+
error: err,
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Batch-load cached authors for posts without a local author join. */
|
|
519
|
+
export async function loadCachedAuthorMap(
|
|
520
|
+
db: Database,
|
|
521
|
+
posts: PostWithAuthor[],
|
|
522
|
+
): Promise<Map<string, AuthorInfo>> {
|
|
523
|
+
const remoteAttributedTos = [
|
|
524
|
+
...new Set(posts.filter((p) => !p.author).map((p) => p.attributedTo)),
|
|
525
|
+
];
|
|
526
|
+
if (remoteAttributedTos.length === 0) return new Map();
|
|
527
|
+
const cachedAuthors = await db
|
|
528
|
+
.select({
|
|
529
|
+
apId: actorCache.apId,
|
|
530
|
+
preferredUsername: actorCache.preferredUsername,
|
|
531
|
+
name: actorCache.name,
|
|
532
|
+
iconUrl: actorCache.iconUrl,
|
|
533
|
+
})
|
|
534
|
+
.from(actorCache)
|
|
535
|
+
.where(inArray(actorCache.apId, remoteAttributedTos));
|
|
536
|
+
return new Map(cachedAuthors.map((a) => [a.apId, a]));
|
|
537
|
+
}
|