@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,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
|
+
}
|