@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,616 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { Context } from "hono";
|
|
3
|
+
import { and, count, eq, sql } from "drizzle-orm";
|
|
4
|
+
import type { Env, Variables } from "../types.ts";
|
|
5
|
+
import { actors, objects as objectsTable } from "../../db/index.ts";
|
|
6
|
+
import { notDeleted } from "../../db/index.ts";
|
|
7
|
+
import { actorApId, getDomain, safeJsonParse } from "../federation-helpers.ts";
|
|
8
|
+
import { communityApId } from "../lib/ap-ids.ts";
|
|
9
|
+
import {
|
|
10
|
+
getInstanceActor,
|
|
11
|
+
INSTANCE_ACTOR_USERNAME,
|
|
12
|
+
loadFederatedCommunity,
|
|
13
|
+
} from "./activitypub/query-helpers.ts";
|
|
14
|
+
import inboxRoutes from "./activitypub/inbox.ts";
|
|
15
|
+
import outboxRoutes from "./activitypub/outbox.ts";
|
|
16
|
+
import { CacheTags, CacheTTL, withCache } from "../middleware/cache.ts";
|
|
17
|
+
import { safeUrlJoin } from "../lib/activitypub-helpers.ts";
|
|
18
|
+
import { activityJson, jrdJson } from "../lib/ap-response.ts";
|
|
19
|
+
|
|
20
|
+
type HonoContext = Context<{ Bindings: Env; Variables: Variables }>;
|
|
21
|
+
|
|
22
|
+
const ap = new Hono<{ Bindings: Env; Variables: Variables }>();
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Shared constants and helpers
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
const AP_CONTENT_TYPE = "application/activity+json";
|
|
29
|
+
|
|
30
|
+
// Default software.version advertised in NodeInfo when the deploy pipeline
|
|
31
|
+
// does not inject `YURUCOMMU_SOFTWARE_VERSION`. Keep this in sync with
|
|
32
|
+
// package.json; the env override lets the build report the real build version
|
|
33
|
+
// without editing source.
|
|
34
|
+
const YURUCOMMU_VERSION = "2.0.0";
|
|
35
|
+
|
|
36
|
+
const AP_CONTEXT = [
|
|
37
|
+
"https://www.w3.org/ns/activitystreams",
|
|
38
|
+
"https://w3id.org/security/v1",
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
41
|
+
// Conventional Mastodon-style JSON-LD context extension. The served Person
|
|
42
|
+
// actor doc uses PropertyValue attachments, alsoKnownAs / movedTo migration
|
|
43
|
+
// declarations, and manuallyApprovesFollowers; strict consumers (Mastodon)
|
|
44
|
+
// only interpret these terms when they are declared in @context. Mirrors what
|
|
45
|
+
// Mastodon emits on its own actor documents.
|
|
46
|
+
const ACTOR_CONTEXT_EXTENSION = {
|
|
47
|
+
schema: "http://schema.org#",
|
|
48
|
+
PropertyValue: "schema:PropertyValue",
|
|
49
|
+
value: "schema:value",
|
|
50
|
+
toot: "http://joinmastodon.org/ns#",
|
|
51
|
+
alsoKnownAs: { "@id": "as:alsoKnownAs", "@type": "@id" },
|
|
52
|
+
movedTo: { "@id": "as:movedTo", "@type": "@id" },
|
|
53
|
+
manuallyApprovesFollowers: "as:manuallyApprovesFollowers",
|
|
54
|
+
// The actor docs emit `discoverable` (Mastodon's search-indexing opt-out
|
|
55
|
+
// signal); declare its term so a strict JSON-LD processor doesn't drop it on
|
|
56
|
+
// expansion. Mirrors Mastodon's own context.
|
|
57
|
+
discoverable: "toot:discoverable",
|
|
58
|
+
} as const;
|
|
59
|
+
|
|
60
|
+
// Full @context for the served Person actor doc: AS2 + security + the
|
|
61
|
+
// Mastodon-style extension terms above.
|
|
62
|
+
const ACTOR_CONTEXT = [...AP_CONTEXT, ACTOR_CONTEXT_EXTENSION] as const;
|
|
63
|
+
|
|
64
|
+
/** Build a standard WebFinger JRD response. */
|
|
65
|
+
function buildWebFingerResponse(
|
|
66
|
+
username: string,
|
|
67
|
+
domain: string,
|
|
68
|
+
apId: string,
|
|
69
|
+
profileHref: string,
|
|
70
|
+
): Record<string, unknown> {
|
|
71
|
+
return {
|
|
72
|
+
subject: `acct:${username}@${domain}`,
|
|
73
|
+
aliases: [apId],
|
|
74
|
+
links: [
|
|
75
|
+
{ rel: "self", type: AP_CONTENT_TYPE, href: apId },
|
|
76
|
+
{
|
|
77
|
+
rel: "http://webfinger.net/rel/profile-page",
|
|
78
|
+
type: "text/html",
|
|
79
|
+
href: profileHref,
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Build an ActivityPub public-key block for an actor. */
|
|
86
|
+
function buildPublicKey(
|
|
87
|
+
actorApId: string,
|
|
88
|
+
publicKeyPem: string,
|
|
89
|
+
): Record<string, string> {
|
|
90
|
+
return {
|
|
91
|
+
id: `${actorApId}#main-key`,
|
|
92
|
+
owner: actorApId,
|
|
93
|
+
publicKeyPem,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Normalize a stored timestamp to an xsd:dateTime string for AS2 `published`.
|
|
99
|
+
* Post timestamps are written with `toISOString()`, but an actor's `created_at`
|
|
100
|
+
* is a SQLite `datetime('now')` value (`YYYY-MM-DD HH:MM:SS[.mmm]`, UTC, space-
|
|
101
|
+
* separated, no zone) — emitting it verbatim yields an INVALID xsd:dateTime
|
|
102
|
+
* (Mastodon can't parse the join date). Convert it to ISO 8601; pass through
|
|
103
|
+
* values that already carry the `T` separator.
|
|
104
|
+
*/
|
|
105
|
+
function toIso8601(value: string | null | undefined): string | undefined {
|
|
106
|
+
if (!value) return undefined;
|
|
107
|
+
if (value.includes("T")) return value;
|
|
108
|
+
const d = new Date(value.replace(" ", "T") + "Z");
|
|
109
|
+
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function countRows(
|
|
113
|
+
c: HonoContext,
|
|
114
|
+
table: typeof actors | typeof objectsTable,
|
|
115
|
+
where: ReturnType<typeof and> | ReturnType<typeof eq>,
|
|
116
|
+
): Promise<number> {
|
|
117
|
+
const db = c.get("db");
|
|
118
|
+
const rows = await db.select({ total: count() }).from(table).where(where);
|
|
119
|
+
return Number(rows[0]?.total ?? 0);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function canViewPrivateActorCollections(
|
|
123
|
+
c: HonoContext,
|
|
124
|
+
actorApId: string,
|
|
125
|
+
): boolean {
|
|
126
|
+
const viewer = c.get("actor");
|
|
127
|
+
return viewer?.ap_id === actorApId;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// NodeInfo - Instance Metadata
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
ap.get("/.well-known/nodeinfo", (c) => {
|
|
135
|
+
const baseUrl = c.env.APP_URL.replace(/\/+$/, "");
|
|
136
|
+
// Advertise BOTH 2.0 and 2.1. NodeInfo 2.0 is the baseline version the widest
|
|
137
|
+
// set of fediverse servers / relays / statistics crawlers fetch; advertising
|
|
138
|
+
// only 2.1 (and letting /nodeinfo/2.0 fall through to the SPA HTML shell) made
|
|
139
|
+
// those consumers see HTML instead of JSON. Consumers pick the highest schema
|
|
140
|
+
// they understand from this list.
|
|
141
|
+
return c.json({
|
|
142
|
+
links: [
|
|
143
|
+
{
|
|
144
|
+
rel: "http://nodeinfo.diaspora.software/ns/schema/2.0",
|
|
145
|
+
href: `${baseUrl}/nodeinfo/2.0`,
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
rel: "http://nodeinfo.diaspora.software/ns/schema/2.1",
|
|
149
|
+
href: `${baseUrl}/nodeinfo/2.1`,
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Shared instance telemetry for both NodeInfo schema versions. The only
|
|
156
|
+
// difference between 2.0 and 2.1 output is the `software` block (2.1 adds
|
|
157
|
+
// `repository`) and the `version` discriminator, so the rest lives here.
|
|
158
|
+
async function nodeinfoCommon(c: HonoContext) {
|
|
159
|
+
const [totalUsers, localPosts] = await Promise.all([
|
|
160
|
+
countRows(c, actors, notDeleted(actors)),
|
|
161
|
+
countRows(
|
|
162
|
+
c,
|
|
163
|
+
objectsTable,
|
|
164
|
+
and(
|
|
165
|
+
eq(objectsTable.type, "Note"),
|
|
166
|
+
eq(objectsTable.isLocal, 1),
|
|
167
|
+
notDeleted(objectsTable),
|
|
168
|
+
),
|
|
169
|
+
),
|
|
170
|
+
]);
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
protocols: ["activitypub"],
|
|
174
|
+
services: {
|
|
175
|
+
inbound: [],
|
|
176
|
+
outbound: [],
|
|
177
|
+
},
|
|
178
|
+
usage: {
|
|
179
|
+
// We do not track per-user last-activity timestamps, so we report only
|
|
180
|
+
// the total user count and OMIT activeMonth / activeHalfyear rather than
|
|
181
|
+
// fabricating them (NodeInfo permits omitting the active-window fields).
|
|
182
|
+
// Reporting total as "active" would advertise false liveness telemetry
|
|
183
|
+
// to relay/instance directories.
|
|
184
|
+
users: {
|
|
185
|
+
total: totalUsers,
|
|
186
|
+
},
|
|
187
|
+
localPosts,
|
|
188
|
+
},
|
|
189
|
+
openRegistrations: false,
|
|
190
|
+
metadata: {
|
|
191
|
+
singleUser: true,
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
ap.get("/nodeinfo/2.0", async (c) => {
|
|
197
|
+
return c.json({
|
|
198
|
+
version: "2.0",
|
|
199
|
+
software: {
|
|
200
|
+
name: "yurucommu",
|
|
201
|
+
// Real running version (build-injected env, else the in-sync default
|
|
202
|
+
// constant). NodeInfo 2.0's software block has no `repository` field —
|
|
203
|
+
// that was introduced in 2.1.
|
|
204
|
+
version: c.env.YURUCOMMU_SOFTWARE_VERSION || YURUCOMMU_VERSION,
|
|
205
|
+
},
|
|
206
|
+
...(await nodeinfoCommon(c)),
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
ap.get("/nodeinfo/2.1", async (c) => {
|
|
211
|
+
return c.json({
|
|
212
|
+
version: "2.1",
|
|
213
|
+
software: {
|
|
214
|
+
name: "yurucommu",
|
|
215
|
+
// Real running version (build-injected env, else the in-sync default
|
|
216
|
+
// constant) instead of a hardcoded literal that silently goes stale.
|
|
217
|
+
version: c.env.YURUCOMMU_SOFTWARE_VERSION || YURUCOMMU_VERSION,
|
|
218
|
+
repository: "https://github.com/tako0614/yurucommu",
|
|
219
|
+
},
|
|
220
|
+
...(await nodeinfoCommon(c)),
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// WebFinger - Actor Discovery (cached 1 hour)
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
// host-meta: the legacy XRD discovery document that points a peer holding only
|
|
229
|
+
// our domain at the WebFinger endpoint (the `lrdd` link template). Modern
|
|
230
|
+
// servers fetch /.well-known/webfinger directly, but some crawlers and older
|
|
231
|
+
// software still resolve host-meta first — without this route it fell through
|
|
232
|
+
// to the SPA HTML shell. The template's `{uri}` is a literal placeholder the
|
|
233
|
+
// caller substitutes, so it must not be URL-encoded.
|
|
234
|
+
ap.get("/.well-known/host-meta", (c) => {
|
|
235
|
+
const baseUrl = c.env.APP_URL.replace(/\/+$/, "");
|
|
236
|
+
const xrd =
|
|
237
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n' +
|
|
238
|
+
'<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">\n' +
|
|
239
|
+
` <Link rel="lrdd" type="application/jrd+json" template="${baseUrl}/.well-known/webfinger?resource={uri}"/>\n` +
|
|
240
|
+
"</XRD>\n";
|
|
241
|
+
c.header("Content-Type", "application/xrd+xml; charset=utf-8");
|
|
242
|
+
return c.body(xrd);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
ap.get(
|
|
246
|
+
"/.well-known/webfinger",
|
|
247
|
+
withCache({
|
|
248
|
+
ttl: CacheTTL.WEBFINGER,
|
|
249
|
+
cacheTag: CacheTags.WEBFINGER,
|
|
250
|
+
queryParamsToInclude: ["resource"],
|
|
251
|
+
}),
|
|
252
|
+
async (c) => {
|
|
253
|
+
const db = c.get("db");
|
|
254
|
+
const resource = c.req.query("resource");
|
|
255
|
+
if (!resource) return c.json({ error: "resource parameter required" }, 400);
|
|
256
|
+
|
|
257
|
+
// Parse resource format: acct:username@domain or https://domain/ap/users/username
|
|
258
|
+
let username: string | null = null;
|
|
259
|
+
let domain: string | null = null;
|
|
260
|
+
|
|
261
|
+
if (resource.startsWith("acct:")) {
|
|
262
|
+
const acctPart = resource.slice(5);
|
|
263
|
+
const [user, host] = acctPart.split("@");
|
|
264
|
+
username = user;
|
|
265
|
+
// DNS host names are case-insensitive (RFC 4343). Lowercase the host so a
|
|
266
|
+
// mixed-case authority (`acct:alice@Example.Test`) matches currentDomain
|
|
267
|
+
// (always lowercased by the URL parser) — the `http` branch below gets this
|
|
268
|
+
// for free from `new URL(...).host`.
|
|
269
|
+
domain = host ? host.toLowerCase() : null;
|
|
270
|
+
} else if (resource.startsWith("http")) {
|
|
271
|
+
try {
|
|
272
|
+
const url = new URL(resource);
|
|
273
|
+
domain = url.host;
|
|
274
|
+
const match = resource.match(/\/users\/([^\/]+)$/);
|
|
275
|
+
if (match) {
|
|
276
|
+
username = match[1];
|
|
277
|
+
}
|
|
278
|
+
} catch {
|
|
279
|
+
return c.json({ error: "Invalid resource format" }, 400);
|
|
280
|
+
}
|
|
281
|
+
} else {
|
|
282
|
+
return c.json({ error: "Invalid resource format" }, 400);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (!username || !domain) {
|
|
286
|
+
return c.json(
|
|
287
|
+
{
|
|
288
|
+
error: "Invalid resource format",
|
|
289
|
+
},
|
|
290
|
+
400,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const baseUrl = c.env.APP_URL;
|
|
295
|
+
const currentDomain = getDomain(baseUrl);
|
|
296
|
+
|
|
297
|
+
if (domain !== currentDomain) {
|
|
298
|
+
return c.json({ error: "Actor not found" }, 404);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (username.toLowerCase() === INSTANCE_ACTOR_USERNAME.toLowerCase()) {
|
|
302
|
+
const instanceActor = await getInstanceActor(c);
|
|
303
|
+
return jrdJson(
|
|
304
|
+
c,
|
|
305
|
+
buildWebFingerResponse(
|
|
306
|
+
INSTANCE_ACTOR_USERNAME,
|
|
307
|
+
domain,
|
|
308
|
+
instanceActor.apId,
|
|
309
|
+
`${baseUrl}/groups`,
|
|
310
|
+
),
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const resolvedActor = await db.query.actors.findFirst({
|
|
315
|
+
// Case-insensitive handle resolution: a remote may WebFinger a local handle
|
|
316
|
+
// with different casing than stored (RFC 7033 local-parts are technically
|
|
317
|
+
// case-sensitive but most fediverse software preserves typed casing), and a
|
|
318
|
+
// case-sensitive `eq` would 404 it and break inbound discovery.
|
|
319
|
+
where: and(
|
|
320
|
+
sql`lower(${actors.preferredUsername}) = ${username.toLowerCase()}`,
|
|
321
|
+
notDeleted(actors),
|
|
322
|
+
),
|
|
323
|
+
columns: { apId: true, preferredUsername: true },
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// An unknown local handle MUST 404 — never fall back to the owner actor.
|
|
327
|
+
// The old fallback resolved ANY `acct:<anything>@<domain>` to the owner with
|
|
328
|
+
// a `subject` echoing the requested (wrong) username, which (1) violates
|
|
329
|
+
// WebFinger (the subject must identify the account the links describe), (2)
|
|
330
|
+
// claims every possible username exists, and (3) does not even work for
|
|
331
|
+
// federation: a conformant peer fetches the resolved actor, reads its real
|
|
332
|
+
// `preferredUsername`, and re-WebFingers THAT — the round-trip subject then
|
|
333
|
+
// mismatches the original query and the actor is rejected. So the fallback
|
|
334
|
+
// had no working upside and real downsides (identity confusion / spoofed
|
|
335
|
+
// existence). Resolve only an exact local actor — but a handle may instead
|
|
336
|
+
// name a federated COMMUNITY, so try a public Group actor before 404ing.
|
|
337
|
+
if (!resolvedActor) {
|
|
338
|
+
const community = await loadFederatedCommunity(
|
|
339
|
+
db,
|
|
340
|
+
communityApId(baseUrl.replace(/\/+$/, ""), username),
|
|
341
|
+
);
|
|
342
|
+
if (community) {
|
|
343
|
+
return jrdJson(
|
|
344
|
+
c,
|
|
345
|
+
buildWebFingerResponse(
|
|
346
|
+
community.preferredUsername,
|
|
347
|
+
domain,
|
|
348
|
+
community.apId,
|
|
349
|
+
`${baseUrl}/groups/${community.preferredUsername}`,
|
|
350
|
+
),
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
return c.json({ error: "Actor not found" }, 404);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Echo the CANONICAL username as the subject (not the requested casing) so
|
|
357
|
+
// the WebFinger round-trip a remote performs stays self-consistent.
|
|
358
|
+
const canonicalUsername = resolvedActor.preferredUsername;
|
|
359
|
+
return jrdJson(
|
|
360
|
+
c,
|
|
361
|
+
buildWebFingerResponse(
|
|
362
|
+
canonicalUsername,
|
|
363
|
+
domain,
|
|
364
|
+
resolvedActor.apId,
|
|
365
|
+
`${baseUrl}/users/${canonicalUsername}`,
|
|
366
|
+
),
|
|
367
|
+
);
|
|
368
|
+
},
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
// Actor Profile Endpoint (cached 10 minutes)
|
|
373
|
+
// ---------------------------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
ap.get(
|
|
376
|
+
"/ap/users/:username",
|
|
377
|
+
// NOTE: no varyByActor — the session actor is never extracted on /ap/* (that
|
|
378
|
+
// middleware is mounted only on /api, /media, /.takos/tools), so this is an
|
|
379
|
+
// anonymous public AP document. The owner-only showCollections branch is
|
|
380
|
+
// therefore unreachable here by design (AP identity is the HTTP signature, not
|
|
381
|
+
// a session); a per-viewer flag would be dead + a latent footgun.
|
|
382
|
+
withCache({
|
|
383
|
+
ttl: CacheTTL.ACTIVITYPUB_ACTOR,
|
|
384
|
+
cacheTag: CacheTags.ACTOR,
|
|
385
|
+
}),
|
|
386
|
+
async (c) => {
|
|
387
|
+
const db = c.get("db");
|
|
388
|
+
const username = c.req.param("username");
|
|
389
|
+
const baseUrl = c.env.APP_URL;
|
|
390
|
+
const apId = actorApId(baseUrl, username);
|
|
391
|
+
|
|
392
|
+
const actor = await db.query.actors.findFirst({
|
|
393
|
+
where: and(eq(actors.apId, apId), notDeleted(actors)),
|
|
394
|
+
columns: {
|
|
395
|
+
apId: true,
|
|
396
|
+
type: true,
|
|
397
|
+
preferredUsername: true,
|
|
398
|
+
name: true,
|
|
399
|
+
summary: true,
|
|
400
|
+
iconUrl: true,
|
|
401
|
+
headerUrl: true,
|
|
402
|
+
inbox: true,
|
|
403
|
+
outbox: true,
|
|
404
|
+
followersUrl: true,
|
|
405
|
+
followingUrl: true,
|
|
406
|
+
publicKeyPem: true,
|
|
407
|
+
followerCount: true,
|
|
408
|
+
followingCount: true,
|
|
409
|
+
postCount: true,
|
|
410
|
+
isPrivate: true,
|
|
411
|
+
createdAt: true,
|
|
412
|
+
fieldsJson: true,
|
|
413
|
+
alsoKnownAsJson: true,
|
|
414
|
+
movedTo: true,
|
|
415
|
+
},
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
if (!actor) return c.json({ error: "Actor not found" }, 404);
|
|
419
|
+
|
|
420
|
+
const showCollections =
|
|
421
|
+
!actor.isPrivate || canViewPrivateActorCollections(c, actor.apId);
|
|
422
|
+
|
|
423
|
+
const actorResponse: Record<string, unknown> = {
|
|
424
|
+
"@context": ACTOR_CONTEXT,
|
|
425
|
+
id: actor.apId,
|
|
426
|
+
type: actor.type,
|
|
427
|
+
preferredUsername: actor.preferredUsername,
|
|
428
|
+
name: actor.name,
|
|
429
|
+
summary: actor.summary,
|
|
430
|
+
url: `${baseUrl}/users/${username}`,
|
|
431
|
+
icon: actor.iconUrl
|
|
432
|
+
? { type: "Image", url: safeUrlJoin(baseUrl, actor.iconUrl) }
|
|
433
|
+
: undefined,
|
|
434
|
+
image: actor.headerUrl
|
|
435
|
+
? { type: "Image", url: safeUrlJoin(baseUrl, actor.headerUrl) }
|
|
436
|
+
: undefined,
|
|
437
|
+
inbox: actor.inbox,
|
|
438
|
+
outbox: showCollections ? actor.outbox : undefined,
|
|
439
|
+
followers: showCollections ? actor.followersUrl : undefined,
|
|
440
|
+
following: showCollections ? actor.followingUrl : undefined,
|
|
441
|
+
// Advertise sharedInbox so remote servers can deduplicate fan-out
|
|
442
|
+
// delivery (Mastodon convention). The endpoint accepts signed
|
|
443
|
+
// activities just like the per-actor inbox.
|
|
444
|
+
endpoints: {
|
|
445
|
+
sharedInbox: `${baseUrl}/ap/inbox`,
|
|
446
|
+
},
|
|
447
|
+
publicKey: buildPublicKey(actor.apId, actor.publicKeyPem),
|
|
448
|
+
discoverable: !actor.isPrivate,
|
|
449
|
+
// Advertise the lock so remote servers know inbound follows are held
|
|
450
|
+
// pending (handleFollow keeps them pending for private accounts). Mirrors
|
|
451
|
+
// the Update(Person) object built in actors.ts.
|
|
452
|
+
manuallyApprovesFollowers: Boolean(actor.isPrivate),
|
|
453
|
+
published: toIso8601(actor.createdAt),
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
// Structured profile metadata -> PropertyValue attachments (Mastodon
|
|
457
|
+
// convention). Stored as a JSON array of { name, value }.
|
|
458
|
+
const fields = safeJsonParse<Array<{ name?: unknown; value?: unknown }>>(
|
|
459
|
+
actor.fieldsJson,
|
|
460
|
+
[],
|
|
461
|
+
);
|
|
462
|
+
if (Array.isArray(fields) && fields.length > 0) {
|
|
463
|
+
actorResponse.attachment = fields
|
|
464
|
+
.filter(
|
|
465
|
+
(f) => typeof f?.name === "string" && typeof f?.value === "string",
|
|
466
|
+
)
|
|
467
|
+
.map((f) => ({
|
|
468
|
+
type: "PropertyValue",
|
|
469
|
+
name: f.name as string,
|
|
470
|
+
value: f.value as string,
|
|
471
|
+
}));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Account-migration declarations. `alsoKnownAs` lists the aliases this
|
|
475
|
+
// account claims; `movedTo` (when set) points remote servers at the new
|
|
476
|
+
// account so they can process a Move.
|
|
477
|
+
const alsoKnownAs = safeJsonParse<string[]>(actor.alsoKnownAsJson, []);
|
|
478
|
+
if (Array.isArray(alsoKnownAs) && alsoKnownAs.length > 0) {
|
|
479
|
+
actorResponse.alsoKnownAs = alsoKnownAs.filter(
|
|
480
|
+
(a) => typeof a === "string",
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
if (actor.movedTo) {
|
|
484
|
+
actorResponse.movedTo = actor.movedTo;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Remove undefined fields
|
|
488
|
+
for (const key of Object.keys(actorResponse)) {
|
|
489
|
+
if (actorResponse[key] === undefined) {
|
|
490
|
+
delete actorResponse[key];
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return activityJson(c, actorResponse);
|
|
495
|
+
},
|
|
496
|
+
);
|
|
497
|
+
|
|
498
|
+
// ---------------------------------------------------------------------------
|
|
499
|
+
// Group Actor / Instance Community (cached 10 minutes)
|
|
500
|
+
// ---------------------------------------------------------------------------
|
|
501
|
+
|
|
502
|
+
ap.get(
|
|
503
|
+
"/ap/actor",
|
|
504
|
+
withCache({
|
|
505
|
+
ttl: CacheTTL.ACTIVITYPUB_ACTOR,
|
|
506
|
+
cacheTag: CacheTags.COMMUNITY,
|
|
507
|
+
}),
|
|
508
|
+
async (c) => {
|
|
509
|
+
const baseUrl = c.env.APP_URL;
|
|
510
|
+
const instanceActor = await getInstanceActor(c);
|
|
511
|
+
|
|
512
|
+
const actorResponse = {
|
|
513
|
+
"@context": [
|
|
514
|
+
...AP_CONTEXT,
|
|
515
|
+
{
|
|
516
|
+
apc: "https://yurucommu.com/ns/apc#",
|
|
517
|
+
rooms: { "@id": "apc:rooms", "@type": "@id" },
|
|
518
|
+
joinPolicy: "apc:joinPolicy",
|
|
519
|
+
postPolicy: "apc:postPolicy",
|
|
520
|
+
visibility: "apc:visibility",
|
|
521
|
+
},
|
|
522
|
+
],
|
|
523
|
+
id: instanceActor.apId,
|
|
524
|
+
type: "Group",
|
|
525
|
+
preferredUsername: instanceActor.preferredUsername,
|
|
526
|
+
name: instanceActor.name || "Yurucommu",
|
|
527
|
+
summary: instanceActor.summary || "",
|
|
528
|
+
inbox: `${baseUrl}/ap/actor/inbox`,
|
|
529
|
+
outbox: `${baseUrl}/ap/actor/outbox`,
|
|
530
|
+
followers: `${baseUrl}/ap/actor/followers`,
|
|
531
|
+
following: `${baseUrl}/ap/actor/following`,
|
|
532
|
+
endpoints: {
|
|
533
|
+
sharedInbox: `${baseUrl}/ap/inbox`,
|
|
534
|
+
},
|
|
535
|
+
publicKey: buildPublicKey(instanceActor.apId, instanceActor.publicKeyPem),
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
return activityJson(c, actorResponse);
|
|
539
|
+
},
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
// ---------------------------------------------------------------------------
|
|
543
|
+
// Community Group Actor (cached 10 minutes)
|
|
544
|
+
//
|
|
545
|
+
// A yurucommu community federates as a standard fediverse Group actor
|
|
546
|
+
// (Lemmy/Mobilizon style): remotes WebFinger `acct:<name>@host`, fetch this
|
|
547
|
+
// document, and Follow the inbox to join. Only PUBLIC, non-deleted communities
|
|
548
|
+
// are served (a private community's existence is members-only). The human web
|
|
549
|
+
// page (`/groups/<name>`) is already an SPA route, so `url` resolves.
|
|
550
|
+
// ---------------------------------------------------------------------------
|
|
551
|
+
|
|
552
|
+
ap.get(
|
|
553
|
+
"/ap/groups/:name",
|
|
554
|
+
withCache({
|
|
555
|
+
ttl: CacheTTL.ACTIVITYPUB_ACTOR,
|
|
556
|
+
cacheTag: CacheTags.COMMUNITY,
|
|
557
|
+
}),
|
|
558
|
+
async (c) => {
|
|
559
|
+
const db = c.get("db");
|
|
560
|
+
const baseUrl = c.env.APP_URL;
|
|
561
|
+
const name = c.req.param("name");
|
|
562
|
+
const community = await loadFederatedCommunity(
|
|
563
|
+
db,
|
|
564
|
+
communityApId(baseUrl.replace(/\/+$/, ""), name),
|
|
565
|
+
);
|
|
566
|
+
if (!community) return c.json({ error: "Community not found" }, 404);
|
|
567
|
+
|
|
568
|
+
const actorResponse: Record<string, unknown> = {
|
|
569
|
+
"@context": ACTOR_CONTEXT,
|
|
570
|
+
id: community.apId,
|
|
571
|
+
type: "Group",
|
|
572
|
+
preferredUsername: community.preferredUsername,
|
|
573
|
+
name: community.name,
|
|
574
|
+
summary: community.summary ?? "",
|
|
575
|
+
url: `${baseUrl}/groups/${community.preferredUsername}`,
|
|
576
|
+
icon: community.iconUrl
|
|
577
|
+
? { type: "Image", url: safeUrlJoin(baseUrl, community.iconUrl) }
|
|
578
|
+
: undefined,
|
|
579
|
+
inbox: community.inbox,
|
|
580
|
+
outbox: community.outbox,
|
|
581
|
+
followers: community.followersUrl,
|
|
582
|
+
// Lemmy/Mobilizon convention: the collection of actors that moderate this
|
|
583
|
+
// group (its owner + moderators). Lets those consumers attribute and
|
|
584
|
+
// authorize moderation activities.
|
|
585
|
+
moderators: `${community.apId}/moderators`,
|
|
586
|
+
endpoints: {
|
|
587
|
+
sharedInbox: `${baseUrl}/ap/inbox`,
|
|
588
|
+
},
|
|
589
|
+
publicKey: buildPublicKey(community.apId, community.publicKeyPem),
|
|
590
|
+
discoverable: true,
|
|
591
|
+
// open-join communities auto-accept Follows; approval/invite hold them
|
|
592
|
+
// pending (the inbox Follow handler enforces this).
|
|
593
|
+
manuallyApprovesFollowers: community.joinPolicy !== "open",
|
|
594
|
+
published: toIso8601(community.createdAt),
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
for (const key of Object.keys(actorResponse)) {
|
|
598
|
+
if (actorResponse[key] === undefined) delete actorResponse[key];
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
return activityJson(c, actorResponse);
|
|
602
|
+
},
|
|
603
|
+
);
|
|
604
|
+
|
|
605
|
+
// ---------------------------------------------------------------------------
|
|
606
|
+
// Shared inbox (Mastodon convention)
|
|
607
|
+
//
|
|
608
|
+
// The shared inbox POST handler (`/ap/inbox`) lives in `inbox.ts` alongside
|
|
609
|
+
// the per-actor inbox so it reuses the same signature-verify / dedup / store
|
|
610
|
+
// pipeline and dispatch logic. It is mounted via `inboxRoutes` below.
|
|
611
|
+
// ---------------------------------------------------------------------------
|
|
612
|
+
|
|
613
|
+
ap.route("/", inboxRoutes);
|
|
614
|
+
ap.route("/", outboxRoutes);
|
|
615
|
+
|
|
616
|
+
export default ap;
|