@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,658 @@
|
|
|
1
|
+
// Direct Messages - AP Native
|
|
2
|
+
// DMs are Note objects with visibility='direct' and to=[recipient]
|
|
3
|
+
// Threading via conversation field
|
|
4
|
+
|
|
5
|
+
import { Hono } from "hono";
|
|
6
|
+
import { and, desc, eq, inArray, like } from "drizzle-orm";
|
|
7
|
+
import type { Database } from "../../../db/index.ts";
|
|
8
|
+
import {
|
|
9
|
+
activities,
|
|
10
|
+
actorCache,
|
|
11
|
+
actors,
|
|
12
|
+
blocks,
|
|
13
|
+
inbox as inboxTable,
|
|
14
|
+
objectRecipients,
|
|
15
|
+
objects,
|
|
16
|
+
} from "../../../db/index.ts";
|
|
17
|
+
import {
|
|
18
|
+
deleteObjectCascade,
|
|
19
|
+
purgeMediaBlobs,
|
|
20
|
+
} from "../posts/delete-cascade.ts";
|
|
21
|
+
import type { Env, Variables } from "../../types.ts";
|
|
22
|
+
import {
|
|
23
|
+
activityApId,
|
|
24
|
+
formatUsername,
|
|
25
|
+
generateId,
|
|
26
|
+
isLocal,
|
|
27
|
+
objectApId,
|
|
28
|
+
parseLimit,
|
|
29
|
+
safeJsonParse,
|
|
30
|
+
} from "../../federation-helpers.ts";
|
|
31
|
+
import {
|
|
32
|
+
MAX_DM_CONTENT_LENGTH,
|
|
33
|
+
MAX_DM_PAGE_LIMIT,
|
|
34
|
+
resolveConversationId,
|
|
35
|
+
} from "./query-helpers.ts";
|
|
36
|
+
import { enqueueDeliveryToActor } from "../../lib/delivery/queue.ts";
|
|
37
|
+
import { feedCursorWhere } from "../../lib/feed-cursor.ts";
|
|
38
|
+
import { logger } from "../../lib/logger.ts";
|
|
39
|
+
|
|
40
|
+
const log = logger.child({ component: "dm.messages" });
|
|
41
|
+
|
|
42
|
+
// `.batch` lives only on the concrete D1/libsql subclasses, not the Database
|
|
43
|
+
// union; reach it through a narrow structural cast (matching the other routes).
|
|
44
|
+
type Batchable = { batch: (stmts: unknown[]) => Promise<unknown> };
|
|
45
|
+
|
|
46
|
+
const dm = new Hono<{ Bindings: Env; Variables: Variables }>();
|
|
47
|
+
|
|
48
|
+
type Attachment = {
|
|
49
|
+
type?: string;
|
|
50
|
+
mediaType?: string;
|
|
51
|
+
url?: string;
|
|
52
|
+
[key: string]: unknown;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// --- Shared helpers (file-local) ---
|
|
56
|
+
|
|
57
|
+
type ActorInfo = {
|
|
58
|
+
apId: string;
|
|
59
|
+
preferredUsername: string | null;
|
|
60
|
+
name: string | null;
|
|
61
|
+
iconUrl: string | null;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
type SenderInfo = {
|
|
65
|
+
ap_id: string;
|
|
66
|
+
username: string;
|
|
67
|
+
preferred_username: string | null;
|
|
68
|
+
name: string | null;
|
|
69
|
+
icon_url: string | null;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Only the columns the authorization filter + formatter touch (the fetch query
|
|
73
|
+
// projects exactly these — see fetchAuthorizedMessages; avoids pulling raw_json
|
|
74
|
+
// on the 4s-polled endpoint).
|
|
75
|
+
type DmMessageRow = Pick<
|
|
76
|
+
typeof objects.$inferSelect,
|
|
77
|
+
| "apId"
|
|
78
|
+
| "attributedTo"
|
|
79
|
+
| "content"
|
|
80
|
+
| "attachmentsJson"
|
|
81
|
+
| "published"
|
|
82
|
+
| "toJson"
|
|
83
|
+
>;
|
|
84
|
+
|
|
85
|
+
type DmMessageResponse = {
|
|
86
|
+
id: string;
|
|
87
|
+
sender: SenderInfo;
|
|
88
|
+
content: string | null;
|
|
89
|
+
attachments?: Attachment[];
|
|
90
|
+
created_at: string | null;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** Validate trimmed DM content; returns the trimmed string or an error response. */
|
|
94
|
+
function validateContent(
|
|
95
|
+
raw: unknown,
|
|
96
|
+
): string | { error: string; status: 400 } {
|
|
97
|
+
// The json<{content:string}>() cast is compile-time only; a client can send a
|
|
98
|
+
// non-string. Guard before .trim() else TypeError → 500 (the global handler
|
|
99
|
+
// deliberately does not mask TypeError as 400). Mirrors the profile/post/invite
|
|
100
|
+
// validators.
|
|
101
|
+
if (typeof raw !== "string") {
|
|
102
|
+
return { error: "Message content is required", status: 400 };
|
|
103
|
+
}
|
|
104
|
+
const content = raw.trim();
|
|
105
|
+
if (!content) return { error: "Message content is required", status: 400 };
|
|
106
|
+
if (content.length > MAX_DM_CONTENT_LENGTH) {
|
|
107
|
+
return {
|
|
108
|
+
error: `Message too long (max ${MAX_DM_CONTENT_LENGTH} chars)`,
|
|
109
|
+
status: 400,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return content;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Resolve a `user@domain` handle for a DM recipient. Prefers the stored
|
|
117
|
+
* preferredUsername paired with the recipient's host, falling back to
|
|
118
|
+
* deriving the handle from the actor id (`formatUsername`).
|
|
119
|
+
*/
|
|
120
|
+
function resolveRecipientHandle(
|
|
121
|
+
preferredUsername: string | null,
|
|
122
|
+
apId: string,
|
|
123
|
+
): string {
|
|
124
|
+
if (preferredUsername) {
|
|
125
|
+
try {
|
|
126
|
+
return `${preferredUsername}@${new URL(apId).host}`;
|
|
127
|
+
} catch {
|
|
128
|
+
// fall through to apId-derived handle
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return formatUsername(apId);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Build a sender info object from a current-session actor. */
|
|
135
|
+
function buildSenderFromActor(actor: {
|
|
136
|
+
ap_id: string;
|
|
137
|
+
preferred_username: string | null;
|
|
138
|
+
name: string | null;
|
|
139
|
+
icon_url: string | null;
|
|
140
|
+
}): SenderInfo {
|
|
141
|
+
return {
|
|
142
|
+
ap_id: actor.ap_id,
|
|
143
|
+
username: formatUsername(actor.ap_id),
|
|
144
|
+
preferred_username: actor.preferred_username,
|
|
145
|
+
name: actor.name,
|
|
146
|
+
icon_url: actor.icon_url,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Fetch direct messages the actor is authorized to see, filtered by
|
|
152
|
+
* conversation. Returns one page (newest-first, capped at `limit`) plus
|
|
153
|
+
* `hasMore` — whether an OLDER page exists — so the thread can offer a "load
|
|
154
|
+
* older" affordance. `before` is the `published` of the oldest message already
|
|
155
|
+
* shown (older messages are fetched with `published < before`).
|
|
156
|
+
*/
|
|
157
|
+
async function fetchAuthorizedMessages(
|
|
158
|
+
db: Database,
|
|
159
|
+
actorApId: string,
|
|
160
|
+
conversationId: string,
|
|
161
|
+
limit: number,
|
|
162
|
+
before: string | undefined,
|
|
163
|
+
): Promise<{ rows: DmMessageRow[]; hasMore: boolean }> {
|
|
164
|
+
// Build where clause: filter by conversation + visibility + type
|
|
165
|
+
// Authorization is re-validated in code below (defense-in-depth)
|
|
166
|
+
const baseCondition = and(
|
|
167
|
+
eq(objects.visibility, "direct"),
|
|
168
|
+
eq(objects.type, "Note"),
|
|
169
|
+
eq(objects.conversation, conversationId),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
// Composite (published, apId) cursor so two messages sharing a published
|
|
173
|
+
// millisecond aren't skipped on a load-older that straddles that ms (see
|
|
174
|
+
// lib/feed-cursor.ts). The client builds the cursor from the oldest shown
|
|
175
|
+
// message; a published-only value from an older client is still accepted.
|
|
176
|
+
const cursor = feedCursorWhere(objects.published, objects.apId, before);
|
|
177
|
+
const whereClause = cursor ? and(baseCondition!, cursor) : baseCondition;
|
|
178
|
+
|
|
179
|
+
// Fetch one extra row to detect whether an older page exists. Project only the
|
|
180
|
+
// 6 columns the formatter/authorization touch — a bare select() pulled every
|
|
181
|
+
// column incl. the large raw_json blob on a 4s-polled endpoint (mirrors the
|
|
182
|
+
// POST_FEED_COLUMNS projection already used by the timeline).
|
|
183
|
+
const messages = await db
|
|
184
|
+
.select({
|
|
185
|
+
apId: objects.apId,
|
|
186
|
+
attributedTo: objects.attributedTo,
|
|
187
|
+
content: objects.content,
|
|
188
|
+
attachmentsJson: objects.attachmentsJson,
|
|
189
|
+
published: objects.published,
|
|
190
|
+
toJson: objects.toJson,
|
|
191
|
+
})
|
|
192
|
+
.from(objects)
|
|
193
|
+
.where(whereClause!)
|
|
194
|
+
.orderBy(desc(objects.published), desc(objects.apId))
|
|
195
|
+
.limit(limit + 1);
|
|
196
|
+
|
|
197
|
+
// Defence-in-depth: re-validate authorization at the code level. (In practice
|
|
198
|
+
// every row in the actor's own conversation passes, so the +1 reliably signals
|
|
199
|
+
// an older page.)
|
|
200
|
+
const authorized = messages.filter((msg) => {
|
|
201
|
+
if (msg.attributedTo === actorApId) return true;
|
|
202
|
+
const toRecipients = safeJsonParse<string[]>(msg.toJson, []);
|
|
203
|
+
return toRecipients.includes(actorApId);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const hasMore = authorized.length > limit;
|
|
207
|
+
return { rows: hasMore ? authorized.slice(0, limit) : authorized, hasMore };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Build a map from ap_id -> actor info, checking local actors then cached actors. */
|
|
211
|
+
async function resolveAuthorInfoMap(
|
|
212
|
+
db: Database,
|
|
213
|
+
authorApIds: string[],
|
|
214
|
+
): Promise<Map<string, ActorInfo>> {
|
|
215
|
+
const localActors = await db
|
|
216
|
+
.select({
|
|
217
|
+
apId: actors.apId,
|
|
218
|
+
preferredUsername: actors.preferredUsername,
|
|
219
|
+
name: actors.name,
|
|
220
|
+
iconUrl: actors.iconUrl,
|
|
221
|
+
})
|
|
222
|
+
.from(actors)
|
|
223
|
+
.where(inArray(actors.apId, authorApIds));
|
|
224
|
+
|
|
225
|
+
const localMap = new Map<string, ActorInfo>(
|
|
226
|
+
localActors.map((a) => [a.apId, a]),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
const remoteApIds = authorApIds.filter((id) => !localMap.has(id));
|
|
230
|
+
if (remoteApIds.length > 0) {
|
|
231
|
+
const cached = await db
|
|
232
|
+
.select({
|
|
233
|
+
apId: actorCache.apId,
|
|
234
|
+
preferredUsername: actorCache.preferredUsername,
|
|
235
|
+
name: actorCache.name,
|
|
236
|
+
iconUrl: actorCache.iconUrl,
|
|
237
|
+
})
|
|
238
|
+
.from(actorCache)
|
|
239
|
+
.where(inArray(actorCache.apId, remoteApIds));
|
|
240
|
+
|
|
241
|
+
for (const a of cached) {
|
|
242
|
+
localMap.set(a.apId, a);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return localMap;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Map raw DB message rows to the API response shape (chronological order). */
|
|
250
|
+
function formatMessages(
|
|
251
|
+
messages: DmMessageRow[],
|
|
252
|
+
authorMap: Map<string, ActorInfo>,
|
|
253
|
+
): DmMessageResponse[] {
|
|
254
|
+
return messages.reverse().map((msg) => {
|
|
255
|
+
const info = authorMap.get(msg.attributedTo);
|
|
256
|
+
return {
|
|
257
|
+
id: msg.apId,
|
|
258
|
+
sender: {
|
|
259
|
+
ap_id: msg.attributedTo,
|
|
260
|
+
username: formatUsername(msg.attributedTo),
|
|
261
|
+
preferred_username: info?.preferredUsername || null,
|
|
262
|
+
name: info?.name || null,
|
|
263
|
+
icon_url: info?.iconUrl || null,
|
|
264
|
+
},
|
|
265
|
+
content: msg.content,
|
|
266
|
+
attachments: safeJsonParse<Attachment[]>(msg.attachmentsJson, []),
|
|
267
|
+
created_at: msg.published,
|
|
268
|
+
};
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Fetch messages for a conversation, resolve authors, and format for API response. */
|
|
273
|
+
async function fetchAndFormatMessages(
|
|
274
|
+
db: Database,
|
|
275
|
+
actorApId: string,
|
|
276
|
+
conversationId: string,
|
|
277
|
+
limit: number,
|
|
278
|
+
before: string | undefined,
|
|
279
|
+
): Promise<{ messages: DmMessageResponse[]; hasMore: boolean }> {
|
|
280
|
+
const { rows, hasMore } = await fetchAuthorizedMessages(
|
|
281
|
+
db,
|
|
282
|
+
actorApId,
|
|
283
|
+
conversationId,
|
|
284
|
+
limit,
|
|
285
|
+
before,
|
|
286
|
+
);
|
|
287
|
+
const authorApIds = [...new Set(rows.map((m) => m.attributedTo))];
|
|
288
|
+
const authorMap = await resolveAuthorInfoMap(db, authorApIds);
|
|
289
|
+
return { messages: formatMessages(rows, authorMap), hasMore };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Look up a direct-message Note that the actor owns (for edit/delete). */
|
|
293
|
+
async function findOwnedDmMessage(
|
|
294
|
+
db: Database,
|
|
295
|
+
messageId: string,
|
|
296
|
+
actorApId: string,
|
|
297
|
+
): Promise<
|
|
298
|
+
| { apId: string; attributedTo: string; conversation: string | null }
|
|
299
|
+
| {
|
|
300
|
+
error: string;
|
|
301
|
+
status: 403 | 404;
|
|
302
|
+
}
|
|
303
|
+
> {
|
|
304
|
+
const message = await db
|
|
305
|
+
.select({
|
|
306
|
+
apId: objects.apId,
|
|
307
|
+
attributedTo: objects.attributedTo,
|
|
308
|
+
conversation: objects.conversation,
|
|
309
|
+
})
|
|
310
|
+
.from(objects)
|
|
311
|
+
.where(
|
|
312
|
+
and(
|
|
313
|
+
eq(objects.apId, messageId),
|
|
314
|
+
eq(objects.visibility, "direct"),
|
|
315
|
+
eq(objects.type, "Note"),
|
|
316
|
+
),
|
|
317
|
+
)
|
|
318
|
+
.get();
|
|
319
|
+
|
|
320
|
+
if (!message) return { error: "Message not found", status: 404 };
|
|
321
|
+
if (message.attributedTo !== actorApId) {
|
|
322
|
+
return { error: "Forbidden", status: 403 };
|
|
323
|
+
}
|
|
324
|
+
return message;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Create the DM Note object row. */
|
|
328
|
+
// Build (but do not execute) the insert for a DM Note. Returned as a statement
|
|
329
|
+
// so the caller can co-commit it with the recipient/activity/inbox rows in one
|
|
330
|
+
// atomic batch (D1 has no interactive transactions).
|
|
331
|
+
function dmNoteInsert(
|
|
332
|
+
db: Database,
|
|
333
|
+
data: {
|
|
334
|
+
apId: string;
|
|
335
|
+
actorApId: string;
|
|
336
|
+
content: string;
|
|
337
|
+
toJson: string;
|
|
338
|
+
conversationId: string;
|
|
339
|
+
published: string;
|
|
340
|
+
},
|
|
341
|
+
) {
|
|
342
|
+
return db.insert(objects).values({
|
|
343
|
+
apId: data.apId,
|
|
344
|
+
type: "Note",
|
|
345
|
+
attributedTo: data.actorApId,
|
|
346
|
+
content: data.content,
|
|
347
|
+
visibility: "direct",
|
|
348
|
+
toJson: data.toJson,
|
|
349
|
+
ccJson: JSON.stringify([]),
|
|
350
|
+
conversation: data.conversationId,
|
|
351
|
+
published: data.published,
|
|
352
|
+
isLocal: 1,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// --- Route handlers ---
|
|
357
|
+
|
|
358
|
+
dm.get("/user/:encodedApId/messages", async (c) => {
|
|
359
|
+
const actor = c.get("actor");
|
|
360
|
+
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
361
|
+
|
|
362
|
+
const db = c.get("db");
|
|
363
|
+
const otherApId = decodeURIComponent(c.req.param("encodedApId"));
|
|
364
|
+
const limit = parseLimit(c.req.query("limit"), 50, MAX_DM_PAGE_LIMIT);
|
|
365
|
+
const before = c.req.query("before");
|
|
366
|
+
// Resolve to the STORED conversation id of an existing thread rather than
|
|
367
|
+
// always recomputing the current-scheme id, so messages/read-status stay
|
|
368
|
+
// matched for threads created before the current id scheme.
|
|
369
|
+
const conversationId = await resolveConversationId(
|
|
370
|
+
db,
|
|
371
|
+
c.env.APP_URL,
|
|
372
|
+
actor.ap_id,
|
|
373
|
+
otherApId,
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
const { messages, hasMore } = await fetchAndFormatMessages(
|
|
377
|
+
db,
|
|
378
|
+
actor.ap_id,
|
|
379
|
+
conversationId,
|
|
380
|
+
limit,
|
|
381
|
+
before,
|
|
382
|
+
);
|
|
383
|
+
return c.json({
|
|
384
|
+
messages,
|
|
385
|
+
conversation_id: conversationId,
|
|
386
|
+
has_more: hasMore,
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// Send message to a specific user (creates Note with direct visibility)
|
|
391
|
+
dm.post("/user/:encodedApId/messages", async (c) => {
|
|
392
|
+
const actor = c.get("actor");
|
|
393
|
+
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
394
|
+
|
|
395
|
+
const db = c.get("db");
|
|
396
|
+
const otherApId = decodeURIComponent(c.req.param("encodedApId"));
|
|
397
|
+
const body = await c.req.json<{ content: string }>();
|
|
398
|
+
const baseUrl = c.env.APP_URL;
|
|
399
|
+
|
|
400
|
+
const contentOrError = validateContent(body.content);
|
|
401
|
+
if (typeof contentOrError !== "string") {
|
|
402
|
+
return c.json({ error: contentOrError.error }, contentOrError.status);
|
|
403
|
+
}
|
|
404
|
+
const content = contentOrError;
|
|
405
|
+
|
|
406
|
+
// Verify other user exists (check both local actors and cached remote actors)
|
|
407
|
+
const localActor = await db
|
|
408
|
+
.select({
|
|
409
|
+
apId: actors.apId,
|
|
410
|
+
inbox: actors.inbox,
|
|
411
|
+
preferredUsername: actors.preferredUsername,
|
|
412
|
+
})
|
|
413
|
+
.from(actors)
|
|
414
|
+
.where(eq(actors.apId, otherApId))
|
|
415
|
+
.get();
|
|
416
|
+
|
|
417
|
+
const cachedActor = !localActor
|
|
418
|
+
? await db
|
|
419
|
+
.select({
|
|
420
|
+
apId: actorCache.apId,
|
|
421
|
+
inbox: actorCache.inbox,
|
|
422
|
+
preferredUsername: actorCache.preferredUsername,
|
|
423
|
+
})
|
|
424
|
+
.from(actorCache)
|
|
425
|
+
.where(eq(actorCache.apId, otherApId))
|
|
426
|
+
.get()
|
|
427
|
+
: null;
|
|
428
|
+
|
|
429
|
+
const otherActor = localActor || cachedActor;
|
|
430
|
+
if (!otherActor) return c.json({ error: "User not found" }, 404);
|
|
431
|
+
|
|
432
|
+
// Reject if the recipient has blocked the sender. Respond with 404 (the same
|
|
433
|
+
// shape as a non-existent recipient) so the sender cannot distinguish a block
|
|
434
|
+
// from a missing user and thereby learn they were blocked.
|
|
435
|
+
const blockedBy = await db
|
|
436
|
+
.select({ blockerApId: blocks.blockerApId })
|
|
437
|
+
.from(blocks)
|
|
438
|
+
.where(
|
|
439
|
+
and(
|
|
440
|
+
eq(blocks.blockerApId, otherApId),
|
|
441
|
+
eq(blocks.blockedApId, actor.ap_id),
|
|
442
|
+
),
|
|
443
|
+
)
|
|
444
|
+
.get();
|
|
445
|
+
if (blockedBy) return c.json({ error: "User not found" }, 404);
|
|
446
|
+
|
|
447
|
+
const apId = objectApId(baseUrl, generateId());
|
|
448
|
+
const now = new Date().toISOString();
|
|
449
|
+
// Reuse an existing thread's stored conversation id so a reply does not split
|
|
450
|
+
// a thread created before the current id scheme into a second id; a brand new
|
|
451
|
+
// conversation falls back to the current-scheme id.
|
|
452
|
+
const conversationId = await resolveConversationId(
|
|
453
|
+
db,
|
|
454
|
+
baseUrl,
|
|
455
|
+
actor.ap_id,
|
|
456
|
+
otherApId,
|
|
457
|
+
);
|
|
458
|
+
const toJson = JSON.stringify([otherApId]);
|
|
459
|
+
|
|
460
|
+
const isRecipientLocal = !!localActor;
|
|
461
|
+
const deliveryActivityId = activityApId(baseUrl, generateId());
|
|
462
|
+
// Address the recipient with a Mention tag so remote servers (e.g. Mastodon)
|
|
463
|
+
// surface the DM as a notification. Prefer the stored preferredUsername, then
|
|
464
|
+
// fall back to deriving user@domain from the recipient actor id.
|
|
465
|
+
const recipientName = `@${resolveRecipientHandle(otherActor.preferredUsername, otherApId)}`;
|
|
466
|
+
const mentionTag = [
|
|
467
|
+
{ type: "Mention", href: otherApId, name: recipientName },
|
|
468
|
+
];
|
|
469
|
+
const remoteCreateActivity = !isRecipientLocal
|
|
470
|
+
? {
|
|
471
|
+
"@context": "https://www.w3.org/ns/activitystreams",
|
|
472
|
+
id: deliveryActivityId,
|
|
473
|
+
type: "Create",
|
|
474
|
+
actor: actor.ap_id,
|
|
475
|
+
to: [otherApId],
|
|
476
|
+
tag: mentionTag,
|
|
477
|
+
object: {
|
|
478
|
+
id: apId,
|
|
479
|
+
type: "Note",
|
|
480
|
+
attributedTo: actor.ap_id,
|
|
481
|
+
to: [otherApId],
|
|
482
|
+
content,
|
|
483
|
+
published: now,
|
|
484
|
+
conversation: conversationId,
|
|
485
|
+
tag: mentionTag,
|
|
486
|
+
},
|
|
487
|
+
}
|
|
488
|
+
: null;
|
|
489
|
+
|
|
490
|
+
// Co-commit the message atomically. D1 has no interactive transactions; a
|
|
491
|
+
// sequence of separate inserts could commit the Note without its
|
|
492
|
+
// object_recipients row, and the recipient's DM reader resolves membership
|
|
493
|
+
// ONLY via object_recipients — so an orphan Note would be permanently
|
|
494
|
+
// invisible to the recipient. batch() is atomic (mirrors the community-chat
|
|
495
|
+
// send), so the Note + recipient + activity (+ inbox notification) land or
|
|
496
|
+
// fail together.
|
|
497
|
+
const noteStmt = dmNoteInsert(db, {
|
|
498
|
+
apId,
|
|
499
|
+
actorApId: actor.ap_id,
|
|
500
|
+
content,
|
|
501
|
+
toJson,
|
|
502
|
+
conversationId,
|
|
503
|
+
published: now,
|
|
504
|
+
});
|
|
505
|
+
const batchOps = isRecipientLocal
|
|
506
|
+
? [
|
|
507
|
+
noteStmt,
|
|
508
|
+
db
|
|
509
|
+
.insert(objectRecipients)
|
|
510
|
+
.values({ objectApId: apId, recipientApId: otherApId, type: "to" })
|
|
511
|
+
.onConflictDoNothing(),
|
|
512
|
+
db.insert(activities).values({
|
|
513
|
+
apId: deliveryActivityId,
|
|
514
|
+
type: "Create",
|
|
515
|
+
actorApId: actor.ap_id,
|
|
516
|
+
objectApId: apId,
|
|
517
|
+
rawJson: JSON.stringify({
|
|
518
|
+
type: "Create",
|
|
519
|
+
actor: actor.ap_id,
|
|
520
|
+
object: apId,
|
|
521
|
+
}),
|
|
522
|
+
direction: "inbound",
|
|
523
|
+
}),
|
|
524
|
+
db.insert(inboxTable).values({
|
|
525
|
+
actorApId: otherApId,
|
|
526
|
+
activityApId: deliveryActivityId,
|
|
527
|
+
}),
|
|
528
|
+
]
|
|
529
|
+
: [
|
|
530
|
+
noteStmt,
|
|
531
|
+
db.insert(activities).values({
|
|
532
|
+
apId: deliveryActivityId,
|
|
533
|
+
type: "Create",
|
|
534
|
+
actorApId: actor.ap_id,
|
|
535
|
+
objectApId: apId,
|
|
536
|
+
rawJson: JSON.stringify(remoteCreateActivity),
|
|
537
|
+
direction: "outbound",
|
|
538
|
+
}),
|
|
539
|
+
];
|
|
540
|
+
|
|
541
|
+
try {
|
|
542
|
+
await (db as unknown as Batchable).batch(batchOps);
|
|
543
|
+
} catch (e) {
|
|
544
|
+
log.error("Failed to insert message", {
|
|
545
|
+
event: "dm.message.insert_failed",
|
|
546
|
+
actor: actor.ap_id,
|
|
547
|
+
recipient: otherApId,
|
|
548
|
+
error: e,
|
|
549
|
+
});
|
|
550
|
+
return c.json({ error: "Failed to send message" }, 500);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (!isLocal(otherApId, baseUrl)) {
|
|
554
|
+
await enqueueDeliveryToActor(c.env, deliveryActivityId, otherApId);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return c.json(
|
|
558
|
+
{
|
|
559
|
+
message: {
|
|
560
|
+
id: apId,
|
|
561
|
+
sender: buildSenderFromActor(actor),
|
|
562
|
+
content,
|
|
563
|
+
created_at: now,
|
|
564
|
+
},
|
|
565
|
+
conversation_id: conversationId,
|
|
566
|
+
},
|
|
567
|
+
201,
|
|
568
|
+
);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
// Edit a DM message
|
|
572
|
+
dm.patch("/messages/:messageId", async (c) => {
|
|
573
|
+
const actor = c.get("actor");
|
|
574
|
+
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
575
|
+
|
|
576
|
+
const db = c.get("db");
|
|
577
|
+
const body = await c.req.json<{ content: string }>();
|
|
578
|
+
|
|
579
|
+
const contentOrError = validateContent(body.content);
|
|
580
|
+
if (typeof contentOrError !== "string") {
|
|
581
|
+
return c.json({ error: contentOrError.error }, contentOrError.status);
|
|
582
|
+
}
|
|
583
|
+
const content = contentOrError;
|
|
584
|
+
|
|
585
|
+
const messageOrError = await findOwnedDmMessage(
|
|
586
|
+
db,
|
|
587
|
+
c.req.param("messageId"),
|
|
588
|
+
actor.ap_id,
|
|
589
|
+
);
|
|
590
|
+
if ("error" in messageOrError) {
|
|
591
|
+
return c.json({ error: messageOrError.error }, messageOrError.status);
|
|
592
|
+
}
|
|
593
|
+
const message = messageOrError;
|
|
594
|
+
|
|
595
|
+
const now = new Date().toISOString();
|
|
596
|
+
await db
|
|
597
|
+
.update(objects)
|
|
598
|
+
.set({ content, updated: now })
|
|
599
|
+
.where(eq(objects.apId, message.apId));
|
|
600
|
+
|
|
601
|
+
return c.json({
|
|
602
|
+
success: true,
|
|
603
|
+
message: { id: message.apId, content, updated_at: now },
|
|
604
|
+
});
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
// Delete a DM message
|
|
608
|
+
dm.delete("/messages/:messageId", async (c) => {
|
|
609
|
+
const actor = c.get("actor");
|
|
610
|
+
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
611
|
+
|
|
612
|
+
const db = c.get("db");
|
|
613
|
+
|
|
614
|
+
const messageOrError = await findOwnedDmMessage(
|
|
615
|
+
db,
|
|
616
|
+
c.req.param("messageId"),
|
|
617
|
+
actor.ap_id,
|
|
618
|
+
);
|
|
619
|
+
if ("error" in messageOrError) {
|
|
620
|
+
return c.json({ error: messageOrError.error }, messageOrError.status);
|
|
621
|
+
}
|
|
622
|
+
const message = messageOrError;
|
|
623
|
+
|
|
624
|
+
// Sequential operations (D1 doesn't support interactive transactions).
|
|
625
|
+
// Also remove the delivery Create activity + the recipient's inbox row created
|
|
626
|
+
// at send time (messages.ts send path). These tables are addressed by AP id
|
|
627
|
+
// with no FK to `objects`, so deleting only the object orphans them — and
|
|
628
|
+
// because the notifications query LEFT JOINs the now-missing object (a
|
|
629
|
+
// deleted DM's object is gone → NULL visibility → not excluded as "direct"),
|
|
630
|
+
// the orphan Create inbox row would resurface as a blank "mention"
|
|
631
|
+
// notification with a dead /post link. Drop them first.
|
|
632
|
+
const relatedActivities = await db
|
|
633
|
+
.select({ apId: activities.apId })
|
|
634
|
+
.from(activities)
|
|
635
|
+
.where(eq(activities.objectApId, message.apId));
|
|
636
|
+
const activityIds = relatedActivities.map((a) => a.apId);
|
|
637
|
+
if (activityIds.length > 0) {
|
|
638
|
+
await db
|
|
639
|
+
.delete(inboxTable)
|
|
640
|
+
.where(inArray(inboxTable.activityApId, activityIds));
|
|
641
|
+
await db.delete(activities).where(inArray(activities.apId, activityIds));
|
|
642
|
+
}
|
|
643
|
+
// Reap the message's child rows AND any attached R2 blob + media_uploads row
|
|
644
|
+
// via the shared cascade (covers objectRecipients + media + likes/announces/
|
|
645
|
+
// bookmarks/story*), then drop the object. Local DMs are text-only today so
|
|
646
|
+
// there is no blob to leak yet, but routing through the cascade now keeps DM
|
|
647
|
+
// deletion correct the moment DM media upload is wired in (and a leaked DM
|
|
648
|
+
// blob would be PRIVATE) — matching the post and story delete paths.
|
|
649
|
+
const mediaKeys = await deleteObjectCascade(db, message.apId, c.env.MEDIA);
|
|
650
|
+
await db.delete(objects).where(eq(objects.apId, message.apId));
|
|
651
|
+
// Irreversible R2 purge LAST — after the objects row is gone, so a failed
|
|
652
|
+
// object delete can't leave a live (private) DM pointing at a deleted blob.
|
|
653
|
+
await purgeMediaBlobs(c.env.MEDIA, mediaKeys);
|
|
654
|
+
|
|
655
|
+
return c.json({ success: true });
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
export default dm;
|