@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,487 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
import { and, count, desc, eq, inArray, sql } from "drizzle-orm";
|
|
3
|
+
import type { Database } from "../../db/index.ts";
|
|
4
|
+
import {
|
|
5
|
+
actorCache,
|
|
6
|
+
actors,
|
|
7
|
+
announces,
|
|
8
|
+
blocks,
|
|
9
|
+
bookmarks,
|
|
10
|
+
follows,
|
|
11
|
+
likes,
|
|
12
|
+
mutes,
|
|
13
|
+
} from "../../db/index.ts";
|
|
14
|
+
import { chunkForInClause } from "../lib/chunk.ts";
|
|
15
|
+
import type { Actor, Env, Variables } from "../types.ts";
|
|
16
|
+
import {
|
|
17
|
+
actorApId,
|
|
18
|
+
formatUsername,
|
|
19
|
+
getDomain,
|
|
20
|
+
parseLimit,
|
|
21
|
+
parseOffset,
|
|
22
|
+
} from "../federation-helpers.ts";
|
|
23
|
+
|
|
24
|
+
// Hono context with our app's bindings and variables
|
|
25
|
+
export type AppContext = Context<
|
|
26
|
+
{ Bindings: Env; Variables: Variables },
|
|
27
|
+
string
|
|
28
|
+
>;
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Constants
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
// Capped at 90 (not 100): a profile page's post ids are re-queried via
|
|
35
|
+
// `inArray(col, postApIds)` for like/bookmark/announce enrichment, and
|
|
36
|
+
// Cloudflare D1 allows at most 100 bound parameters per query. (libsql, which
|
|
37
|
+
// the tests run on, allows ~32k and hides this.)
|
|
38
|
+
export const MAX_ACTOR_POSTS_LIMIT = 90;
|
|
39
|
+
export const MAX_PROFILE_NAME_LENGTH = 50;
|
|
40
|
+
export const MAX_PROFILE_SUMMARY_LENGTH = 500;
|
|
41
|
+
export const MAX_PROFILE_URL_LENGTH = 2000;
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Types
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
export type ActorInfo = {
|
|
48
|
+
apId: string;
|
|
49
|
+
preferredUsername: string | null;
|
|
50
|
+
name: string | null;
|
|
51
|
+
iconUrl: string | null;
|
|
52
|
+
summary?: string | null;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Helpers
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
export function isValidHttpUrl(value: string): boolean {
|
|
60
|
+
try {
|
|
61
|
+
const parsed = new URL(value);
|
|
62
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Validate a profile image URL (icon / header). Accepts either an app-relative
|
|
70
|
+
* upload path (`/media/<hash>.<ext>` produced by POST /api/media/upload) or an
|
|
71
|
+
* absolute http(s) URL. Relative upload paths are stored verbatim and
|
|
72
|
+
* absolutized against APP_URL only when serialized for federation, mirroring
|
|
73
|
+
* how community icons and story attachments are handled.
|
|
74
|
+
*/
|
|
75
|
+
export function isValidProfileImageUrl(value: string): boolean {
|
|
76
|
+
if (value.startsWith("/media/")) return true;
|
|
77
|
+
return isValidHttpUrl(value);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Batch-load actor info from both local and cached tables, returning a
|
|
82
|
+
* single lookup map keyed by apId. Local actors take precedence.
|
|
83
|
+
*/
|
|
84
|
+
export async function loadActorInfoMap(
|
|
85
|
+
db: Database,
|
|
86
|
+
apIds: string[],
|
|
87
|
+
mode: "full" | "author" = "full",
|
|
88
|
+
): Promise<Map<string, ActorInfo>> {
|
|
89
|
+
if (apIds.length === 0) return new Map();
|
|
90
|
+
|
|
91
|
+
const selectFull = {
|
|
92
|
+
apId: actors.apId,
|
|
93
|
+
preferredUsername: actors.preferredUsername,
|
|
94
|
+
name: actors.name,
|
|
95
|
+
iconUrl: actors.iconUrl,
|
|
96
|
+
summary: actors.summary,
|
|
97
|
+
};
|
|
98
|
+
const selectAuthor = {
|
|
99
|
+
apId: actors.apId,
|
|
100
|
+
preferredUsername: actors.preferredUsername,
|
|
101
|
+
name: actors.name,
|
|
102
|
+
iconUrl: actors.iconUrl,
|
|
103
|
+
};
|
|
104
|
+
const selectCacheFull = {
|
|
105
|
+
apId: actorCache.apId,
|
|
106
|
+
preferredUsername: actorCache.preferredUsername,
|
|
107
|
+
name: actorCache.name,
|
|
108
|
+
iconUrl: actorCache.iconUrl,
|
|
109
|
+
summary: actorCache.summary,
|
|
110
|
+
};
|
|
111
|
+
const selectCacheAuthor = {
|
|
112
|
+
apId: actorCache.apId,
|
|
113
|
+
preferredUsername: actorCache.preferredUsername,
|
|
114
|
+
name: actorCache.name,
|
|
115
|
+
iconUrl: actorCache.iconUrl,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const localSelect = mode === "full" ? selectFull : selectAuthor;
|
|
119
|
+
const cacheSelect = mode === "full" ? selectCacheFull : selectCacheAuthor;
|
|
120
|
+
|
|
121
|
+
// Chunk the IN(...) lookups: callers may pass a whole page of author ids
|
|
122
|
+
// (up to ~90) and D1 caps a query at 100 bound parameters. Chunks are
|
|
123
|
+
// disjoint id slices, so merging their maps is collision-free; cached-then-
|
|
124
|
+
// local ordering still gives local-wins within each chunk.
|
|
125
|
+
const map = new Map<string, ActorInfo>();
|
|
126
|
+
for (const ids of chunkForInClause(apIds)) {
|
|
127
|
+
const [local, cached] = await Promise.all([
|
|
128
|
+
db.select(localSelect).from(actors).where(inArray(actors.apId, ids)),
|
|
129
|
+
db
|
|
130
|
+
.select(cacheSelect)
|
|
131
|
+
.from(actorCache)
|
|
132
|
+
.where(inArray(actorCache.apId, ids)),
|
|
133
|
+
]);
|
|
134
|
+
for (const a of cached) map.set(a.apId, a);
|
|
135
|
+
for (const a of local) map.set(a.apId, a); // local wins
|
|
136
|
+
}
|
|
137
|
+
return map;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Format a looked-up actor into the common JSON shape used by blocked/muted/followers/following lists.
|
|
142
|
+
*/
|
|
143
|
+
export function formatActorSummary(
|
|
144
|
+
apId: string,
|
|
145
|
+
info: ActorInfo | undefined,
|
|
146
|
+
): {
|
|
147
|
+
ap_id: string;
|
|
148
|
+
username: string;
|
|
149
|
+
preferred_username: string | null;
|
|
150
|
+
name: string | null;
|
|
151
|
+
icon_url: string | null;
|
|
152
|
+
summary: string | null;
|
|
153
|
+
} {
|
|
154
|
+
return {
|
|
155
|
+
ap_id: apId,
|
|
156
|
+
username: formatUsername(apId),
|
|
157
|
+
preferred_username: info?.preferredUsername || null,
|
|
158
|
+
name: info?.name || null,
|
|
159
|
+
icon_url: info?.iconUrl || null,
|
|
160
|
+
summary: info?.summary ?? null,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Resolve an identifier (AP ID, @user@domain, or bare username) to an AP ID string.
|
|
166
|
+
* Returns null when the identifier cannot be resolved.
|
|
167
|
+
*/
|
|
168
|
+
export async function resolveActorApId(
|
|
169
|
+
db: Database,
|
|
170
|
+
baseUrl: string,
|
|
171
|
+
identifier: string,
|
|
172
|
+
): Promise<string | null> {
|
|
173
|
+
if (identifier.startsWith("http")) return identifier;
|
|
174
|
+
|
|
175
|
+
if (!identifier.includes("@")) return actorApId(baseUrl, identifier);
|
|
176
|
+
|
|
177
|
+
const stripped = identifier.replace(/^@/, "");
|
|
178
|
+
const parts = stripped.split("@");
|
|
179
|
+
const username = parts[0];
|
|
180
|
+
if (!username) return null;
|
|
181
|
+
|
|
182
|
+
if (parts.length === 1) return actorApId(baseUrl, username);
|
|
183
|
+
|
|
184
|
+
const domain = parts.slice(1).join("@");
|
|
185
|
+
if (!domain) return null;
|
|
186
|
+
if (domain === getDomain(baseUrl)) return actorApId(baseUrl, username);
|
|
187
|
+
|
|
188
|
+
const cached = await db
|
|
189
|
+
.select({ apId: actorCache.apId })
|
|
190
|
+
.from(actorCache)
|
|
191
|
+
.where(
|
|
192
|
+
and(
|
|
193
|
+
eq(actorCache.preferredUsername, username),
|
|
194
|
+
// Match the domain as a LITERAL substring of the AP-ID via instr() — NOT
|
|
195
|
+
// `LIKE '%' || domain || '%'`. The domain comes from a federated handle
|
|
196
|
+
// (`@user@domain`) and is caller-influenceable: an unescaped LIKE would
|
|
197
|
+
// (a) treat a `%`/`_` in `domain` as a wildcard — `@alice@%` would match
|
|
198
|
+
// ANY cached `alice` regardless of domain (wrong-actor resolution), and
|
|
199
|
+
// (b) trip D1's LIKE pattern-complexity limit (SQLITE_ERROR 7500) for a
|
|
200
|
+
// long domain. instr() is literal (no wildcards, no escaping) and has no
|
|
201
|
+
// length limit, so `@alice@%` correctly resolves to null. Both sides are
|
|
202
|
+
// lowercased to preserve LIKE's ASCII case-insensitivity: a handle typed
|
|
203
|
+
// with a non-canonical host case (`@alice@Mastodon.Social`) — which
|
|
204
|
+
// webfinger treats as equivalent — still matches the lowercase cached host.
|
|
205
|
+
sql`instr(lower(${actorCache.apId}), lower(${domain})) > 0`,
|
|
206
|
+
),
|
|
207
|
+
)
|
|
208
|
+
.get();
|
|
209
|
+
return cached?.apId || null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Check that an actor exists in either the local or cached table.
|
|
214
|
+
*/
|
|
215
|
+
export async function actorExists(
|
|
216
|
+
db: Database,
|
|
217
|
+
apId: string,
|
|
218
|
+
): Promise<boolean> {
|
|
219
|
+
const [local, cached] = await Promise.all([
|
|
220
|
+
db
|
|
221
|
+
.select({ apId: actors.apId })
|
|
222
|
+
.from(actors)
|
|
223
|
+
.where(and(eq(actors.apId, apId), sql`${actors.deletedAt} IS NULL`))
|
|
224
|
+
.get(),
|
|
225
|
+
db
|
|
226
|
+
.select({ apId: actorCache.apId })
|
|
227
|
+
.from(actorCache)
|
|
228
|
+
.where(eq(actorCache.apId, apId))
|
|
229
|
+
.get(),
|
|
230
|
+
]);
|
|
231
|
+
return !!(local || cached);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Require the current actor from context. Returns the actor or a 401 Response.
|
|
236
|
+
*/
|
|
237
|
+
export function requireActor(c: AppContext): Actor | Response {
|
|
238
|
+
const actor = c.get("actor");
|
|
239
|
+
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
240
|
+
return actor;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Batch-load interaction status (liked, bookmarked, reposted) for the given
|
|
245
|
+
* post AP IDs. Returns empty sets when the actor is not logged in.
|
|
246
|
+
*/
|
|
247
|
+
export async function loadPostInteractions(
|
|
248
|
+
db: Database,
|
|
249
|
+
actorApIdVal: string | null,
|
|
250
|
+
postApIds: string[],
|
|
251
|
+
): Promise<{
|
|
252
|
+
likedIds: Set<string>;
|
|
253
|
+
bookmarkedIds: Set<string>;
|
|
254
|
+
repostedIds: Set<string>;
|
|
255
|
+
}> {
|
|
256
|
+
if (!actorApIdVal || postApIds.length === 0) {
|
|
257
|
+
return {
|
|
258
|
+
likedIds: new Set(),
|
|
259
|
+
bookmarkedIds: new Set(),
|
|
260
|
+
repostedIds: new Set(),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const [likeRows, bookmarkRows, announceRows] = await Promise.all([
|
|
265
|
+
db
|
|
266
|
+
.select({ objectApId: likes.objectApId })
|
|
267
|
+
.from(likes)
|
|
268
|
+
.where(
|
|
269
|
+
and(
|
|
270
|
+
eq(likes.actorApId, actorApIdVal),
|
|
271
|
+
inArray(likes.objectApId, postApIds),
|
|
272
|
+
),
|
|
273
|
+
),
|
|
274
|
+
db
|
|
275
|
+
.select({ objectApId: bookmarks.objectApId })
|
|
276
|
+
.from(bookmarks)
|
|
277
|
+
.where(
|
|
278
|
+
and(
|
|
279
|
+
eq(bookmarks.actorApId, actorApIdVal),
|
|
280
|
+
inArray(bookmarks.objectApId, postApIds),
|
|
281
|
+
),
|
|
282
|
+
),
|
|
283
|
+
db
|
|
284
|
+
.select({ objectApId: announces.objectApId })
|
|
285
|
+
.from(announces)
|
|
286
|
+
.where(
|
|
287
|
+
and(
|
|
288
|
+
eq(announces.actorApId, actorApIdVal),
|
|
289
|
+
inArray(announces.objectApId, postApIds),
|
|
290
|
+
),
|
|
291
|
+
),
|
|
292
|
+
]);
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
likedIds: new Set(likeRows.map((l) => l.objectApId)),
|
|
296
|
+
bookmarkedIds: new Set(bookmarkRows.map((b) => b.objectApId)),
|
|
297
|
+
repostedIds: new Set(announceRows.map((a) => a.objectApId)),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Generic list handler for relation lists (blocked, muted).
|
|
303
|
+
* Fetches paginated relations, batch-loads actor info, and returns formatted summaries.
|
|
304
|
+
*/
|
|
305
|
+
export async function listRelation<
|
|
306
|
+
T extends { [K in ApIdKey]: string },
|
|
307
|
+
ApIdKey extends string,
|
|
308
|
+
>(
|
|
309
|
+
c: AppContext,
|
|
310
|
+
findMany: (
|
|
311
|
+
db: Database,
|
|
312
|
+
actorApIdVal: string,
|
|
313
|
+
limit: number,
|
|
314
|
+
offset: number,
|
|
315
|
+
) => Promise<T[]>,
|
|
316
|
+
apIdKey: ApIdKey,
|
|
317
|
+
responseKey: string,
|
|
318
|
+
): Promise<Response> {
|
|
319
|
+
const result = requireActor(c);
|
|
320
|
+
if (result instanceof Response) return result;
|
|
321
|
+
const actor = result;
|
|
322
|
+
|
|
323
|
+
const db = c.get("db");
|
|
324
|
+
const limit = parseLimit(c.req.query("limit"), 100, 500);
|
|
325
|
+
const offset = parseOffset(c.req.query("offset"), 0, 10000);
|
|
326
|
+
|
|
327
|
+
const rows = await findMany(db, actor.ap_id, limit, offset);
|
|
328
|
+
const targetApIds = rows.map((r) => r[apIdKey]);
|
|
329
|
+
const infoMap = await loadActorInfoMap(db, targetApIds);
|
|
330
|
+
|
|
331
|
+
return c.json({
|
|
332
|
+
[responseKey]: rows.map((r) =>
|
|
333
|
+
formatActorSummary(r[apIdKey], infoMap.get(r[apIdKey])),
|
|
334
|
+
),
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Per-actor cap on block / mute rows. The target ap_id is an arbitrary string
|
|
339
|
+
// (no FK, no existence check — a remote actor need not be resolvable), so
|
|
340
|
+
// without a cap one account could loop POST with distinct synthetic ids and grow
|
|
341
|
+
// the blocks/mutes table without bound. 5000 is far above any real user's set.
|
|
342
|
+
export const MAX_RELATIONS_PER_ACTOR = 5000;
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Generic create handler for relation upserts (block, mute).
|
|
346
|
+
*/
|
|
347
|
+
export async function createRelation(
|
|
348
|
+
c: AppContext,
|
|
349
|
+
verb: string,
|
|
350
|
+
upsert: (
|
|
351
|
+
db: Database,
|
|
352
|
+
actorApIdVal: string,
|
|
353
|
+
targetApId: string,
|
|
354
|
+
) => Promise<unknown>,
|
|
355
|
+
countExisting: (db: Database, actorApIdVal: string) => Promise<number>,
|
|
356
|
+
): Promise<Response> {
|
|
357
|
+
const result = requireActor(c);
|
|
358
|
+
if (result instanceof Response) return result;
|
|
359
|
+
const actor = result;
|
|
360
|
+
|
|
361
|
+
const body = await c.req.json<{ ap_id: string }>();
|
|
362
|
+
if (!body.ap_id) return c.json({ error: "ap_id required" }, 400);
|
|
363
|
+
if (body.ap_id === actor.ap_id) {
|
|
364
|
+
return c.json({ error: `Cannot ${verb} yourself` }, 400);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const db = c.get("db");
|
|
368
|
+
// Bound the per-actor relation set (soft cap; a small concurrent overshoot is
|
|
369
|
+
// harmless). onConflictDoNothing means re-blocking an existing target is a
|
|
370
|
+
// no-op, so this only rejects genuinely-new rows past the limit.
|
|
371
|
+
if ((await countExisting(db, actor.ap_id)) >= MAX_RELATIONS_PER_ACTOR) {
|
|
372
|
+
return c.json({ error: `${verb} limit reached` }, 429);
|
|
373
|
+
}
|
|
374
|
+
await upsert(db, actor.ap_id, body.ap_id);
|
|
375
|
+
|
|
376
|
+
return c.json({ success: true });
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Generic delete handler for relation removals (unblock, unmute).
|
|
381
|
+
*/
|
|
382
|
+
export async function deleteRelation(
|
|
383
|
+
c: AppContext,
|
|
384
|
+
label: string,
|
|
385
|
+
remove: (
|
|
386
|
+
db: Database,
|
|
387
|
+
actorApIdVal: string,
|
|
388
|
+
targetApId: string,
|
|
389
|
+
) => Promise<unknown>,
|
|
390
|
+
): Promise<Response> {
|
|
391
|
+
const result = requireActor(c);
|
|
392
|
+
if (result instanceof Response) return result;
|
|
393
|
+
const actor = result;
|
|
394
|
+
|
|
395
|
+
const body = await c.req.json<{ ap_id: string }>();
|
|
396
|
+
if (!body.ap_id) return c.json({ error: "ap_id required" }, 400);
|
|
397
|
+
|
|
398
|
+
const db = c.get("db");
|
|
399
|
+
await remove(db, actor.ap_id, body.ap_id);
|
|
400
|
+
|
|
401
|
+
return c.json({ success: true });
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Shared handler for followers / following lists.
|
|
406
|
+
*/
|
|
407
|
+
export async function listFollowRelation(
|
|
408
|
+
c: AppContext,
|
|
409
|
+
direction: "followers" | "following",
|
|
410
|
+
): Promise<Response> {
|
|
411
|
+
const identifier = c.req.param("identifier");
|
|
412
|
+
if (!identifier) return c.json({ error: "Actor not found" }, 404);
|
|
413
|
+
const apId = await resolveActorApId(c.get("db"), c.env.APP_URL, identifier);
|
|
414
|
+
if (!apId) return c.json({ error: "Actor not found" }, 404);
|
|
415
|
+
|
|
416
|
+
const limit = parseLimit(c.req.query("limit"), 50, 100);
|
|
417
|
+
const offset = parseOffset(c.req.query("offset"), 0, 10000);
|
|
418
|
+
const db = c.get("db");
|
|
419
|
+
|
|
420
|
+
const isFollowers = direction === "followers";
|
|
421
|
+
const whereCondition = isFollowers
|
|
422
|
+
? and(eq(follows.followingApId, apId), eq(follows.status, "accepted"))
|
|
423
|
+
: and(eq(follows.followerApId, apId), eq(follows.status, "accepted"));
|
|
424
|
+
|
|
425
|
+
// A private (locked) account's follower/following LIST is owner-only — mirror
|
|
426
|
+
// the ActivityPub collection gate (canViewPrivateActorCollections), which this
|
|
427
|
+
// API path was missing, so a locked account's social graph (who follows it /
|
|
428
|
+
// who it follows) is not exposed to non-owners. The count stays (it is shown on
|
|
429
|
+
// the profile); only the member list (the sensitive WHO) is withheld.
|
|
430
|
+
const viewer = c.get("actor");
|
|
431
|
+
const targetRow = await db
|
|
432
|
+
.select({ isPrivate: actors.isPrivate })
|
|
433
|
+
.from(actors)
|
|
434
|
+
.where(eq(actors.apId, apId))
|
|
435
|
+
.get();
|
|
436
|
+
if (targetRow?.isPrivate && viewer?.ap_id !== apId) {
|
|
437
|
+
const total =
|
|
438
|
+
(
|
|
439
|
+
await db
|
|
440
|
+
.select({ count: count() })
|
|
441
|
+
.from(follows)
|
|
442
|
+
.where(whereCondition)
|
|
443
|
+
.get()
|
|
444
|
+
)?.count ?? 0;
|
|
445
|
+
return c.json({ [direction]: [], total, limit, offset, has_more: false });
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const [followRows, totalResult] = await Promise.all([
|
|
449
|
+
db
|
|
450
|
+
.select()
|
|
451
|
+
.from(follows)
|
|
452
|
+
.where(whereCondition)
|
|
453
|
+
// createdAt (nowIso millisecond text) is NOT unique, so OFFSET paging over
|
|
454
|
+
// it alone has undefined order among same-millisecond ties — a tied row can
|
|
455
|
+
// shift across a page boundary and be skipped or duplicated. Append the
|
|
456
|
+
// composite-PK columns as a unique tiebreaker for a deterministic order
|
|
457
|
+
// (mirrors the federated /ap/.../followers keyset paging).
|
|
458
|
+
.orderBy(
|
|
459
|
+
desc(follows.createdAt),
|
|
460
|
+
desc(follows.followerApId),
|
|
461
|
+
desc(follows.followingApId),
|
|
462
|
+
)
|
|
463
|
+
.offset(offset)
|
|
464
|
+
.limit(limit),
|
|
465
|
+
db.select({ count: count() }).from(follows).where(whereCondition).get(),
|
|
466
|
+
]);
|
|
467
|
+
|
|
468
|
+
const total = totalResult?.count ?? 0;
|
|
469
|
+
|
|
470
|
+
const extractApId = isFollowers
|
|
471
|
+
? (f: { followerApId: string }) => f.followerApId
|
|
472
|
+
: (f: { followingApId: string }) => f.followingApId;
|
|
473
|
+
const targetApIds = followRows.map(extractApId);
|
|
474
|
+
const infoMap = await loadActorInfoMap(db, targetApIds);
|
|
475
|
+
const items = followRows.map((f) => {
|
|
476
|
+
const id = extractApId(f);
|
|
477
|
+
return formatActorSummary(id, infoMap.get(id));
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
return c.json({
|
|
481
|
+
[direction]: items,
|
|
482
|
+
total,
|
|
483
|
+
limit,
|
|
484
|
+
offset,
|
|
485
|
+
has_more: offset + items.length < total,
|
|
486
|
+
});
|
|
487
|
+
}
|