@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,1311 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { deleteCookie } from "hono/cookie";
|
|
3
|
+
import {
|
|
4
|
+
and,
|
|
5
|
+
asc,
|
|
6
|
+
count,
|
|
7
|
+
desc,
|
|
8
|
+
eq,
|
|
9
|
+
inArray,
|
|
10
|
+
isNotNull,
|
|
11
|
+
isNull,
|
|
12
|
+
lt,
|
|
13
|
+
ne,
|
|
14
|
+
or,
|
|
15
|
+
sql,
|
|
16
|
+
} from "drizzle-orm";
|
|
17
|
+
import type { BatchItem } from "drizzle-orm/batch";
|
|
18
|
+
import {
|
|
19
|
+
activities,
|
|
20
|
+
actorCache,
|
|
21
|
+
actors,
|
|
22
|
+
blocks,
|
|
23
|
+
communities,
|
|
24
|
+
deliveryQueue,
|
|
25
|
+
follows,
|
|
26
|
+
inbox,
|
|
27
|
+
mediaUploads,
|
|
28
|
+
mutes,
|
|
29
|
+
notDeleted,
|
|
30
|
+
nowIso,
|
|
31
|
+
objects,
|
|
32
|
+
} from "../../db/index.ts";
|
|
33
|
+
import type { Database } from "../../db/index.ts";
|
|
34
|
+
import type { Env, Variables } from "../types.ts";
|
|
35
|
+
import {
|
|
36
|
+
activityApId,
|
|
37
|
+
formatUsername,
|
|
38
|
+
generateId,
|
|
39
|
+
isSafeRemoteUrl,
|
|
40
|
+
parseLimit,
|
|
41
|
+
parseOffset,
|
|
42
|
+
safeJsonParse,
|
|
43
|
+
} from "../federation-helpers.ts";
|
|
44
|
+
import { enqueueFanoutToFollowers } from "../lib/delivery/queue.ts";
|
|
45
|
+
import {
|
|
46
|
+
destinationDeclaresAlias,
|
|
47
|
+
resolveMoveTarget,
|
|
48
|
+
} from "../lib/account-migration.ts";
|
|
49
|
+
import { getInstanceFetchSigner } from "./activitypub/query-helpers.ts";
|
|
50
|
+
import { severFollowEdge } from "./activitypub/handlers/inbox-interaction-handlers.ts";
|
|
51
|
+
import { teardownActor } from "./account-teardown.ts";
|
|
52
|
+
import { CacheTags, CacheTTL, withCache } from "../middleware/cache.ts";
|
|
53
|
+
import {
|
|
54
|
+
actorExists,
|
|
55
|
+
createRelation,
|
|
56
|
+
deleteRelation,
|
|
57
|
+
isValidHttpUrl,
|
|
58
|
+
isValidProfileImageUrl,
|
|
59
|
+
listFollowRelation,
|
|
60
|
+
listRelation,
|
|
61
|
+
loadActorInfoMap,
|
|
62
|
+
loadPostInteractions,
|
|
63
|
+
MAX_ACTOR_POSTS_LIMIT,
|
|
64
|
+
MAX_PROFILE_NAME_LENGTH,
|
|
65
|
+
MAX_PROFILE_SUMMARY_LENGTH,
|
|
66
|
+
MAX_PROFILE_URL_LENGTH,
|
|
67
|
+
requireActor,
|
|
68
|
+
resolveActorApId,
|
|
69
|
+
} from "./actors-helpers.ts";
|
|
70
|
+
import { reapReplacedMediaUrl } from "./posts/delete-cascade.ts";
|
|
71
|
+
import { safeUrlJoin } from "../lib/activitypub-helpers.ts";
|
|
72
|
+
import { encodeFeedCursor, feedCursorWhere } from "../lib/feed-cursor.ts";
|
|
73
|
+
import { chunkForInClause } from "../lib/chunk.ts";
|
|
74
|
+
import { logger } from "../lib/logger.ts";
|
|
75
|
+
|
|
76
|
+
const log = logger.child({ component: "actors" });
|
|
77
|
+
|
|
78
|
+
// Mastodon-parity profile metadata limits. Mastodon caps profile fields at 4
|
|
79
|
+
// rows with bounded name/value lengths; we mirror that to keep the served
|
|
80
|
+
// actor document and federated Update(Person) bounded.
|
|
81
|
+
const MAX_PROFILE_FIELDS = 4;
|
|
82
|
+
const MAX_PROFILE_FIELD_NAME_LENGTH = 255;
|
|
83
|
+
const MAX_PROFILE_FIELD_VALUE_LENGTH = 255;
|
|
84
|
+
// Bound declared aliases (alsoKnownAs) so the actor document stays bounded.
|
|
85
|
+
const MAX_ALSO_KNOWN_AS = 10;
|
|
86
|
+
// Personal-portability export caps so a single archive request cannot OOM.
|
|
87
|
+
const MAX_EXPORT_POSTS = 5000;
|
|
88
|
+
const MAX_EXPORT_RELATIONS = 10000;
|
|
89
|
+
const MAX_EXPORT_MEDIA = 10000;
|
|
90
|
+
|
|
91
|
+
type ProfileField = { name: string; value: string };
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Sanitize a structured profile fields payload into a bounded array of
|
|
95
|
+
* { name, value } rows. Non-string / empty rows are dropped; the result is
|
|
96
|
+
* capped at MAX_PROFILE_FIELDS with trimmed, length-bounded values.
|
|
97
|
+
*/
|
|
98
|
+
function sanitizeProfileFields(input: unknown): ProfileField[] {
|
|
99
|
+
if (!Array.isArray(input)) return [];
|
|
100
|
+
const out: ProfileField[] = [];
|
|
101
|
+
for (const row of input) {
|
|
102
|
+
if (out.length >= MAX_PROFILE_FIELDS) break;
|
|
103
|
+
if (!row || typeof row !== "object") continue;
|
|
104
|
+
const name = (row as { name?: unknown }).name;
|
|
105
|
+
const value = (row as { value?: unknown }).value;
|
|
106
|
+
if (typeof name !== "string" || typeof value !== "string") continue;
|
|
107
|
+
const trimmedName = name.trim().slice(0, MAX_PROFILE_FIELD_NAME_LENGTH);
|
|
108
|
+
const trimmedValue = value.trim().slice(0, MAX_PROFILE_FIELD_VALUE_LENGTH);
|
|
109
|
+
if (trimmedName.length === 0 && trimmedValue.length === 0) continue;
|
|
110
|
+
out.push({ name: trimmedName, value: trimmedValue });
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Render stored profile fields as PropertyValue attachments for the federated
|
|
117
|
+
* Person object (mirrors the served actor document in routes/activitypub.ts).
|
|
118
|
+
*/
|
|
119
|
+
function fieldsToAttachments(
|
|
120
|
+
fields: ProfileField[],
|
|
121
|
+
): Array<{ type: "PropertyValue"; name: string; value: string }> {
|
|
122
|
+
return fields.map((f) => ({
|
|
123
|
+
type: "PropertyValue",
|
|
124
|
+
name: f.name,
|
|
125
|
+
value: f.value,
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Tombstone reaper horizon. A deleted account's row is kept as a tombstone so
|
|
130
|
+
// the queued Delete(actor) deliver_endpoint jobs can sign with the actor's
|
|
131
|
+
// private key when they drain. Once those jobs have drained AND enough time has
|
|
132
|
+
// passed that no further delivery attempts are possible, the tombstone (and its
|
|
133
|
+
// signing material) can be hard-deleted. The horizon is comfortably past the
|
|
134
|
+
// delivery backoff series (~4.3h total) so a still-retrying Delete is never
|
|
135
|
+
// reaped out from under the signer; the no-pending-jobs check below is the
|
|
136
|
+
// primary guard and this is a belt-and-braces lower bound.
|
|
137
|
+
const TOMBSTONE_REAP_AFTER_MS = 24 * 60 * 60 * 1000;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Hard-delete tombstoned local actors whose federation Delete has drained.
|
|
141
|
+
*
|
|
142
|
+
* A tombstone is only reaped when (a) its `deletedAt` is older than
|
|
143
|
+
* TOMBSTONE_REAP_AFTER_MS and (b) it has NO non-terminal (pending / processing
|
|
144
|
+
* / failed / retry_wait) delivery_queue rows for any of its Delete activities —
|
|
145
|
+
* i.e. nothing still needs the private key to sign a retry. The preserved Delete activity
|
|
146
|
+
* rows are removed alongside the actor so they do not accumulate forever.
|
|
147
|
+
*
|
|
148
|
+
* Returns the number of tombstones hard-deleted.
|
|
149
|
+
*/
|
|
150
|
+
export async function reapDrainedTombstones(db: Database): Promise<number> {
|
|
151
|
+
const cutoff = new Date(Date.now() - TOMBSTONE_REAP_AFTER_MS).toISOString();
|
|
152
|
+
|
|
153
|
+
const candidates = await db
|
|
154
|
+
.select({ apId: actors.apId })
|
|
155
|
+
.from(actors)
|
|
156
|
+
.where(
|
|
157
|
+
and(sql`${actors.deletedAt} IS NOT NULL`, lt(actors.deletedAt, cutoff)),
|
|
158
|
+
)
|
|
159
|
+
.limit(100);
|
|
160
|
+
if (candidates.length === 0) return 0;
|
|
161
|
+
|
|
162
|
+
let reaped = 0;
|
|
163
|
+
for (const { apId } of candidates) {
|
|
164
|
+
// Delete activities this actor authored (preserved through teardown for
|
|
165
|
+
// the delivery signer). Outbound by construction.
|
|
166
|
+
const deleteActivities = await db
|
|
167
|
+
.select({ apId: activities.apId })
|
|
168
|
+
.from(activities)
|
|
169
|
+
.where(
|
|
170
|
+
and(eq(activities.actorApId, apId), eq(activities.type, "Delete")),
|
|
171
|
+
);
|
|
172
|
+
const deleteActivityIds = deleteActivities.map((a) => a.apId);
|
|
173
|
+
|
|
174
|
+
if (deleteActivityIds.length > 0) {
|
|
175
|
+
// Any non-terminal delivery job for those Delete activities means the
|
|
176
|
+
// signer may still need this actor's key — skip reaping for now. Chunked:
|
|
177
|
+
// a prolific deleted actor can have >100 Delete activities, which would
|
|
178
|
+
// blow D1's 100-bound-param cap and 500 this fire-and-forget reap, leaking
|
|
179
|
+
// the tombstone (and its signing key) forever.
|
|
180
|
+
let hasPendingDelivery = false;
|
|
181
|
+
for (const chunk of chunkForInClause(deleteActivityIds)) {
|
|
182
|
+
const pending = await db
|
|
183
|
+
.select({ id: deliveryQueue.id })
|
|
184
|
+
.from(deliveryQueue)
|
|
185
|
+
.where(
|
|
186
|
+
and(
|
|
187
|
+
inArray(deliveryQueue.activityApId, chunk),
|
|
188
|
+
// Non-terminal delivery states (anything other than the terminal
|
|
189
|
+
// "delivered" / "dead_letter"). A "retry_wait" row is a Delete
|
|
190
|
+
// between attempts and will be re-sent, so reaping the tombstone
|
|
191
|
+
// (and its signing key) while one exists would strand the retry
|
|
192
|
+
// unsigned. queue-delivery.ts writes: pending / processing /
|
|
193
|
+
// failed / retry_wait / delivered / dead_letter.
|
|
194
|
+
inArray(deliveryQueue.status, [
|
|
195
|
+
"pending",
|
|
196
|
+
"processing",
|
|
197
|
+
"failed",
|
|
198
|
+
"retry_wait",
|
|
199
|
+
]),
|
|
200
|
+
),
|
|
201
|
+
)
|
|
202
|
+
.limit(1)
|
|
203
|
+
.get();
|
|
204
|
+
if (pending) {
|
|
205
|
+
hasPendingDelivery = true;
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (hasPendingDelivery) continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Drained: remove the terminal delivery_queue rows, the preserved Delete
|
|
213
|
+
// activities, then the tombstone row itself (chunked for D1's param cap).
|
|
214
|
+
if (deleteActivityIds.length > 0) {
|
|
215
|
+
for (const chunk of chunkForInClause(deleteActivityIds)) {
|
|
216
|
+
await db
|
|
217
|
+
.delete(deliveryQueue)
|
|
218
|
+
.where(inArray(deliveryQueue.activityApId, chunk));
|
|
219
|
+
await db.delete(activities).where(inArray(activities.apId, chunk));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
await db.delete(actors).where(eq(actors.apId, apId));
|
|
223
|
+
reaped += 1;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return reaped;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Cancel the stranded outbound Delete(actor) for a tombstone that is about to be
|
|
231
|
+
* REVIVED (a freed handle re-registered onto the same deterministic apId).
|
|
232
|
+
*
|
|
233
|
+
* A tombstone keeps its OLD signing key precisely so the queued Delete(actor)
|
|
234
|
+
* delivery jobs can sign with it at send time (read live from the actor row).
|
|
235
|
+
* Re-registration rotates that row to a FRESH key + identity, which would make
|
|
236
|
+
* any still-pending Delete job sign with the wrong key (invalid signature) or
|
|
237
|
+
* target a now-live actor. So before reviving we cancel the stranded Delete:
|
|
238
|
+
* remove its non-terminal (pending / processing / failed / retry_wait)
|
|
239
|
+
* delivery_queue rows AND the preserved Delete activity rows, so re-registration
|
|
240
|
+
* starts clean and no half-signed Delete is sent.
|
|
241
|
+
*
|
|
242
|
+
* Mirrors the activity/queue cleanup `reapDrainedTombstones` performs, but is
|
|
243
|
+
* unconditional (the revive supersedes the Delete) and only scoped to non-
|
|
244
|
+
* terminal delivery rows; terminal rows are removed alongside the activity.
|
|
245
|
+
* Returns the number of Delete activities cancelled.
|
|
246
|
+
*/
|
|
247
|
+
// D1 has no interactive transactions, but both the D1 and libsql drivers expose
|
|
248
|
+
// `db.batch([...])`, which commits a list of prepared statements atomically. The
|
|
249
|
+
// shared `Database` union aliases the abstract `BaseSQLiteDatabase` base (which
|
|
250
|
+
// does not surface `batch`), so we narrow to the concrete batch surface here
|
|
251
|
+
// rather than weakening the shared type (mirrors inbox-interaction-handlers.ts).
|
|
252
|
+
type BatchStatement = BatchItem<"sqlite">;
|
|
253
|
+
interface BatchableDb {
|
|
254
|
+
batch(
|
|
255
|
+
statements: readonly [BatchStatement, ...BatchStatement[]],
|
|
256
|
+
): Promise<unknown>;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export async function cancelTombstoneDelete(
|
|
260
|
+
db: Database,
|
|
261
|
+
apId: string,
|
|
262
|
+
): Promise<number> {
|
|
263
|
+
const deleteActivities = await db
|
|
264
|
+
.select({ apId: activities.apId })
|
|
265
|
+
.from(activities)
|
|
266
|
+
.where(and(eq(activities.actorApId, apId), eq(activities.type, "Delete")));
|
|
267
|
+
const deleteActivityIds = deleteActivities.map((a) => a.apId);
|
|
268
|
+
if (deleteActivityIds.length === 0) return 0;
|
|
269
|
+
|
|
270
|
+
// Drop every delivery_queue row for those Delete activities (any status — the
|
|
271
|
+
// Delete is superseded, including in-flight retry_wait jobs) together with the
|
|
272
|
+
// preserved Delete activity rows, so no signer can pick up a job referencing
|
|
273
|
+
// an activity whose actor row has been rotated. Each chunk's two deletes stay
|
|
274
|
+
// paired in one atomic batch; chunked because a prolific actor can have >100
|
|
275
|
+
// Delete activities, which would blow D1's 100-bound-param cap and 500 the
|
|
276
|
+
// revive.
|
|
277
|
+
for (const chunk of chunkForInClause(deleteActivityIds)) {
|
|
278
|
+
await (db as unknown as BatchableDb).batch([
|
|
279
|
+
db
|
|
280
|
+
.delete(deliveryQueue)
|
|
281
|
+
.where(inArray(deliveryQueue.activityApId, chunk)),
|
|
282
|
+
db.delete(activities).where(inArray(activities.apId, chunk)),
|
|
283
|
+
]);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return deleteActivityIds.length;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Best-effort, opportunistic tombstone reaping on the read path. This Worker
|
|
290
|
+
// has no `scheduled` handler, so (mirroring maybeCleanupExpiredStories) the
|
|
291
|
+
// sweep is triggered probabilistically and guarded so at most one runs per
|
|
292
|
+
// isolate at a time. Tombstones are already excluded from every serving query,
|
|
293
|
+
// so a missed sweep only delays storage/key-material reclamation.
|
|
294
|
+
let tombstoneReapInFlight = false;
|
|
295
|
+
|
|
296
|
+
export function maybeReapDrainedTombstones(db: Database): void {
|
|
297
|
+
if (tombstoneReapInFlight) return;
|
|
298
|
+
if (Math.random() >= 0.01) return; // ~1% of eligible requests per isolate
|
|
299
|
+
|
|
300
|
+
tombstoneReapInFlight = true;
|
|
301
|
+
reapDrainedTombstones(db)
|
|
302
|
+
.catch((err) => {
|
|
303
|
+
log.warn("Failed to reap drained tombstones", {
|
|
304
|
+
event: "actors.tombstone.reap_failed",
|
|
305
|
+
error: err,
|
|
306
|
+
});
|
|
307
|
+
})
|
|
308
|
+
.finally(() => {
|
|
309
|
+
tombstoneReapInFlight = false;
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const actorsRoute = new Hono<{ Bindings: Env; Variables: Variables }>();
|
|
314
|
+
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
// Routes
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
// Get all local actors (cached 5 minutes)
|
|
320
|
+
actorsRoute.get(
|
|
321
|
+
"/",
|
|
322
|
+
withCache({
|
|
323
|
+
ttl: CacheTTL.ACTOR_PROFILE,
|
|
324
|
+
cacheTag: CacheTags.ACTOR,
|
|
325
|
+
}),
|
|
326
|
+
async (c) => {
|
|
327
|
+
const db = c.get("db");
|
|
328
|
+
const limit = parseLimit(c.req.query("limit"), 100, 500);
|
|
329
|
+
const offset = parseOffset(c.req.query("offset"), 0, 10000);
|
|
330
|
+
|
|
331
|
+
const actorsList = await db
|
|
332
|
+
.select({
|
|
333
|
+
apId: actors.apId,
|
|
334
|
+
preferredUsername: actors.preferredUsername,
|
|
335
|
+
name: actors.name,
|
|
336
|
+
summary: actors.summary,
|
|
337
|
+
iconUrl: actors.iconUrl,
|
|
338
|
+
role: actors.role,
|
|
339
|
+
followerCount: actors.followerCount,
|
|
340
|
+
followingCount: actors.followingCount,
|
|
341
|
+
postCount: actors.postCount,
|
|
342
|
+
createdAt: actors.createdAt,
|
|
343
|
+
})
|
|
344
|
+
.from(actors)
|
|
345
|
+
.where(notDeleted(actors))
|
|
346
|
+
.orderBy(asc(actors.createdAt))
|
|
347
|
+
.limit(limit)
|
|
348
|
+
.offset(offset);
|
|
349
|
+
|
|
350
|
+
return c.json({
|
|
351
|
+
actors: actorsList.map((a) => ({
|
|
352
|
+
ap_id: a.apId,
|
|
353
|
+
preferred_username: a.preferredUsername,
|
|
354
|
+
name: a.name,
|
|
355
|
+
summary: a.summary,
|
|
356
|
+
icon_url: a.iconUrl,
|
|
357
|
+
role: a.role,
|
|
358
|
+
follower_count: a.followerCount,
|
|
359
|
+
following_count: a.followingCount,
|
|
360
|
+
post_count: a.postCount,
|
|
361
|
+
created_at: a.createdAt,
|
|
362
|
+
username: formatUsername(a.apId),
|
|
363
|
+
})),
|
|
364
|
+
});
|
|
365
|
+
},
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
// Get blocked users for current actor
|
|
369
|
+
actorsRoute.get("/me/blocked", async (c) => {
|
|
370
|
+
return listRelation(
|
|
371
|
+
c,
|
|
372
|
+
(db, actorId, limit, offset) =>
|
|
373
|
+
db
|
|
374
|
+
.select({
|
|
375
|
+
blockedApId: blocks.blockedApId,
|
|
376
|
+
createdAt: blocks.createdAt,
|
|
377
|
+
})
|
|
378
|
+
.from(blocks)
|
|
379
|
+
.where(eq(blocks.blockerApId, actorId))
|
|
380
|
+
// createdAt is non-unique millisecond text; add the PK discriminator
|
|
381
|
+
// (blockedApId) so OFFSET paging is deterministic and same-ms ties are
|
|
382
|
+
// never skipped/duplicated across a page boundary.
|
|
383
|
+
.orderBy(desc(blocks.createdAt), desc(blocks.blockedApId))
|
|
384
|
+
.limit(limit)
|
|
385
|
+
.offset(offset),
|
|
386
|
+
"blockedApId",
|
|
387
|
+
"blocked",
|
|
388
|
+
);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// Block a user
|
|
392
|
+
actorsRoute.post("/me/blocked", async (c) => {
|
|
393
|
+
return createRelation(
|
|
394
|
+
c,
|
|
395
|
+
"block",
|
|
396
|
+
async (db, actorId, targetId) => {
|
|
397
|
+
await db
|
|
398
|
+
.insert(blocks)
|
|
399
|
+
.values({ blockerApId: actorId, blockedApId: targetId })
|
|
400
|
+
.onConflictDoNothing();
|
|
401
|
+
// Sever BOTH follow edges + reconcile counts (mirrors the federated
|
|
402
|
+
// handleBlock). Without this a blocked actor who was an accepted follower
|
|
403
|
+
// stays in the fan-out set and keeps receiving the blocker's posts, and
|
|
404
|
+
// both actors' follower/following counts stay inflated — defeating the
|
|
405
|
+
// whole point of the block. severFollowEdge gates each decrement on an
|
|
406
|
+
// EXISTS(... status='accepted') subquery so a pending/absent edge is a
|
|
407
|
+
// clean no-op (no under-count). Pending follow-request rows are deleted by
|
|
408
|
+
// the edge delete inside severFollowEdge regardless of status.
|
|
409
|
+
await severFollowEdge(db, targetId, actorId); // target follows actor
|
|
410
|
+
await severFollowEdge(db, actorId, targetId); // actor follows target
|
|
411
|
+
},
|
|
412
|
+
async (db, actorId) =>
|
|
413
|
+
(
|
|
414
|
+
await db
|
|
415
|
+
.select({ n: count() })
|
|
416
|
+
.from(blocks)
|
|
417
|
+
.where(eq(blocks.blockerApId, actorId))
|
|
418
|
+
.get()
|
|
419
|
+
)?.n ?? 0,
|
|
420
|
+
);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// Unblock a user
|
|
424
|
+
actorsRoute.delete("/me/blocked", async (c) => {
|
|
425
|
+
return deleteRelation(c, "block", (db, actorId, targetId) =>
|
|
426
|
+
db
|
|
427
|
+
.delete(blocks)
|
|
428
|
+
.where(
|
|
429
|
+
and(eq(blocks.blockerApId, actorId), eq(blocks.blockedApId, targetId)),
|
|
430
|
+
),
|
|
431
|
+
);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// Get muted users for current actor
|
|
435
|
+
actorsRoute.get("/me/muted", async (c) => {
|
|
436
|
+
return listRelation(
|
|
437
|
+
c,
|
|
438
|
+
(db, actorId, limit, offset) =>
|
|
439
|
+
db
|
|
440
|
+
.select({
|
|
441
|
+
mutedApId: mutes.mutedApId,
|
|
442
|
+
createdAt: mutes.createdAt,
|
|
443
|
+
})
|
|
444
|
+
.from(mutes)
|
|
445
|
+
.where(eq(mutes.muterApId, actorId))
|
|
446
|
+
// Deterministic tiebreaker on the PK discriminator (mutedApId); see the
|
|
447
|
+
// blocked list above.
|
|
448
|
+
.orderBy(desc(mutes.createdAt), desc(mutes.mutedApId))
|
|
449
|
+
.limit(limit)
|
|
450
|
+
.offset(offset),
|
|
451
|
+
"mutedApId",
|
|
452
|
+
"muted",
|
|
453
|
+
);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// Mute a user
|
|
457
|
+
actorsRoute.post("/me/muted", async (c) => {
|
|
458
|
+
return createRelation(
|
|
459
|
+
c,
|
|
460
|
+
"mute",
|
|
461
|
+
(db, actorId, targetId) =>
|
|
462
|
+
db
|
|
463
|
+
.insert(mutes)
|
|
464
|
+
.values({ muterApId: actorId, mutedApId: targetId })
|
|
465
|
+
.onConflictDoNothing(),
|
|
466
|
+
async (db, actorId) =>
|
|
467
|
+
(
|
|
468
|
+
await db
|
|
469
|
+
.select({ n: count() })
|
|
470
|
+
.from(mutes)
|
|
471
|
+
.where(eq(mutes.muterApId, actorId))
|
|
472
|
+
.get()
|
|
473
|
+
)?.n ?? 0,
|
|
474
|
+
);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// Unmute a user
|
|
478
|
+
actorsRoute.delete("/me/muted", async (c) => {
|
|
479
|
+
return deleteRelation(c, "mute", (db, actorId, targetId) =>
|
|
480
|
+
db
|
|
481
|
+
.delete(mutes)
|
|
482
|
+
.where(and(eq(mutes.muterApId, actorId), eq(mutes.mutedApId, targetId))),
|
|
483
|
+
);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
// Delete own account (local only)
|
|
487
|
+
actorsRoute.post("/me/delete", async (c) => {
|
|
488
|
+
const result = requireActor(c);
|
|
489
|
+
if (result instanceof Response) return result;
|
|
490
|
+
const actor = result;
|
|
491
|
+
|
|
492
|
+
const actorApIdVal = actor.ap_id;
|
|
493
|
+
const db = c.get("db");
|
|
494
|
+
const baseUrl = c.env.APP_URL;
|
|
495
|
+
|
|
496
|
+
try {
|
|
497
|
+
// Gather the owner's SUB-ACCOUNTS (profiles minted via /accounts + /switch)
|
|
498
|
+
// BEFORE the owner's own teardown. A sub-account is a first-class actor that
|
|
499
|
+
// can post / follow / like / join+own communities, so it needs the FULL
|
|
500
|
+
// cascade, not a mere tombstone. They are keyed by ownerActorApId === the
|
|
501
|
+
// owner's apId, which the owner teardown does NOT change (it only nulls the
|
|
502
|
+
// owner's own ownerActorApId field), so gathering them here is order-safe.
|
|
503
|
+
const subAccounts = await db
|
|
504
|
+
.select({ apId: actors.apId, followersUrl: actors.followersUrl })
|
|
505
|
+
.from(actors)
|
|
506
|
+
.where(eq(actors.ownerActorApId, actorApIdVal));
|
|
507
|
+
|
|
508
|
+
// Full owner teardown through the SINGLE shared cascade: it federates a
|
|
509
|
+
// Delete(Actor) (snapshotting follower inboxes before the graph is dropped),
|
|
510
|
+
// reconciles every counterparty's denormalized counters, deletes the actor's
|
|
511
|
+
// edges / interactions / memberships / media / DM state, hands off sole-owned
|
|
512
|
+
// communities to an heir, hard-deletes its authored objects, and
|
|
513
|
+
// tombstones+scrubs the actor row. This is the EXACT cascade teardownActor
|
|
514
|
+
// applies to each sub-account below — one chokepoint instead of a ~440-line
|
|
515
|
+
// hand-rolled copy that had to be kept in sync.
|
|
516
|
+
await teardownActor(db, c.env, baseUrl, actorApIdVal, actor.followers_url);
|
|
517
|
+
// Now fully tear down each sub-account (same cascade the owner just received)
|
|
518
|
+
// so no "deleted" sub-account content / edge / counter / membership / sole
|
|
519
|
+
// ownership survives, and each federates its own Delete(Actor).
|
|
520
|
+
for (const sub of subAccounts) {
|
|
521
|
+
await teardownActor(db, c.env, baseUrl, sub.apId, sub.followersUrl);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
deleteCookie(c, "session");
|
|
525
|
+
|
|
526
|
+
return c.json({ success: true });
|
|
527
|
+
} catch (error) {
|
|
528
|
+
log.error("Account deletion failed", {
|
|
529
|
+
event: "actors.account.delete_failed",
|
|
530
|
+
actor: actorApIdVal,
|
|
531
|
+
error,
|
|
532
|
+
});
|
|
533
|
+
return c.json({ error: "Account deletion failed" }, 500);
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
// Get posts for a specific actor
|
|
538
|
+
actorsRoute.get("/:identifier/posts", async (c) => {
|
|
539
|
+
const currentActor = c.get("actor");
|
|
540
|
+
const identifier = c.req.param("identifier");
|
|
541
|
+
const db = c.get("db");
|
|
542
|
+
|
|
543
|
+
const apId = await resolveActorApId(db, c.env.APP_URL, identifier);
|
|
544
|
+
if (!apId) return c.json({ error: "Actor not found" }, 404);
|
|
545
|
+
|
|
546
|
+
if (!(await actorExists(db, apId))) {
|
|
547
|
+
return c.json({ error: "Actor not found" }, 404);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const limit = parseLimit(c.req.query("limit"), 20, MAX_ACTOR_POSTS_LIMIT);
|
|
551
|
+
const before = c.req.query("before");
|
|
552
|
+
const isOwnProfile = currentActor && currentActor.ap_id === apId;
|
|
553
|
+
|
|
554
|
+
const conditions = [
|
|
555
|
+
eq(objects.type, "Note"),
|
|
556
|
+
isNull(objects.inReplyTo),
|
|
557
|
+
eq(objects.attributedTo, apId),
|
|
558
|
+
];
|
|
559
|
+
if (isOwnProfile) {
|
|
560
|
+
conditions.push(ne(objects.visibility, "direct"));
|
|
561
|
+
// Exclude community GROUP-CHAT messages from the profile post feed. A chat
|
|
562
|
+
// message is a Note addressed to a community audience (audienceJson !== "[]")
|
|
563
|
+
// with NO communityApId, whereas a personal post has an empty audience and a
|
|
564
|
+
// community FEED post has communityApId set. Without this, your own chat
|
|
565
|
+
// messages leak into your profile's posts list (they are correctly hidden
|
|
566
|
+
// from other viewers by the public/empty-audience guard below).
|
|
567
|
+
conditions.push(
|
|
568
|
+
or(eq(objects.audienceJson, "[]"), isNotNull(objects.communityApId))!,
|
|
569
|
+
);
|
|
570
|
+
} else {
|
|
571
|
+
// Non-own profile: only globally-public posts. `visibility = "public"`
|
|
572
|
+
// alone is insufficient because community-scoped and explicitly-addressed
|
|
573
|
+
// posts can carry public-ish visibility while their reach is the audience
|
|
574
|
+
// list; without the empty-audience guard those would leak to anyone
|
|
575
|
+
// viewing the author's profile. An empty `audienceJson` ("[]") marks a
|
|
576
|
+
// post with no community/addressed scope, i.e. truly public reach.
|
|
577
|
+
conditions.push(eq(objects.visibility, "public"));
|
|
578
|
+
conditions.push(eq(objects.audienceJson, "[]"));
|
|
579
|
+
}
|
|
580
|
+
// Composite (published, apId) cursor so posts sharing a published millisecond
|
|
581
|
+
// aren't skipped at a page boundary (see lib/feed-cursor.ts).
|
|
582
|
+
const profileCursor = feedCursorWhere(
|
|
583
|
+
objects.published,
|
|
584
|
+
objects.apId,
|
|
585
|
+
before,
|
|
586
|
+
);
|
|
587
|
+
if (profileCursor) conditions.push(profileCursor);
|
|
588
|
+
|
|
589
|
+
// Project only the columns the response below reads — the profile feed must
|
|
590
|
+
// not load the large `raw_json` blob (and other unused columns), mirroring the
|
|
591
|
+
// timeline's POST_FEED_COLUMNS optimization which this path had missed.
|
|
592
|
+
const posts = await db
|
|
593
|
+
.select({
|
|
594
|
+
apId: objects.apId,
|
|
595
|
+
type: objects.type,
|
|
596
|
+
attributedTo: objects.attributedTo,
|
|
597
|
+
content: objects.content,
|
|
598
|
+
summary: objects.summary,
|
|
599
|
+
attachmentsJson: objects.attachmentsJson,
|
|
600
|
+
inReplyTo: objects.inReplyTo,
|
|
601
|
+
visibility: objects.visibility,
|
|
602
|
+
communityApId: objects.communityApId,
|
|
603
|
+
likeCount: objects.likeCount,
|
|
604
|
+
replyCount: objects.replyCount,
|
|
605
|
+
announceCount: objects.announceCount,
|
|
606
|
+
published: objects.published,
|
|
607
|
+
updated: objects.updated,
|
|
608
|
+
})
|
|
609
|
+
.from(objects)
|
|
610
|
+
.where(and(...conditions))
|
|
611
|
+
.orderBy(desc(objects.published), desc(objects.apId))
|
|
612
|
+
.limit(limit + 1);
|
|
613
|
+
|
|
614
|
+
const hasMore = posts.length > limit;
|
|
615
|
+
if (hasMore) posts.pop();
|
|
616
|
+
const lastPost = posts[posts.length - 1];
|
|
617
|
+
const nextCursor =
|
|
618
|
+
hasMore && lastPost
|
|
619
|
+
? encodeFeedCursor(lastPost.published, lastPost.apId)
|
|
620
|
+
: null;
|
|
621
|
+
|
|
622
|
+
const postApIds = posts.map((p) => p.apId);
|
|
623
|
+
const authorApIds = [...new Set(posts.map((p) => p.attributedTo))];
|
|
624
|
+
|
|
625
|
+
const [authorMap, interactions] = await Promise.all([
|
|
626
|
+
loadActorInfoMap(db, authorApIds, "author"),
|
|
627
|
+
loadPostInteractions(db, currentActor?.ap_id ?? null, postApIds),
|
|
628
|
+
]);
|
|
629
|
+
|
|
630
|
+
const resultList = posts.map((p) => {
|
|
631
|
+
const author = authorMap.get(p.attributedTo);
|
|
632
|
+
return {
|
|
633
|
+
ap_id: p.apId,
|
|
634
|
+
type: p.type,
|
|
635
|
+
author: {
|
|
636
|
+
ap_id: p.attributedTo,
|
|
637
|
+
username: formatUsername(p.attributedTo),
|
|
638
|
+
preferred_username: author?.preferredUsername || null,
|
|
639
|
+
name: author?.name || null,
|
|
640
|
+
icon_url: author?.iconUrl || null,
|
|
641
|
+
},
|
|
642
|
+
content: p.content,
|
|
643
|
+
summary: p.summary,
|
|
644
|
+
attachments: safeJsonParse(p.attachmentsJson, []),
|
|
645
|
+
in_reply_to: p.inReplyTo,
|
|
646
|
+
visibility: p.visibility,
|
|
647
|
+
community_ap_id: p.communityApId,
|
|
648
|
+
like_count: p.likeCount,
|
|
649
|
+
reply_count: p.replyCount,
|
|
650
|
+
announce_count: p.announceCount,
|
|
651
|
+
published: p.published,
|
|
652
|
+
edited_at: p.updated && p.updated !== p.published ? p.updated : null,
|
|
653
|
+
liked: interactions.likedIds.has(p.apId),
|
|
654
|
+
bookmarked: interactions.bookmarkedIds.has(p.apId),
|
|
655
|
+
reposted: interactions.repostedIds.has(p.apId),
|
|
656
|
+
};
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
return c.json({
|
|
660
|
+
posts: resultList,
|
|
661
|
+
has_more: hasMore,
|
|
662
|
+
next_cursor: nextCursor,
|
|
663
|
+
});
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
// Get actor by AP ID or username
|
|
667
|
+
actorsRoute.get("/:identifier", async (c) => {
|
|
668
|
+
const currentActor = c.get("actor");
|
|
669
|
+
const identifier = c.req.param("identifier");
|
|
670
|
+
const baseUrl = c.env.APP_URL;
|
|
671
|
+
const db = c.get("db");
|
|
672
|
+
|
|
673
|
+
// For @user@remote-domain, we may need to return cached data directly
|
|
674
|
+
// (resolveActorApId only returns an apId when the cache has a match)
|
|
675
|
+
const apId = await resolveActorApId(db, baseUrl, identifier);
|
|
676
|
+
if (!apId) return c.json({ error: "Actor not found" }, 404);
|
|
677
|
+
|
|
678
|
+
// Try local actor first
|
|
679
|
+
const localActor = await db
|
|
680
|
+
.select({
|
|
681
|
+
apId: actors.apId,
|
|
682
|
+
preferredUsername: actors.preferredUsername,
|
|
683
|
+
name: actors.name,
|
|
684
|
+
summary: actors.summary,
|
|
685
|
+
iconUrl: actors.iconUrl,
|
|
686
|
+
headerUrl: actors.headerUrl,
|
|
687
|
+
role: actors.role,
|
|
688
|
+
followerCount: actors.followerCount,
|
|
689
|
+
followingCount: actors.followingCount,
|
|
690
|
+
postCount: actors.postCount,
|
|
691
|
+
isPrivate: actors.isPrivate,
|
|
692
|
+
createdAt: actors.createdAt,
|
|
693
|
+
fieldsJson: actors.fieldsJson,
|
|
694
|
+
alsoKnownAsJson: actors.alsoKnownAsJson,
|
|
695
|
+
movedTo: actors.movedTo,
|
|
696
|
+
})
|
|
697
|
+
.from(actors)
|
|
698
|
+
// Exclude tombstoned local actors so a deleted handle is not served as a
|
|
699
|
+
// live profile (consistent with the notDeleted filter used by the actor
|
|
700
|
+
// list and federation-serving queries).
|
|
701
|
+
.where(and(eq(actors.apId, apId), notDeleted(actors)))
|
|
702
|
+
.get();
|
|
703
|
+
|
|
704
|
+
if (!localActor) {
|
|
705
|
+
const cachedActor = await db
|
|
706
|
+
.select()
|
|
707
|
+
.from(actorCache)
|
|
708
|
+
.where(eq(actorCache.apId, apId))
|
|
709
|
+
.get();
|
|
710
|
+
if (!cachedActor) return c.json({ error: "Actor not found" }, 404);
|
|
711
|
+
|
|
712
|
+
// Project the remote actor's AS Person document (cached `rawJson`) so the
|
|
713
|
+
// client banner/fields work for REMOTE actors too: `attachment` ->
|
|
714
|
+
// PropertyValue fields, `alsoKnownAs` -> also_known_as, `movedTo` ->
|
|
715
|
+
// moved_to. Mirrors the local-actor projection below.
|
|
716
|
+
const raw = safeJsonParse<Record<string, unknown>>(cachedActor.rawJson, {});
|
|
717
|
+
const rawAttachment = Array.isArray(raw?.attachment) ? raw.attachment : [];
|
|
718
|
+
const rawAlsoKnownAs = Array.isArray(raw?.alsoKnownAs)
|
|
719
|
+
? raw.alsoKnownAs.filter((a): a is string => typeof a === "string")
|
|
720
|
+
: [];
|
|
721
|
+
const rawMovedTo = typeof raw?.movedTo === "string" ? raw.movedTo : null;
|
|
722
|
+
|
|
723
|
+
return c.json({
|
|
724
|
+
actor: {
|
|
725
|
+
ap_id: cachedActor.apId,
|
|
726
|
+
preferred_username: cachedActor.preferredUsername,
|
|
727
|
+
name: cachedActor.name,
|
|
728
|
+
summary: cachedActor.summary,
|
|
729
|
+
icon_url: cachedActor.iconUrl,
|
|
730
|
+
username: formatUsername(cachedActor.apId),
|
|
731
|
+
fields: sanitizeProfileFields(rawAttachment),
|
|
732
|
+
also_known_as: rawAlsoKnownAs.slice(0, MAX_ALSO_KNOWN_AS),
|
|
733
|
+
moved_to: rawMovedTo,
|
|
734
|
+
is_following: false,
|
|
735
|
+
is_followed_by: false,
|
|
736
|
+
},
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Check follow status if logged in and viewing a different actor
|
|
741
|
+
let is_following = false;
|
|
742
|
+
let is_followed_by = false;
|
|
743
|
+
|
|
744
|
+
if (currentActor && currentActor.ap_id !== apId) {
|
|
745
|
+
const [followingStatus, followedByStatus] = await Promise.all([
|
|
746
|
+
db
|
|
747
|
+
.select({ followerApId: follows.followerApId })
|
|
748
|
+
.from(follows)
|
|
749
|
+
.where(
|
|
750
|
+
and(
|
|
751
|
+
eq(follows.followerApId, currentActor.ap_id),
|
|
752
|
+
eq(follows.followingApId, apId),
|
|
753
|
+
eq(follows.status, "accepted"),
|
|
754
|
+
),
|
|
755
|
+
)
|
|
756
|
+
.get(),
|
|
757
|
+
db
|
|
758
|
+
.select({ followerApId: follows.followerApId })
|
|
759
|
+
.from(follows)
|
|
760
|
+
.where(
|
|
761
|
+
and(
|
|
762
|
+
eq(follows.followerApId, apId),
|
|
763
|
+
eq(follows.followingApId, currentActor.ap_id),
|
|
764
|
+
eq(follows.status, "accepted"),
|
|
765
|
+
),
|
|
766
|
+
)
|
|
767
|
+
.get(),
|
|
768
|
+
]);
|
|
769
|
+
is_following = !!followingStatus;
|
|
770
|
+
is_followed_by = !!followedByStatus;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
return c.json({
|
|
774
|
+
actor: {
|
|
775
|
+
ap_id: localActor.apId,
|
|
776
|
+
preferred_username: localActor.preferredUsername,
|
|
777
|
+
name: localActor.name,
|
|
778
|
+
summary: localActor.summary,
|
|
779
|
+
icon_url: localActor.iconUrl,
|
|
780
|
+
header_url: localActor.headerUrl,
|
|
781
|
+
role: localActor.role,
|
|
782
|
+
follower_count: localActor.followerCount,
|
|
783
|
+
following_count: localActor.followingCount,
|
|
784
|
+
post_count: localActor.postCount,
|
|
785
|
+
is_private: localActor.isPrivate,
|
|
786
|
+
created_at: localActor.createdAt,
|
|
787
|
+
username: formatUsername(localActor.apId),
|
|
788
|
+
fields: sanitizeProfileFields(safeJsonParse(localActor.fieldsJson, [])),
|
|
789
|
+
also_known_as: safeJsonParse<string[]>(localActor.alsoKnownAsJson, []),
|
|
790
|
+
moved_to: localActor.movedTo,
|
|
791
|
+
is_following,
|
|
792
|
+
is_followed_by,
|
|
793
|
+
},
|
|
794
|
+
});
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
// Update own profile
|
|
798
|
+
actorsRoute.put("/me", async (c) => {
|
|
799
|
+
const result = requireActor(c);
|
|
800
|
+
if (result instanceof Response) return result;
|
|
801
|
+
const actor = result;
|
|
802
|
+
|
|
803
|
+
const body = await c.req.json<{
|
|
804
|
+
name?: string;
|
|
805
|
+
summary?: string;
|
|
806
|
+
icon_url?: string;
|
|
807
|
+
header_url?: string;
|
|
808
|
+
is_private?: boolean;
|
|
809
|
+
fields?: Array<{ name?: unknown; value?: unknown }>;
|
|
810
|
+
also_known_as?: unknown;
|
|
811
|
+
}>();
|
|
812
|
+
|
|
813
|
+
const updates: Record<string, string | number | null> = {};
|
|
814
|
+
|
|
815
|
+
if (body.name !== undefined) {
|
|
816
|
+
// The json<{...}>() cast is compile-time only; a client can send a non-string
|
|
817
|
+
// (number/object/array), so guard before .trim() (else TypeError → 500).
|
|
818
|
+
if (typeof body.name !== "string") {
|
|
819
|
+
return c.json({ error: "Invalid name" }, 400);
|
|
820
|
+
}
|
|
821
|
+
const name = body.name.trim();
|
|
822
|
+
if (name.length > MAX_PROFILE_NAME_LENGTH) {
|
|
823
|
+
return c.json(
|
|
824
|
+
{
|
|
825
|
+
error: `Name too long (max ${MAX_PROFILE_NAME_LENGTH} chars)`,
|
|
826
|
+
},
|
|
827
|
+
400,
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
updates.name = name;
|
|
831
|
+
}
|
|
832
|
+
if (body.summary !== undefined) {
|
|
833
|
+
if (typeof body.summary !== "string") {
|
|
834
|
+
return c.json({ error: "Invalid summary" }, 400);
|
|
835
|
+
}
|
|
836
|
+
const summary = body.summary.trim();
|
|
837
|
+
if (summary.length > MAX_PROFILE_SUMMARY_LENGTH) {
|
|
838
|
+
return c.json(
|
|
839
|
+
{
|
|
840
|
+
error: `Summary too long (max ${MAX_PROFILE_SUMMARY_LENGTH} chars)`,
|
|
841
|
+
},
|
|
842
|
+
400,
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
updates.summary = summary.length > 0 ? summary : null;
|
|
846
|
+
}
|
|
847
|
+
for (const [bodyKey, dbKey, label] of [
|
|
848
|
+
["icon_url", "iconUrl", "Icon URL"],
|
|
849
|
+
["header_url", "headerUrl", "Header URL"],
|
|
850
|
+
] as const) {
|
|
851
|
+
const raw = body[bodyKey];
|
|
852
|
+
if (raw !== undefined) {
|
|
853
|
+
if (typeof raw !== "string") {
|
|
854
|
+
return c.json({ error: `Invalid ${bodyKey}` }, 400);
|
|
855
|
+
}
|
|
856
|
+
const trimmed = raw.trim();
|
|
857
|
+
if (trimmed.length > MAX_PROFILE_URL_LENGTH) {
|
|
858
|
+
return c.json(
|
|
859
|
+
{
|
|
860
|
+
error: `${label} too long (max ${MAX_PROFILE_URL_LENGTH} chars)`,
|
|
861
|
+
},
|
|
862
|
+
400,
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
if (trimmed.length > 0 && !isValidProfileImageUrl(trimmed)) {
|
|
866
|
+
return c.json({ error: `Invalid ${bodyKey}` }, 400);
|
|
867
|
+
}
|
|
868
|
+
updates[dbKey] = trimmed.length > 0 ? trimmed : null;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
if (body.is_private !== undefined) {
|
|
872
|
+
updates.isPrivate = body.is_private ? 1 : 0;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// Structured profile metadata (PropertyValue). Sanitized + capped so the
|
|
876
|
+
// served actor document and federated Update(Person) stay bounded.
|
|
877
|
+
let nextFields: ProfileField[] | undefined;
|
|
878
|
+
if (body.fields !== undefined) {
|
|
879
|
+
nextFields = sanitizeProfileFields(body.fields);
|
|
880
|
+
updates.fieldsJson = JSON.stringify(nextFields);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// Account-migration aliases (alsoKnownAs). Accept an array of AP-ID URLs the
|
|
884
|
+
// account claims; bound + validate so a Move target can reference us.
|
|
885
|
+
let nextAlsoKnownAs: string[] | undefined;
|
|
886
|
+
if (body.also_known_as !== undefined) {
|
|
887
|
+
if (!Array.isArray(body.also_known_as)) {
|
|
888
|
+
return c.json({ error: "also_known_as must be an array" }, 400);
|
|
889
|
+
}
|
|
890
|
+
const aliases: string[] = [];
|
|
891
|
+
for (const raw of body.also_known_as) {
|
|
892
|
+
if (aliases.length >= MAX_ALSO_KNOWN_AS) break;
|
|
893
|
+
if (typeof raw !== "string") continue;
|
|
894
|
+
const trimmed = raw.trim();
|
|
895
|
+
if (trimmed.length === 0) continue;
|
|
896
|
+
if (!isValidHttpUrl(trimmed)) {
|
|
897
|
+
return c.json({ error: `Invalid alsoKnownAs entry: ${trimmed}` }, 400);
|
|
898
|
+
}
|
|
899
|
+
if (!aliases.includes(trimmed)) aliases.push(trimmed);
|
|
900
|
+
}
|
|
901
|
+
nextAlsoKnownAs = aliases;
|
|
902
|
+
updates.alsoKnownAsJson = JSON.stringify(aliases);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
if (Object.keys(updates).length === 0) {
|
|
906
|
+
return c.json({ error: "No fields to update" }, 400);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
const db = c.get("db");
|
|
910
|
+
await db.update(actors).set(updates).where(eq(actors.apId, actor.ap_id));
|
|
911
|
+
|
|
912
|
+
// A replaced avatar/header is attached to no object, so no GC path reclaims
|
|
913
|
+
// the prior blob — reap it now if the old URL is a local /media upload no
|
|
914
|
+
// longer referenced anywhere (best-effort; never fails the update).
|
|
915
|
+
for (const [dbKey, oldUrl] of [
|
|
916
|
+
["iconUrl", actor.icon_url],
|
|
917
|
+
["headerUrl", actor.header_url],
|
|
918
|
+
] as const) {
|
|
919
|
+
if (updates[dbKey] !== undefined && oldUrl && oldUrl !== updates[dbKey]) {
|
|
920
|
+
await reapReplacedMediaUrl(db, oldUrl, actor.ap_id, c.env.MEDIA);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// The `actor` context snapshot predates the new columns, so to federate a
|
|
925
|
+
// faithful Person we read the current persisted values when the request did
|
|
926
|
+
// not itself supply them.
|
|
927
|
+
if (nextFields === undefined || nextAlsoKnownAs === undefined) {
|
|
928
|
+
const persisted = await db
|
|
929
|
+
.select({
|
|
930
|
+
fieldsJson: actors.fieldsJson,
|
|
931
|
+
alsoKnownAsJson: actors.alsoKnownAsJson,
|
|
932
|
+
})
|
|
933
|
+
.from(actors)
|
|
934
|
+
.where(eq(actors.apId, actor.ap_id))
|
|
935
|
+
.get();
|
|
936
|
+
if (nextFields === undefined) {
|
|
937
|
+
nextFields = sanitizeProfileFields(
|
|
938
|
+
safeJsonParse(persisted?.fieldsJson, []),
|
|
939
|
+
);
|
|
940
|
+
}
|
|
941
|
+
if (nextAlsoKnownAs === undefined) {
|
|
942
|
+
const parsed = safeJsonParse<string[]>(persisted?.alsoKnownAsJson, []);
|
|
943
|
+
nextAlsoKnownAs = Array.isArray(parsed)
|
|
944
|
+
? parsed.filter((a): a is string => typeof a === "string")
|
|
945
|
+
: [];
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// Federate the profile change so remote followers do not see a stale
|
|
950
|
+
// Person. Every field this route can mutate (name / summary / icon /
|
|
951
|
+
// header / is_private) is part of the published actor document, so any
|
|
952
|
+
// applied update is federated-visible: build a fresh Update(Person) from
|
|
953
|
+
// the post-update values and fan it out to followers.
|
|
954
|
+
const baseUrl = c.env.APP_URL;
|
|
955
|
+
const nextName =
|
|
956
|
+
"name" in updates ? (updates.name as string | null) : actor.name;
|
|
957
|
+
const nextSummary =
|
|
958
|
+
"summary" in updates ? (updates.summary as string | null) : actor.summary;
|
|
959
|
+
const nextIconUrl =
|
|
960
|
+
"iconUrl" in updates ? (updates.iconUrl as string | null) : actor.icon_url;
|
|
961
|
+
const nextHeaderUrl =
|
|
962
|
+
"headerUrl" in updates
|
|
963
|
+
? (updates.headerUrl as string | null)
|
|
964
|
+
: actor.header_url;
|
|
965
|
+
const nextIsPrivate =
|
|
966
|
+
"isPrivate" in updates ? (updates.isPrivate as number) : actor.is_private;
|
|
967
|
+
|
|
968
|
+
// Mirror the actor document served at the federation actor endpoint
|
|
969
|
+
// (routes/activitypub.ts) so remote servers receive a consistent Person.
|
|
970
|
+
const personObject: Record<string, unknown> = {
|
|
971
|
+
id: actor.ap_id,
|
|
972
|
+
type: actor.type,
|
|
973
|
+
preferredUsername: actor.preferred_username,
|
|
974
|
+
name: nextName,
|
|
975
|
+
summary: nextSummary,
|
|
976
|
+
inbox: actor.inbox,
|
|
977
|
+
outbox: actor.outbox,
|
|
978
|
+
followers: actor.followers_url,
|
|
979
|
+
following: actor.following_url,
|
|
980
|
+
endpoints: {
|
|
981
|
+
sharedInbox: `${baseUrl}/ap/inbox`,
|
|
982
|
+
},
|
|
983
|
+
publicKey: {
|
|
984
|
+
id: `${actor.ap_id}#main-key`,
|
|
985
|
+
owner: actor.ap_id,
|
|
986
|
+
publicKeyPem: actor.public_key_pem,
|
|
987
|
+
},
|
|
988
|
+
discoverable: !nextIsPrivate,
|
|
989
|
+
manuallyApprovesFollowers: Boolean(nextIsPrivate),
|
|
990
|
+
};
|
|
991
|
+
if (nextIconUrl) {
|
|
992
|
+
// Relative `/media/...` upload paths must be absolutized so remote servers
|
|
993
|
+
// can dereference the avatar; absolute URLs pass through unchanged.
|
|
994
|
+
personObject.icon = {
|
|
995
|
+
type: "Image",
|
|
996
|
+
url: safeUrlJoin(baseUrl, nextIconUrl),
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
if (nextHeaderUrl) {
|
|
1000
|
+
personObject.image = {
|
|
1001
|
+
type: "Image",
|
|
1002
|
+
url: safeUrlJoin(baseUrl, nextHeaderUrl),
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
if (nextFields && nextFields.length > 0) {
|
|
1006
|
+
personObject.attachment = fieldsToAttachments(nextFields);
|
|
1007
|
+
}
|
|
1008
|
+
if (nextAlsoKnownAs && nextAlsoKnownAs.length > 0) {
|
|
1009
|
+
personObject.alsoKnownAs = nextAlsoKnownAs;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const updateActivityId = activityApId(baseUrl, generateId());
|
|
1013
|
+
const updateActivity = {
|
|
1014
|
+
"@context": [
|
|
1015
|
+
"https://www.w3.org/ns/activitystreams",
|
|
1016
|
+
"https://w3id.org/security/v1",
|
|
1017
|
+
{
|
|
1018
|
+
schema: "http://schema.org#",
|
|
1019
|
+
PropertyValue: "schema:PropertyValue",
|
|
1020
|
+
value: "schema:value",
|
|
1021
|
+
toot: "http://joinmastodon.org/ns#",
|
|
1022
|
+
alsoKnownAs: { "@id": "as:alsoKnownAs", "@type": "@id" },
|
|
1023
|
+
movedTo: { "@id": "as:movedTo", "@type": "@id" },
|
|
1024
|
+
manuallyApprovesFollowers: "as:manuallyApprovesFollowers",
|
|
1025
|
+
},
|
|
1026
|
+
],
|
|
1027
|
+
id: updateActivityId,
|
|
1028
|
+
type: "Update",
|
|
1029
|
+
actor: actor.ap_id,
|
|
1030
|
+
to: ["https://www.w3.org/ns/activitystreams#Public"],
|
|
1031
|
+
cc: [actor.followers_url],
|
|
1032
|
+
object: personObject,
|
|
1033
|
+
};
|
|
1034
|
+
|
|
1035
|
+
try {
|
|
1036
|
+
await db.insert(activities).values({
|
|
1037
|
+
apId: updateActivityId,
|
|
1038
|
+
type: "Update",
|
|
1039
|
+
actorApId: actor.ap_id,
|
|
1040
|
+
objectApId: actor.ap_id,
|
|
1041
|
+
rawJson: JSON.stringify(updateActivity),
|
|
1042
|
+
direction: "outbound",
|
|
1043
|
+
});
|
|
1044
|
+
await enqueueFanoutToFollowers(c.env, updateActivityId, actor.ap_id);
|
|
1045
|
+
} catch (err) {
|
|
1046
|
+
// Federation is best-effort; the local profile update already succeeded.
|
|
1047
|
+
log.error("Failed to enqueue profile Update federation", {
|
|
1048
|
+
event: "actors.profile.update_federation_failed",
|
|
1049
|
+
actor: actor.ap_id,
|
|
1050
|
+
error: err,
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
return c.json({ success: true });
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
// Initiate an account migration: declare the destination and federate a
|
|
1058
|
+
// Move(actor -> target) to followers. The destination must already list this
|
|
1059
|
+
// account in its own `alsoKnownAs` (verified by remote servers); we persist
|
|
1060
|
+
// `moved_to` so the served actor document advertises the migration and emit
|
|
1061
|
+
// the Move so followers can re-follow the target.
|
|
1062
|
+
actorsRoute.post("/me/move", async (c) => {
|
|
1063
|
+
const result = requireActor(c);
|
|
1064
|
+
if (result instanceof Response) return result;
|
|
1065
|
+
const actor = result;
|
|
1066
|
+
|
|
1067
|
+
const body = await c.req
|
|
1068
|
+
.json<{ target?: unknown }>()
|
|
1069
|
+
.catch(() => ({}) as { target?: unknown });
|
|
1070
|
+
const rawTarget = typeof body.target === "string" ? body.target.trim() : "";
|
|
1071
|
+
if (rawTarget.length === 0) {
|
|
1072
|
+
return c.json({ error: "target is required" }, 400);
|
|
1073
|
+
}
|
|
1074
|
+
// Accept either a full actor URL or a @user@domain fediverse handle (the
|
|
1075
|
+
// latter is WebFinger-resolved to its actor URL — what the move field's
|
|
1076
|
+
// placeholder shows and what users actually know).
|
|
1077
|
+
const target = await resolveMoveTarget(rawTarget);
|
|
1078
|
+
if (!target || !isValidHttpUrl(target)) {
|
|
1079
|
+
return c.json({ error: "Invalid move target" }, 400);
|
|
1080
|
+
}
|
|
1081
|
+
if (target === actor.ap_id) {
|
|
1082
|
+
return c.json({ error: "Cannot move an account to itself" }, 400);
|
|
1083
|
+
}
|
|
1084
|
+
if (!isSafeRemoteUrl(target)) {
|
|
1085
|
+
return c.json({ error: "Invalid move target" }, 400);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// Refuse to advertise a migration the destination has not consented to. A
|
|
1089
|
+
// compliant receiver (Mastodon, and our own inbound handleMove) REJECTS a
|
|
1090
|
+
// Move whose destination does not list this account in its `alsoKnownAs`, so
|
|
1091
|
+
// without this check the move would silently no-op on every follower's server
|
|
1092
|
+
// while appearing successful locally. Verifying here gives the user an
|
|
1093
|
+
// actionable error: add this account as an alias on the destination first.
|
|
1094
|
+
if (
|
|
1095
|
+
!(await destinationDeclaresAlias(
|
|
1096
|
+
target,
|
|
1097
|
+
actor.ap_id,
|
|
1098
|
+
await getInstanceFetchSigner(c),
|
|
1099
|
+
))
|
|
1100
|
+
) {
|
|
1101
|
+
return c.json(
|
|
1102
|
+
{
|
|
1103
|
+
error:
|
|
1104
|
+
"The destination account must list this account in its aliases (alsoKnownAs) before migrating. Add this account as an alias there, then retry.",
|
|
1105
|
+
},
|
|
1106
|
+
422,
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
const db = c.get("db");
|
|
1111
|
+
const baseUrl = c.env.APP_URL;
|
|
1112
|
+
|
|
1113
|
+
await db
|
|
1114
|
+
.update(actors)
|
|
1115
|
+
.set({ movedTo: target })
|
|
1116
|
+
.where(eq(actors.apId, actor.ap_id));
|
|
1117
|
+
|
|
1118
|
+
// Federate Move(actor) addressed to followers so they migrate their follow.
|
|
1119
|
+
const moveActivityId = activityApId(baseUrl, generateId());
|
|
1120
|
+
const moveActivity = {
|
|
1121
|
+
"@context": "https://www.w3.org/ns/activitystreams",
|
|
1122
|
+
id: moveActivityId,
|
|
1123
|
+
type: "Move",
|
|
1124
|
+
actor: actor.ap_id,
|
|
1125
|
+
object: actor.ap_id,
|
|
1126
|
+
target,
|
|
1127
|
+
to: ["https://www.w3.org/ns/activitystreams#Public"],
|
|
1128
|
+
cc: [actor.followers_url],
|
|
1129
|
+
};
|
|
1130
|
+
|
|
1131
|
+
try {
|
|
1132
|
+
await db.insert(activities).values({
|
|
1133
|
+
apId: moveActivityId,
|
|
1134
|
+
type: "Move",
|
|
1135
|
+
actorApId: actor.ap_id,
|
|
1136
|
+
objectApId: actor.ap_id,
|
|
1137
|
+
rawJson: JSON.stringify(moveActivity),
|
|
1138
|
+
direction: "outbound",
|
|
1139
|
+
});
|
|
1140
|
+
await enqueueFanoutToFollowers(c.env, moveActivityId, actor.ap_id);
|
|
1141
|
+
} catch (err) {
|
|
1142
|
+
// Federation is best-effort; the local moved_to marker already persisted.
|
|
1143
|
+
log.error("Failed to enqueue account Move federation", {
|
|
1144
|
+
event: "actors.account.move_federation_failed",
|
|
1145
|
+
actor: actor.ap_id,
|
|
1146
|
+
error: err,
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
return c.json({ success: true, moved_to: target });
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
// Personal-portability data export: a bounded JSON archive of the actor's
|
|
1154
|
+
// profile + authored posts (outbox-style) + follow graph + media manifest.
|
|
1155
|
+
// Every collection is capped so a single request cannot OOM the worker; this
|
|
1156
|
+
// is a portability aid, not a streaming full backup.
|
|
1157
|
+
actorsRoute.get("/me/export", async (c) => {
|
|
1158
|
+
const result = requireActor(c);
|
|
1159
|
+
if (result instanceof Response) return result;
|
|
1160
|
+
const actor = result;
|
|
1161
|
+
const db = c.get("db");
|
|
1162
|
+
const actorApIdVal = actor.ap_id;
|
|
1163
|
+
|
|
1164
|
+
const profileRow = await db
|
|
1165
|
+
.select({
|
|
1166
|
+
apId: actors.apId,
|
|
1167
|
+
type: actors.type,
|
|
1168
|
+
preferredUsername: actors.preferredUsername,
|
|
1169
|
+
name: actors.name,
|
|
1170
|
+
summary: actors.summary,
|
|
1171
|
+
iconUrl: actors.iconUrl,
|
|
1172
|
+
headerUrl: actors.headerUrl,
|
|
1173
|
+
isPrivate: actors.isPrivate,
|
|
1174
|
+
role: actors.role,
|
|
1175
|
+
createdAt: actors.createdAt,
|
|
1176
|
+
fieldsJson: actors.fieldsJson,
|
|
1177
|
+
alsoKnownAsJson: actors.alsoKnownAsJson,
|
|
1178
|
+
movedTo: actors.movedTo,
|
|
1179
|
+
})
|
|
1180
|
+
.from(actors)
|
|
1181
|
+
.where(eq(actors.apId, actorApIdVal))
|
|
1182
|
+
.get();
|
|
1183
|
+
|
|
1184
|
+
if (!profileRow) return c.json({ error: "Actor not found" }, 404);
|
|
1185
|
+
|
|
1186
|
+
const [authoredPosts, following, followers, media] = await Promise.all([
|
|
1187
|
+
db
|
|
1188
|
+
.select()
|
|
1189
|
+
.from(objects)
|
|
1190
|
+
.where(and(eq(objects.attributedTo, actorApIdVal), notDeleted(objects)))
|
|
1191
|
+
.orderBy(desc(objects.published))
|
|
1192
|
+
.limit(MAX_EXPORT_POSTS),
|
|
1193
|
+
db
|
|
1194
|
+
.select({
|
|
1195
|
+
followingApId: follows.followingApId,
|
|
1196
|
+
status: follows.status,
|
|
1197
|
+
createdAt: follows.createdAt,
|
|
1198
|
+
})
|
|
1199
|
+
.from(follows)
|
|
1200
|
+
.where(eq(follows.followerApId, actorApIdVal))
|
|
1201
|
+
.orderBy(desc(follows.createdAt))
|
|
1202
|
+
.limit(MAX_EXPORT_RELATIONS),
|
|
1203
|
+
db
|
|
1204
|
+
.select({
|
|
1205
|
+
followerApId: follows.followerApId,
|
|
1206
|
+
status: follows.status,
|
|
1207
|
+
createdAt: follows.createdAt,
|
|
1208
|
+
})
|
|
1209
|
+
.from(follows)
|
|
1210
|
+
.where(eq(follows.followingApId, actorApIdVal))
|
|
1211
|
+
.orderBy(desc(follows.createdAt))
|
|
1212
|
+
.limit(MAX_EXPORT_RELATIONS),
|
|
1213
|
+
db
|
|
1214
|
+
.select({
|
|
1215
|
+
id: mediaUploads.id,
|
|
1216
|
+
r2Key: mediaUploads.r2Key,
|
|
1217
|
+
contentType: mediaUploads.contentType,
|
|
1218
|
+
size: mediaUploads.size,
|
|
1219
|
+
createdAt: mediaUploads.createdAt,
|
|
1220
|
+
})
|
|
1221
|
+
.from(mediaUploads)
|
|
1222
|
+
.where(eq(mediaUploads.uploaderApId, actorApIdVal))
|
|
1223
|
+
.orderBy(desc(mediaUploads.createdAt))
|
|
1224
|
+
.limit(MAX_EXPORT_MEDIA),
|
|
1225
|
+
]);
|
|
1226
|
+
|
|
1227
|
+
const archive = {
|
|
1228
|
+
"@context": "https://www.w3.org/ns/activitystreams",
|
|
1229
|
+
exported_at: new Date().toISOString(),
|
|
1230
|
+
actor: {
|
|
1231
|
+
ap_id: profileRow.apId,
|
|
1232
|
+
type: profileRow.type,
|
|
1233
|
+
preferred_username: profileRow.preferredUsername,
|
|
1234
|
+
name: profileRow.name,
|
|
1235
|
+
summary: profileRow.summary,
|
|
1236
|
+
icon_url: profileRow.iconUrl,
|
|
1237
|
+
header_url: profileRow.headerUrl,
|
|
1238
|
+
is_private: profileRow.isPrivate,
|
|
1239
|
+
role: profileRow.role,
|
|
1240
|
+
created_at: profileRow.createdAt,
|
|
1241
|
+
fields: sanitizeProfileFields(safeJsonParse(profileRow.fieldsJson, [])),
|
|
1242
|
+
also_known_as: safeJsonParse<string[]>(profileRow.alsoKnownAsJson, []),
|
|
1243
|
+
moved_to: profileRow.movedTo,
|
|
1244
|
+
username: formatUsername(profileRow.apId),
|
|
1245
|
+
},
|
|
1246
|
+
outbox: {
|
|
1247
|
+
type: "OrderedCollection",
|
|
1248
|
+
total_items: authoredPosts.length,
|
|
1249
|
+
truncated: authoredPosts.length >= MAX_EXPORT_POSTS,
|
|
1250
|
+
ordered_items: authoredPosts.map((p) => ({
|
|
1251
|
+
ap_id: p.apId,
|
|
1252
|
+
type: p.type,
|
|
1253
|
+
content: p.content,
|
|
1254
|
+
summary: p.summary,
|
|
1255
|
+
attachments: safeJsonParse(p.attachmentsJson, []),
|
|
1256
|
+
in_reply_to: p.inReplyTo,
|
|
1257
|
+
visibility: p.visibility,
|
|
1258
|
+
community_ap_id: p.communityApId,
|
|
1259
|
+
published: p.published,
|
|
1260
|
+
updated: p.updated,
|
|
1261
|
+
})),
|
|
1262
|
+
},
|
|
1263
|
+
following: {
|
|
1264
|
+
total_items: following.length,
|
|
1265
|
+
truncated: following.length >= MAX_EXPORT_RELATIONS,
|
|
1266
|
+
items: following.map((f) => ({
|
|
1267
|
+
ap_id: f.followingApId,
|
|
1268
|
+
status: f.status,
|
|
1269
|
+
created_at: f.createdAt,
|
|
1270
|
+
})),
|
|
1271
|
+
},
|
|
1272
|
+
followers: {
|
|
1273
|
+
total_items: followers.length,
|
|
1274
|
+
truncated: followers.length >= MAX_EXPORT_RELATIONS,
|
|
1275
|
+
items: followers.map((f) => ({
|
|
1276
|
+
ap_id: f.followerApId,
|
|
1277
|
+
status: f.status,
|
|
1278
|
+
created_at: f.createdAt,
|
|
1279
|
+
})),
|
|
1280
|
+
},
|
|
1281
|
+
media: {
|
|
1282
|
+
total_items: media.length,
|
|
1283
|
+
truncated: media.length >= MAX_EXPORT_MEDIA,
|
|
1284
|
+
items: media.map((m) => ({
|
|
1285
|
+
id: m.id,
|
|
1286
|
+
key: m.r2Key,
|
|
1287
|
+
content_type: m.contentType,
|
|
1288
|
+
size: m.size,
|
|
1289
|
+
created_at: m.createdAt,
|
|
1290
|
+
})),
|
|
1291
|
+
},
|
|
1292
|
+
};
|
|
1293
|
+
|
|
1294
|
+
c.header(
|
|
1295
|
+
"Content-Disposition",
|
|
1296
|
+
`attachment; filename="${profileRow.preferredUsername}-export.json"`,
|
|
1297
|
+
);
|
|
1298
|
+
return c.json(archive);
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
// Get actor's followers
|
|
1302
|
+
actorsRoute.get("/:identifier/followers", async (c) =>
|
|
1303
|
+
listFollowRelation(c, "followers"),
|
|
1304
|
+
);
|
|
1305
|
+
|
|
1306
|
+
// Get actor's following
|
|
1307
|
+
actorsRoute.get("/:identifier/following", async (c) =>
|
|
1308
|
+
listFollowRelation(c, "following"),
|
|
1309
|
+
);
|
|
1310
|
+
|
|
1311
|
+
export default actorsRoute;
|