@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.
Files changed (185) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +82 -0
  3. package/migrations/0001_init.sql +495 -0
  4. package/migrations/0002_social_remote_actor_edges.sql +92 -0
  5. package/migrations/0003_activity_remote_object_edges.sql +68 -0
  6. package/migrations/0004_blocklist.sql +26 -0
  7. package/migrations/0005_story_community_scope.sql +13 -0
  8. package/migrations/0006_dm_community_read_status.sql +19 -0
  9. package/migrations/0007_moderation_reports.sql +22 -0
  10. package/migrations/0008_actor_fields_aka.sql +18 -0
  11. package/migrations/0009_object_tags.sql +13 -0
  12. package/migrations/0010_object_recipients_drop_actor_fk.sql +34 -0
  13. package/migrations/0011_drop_remote_actor_fks.sql +205 -0
  14. package/migrations/0012_objects_content_fts.sql +39 -0
  15. package/migrations/0013_efficiency_indexes.sql +13 -0
  16. package/migrations/0014_inbox_actor_created_idx.sql +15 -0
  17. package/migrations/0015_community_bans.sql +16 -0
  18. package/migrations/0016_namespace_takos_oidc_subject.sql +19 -0
  19. package/migrations/0017_mobile_push_registrations.sql +22 -0
  20. package/migrations/README.md +122 -0
  21. package/package.json +75 -0
  22. package/packages/api/LICENSE +16 -0
  23. package/packages/api/package.json +30 -0
  24. package/packages/api/src/index.ts +4 -0
  25. package/packages/api/src/lib/api/account.ts +20 -0
  26. package/packages/api/src/lib/api/actors.ts +149 -0
  27. package/packages/api/src/lib/api/auth.ts +46 -0
  28. package/packages/api/src/lib/api/communities.ts +329 -0
  29. package/packages/api/src/lib/api/dm.test.ts +67 -0
  30. package/packages/api/src/lib/api/dm.ts +236 -0
  31. package/packages/api/src/lib/api/fetch.ts +111 -0
  32. package/packages/api/src/lib/api/follow.ts +30 -0
  33. package/packages/api/src/lib/api/media.ts +100 -0
  34. package/packages/api/src/lib/api/moderation.ts +98 -0
  35. package/packages/api/src/lib/api/normalize.ts +71 -0
  36. package/packages/api/src/lib/api/notifications.test.ts +63 -0
  37. package/packages/api/src/lib/api/notifications.ts +61 -0
  38. package/packages/api/src/lib/api/posts.test.ts +110 -0
  39. package/packages/api/src/lib/api/posts.ts +181 -0
  40. package/packages/api/src/lib/api/recommendations.ts +22 -0
  41. package/packages/api/src/lib/api/search.ts +88 -0
  42. package/packages/api/src/lib/api/stories.ts +80 -0
  43. package/packages/api/src/lib/api.ts +15 -0
  44. package/packages/api/src/lib/fetch-with-timeout.ts +42 -0
  45. package/packages/api/src/lib/transport.ts +40 -0
  46. package/packages/api/src/social-server.ts +47 -0
  47. package/packages/api/src/types/index.ts +185 -0
  48. package/scripts/apply-takosumi-migrations.ts +621 -0
  49. package/src/backend/federation-helpers.ts +36 -0
  50. package/src/backend/index.ts +872 -0
  51. package/src/backend/lib/account-migration.ts +106 -0
  52. package/src/backend/lib/activitypub-actor-cache.ts +238 -0
  53. package/src/backend/lib/activitypub-helpers.ts +131 -0
  54. package/src/backend/lib/activitypub-validators.ts +323 -0
  55. package/src/backend/lib/ap-context.ts +16 -0
  56. package/src/backend/lib/ap-ids.ts +101 -0
  57. package/src/backend/lib/ap-response.ts +30 -0
  58. package/src/backend/lib/ap-signing.ts +87 -0
  59. package/src/backend/lib/ap-verify.ts +670 -0
  60. package/src/backend/lib/auth-lockout.ts +230 -0
  61. package/src/backend/lib/backend-paths.ts +34 -0
  62. package/src/backend/lib/base64.ts +30 -0
  63. package/src/backend/lib/blocklist-purge.ts +109 -0
  64. package/src/backend/lib/blocklist.ts +279 -0
  65. package/src/backend/lib/chunk.ts +33 -0
  66. package/src/backend/lib/client-ip.ts +169 -0
  67. package/src/backend/lib/community-visibility.ts +230 -0
  68. package/src/backend/lib/crypto.ts +424 -0
  69. package/src/backend/lib/delivery/circuit.ts +265 -0
  70. package/src/backend/lib/delivery/metrics.ts +30 -0
  71. package/src/backend/lib/delivery/planner.ts +190 -0
  72. package/src/backend/lib/delivery/queue-batching.ts +626 -0
  73. package/src/backend/lib/delivery/queue-delivery.ts +641 -0
  74. package/src/backend/lib/delivery/queue.ts +576 -0
  75. package/src/backend/lib/delivery/transformers.ts +56 -0
  76. package/src/backend/lib/delivery/types.ts +139 -0
  77. package/src/backend/lib/errors.ts +114 -0
  78. package/src/backend/lib/federation-fetch.ts +296 -0
  79. package/src/backend/lib/feed-cursor.ts +57 -0
  80. package/src/backend/lib/feed-exclude.ts +48 -0
  81. package/src/backend/lib/hex.ts +8 -0
  82. package/src/backend/lib/log-mask.ts +213 -0
  83. package/src/backend/lib/logger.ts +285 -0
  84. package/src/backend/lib/mobile-contract.ts +137 -0
  85. package/src/backend/lib/oauth-providers.ts +324 -0
  86. package/src/backend/lib/oauth-utils.ts +148 -0
  87. package/src/backend/lib/oidc-id-token.ts +151 -0
  88. package/src/backend/lib/parse-helpers.ts +31 -0
  89. package/src/backend/lib/post-visibility.ts +190 -0
  90. package/src/backend/lib/session-actor.ts +61 -0
  91. package/src/backend/lib/ssrf.ts +428 -0
  92. package/src/backend/lib/strip-image-metadata.ts +191 -0
  93. package/src/backend/middleware/bearer-auth.ts +70 -0
  94. package/src/backend/middleware/body-limit.ts +212 -0
  95. package/src/backend/middleware/cache.ts +429 -0
  96. package/src/backend/middleware/csrf.ts +130 -0
  97. package/src/backend/middleware/error-handler.ts +77 -0
  98. package/src/backend/middleware/rate-limit.ts +308 -0
  99. package/src/backend/public.ts +21 -0
  100. package/src/backend/routes/account-teardown.ts +430 -0
  101. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +354 -0
  102. package/src/backend/routes/activitypub/handlers/inbound-timestamp.ts +29 -0
  103. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1634 -0
  104. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +547 -0
  105. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +497 -0
  106. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +262 -0
  107. package/src/backend/routes/activitypub/handlers/user-inbox-handlers.ts +35 -0
  108. package/src/backend/routes/activitypub/inbox-types.ts +74 -0
  109. package/src/backend/routes/activitypub/inbox.ts +1191 -0
  110. package/src/backend/routes/activitypub/outbox.ts +0 -0
  111. package/src/backend/routes/activitypub/query-helpers.ts +227 -0
  112. package/src/backend/routes/activitypub.ts +616 -0
  113. package/src/backend/routes/actors-helpers.ts +487 -0
  114. package/src/backend/routes/actors.ts +1311 -0
  115. package/src/backend/routes/apps.ts +313 -0
  116. package/src/backend/routes/auth-helpers.ts +566 -0
  117. package/src/backend/routes/auth.ts +615 -0
  118. package/src/backend/routes/communities/membership-invites.ts +208 -0
  119. package/src/backend/routes/communities/membership-join.ts +335 -0
  120. package/src/backend/routes/communities/membership-members.ts +539 -0
  121. package/src/backend/routes/communities/membership-requests.ts +296 -0
  122. package/src/backend/routes/communities/membership-shared.ts +364 -0
  123. package/src/backend/routes/communities/messages.ts +479 -0
  124. package/src/backend/routes/communities/routes.ts +624 -0
  125. package/src/backend/routes/communities.ts +21 -0
  126. package/src/backend/routes/dm/contacts.ts +525 -0
  127. package/src/backend/routes/dm/conversations-helpers.ts +197 -0
  128. package/src/backend/routes/dm/conversations.ts +25 -0
  129. package/src/backend/routes/dm/messages.ts +658 -0
  130. package/src/backend/routes/dm/query-helpers.ts +85 -0
  131. package/src/backend/routes/dm/read-archive.ts +228 -0
  132. package/src/backend/routes/dm/requests.ts +222 -0
  133. package/src/backend/routes/dm/typing.ts +81 -0
  134. package/src/backend/routes/dm.ts +15 -0
  135. package/src/backend/routes/follow-helpers.ts +370 -0
  136. package/src/backend/routes/follow.ts +588 -0
  137. package/src/backend/routes/media.ts +692 -0
  138. package/src/backend/routes/mobile.ts +159 -0
  139. package/src/backend/routes/moderation.ts +373 -0
  140. package/src/backend/routes/notifications.ts +757 -0
  141. package/src/backend/routes/posts/delete-cascade.ts +330 -0
  142. package/src/backend/routes/posts/interactions.ts +795 -0
  143. package/src/backend/routes/posts/post-helpers.ts +847 -0
  144. package/src/backend/routes/posts/queries.ts +537 -0
  145. package/src/backend/routes/posts/routes.ts +865 -0
  146. package/src/backend/routes/posts/transformers.ts +161 -0
  147. package/src/backend/routes/posts.ts +17 -0
  148. package/src/backend/routes/recommendations.ts +88 -0
  149. package/src/backend/routes/search.ts +730 -0
  150. package/src/backend/routes/stories/interactions.ts +576 -0
  151. package/src/backend/routes/stories/query-helpers.ts +482 -0
  152. package/src/backend/routes/stories/routes.ts +906 -0
  153. package/src/backend/routes/stories.ts +13 -0
  154. package/src/backend/routes/takos-tools/dm.ts +249 -0
  155. package/src/backend/routes/takos-tools/follows.ts +225 -0
  156. package/src/backend/routes/takos-tools/posts.ts +292 -0
  157. package/src/backend/routes/takos-tools/search.ts +228 -0
  158. package/src/backend/routes/takos-tools/timeline.ts +132 -0
  159. package/src/backend/routes/takos-tools/types.ts +10 -0
  160. package/src/backend/routes/takos-tools-response.ts +178 -0
  161. package/src/backend/routes/takos-tools.ts +153 -0
  162. package/src/backend/routes/timeline.ts +755 -0
  163. package/src/backend/runtime/bun.ts +620 -0
  164. package/src/backend/runtime/cloudflare.ts +202 -0
  165. package/src/backend/runtime/compat-bun/types.ts +44 -0
  166. package/src/backend/runtime/memory-kv.ts +104 -0
  167. package/src/backend/runtime/shared.ts +142 -0
  168. package/src/backend/runtime/types.ts +205 -0
  169. package/src/backend/server.ts +636 -0
  170. package/src/backend/types.ts +143 -0
  171. package/src/db/index.ts +97 -0
  172. package/src/db/schema/actors.ts +129 -0
  173. package/src/db/schema/communities.ts +133 -0
  174. package/src/db/schema/date-utils.ts +17 -0
  175. package/src/db/schema/index.ts +17 -0
  176. package/src/db/schema/messaging.ts +241 -0
  177. package/src/db/schema/mobile.ts +37 -0
  178. package/src/db/schema/posts.ts +150 -0
  179. package/src/db/schema/relations.ts +266 -0
  180. package/src/db/schema/reports.ts +33 -0
  181. package/src/db/schema/social.ts +106 -0
  182. package/src/db/schema/stories.ts +70 -0
  183. package/src/db/schema.ts +15 -0
  184. package/src/plugin/public.ts +7 -0
  185. package/src/runtime/site-worker.ts +10 -0
@@ -0,0 +1,106 @@
1
+ // Account-migration (ActivityPub Move) consent verification, shared by the
2
+ // INBOUND Move handler (which only honors a Move whose destination consents) and
3
+ // the OUTBOUND /me/move endpoint (which refuses to advertise a migration the
4
+ // destination has not consented to, so the local user gets an actionable error
5
+ // instead of a silent no-op on every compliant receiver).
6
+
7
+ import { fetchWithTimeout } from "./federation-fetch.ts";
8
+ import { signRequest } from "./ap-signing.ts";
9
+ import type { RemoteFetchSigner } from "./activitypub-actor-cache.ts";
10
+ import { parseWebFinger } from "./activitypub-validators.ts";
11
+ import { isSafeRemoteUrl, normalizeRemoteDomain } from "./ssrf.ts";
12
+
13
+ const ALIAS_FETCH_TIMEOUT_MS = 15000;
14
+
15
+ // `@user@domain` or `user@domain` (a fediverse handle). Rejects embedded
16
+ // whitespace and extra `@` so it never matches a URL or a malformed string.
17
+ const HANDLE_RE = /^@?([^@\s]+)@([^@\s]+)$/;
18
+
19
+ /**
20
+ * Resolve a migration target that may be EITHER a full actor URL or a
21
+ * `@user@domain` fediverse handle (what users actually know — and what the
22
+ * Settings move field's placeholder shows). A handle is resolved via WebFinger
23
+ * to its `self` ActivityPub actor URL; a URL is returned untouched for the
24
+ * caller to SSRF-validate. Returns null if the input is neither a usable URL
25
+ * nor a resolvable handle. Fails closed on any error.
26
+ */
27
+ export async function resolveMoveTarget(input: string): Promise<string | null> {
28
+ const trimmed = input.trim();
29
+ if (/^https?:\/\//i.test(trimmed)) {
30
+ // Full actor URL: the caller still runs isValidHttpUrl + isSafeRemoteUrl.
31
+ return trimmed;
32
+ }
33
+ const match = trimmed.match(HANDLE_RE);
34
+ if (!match) return null;
35
+ const [, username, domain] = match;
36
+ const safeDomain = normalizeRemoteDomain(domain);
37
+ if (!safeDomain) return null;
38
+ try {
39
+ const webfingerUrl = `https://${safeDomain}/.well-known/webfinger?resource=acct:${username}@${safeDomain}`;
40
+ const res = await fetchWithTimeout(webfingerUrl, {
41
+ headers: { Accept: "application/jrd+json" },
42
+ timeout: ALIAS_FETCH_TIMEOUT_MS,
43
+ });
44
+ if (!res.ok) return null;
45
+ const raw: unknown = await res.json();
46
+ const doc = parseWebFinger(raw);
47
+ const self = doc.links?.find(
48
+ (l) => l.rel === "self" && l.type === "application/activity+json",
49
+ );
50
+ if (!self?.href || !isSafeRemoteUrl(self.href)) return null;
51
+ return self.href;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Verify the destination actor of a Move declares the origin actor in its
59
+ * `alsoKnownAs` (the standard Mastodon account-migration consent check): a
60
+ * signed Move only proves the ORIGIN consents to leave; the destination's
61
+ * back-reference is what proves the two accounts are the same person and stops
62
+ * a follower-stealing redirect to an unconsenting account.
63
+ *
64
+ * Fetches the destination actor document fresh and FAILS CLOSED on any error
65
+ * (network failure, non-2xx, malformed document, id mismatch, missing alias).
66
+ * Callers must SSRF-guard `newActorApId` before calling.
67
+ */
68
+ export async function destinationDeclaresAlias(
69
+ newActorApId: string,
70
+ oldActorApId: string,
71
+ signer?: RemoteFetchSigner,
72
+ ): Promise<boolean> {
73
+ try {
74
+ const res = await fetchWithTimeout(newActorApId, {
75
+ headers: {
76
+ Accept: "application/activity+json, application/ld+json",
77
+ // Sign as the instance actor so a destination on a secure-mode
78
+ // instance serves its actor doc — otherwise the alias (consent) check
79
+ // 401s and the Move fails closed even when consent was declared.
80
+ ...(signer
81
+ ? await signRequest(
82
+ signer.privateKeyPem,
83
+ signer.keyId,
84
+ "GET",
85
+ newActorApId,
86
+ )
87
+ : {}),
88
+ },
89
+ timeout: ALIAS_FETCH_TIMEOUT_MS,
90
+ });
91
+ if (!res.ok) return false;
92
+ const raw: unknown = await res.json();
93
+ if (!raw || typeof raw !== "object") return false;
94
+ const doc = raw as { id?: unknown; alsoKnownAs?: unknown };
95
+ if (doc.id !== newActorApId) return false;
96
+ const aka = doc.alsoKnownAs;
97
+ const aliases = Array.isArray(aka)
98
+ ? aka
99
+ : typeof aka === "string"
100
+ ? [aka]
101
+ : [];
102
+ return aliases.includes(oldActorApId);
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Canonical remote-actor fetch + parse + cache helper.
3
+ *
4
+ * Before this module existed, four separate code paths (inbox cold-cache fill,
5
+ * Move-target refresh, delivery resolve-actor, and remote-follow) each inlined
6
+ * their own fetch/parse/guard/upsert block with *divergent* column sets. The
7
+ * inbox path in particular omitted `outbox` / `followersUrl` / `sharedInbox`,
8
+ * so which columns a cached actor row carried depended on whichever path
9
+ * happened to fetch it first. `sharedInbox` is the primary fan-out target for
10
+ * Mastodon-scale servers, so a row first seen via the inbox path silently lost
11
+ * the column that drives delivery — a federation-correctness bug.
12
+ *
13
+ * This helper owns the single canonical SUPERSET cache-field shape and the
14
+ * one fetch/guard/upsert flow, so every cached actor row is now populated
15
+ * identically regardless of entry path.
16
+ */
17
+ import { eq } from "drizzle-orm";
18
+ import { actorCache } from "../../db/index.ts";
19
+ import type { Database } from "../../db/index.ts";
20
+ import {
21
+ fetchWithTimeout,
22
+ isSafeRemoteUrl,
23
+ signRequest,
24
+ } from "../federation-helpers.ts";
25
+ import {
26
+ tryParseRemoteActor,
27
+ type RemoteActorDocument,
28
+ } from "./activitypub-validators.ts";
29
+
30
+ /**
31
+ * The signing identity used to HTTP-sign an outbound actor GET so instances
32
+ * running in authorized-fetch / secure mode (which 401 unsigned GETs) will
33
+ * serve the actor document. `keyId` must resolve to a publicly-fetchable key
34
+ * (e.g. the instance actor's `#main-key`) so the remote can verify us.
35
+ */
36
+ export interface RemoteFetchSigner {
37
+ keyId: string;
38
+ privateKeyPem: string;
39
+ }
40
+
41
+ /**
42
+ * Load the instance actor's signing identity straight from the DB (there is
43
+ * exactly one instance actor row per deployment), WITHOUT lazy-creating it.
44
+ * Returns null if the row does not exist yet — callers then fall back to an
45
+ * unsigned fetch. Used by paths that have only a `db` handle (e.g. inbound
46
+ * signature verification) and not the request context the lazy-creating
47
+ * `getInstanceFetchSigner(c)` needs.
48
+ */
49
+ export async function getInstanceFetchSignerByDb(
50
+ db: Database,
51
+ ): Promise<RemoteFetchSigner | null> {
52
+ const row = await db.query.instanceActor.findFirst({
53
+ columns: { apId: true, privateKeyPem: true },
54
+ });
55
+ if (!row?.privateKeyPem) return null;
56
+ return { keyId: `${row.apId}#main-key`, privateKeyPem: row.privateKeyPem };
57
+ }
58
+
59
+ const DEFAULT_FETCH_TIMEOUT_MS = 15000;
60
+
61
+ /**
62
+ * Remote actor display fields are attacker-controlled — bounded only by the
63
+ * fetched document size, which can run to megabytes. The cached `name` /
64
+ * `summary` / `preferredUsername` columns are rendered verbatim in every feed
65
+ * row and search result, so an unbounded value bloats those payloads (and the
66
+ * handle the client builds). Truncate at the single cache chokepoint, mirroring
67
+ * the local profile caps (display name 50, summary 500). `rawJson` keeps the
68
+ * full document for re-parsing; only the indexed/rendered columns are bounded.
69
+ */
70
+ const MAX_REMOTE_NAME_LENGTH = 50;
71
+ const MAX_REMOTE_SUMMARY_LENGTH = 500;
72
+ const MAX_REMOTE_USERNAME_LENGTH = 100;
73
+
74
+ function boundField(s: string | null | undefined, max: number): string | null {
75
+ if (typeof s !== "string" || s.length === 0) return null;
76
+ return s.length > max ? s.slice(0, max) : s;
77
+ }
78
+
79
+ /** Drizzle insert-values shape for the `actor_cache` table. */
80
+ type ActorCacheInsert = typeof actorCache.$inferInsert;
81
+
82
+ /**
83
+ * The ONE canonical superset of columns written to `actor_cache`. Every fetch
84
+ * path goes through this so no entry point can silently drop a column (notably
85
+ * `outbox` / `followersUrl` / `sharedInbox`, the delivery-relevant ones).
86
+ */
87
+ export function buildActorCacheFields(
88
+ data: RemoteActorDocument,
89
+ ): Omit<ActorCacheInsert, "apId" | "createdAt"> {
90
+ return {
91
+ type: data.type || "Person",
92
+ preferredUsername: boundField(
93
+ data.preferredUsername,
94
+ MAX_REMOTE_USERNAME_LENGTH,
95
+ ),
96
+ name: boundField(data.name, MAX_REMOTE_NAME_LENGTH),
97
+ summary: boundField(data.summary, MAX_REMOTE_SUMMARY_LENGTH),
98
+ iconUrl: data.icon?.url || null,
99
+ inbox: data.inbox!,
100
+ outbox: data.outbox || null,
101
+ followersUrl: data.followers || null,
102
+ followingUrl: data.following || null,
103
+ sharedInbox: data.endpoints?.sharedInbox || null,
104
+ publicKeyId: data.publicKey?.id || null,
105
+ publicKeyPem: data.publicKey?.publicKeyPem || null,
106
+ rawJson: JSON.stringify(data),
107
+ lastFetchedAt: new Date().toISOString(),
108
+ };
109
+ }
110
+
111
+ /** Why a fetch+upsert did not produce a cached row. */
112
+ export type ActorCacheFailureReason =
113
+ | "fetch_failed" // network/timeout error or thrown during fetch
114
+ | "fetch_not_ok" // non-2xx HTTP response
115
+ | "invalid_document" // body did not parse as a remote actor
116
+ | "id_mismatch" // returned `id` did not match the requested URL
117
+ | "missing_inbox" // no inbox, or inbox/id failed the SSRF safety check
118
+ | "missing_public_key"; // required public key absent (mode === "require-key")
119
+
120
+ export type ActorCacheResult =
121
+ | { ok: true; data: RemoteActorDocument; row: typeof actorCache.$inferSelect }
122
+ | { ok: false; reason: ActorCacheFailureReason };
123
+
124
+ export interface FetchAndUpsertActorCacheOptions {
125
+ /** Fetch timeout in ms. Defaults to 15s. */
126
+ timeout?: number;
127
+ /**
128
+ * `"upsert"` (default) refreshes an existing row via `onConflictDoUpdate`.
129
+ * `"insert"` is cache-when-absent: it uses `onConflictDoNothing`, so a row
130
+ * that already exists is left untouched and the just-fetched `row` is still
131
+ * returned by re-reading it.
132
+ */
133
+ mode?: "upsert" | "insert";
134
+ /**
135
+ * When `"require-key"`, an actor document without a `publicKey.publicKeyPem`
136
+ * is rejected with `missing_public_key`. Defaults to `"allow-keyless"`,
137
+ * matching the refresh/delivery paths that tolerate a missing key.
138
+ */
139
+ publicKey?: "require-key" | "allow-keyless";
140
+ /**
141
+ * When provided, the outbound GET is HTTP-signed with this identity so a
142
+ * remote running in authorized-fetch / secure mode serves the document
143
+ * instead of 401ing the unsigned request. Omit for plain (unsigned) fetches.
144
+ */
145
+ signer?: RemoteFetchSigner;
146
+ }
147
+
148
+ /**
149
+ * Fetch a remote actor document, validate it, and upsert it into
150
+ * `actor_cache` using the single canonical column set. Returns a discriminated
151
+ * result so callers can surface their own error responses while still sharing
152
+ * the fetch/guard/upsert logic.
153
+ *
154
+ * Guards (in order): SSRF safety on the requested URL, HTTP ok, parseable
155
+ * actor document, `id` equals the requested URL, inbox present and SSRF-safe,
156
+ * and (optionally) a public key present.
157
+ */
158
+ export async function fetchAndUpsertActorCache(
159
+ db: Database,
160
+ actorApId: string,
161
+ options: FetchAndUpsertActorCacheOptions = {},
162
+ ): Promise<ActorCacheResult> {
163
+ const {
164
+ timeout = DEFAULT_FETCH_TIMEOUT_MS,
165
+ mode = "upsert",
166
+ publicKey = "allow-keyless",
167
+ signer,
168
+ } = options;
169
+
170
+ if (!isSafeRemoteUrl(actorApId)) {
171
+ return { ok: false, reason: "missing_inbox" };
172
+ }
173
+
174
+ let data: RemoteActorDocument | null;
175
+ try {
176
+ const headers: Record<string, string> = {
177
+ Accept: "application/activity+json, application/ld+json",
178
+ };
179
+ if (signer) {
180
+ // Authorized-fetch: sign the bodyless GET as the instance actor so a
181
+ // secure-mode remote (which 401s unsigned GETs) serves the document.
182
+ // signRequest covers `(request-target) host date` for a bodyless request.
183
+ Object.assign(
184
+ headers,
185
+ await signRequest(signer.privateKeyPem, signer.keyId, "GET", actorApId),
186
+ );
187
+ }
188
+ const res = await fetchWithTimeout(actorApId, {
189
+ headers,
190
+ timeout,
191
+ });
192
+ if (!res.ok) return { ok: false, reason: "fetch_not_ok" };
193
+ const raw: unknown = await res.json();
194
+ data = tryParseRemoteActor(raw);
195
+ } catch {
196
+ return { ok: false, reason: "fetch_failed" };
197
+ }
198
+
199
+ if (!data) return { ok: false, reason: "invalid_document" };
200
+ if (data.id !== actorApId) return { ok: false, reason: "id_mismatch" };
201
+ if (
202
+ !data.inbox ||
203
+ !isSafeRemoteUrl(data.id) ||
204
+ !isSafeRemoteUrl(data.inbox)
205
+ ) {
206
+ return { ok: false, reason: "missing_inbox" };
207
+ }
208
+ if (publicKey === "require-key" && !data.publicKey?.publicKeyPem) {
209
+ return { ok: false, reason: "missing_public_key" };
210
+ }
211
+
212
+ const fields = buildActorCacheFields(data);
213
+
214
+ if (mode === "insert") {
215
+ // Cache-when-absent: leave an existing row untouched. The early-existence
216
+ // check at the call site is best-effort, so two isolates racing the same
217
+ // cold actor can both reach this insert; `onConflictDoNothing` keeps that
218
+ // race-safe instead of throwing a primary-key violation.
219
+ await db
220
+ .insert(actorCache)
221
+ .values({ apId: data.id, ...fields })
222
+ .onConflictDoNothing();
223
+ } else {
224
+ await db
225
+ .insert(actorCache)
226
+ .values({ apId: data.id, ...fields })
227
+ .onConflictDoUpdate({ target: actorCache.apId, set: fields });
228
+ }
229
+
230
+ const row = await db
231
+ .select()
232
+ .from(actorCache)
233
+ .where(eq(actorCache.apId, data.id))
234
+ .get();
235
+ if (!row) return { ok: false, reason: "fetch_failed" };
236
+
237
+ return { ok: true, data, row };
238
+ }
@@ -0,0 +1,131 @@
1
+ import type { Actor } from "../types.ts";
2
+
3
+ interface StoryData {
4
+ apId: string;
5
+ attributedTo: string;
6
+ attachment: {
7
+ type: string;
8
+ mediaType: string;
9
+ url: string;
10
+ r2_key: string;
11
+ };
12
+ displayDuration: string;
13
+ // Optional caption/text shown over the story. Federated to remote instances
14
+ // as the AS2 Note `content` so they can render the same caption locally.
15
+ caption?: string;
16
+ overlays?: unknown[];
17
+ endTime: string;
18
+ published: string;
19
+ }
20
+
21
+ /**
22
+ * Safely join a base URL and a path segment.
23
+ * Returns the path unchanged if it is already an absolute URL.
24
+ */
25
+ export function safeUrlJoin(baseUrl: string, path: string): string {
26
+ if (path.startsWith("http://") || path.startsWith("https://")) {
27
+ return path;
28
+ }
29
+
30
+ const cleanBase = baseUrl.replace(/\/+$/, "");
31
+ const normalizedPath = path.startsWith("/") ? path : "/" + path;
32
+
33
+ try {
34
+ const base = new URL(cleanBase);
35
+ return base.origin + base.pathname.replace(/\/+$/, "") + normalizedPath;
36
+ } catch {
37
+ return cleanBase + normalizedPath;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Map stored attachment rows to AP-standard `Document` attachments for every
43
+ * federation egress point (the embedded object in an outbound Create, and the
44
+ * served `/ap/objects/:id` document). The internal shape is
45
+ * `{ url: "/media/<hash>", content_type, r2_key }`; a remote needs
46
+ * `{ type: "Document", mediaType, url: <absolute> }`:
47
+ * - `url` is absolutized (a relative `/media/...` would resolve against the
48
+ * REMOTE's origin and 404);
49
+ * - `content_type` is renamed to `mediaType` — the AP/Mastodon field used to
50
+ * recognize and render the media (without it, the image may not display);
51
+ * - `type: "Document"` is added (the standard AP media-attachment type);
52
+ * - the internal `r2_key` (and any other non-AP field) is DROPPED so storage
53
+ * details never leak to remotes;
54
+ * - `name` (alt text) is preserved when present.
55
+ * Attachments without a usable `url` are skipped.
56
+ */
57
+ export function toApAttachments(
58
+ attachments: unknown[],
59
+ baseUrl: string,
60
+ ): Array<Record<string, unknown>> {
61
+ return attachments.flatMap((att) => {
62
+ if (!att || typeof att !== "object" || Array.isArray(att)) return [];
63
+ const a = att as Record<string, unknown>;
64
+ const url = typeof a.url === "string" ? a.url : "";
65
+ if (url.length === 0) return [];
66
+ const mediaType =
67
+ (typeof a.mediaType === "string" && a.mediaType) ||
68
+ (typeof a.content_type === "string" && a.content_type) ||
69
+ undefined;
70
+ const name =
71
+ typeof a.name === "string" && a.name.length > 0 ? a.name : undefined;
72
+ return [
73
+ {
74
+ type: "Document",
75
+ ...(mediaType ? { mediaType } : {}),
76
+ url: safeUrlJoin(baseUrl, url),
77
+ ...(name ? { name } : {}),
78
+ },
79
+ ];
80
+ });
81
+ }
82
+
83
+ /**
84
+ * Convert a Story to ActivityPub format
85
+ */
86
+ export function storyToActivityPub(
87
+ story: StoryData,
88
+ actor: Actor,
89
+ baseUrl: string,
90
+ ): object {
91
+ const attachmentUrl = safeUrlJoin(baseUrl, story.attachment.url);
92
+
93
+ return {
94
+ // Terms are inlined (not just a remote context URL) so plain AS2 consumers
95
+ // need not dereference https://yurucommu.com/ns/story. This object MUST stay
96
+ // byte-for-term identical to the published context at
97
+ // the public yurucommu namespace context hosted by the official client site.
98
+ "@context": [
99
+ "https://www.w3.org/ns/activitystreams",
100
+ {
101
+ story: "https://yurucommu.com/ns/story#",
102
+ xsd: "http://www.w3.org/2001/XMLSchema#",
103
+ Story: "story:Story",
104
+ displayDuration: {
105
+ "@id": "story:displayDuration",
106
+ "@type": "xsd:duration",
107
+ },
108
+ overlays: { "@id": "story:overlays", "@container": "@list" },
109
+ position: "story:position",
110
+ },
111
+ ],
112
+ id: story.apId,
113
+ type: ["Story", "Note"],
114
+ attributedTo: actor.ap_id,
115
+ published: story.published,
116
+ endTime: story.endTime,
117
+ to: [`${actor.ap_id}/followers`],
118
+ // The story caption is the Note text; emit it so remote instances render
119
+ // the same caption. Omitted entirely when there is no caption.
120
+ ...(story.caption ? { content: story.caption } : {}),
121
+ attachment: {
122
+ type: story.attachment.type,
123
+ mediaType: story.attachment.mediaType,
124
+ url: attachmentUrl,
125
+ },
126
+ displayDuration: story.displayDuration,
127
+ ...(story.overlays && story.overlays.length > 0
128
+ ? { overlays: story.overlays }
129
+ : {}),
130
+ };
131
+ }