@takosjp/yurucommu-core 3.0.3 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.en.md +92 -0
  2. package/README.md +56 -47
  3. package/migrations/0019_notification_push_delivery.sql +103 -0
  4. package/migrations/README.md +7 -6
  5. package/package.json +11 -5
  6. package/packages/api/package.json +1 -1
  7. package/packages/api/src/lib/api/browser-push.ts +545 -0
  8. package/packages/api/src/lib/api/communities.ts +25 -2
  9. package/packages/api/src/lib/api/dm.ts +14 -2
  10. package/packages/api/src/lib/api/normalize.ts +15 -4
  11. package/packages/api/src/lib/api/notification-target.ts +106 -0
  12. package/packages/api/src/lib/api/notifications.ts +53 -1
  13. package/packages/api/src/lib/api/push-config.ts +132 -0
  14. package/packages/api/src/lib/api.ts +3 -0
  15. package/packages/api/src/social-server.ts +9 -0
  16. package/packages/api/src/types/index.ts +48 -0
  17. package/src/backend/index.ts +67 -3
  18. package/src/backend/lib/attachments.ts +52 -0
  19. package/src/backend/lib/delivery/queue.ts +73 -0
  20. package/src/backend/lib/delivery/types.ts +15 -1
  21. package/src/backend/lib/notification-eligibility.ts +150 -0
  22. package/src/backend/lib/notification-push.ts +1213 -0
  23. package/src/backend/lib/notification-pusher-contract.ts +340 -0
  24. package/src/backend/lib/oauth-providers.ts +7 -6
  25. package/src/backend/lib/session-actor.ts +16 -1
  26. package/src/backend/lib/unread-counts.ts +79 -0
  27. package/src/backend/middleware/csrf.ts +11 -0
  28. package/src/backend/routes/account-teardown.ts +13 -0
  29. package/src/backend/routes/auth-helpers.ts +10 -7
  30. package/src/backend/routes/auth.ts +124 -2
  31. package/src/backend/routes/communities/messages.ts +123 -9
  32. package/src/backend/routes/dm/contacts.ts +6 -44
  33. package/src/backend/routes/dm/messages.ts +51 -4
  34. package/src/backend/routes/notification-pushers.ts +93 -0
  35. package/src/backend/routes/notifications.ts +76 -69
  36. package/src/backend/routes/posts/transformers.ts +8 -10
  37. package/src/backend/server.ts +6 -0
  38. package/src/backend/types.ts +12 -0
  39. package/src/db/index.ts +11 -10
  40. package/src/db/schema/mobile.ts +99 -1
@@ -40,6 +40,7 @@ import {
40
40
  rotateSession,
41
41
  } from "./auth-helpers.ts";
42
42
  import { logger } from "../lib/logger.ts";
43
+ import { rawSessionCredential } from "../lib/session-actor.ts";
43
44
 
44
45
  const log = logger.child({ component: "auth" });
45
46
 
@@ -75,12 +76,20 @@ auth.get("/providers", async (c) => {
75
76
  });
76
77
  });
77
78
 
79
+ function mobileSessionResponse(sessionId: string) {
80
+ return {
81
+ access_token: sessionId,
82
+ token_type: "Bearer",
83
+ expires_in: 30 * 24 * 60 * 60,
84
+ };
85
+ }
86
+
78
87
  // 現在のユーザー情報
79
88
  auth.get("/me", async (c) => {
80
89
  const actor = c.get("actor");
81
90
  if (!actor) return c.json({ error: "Not authenticated" }, 401);
82
91
 
83
- const sessionId = getCookie(c, "session");
92
+ const sessionId = rawSessionCredential(c);
84
93
  let provider: string | null = null;
85
94
  let hasTakosAccess = false;
86
95
 
@@ -206,6 +215,119 @@ auth.post("/login", async (c) => {
206
215
  return c.json({ success: true });
207
216
  });
208
217
 
218
+ // Native password authentication. The credential is a host-owned session,
219
+ // not the password itself and not a browser cookie, so Tauri/native clients can
220
+ // call the same API safely from any app origin.
221
+ auth.post("/mobile/login", async (c) => {
222
+ const config = getAuthConfig(c.env);
223
+ if (!config.passwordEnabled) {
224
+ return c.json({ error: "Password auth not enabled" }, 400);
225
+ }
226
+
227
+ const clientIp = getClientIP(c);
228
+ const lockoutKey = `password:${clientIp}`;
229
+ const lockoutStatus = await getLoginLockoutStatus(c.env.KV, lockoutKey);
230
+ if (lockoutStatus.locked) {
231
+ c.header("Retry-After", String(lockoutStatus.retryAfterSeconds));
232
+ return c.json(lockoutErrorResponse(lockoutStatus.retryAfterSeconds), 429);
233
+ }
234
+
235
+ const body = await parseJsonObject(c);
236
+ const password = body ? parseNonEmptyString(body.password) : undefined;
237
+ if (!password) {
238
+ return c.json({ error: "password is required", code: "BAD_REQUEST" }, 400);
239
+ }
240
+ const isValid = c.env.AUTH_PASSWORD_HASH?.trim()
241
+ ? await verifyBootstrapOrPassword(password, c.env.AUTH_PASSWORD_HASH)
242
+ : false;
243
+ if (!isValid) {
244
+ const failedStatus = await recordFailedLoginAttempt(c.env.KV, lockoutKey);
245
+ if (failedStatus.locked) {
246
+ c.header("Retry-After", String(failedStatus.retryAfterSeconds));
247
+ return c.json(lockoutErrorResponse(failedStatus.retryAfterSeconds), 429);
248
+ }
249
+ return c.json({ error: "Invalid password" }, 401);
250
+ }
251
+
252
+ const db = c.get("db");
253
+ const actorData =
254
+ (await db
255
+ .select()
256
+ .from(actors)
257
+ .where(and(eq(actors.role, "owner"), notDeleted(actors)))
258
+ .get()) ??
259
+ (await createActor(db, c.env, {
260
+ username: "tako",
261
+ name: "tako",
262
+ takosUserId: "password:owner",
263
+ role: "owner",
264
+ }));
265
+ const sessionId = await rotateSession(
266
+ c,
267
+ actorData.apId,
268
+ null,
269
+ null,
270
+ c.env.ENCRYPTION_KEY,
271
+ "mobile password login rotation",
272
+ { setCookie: false },
273
+ );
274
+ await clearLoginLockout(c.env.KV, lockoutKey);
275
+ return c.json(mobileSessionResponse(sessionId));
276
+ });
277
+
278
+ // Exchange a verified native OIDC ID token for the same host-owned session
279
+ // used by password login. This keeps product APIs independent from the
280
+ // operator's token format and makes session revocation host-local.
281
+ auth.post("/mobile/oidc", async (c) => {
282
+ const provider = getProvider(c.env, "takos");
283
+ if (!provider?.issuer || !provider.jwksUrl) {
284
+ return c.json({ error: "OIDC auth not enabled" }, 400);
285
+ }
286
+ const body = await parseJsonObject(c);
287
+ const idToken = body ? parseNonEmptyString(body.id_token) : undefined;
288
+ if (!idToken) {
289
+ return c.json({ error: "id_token is required", code: "BAD_REQUEST" }, 400);
290
+ }
291
+
292
+ try {
293
+ const { clientId } = getClientCredentials(c.env, "takos");
294
+ const claims = await verifyOidcIdToken(idToken, {
295
+ issuer: provider.issuer,
296
+ clientId,
297
+ jwksUrl: provider.jwksUrl,
298
+ });
299
+ const actorData = await findOrCreateOAuthActor(
300
+ c.get("db"),
301
+ c.env,
302
+ "takos",
303
+ {
304
+ id: claims.sub,
305
+ name:
306
+ claims.name ?? claims.preferred_username ?? claims.email ?? "user",
307
+ email: claims.email,
308
+ username: claims.preferred_username,
309
+ },
310
+ );
311
+ if (!actorData) return c.json({ error: "actor_creation_failed" }, 403);
312
+ const sessionId = await rotateSession(
313
+ c,
314
+ actorData.apId,
315
+ "takos",
316
+ null,
317
+ c.env.ENCRYPTION_KEY,
318
+ "mobile oidc login rotation",
319
+ { setCookie: false },
320
+ );
321
+ return c.json(mobileSessionResponse(sessionId));
322
+ } catch (error) {
323
+ log.warn("Mobile OIDC exchange failed", {
324
+ event: "auth.mobile.oidc_exchange_failed",
325
+ error,
326
+ });
327
+ return c.json({ error: "invalid_id_token" }, 401);
328
+ }
329
+ });
330
+
209
331
  // OAuth: 認証開始
210
332
  auth.get("/login/:provider", async (c) => {
211
333
  const providerId = c.req.param("provider");
@@ -388,7 +510,7 @@ auth.get("/callback/:provider", async (c) => {
388
510
 
389
511
  // ログアウト
390
512
  auth.post("/logout", async (c) => {
391
- const sessionId = getCookie(c, "session");
513
+ const sessionId = rawSessionCredential(c);
392
514
  if (sessionId) {
393
515
  await deleteSessionSafely(c.get("db"), c.env, sessionId, "logout");
394
516
  deleteCookie(c, "session");
@@ -1,15 +1,26 @@
1
1
  import { Hono } from "hono";
2
- import { and, desc, eq, isNull, sql } from "drizzle-orm";
2
+ import { and, desc, eq, isNull, ne, sql } from "drizzle-orm";
3
3
  import {
4
4
  activities,
5
5
  communities,
6
6
  communityMembers,
7
+ dmCommunityReadStatus,
8
+ notificationPushers,
9
+ notificationPushJobs,
7
10
  objectRecipients,
8
11
  objects,
9
12
  } from "../../../db/index.ts";
10
13
  import type { Env, Variables } from "../../types.ts";
11
- import { formatUsername, generateId } from "../../federation-helpers.ts";
14
+ import {
15
+ formatUsername,
16
+ generateId,
17
+ safeJsonParse,
18
+ } from "../../federation-helpers.ts";
12
19
  import { feedCursorWhere } from "../../lib/feed-cursor.ts";
20
+ import {
21
+ type ChatAttachment,
22
+ validateChatAttachments,
23
+ } from "../../lib/attachments.ts";
13
24
  import { communityRequiresMembership } from "../../lib/community-visibility.ts";
14
25
  import { rateLimit, RateLimitConfigs } from "../../middleware/rate-limit.ts";
15
26
  import {
@@ -27,6 +38,9 @@ import {
27
38
 
28
39
  const MAX_COMMUNITY_MESSAGE_LENGTH = 5000;
29
40
  const MAX_COMMUNITY_MESSAGES_LIMIT = 100;
41
+ // Cap the per-member read receipts returned alongside a page so a very large
42
+ // community's chat reader stays bounded regardless of local-member count.
43
+ const MAX_COMMUNITY_READ_STATES = 200;
30
44
 
31
45
  // D1's batch() (atomic multi-statement) is only on the concrete D1/libsql
32
46
  // driver, not the shared `Database` union; reach it through a narrow cast.
@@ -151,6 +165,7 @@ messagesRouter.get("/:identifier/messages", async (c) => {
151
165
  apId: objects.apId,
152
166
  attributedTo: objects.attributedTo,
153
167
  content: objects.content,
168
+ attachmentsJson: objects.attachmentsJson,
154
169
  published: objects.published,
155
170
  })
156
171
  .from(objectRecipients)
@@ -176,11 +191,42 @@ messagesRouter.get("/:identifier/messages", async (c) => {
176
191
  icon_url: senderInfo?.iconUrl || null,
177
192
  },
178
193
  content: msg.content,
194
+ attachments: safeJsonParse<ChatAttachment[]>(msg.attachmentsJson, []),
179
195
  created_at: msg.published,
180
196
  };
181
197
  });
182
198
 
183
- return c.json({ messages: result, has_more: hasMore });
199
+ // Per-member read positions (LOCAL-ONLY read receipts): rows exist only for
200
+ // local members that marked the chat read — read state is never federated,
201
+ // so remote members simply never appear here. Restricted to CURRENT members
202
+ // so a kicked member's stale row doesn't leak into the read count, and capped
203
+ // (most-recently-read first) so a huge community can't return an unbounded
204
+ // receipt list on every page fetch.
205
+ const readStates = await db
206
+ .select({
207
+ actorApId: dmCommunityReadStatus.actorApId,
208
+ lastReadAt: dmCommunityReadStatus.lastReadAt,
209
+ })
210
+ .from(dmCommunityReadStatus)
211
+ .innerJoin(
212
+ communityMembers,
213
+ and(
214
+ eq(communityMembers.communityApId, dmCommunityReadStatus.communityApId),
215
+ eq(communityMembers.actorApId, dmCommunityReadStatus.actorApId),
216
+ ),
217
+ )
218
+ .where(eq(dmCommunityReadStatus.communityApId, community.apId))
219
+ .orderBy(desc(dmCommunityReadStatus.lastReadAt))
220
+ .limit(MAX_COMMUNITY_READ_STATES);
221
+
222
+ return c.json({
223
+ messages: result,
224
+ has_more: hasMore,
225
+ read_states: readStates.map((r) => ({
226
+ actor_ap_id: r.actorApId,
227
+ last_read_at: r.lastReadAt,
228
+ })),
229
+ });
184
230
  });
185
231
 
186
232
  // POST /api/communities/:name/messages - Send a chat message
@@ -196,14 +242,31 @@ messagesRouter.post(
196
242
  const db = c.get("db");
197
243
  const baseUrl = c.env.APP_URL;
198
244
  const apId = resolveCommunityApId(baseUrl, identifier);
199
- const body = await c.req.json<{ content: string }>();
200
-
201
- // Guard non-string content before .trim() (else TypeError → 500).
202
- if (typeof body.content !== "string") {
245
+ const body = await c.req.json<{
246
+ content?: string;
247
+ attachments?: unknown;
248
+ }>();
249
+
250
+ const attachmentsResult = validateChatAttachments(body.attachments);
251
+ if (!attachmentsResult.ok) {
252
+ return c.json({ error: attachmentsResult.error }, 400);
253
+ }
254
+ const attachments = attachmentsResult.attachments;
255
+
256
+ // Guard non-string content before .trim() (else TypeError → 500). An
257
+ // attachment-only message (image send) carries no text.
258
+ const rawContent = body.content;
259
+ if (
260
+ typeof rawContent !== "string" &&
261
+ !(
262
+ attachments.length > 0 &&
263
+ (rawContent === undefined || rawContent === null)
264
+ )
265
+ ) {
203
266
  return c.json({ error: "Message content is required" }, 400);
204
267
  }
205
- const content = body.content.trim();
206
- if (!content) {
268
+ const content = typeof rawContent === "string" ? rawContent.trim() : "";
269
+ if (!content && attachments.length === 0) {
207
270
  return c.json({ error: "Message content is required" }, 400);
208
271
  }
209
272
  if (content.length > MAX_COMMUNITY_MESSAGE_LENGTH) {
@@ -257,6 +320,54 @@ messagesRouter.post(
257
320
  const activityId = generateId();
258
321
  const activityApIdVal = `${baseUrl}/ap/activities/${activityId}`;
259
322
 
323
+ // Community talk deliberately has no social-inbox row, so its durable push
324
+ // jobs must commit with the message itself. Resolve only members that own a
325
+ // Yurume pusher at event time; delivery re-checks membership before egress.
326
+ const pushRecipients = await db
327
+ .selectDistinct({ actorApId: notificationPushers.actorApId })
328
+ .from(notificationPushers)
329
+ .innerJoin(
330
+ communityMembers,
331
+ eq(communityMembers.actorApId, notificationPushers.actorApId),
332
+ )
333
+ .where(
334
+ and(
335
+ eq(communityMembers.communityApId, community.apId),
336
+ eq(notificationPushers.product, "yurume"),
337
+ ne(notificationPushers.actorApId, actor.ap_id),
338
+ ),
339
+ );
340
+ const pushJobStatements: unknown[] = [];
341
+ // D1 caps bound parameters at 100. Keep each multi-row insert below that
342
+ // limit while retaining every insert in the same atomic batch.
343
+ const pushJobBatchSize = 8;
344
+ for (
345
+ let offset = 0;
346
+ offset < pushRecipients.length;
347
+ offset += pushJobBatchSize
348
+ ) {
349
+ pushJobStatements.push(
350
+ db
351
+ .insert(notificationPushJobs)
352
+ .values(
353
+ pushRecipients
354
+ .slice(offset, offset + pushJobBatchSize)
355
+ .map(({ actorApId }) => ({
356
+ id: `${actorApId}\n${activityApIdVal}`,
357
+ actorApId,
358
+ activityApId: activityApIdVal,
359
+ product: "yurume",
360
+ status: "pending",
361
+ attempts: 0,
362
+ nextAttemptAt: now,
363
+ createdAt: now,
364
+ updatedAt: now,
365
+ })),
366
+ )
367
+ .onConflictDoNothing(),
368
+ );
369
+ }
370
+
260
371
  // Persist the chat message atomically: the Note, its community-audience
261
372
  // recipient row (which the GET-messages reader joins on), the Create
262
373
  // activity, and the community's lastMessageAt. D1 has no interactive
@@ -270,6 +381,7 @@ messagesRouter.post(
270
381
  type: "Note",
271
382
  attributedTo: actor.ap_id,
272
383
  content,
384
+ attachmentsJson: JSON.stringify(attachments),
273
385
  toJson,
274
386
  audienceJson,
275
387
  visibility: "unlisted",
@@ -293,6 +405,7 @@ messagesRouter.post(
293
405
  .update(communities)
294
406
  .set({ lastMessageAt: now })
295
407
  .where(eq(communities.apId, community.apId)),
408
+ ...pushJobStatements,
296
409
  ]);
297
410
 
298
411
  return c.json(
@@ -307,6 +420,7 @@ messagesRouter.post(
307
420
  icon_url: actor.icon_url,
308
421
  },
309
422
  content,
423
+ attachments,
310
424
  created_at: now,
311
425
  },
312
426
  },
@@ -23,6 +23,7 @@ import {
23
23
  } from "../../../db/index.ts";
24
24
  import { formatUsername } from "../../federation-helpers.ts";
25
25
  import { chunkForInClause } from "../../lib/chunk.ts";
26
+ import { yurumeUnreadCounts } from "../../lib/unread-counts.ts";
26
27
  import {
27
28
  buildActorInfoMap,
28
29
  byTimeDesc,
@@ -476,50 +477,11 @@ contacts.get("/unread/count", async (c) => {
476
477
  const actor = c.get("actor");
477
478
  if (!actor) return c.json({ error: "Unauthorized" }, 401);
478
479
  const db = c.get("db");
479
- const me = actor.ap_id;
480
-
481
- const dmRow = await db.get<{ c: number }>(sql`
482
- SELECT COUNT(*) AS c
483
- FROM objects o
484
- JOIN object_recipients orp
485
- ON orp.object_ap_id = o.ap_id
486
- AND orp.recipient_ap_id = ${me}
487
- AND orp.type = 'to'
488
- LEFT JOIN dm_read_status r
489
- ON r.conversation_id = o.conversation
490
- AND r.actor_ap_id = ${me}
491
- WHERE o.visibility = 'direct'
492
- AND o.type = 'Note'
493
- AND o.conversation IS NOT NULL
494
- AND o.attributed_to != ${me}
495
- AND o.published > COALESCE(r.last_read_at, '1970-01-01T00:00:00Z')
496
- AND o.conversation NOT IN (
497
- SELECT conversation_id FROM dm_archived_conversations
498
- WHERE actor_ap_id = ${me}
499
- )
500
- `);
501
-
502
- const communityRow = await db.get<{ c: number }>(sql`
503
- SELECT COUNT(*) AS c
504
- FROM community_members cm
505
- JOIN object_recipients orp
506
- ON orp.recipient_ap_id = cm.community_ap_id
507
- AND orp.type = 'audience'
508
- JOIN objects o
509
- ON o.ap_id = orp.object_ap_id
510
- AND o.type = 'Note'
511
- AND o.community_ap_id IS NULL
512
- AND o.attributed_to != ${me}
513
- LEFT JOIN dm_community_read_status r
514
- ON r.community_ap_id = cm.community_ap_id
515
- AND r.actor_ap_id = ${me}
516
- WHERE cm.actor_ap_id = ${me}
517
- AND o.published > COALESCE(r.last_read_at, cm.joined_at, '1970-01-01T00:00:00Z')
518
- `);
519
-
520
- const dm = Number(dmRow?.c ?? 0);
521
- const community = Number(communityRow?.c ?? 0);
522
- return c.json({ total: dm + community, dm, community });
480
+
481
+ // Single owner of the DM + community-chat unread SQL (lib/unread-counts.ts),
482
+ // shared with the notification push payload's badge so the two cannot drift.
483
+ const { total, dm, community } = await yurumeUnreadCounts(db, actor.ap_id);
484
+ return c.json({ total, dm, community });
523
485
  });
524
486
 
525
487
  export default contacts;
@@ -10,6 +10,7 @@ import {
10
10
  actorCache,
11
11
  actors,
12
12
  blocks,
13
+ dmReadStatus,
13
14
  inbox as inboxTable,
14
15
  objectRecipients,
15
16
  objects,
@@ -35,6 +36,8 @@ import {
35
36
  } from "./query-helpers.ts";
36
37
  import { enqueueDeliveryToActor } from "../../lib/delivery/queue.ts";
37
38
  import { feedCursorWhere } from "../../lib/feed-cursor.ts";
39
+ import { toApAttachments } from "../../lib/activitypub-helpers.ts";
40
+ import { validateChatAttachments } from "../../lib/attachments.ts";
38
41
  import { logger } from "../../lib/logger.ts";
39
42
 
40
43
  const log = logger.child({ component: "dm.messages" });
@@ -90,19 +93,28 @@ type DmMessageResponse = {
90
93
  created_at: string | null;
91
94
  };
92
95
 
93
- /** Validate trimmed DM content; returns the trimmed string or an error response. */
96
+ /**
97
+ * Validate trimmed DM content; returns the trimmed string or an error response.
98
+ * With `allowEmpty` (an attachment-only message) an empty/absent content is
99
+ * accepted and normalized to "".
100
+ */
94
101
  function validateContent(
95
102
  raw: unknown,
103
+ allowEmpty = false,
96
104
  ): string | { error: string; status: 400 } {
97
105
  // The json<{content:string}>() cast is compile-time only; a client can send a
98
106
  // non-string. Guard before .trim() else TypeError → 500 (the global handler
99
107
  // deliberately does not mask TypeError as 400). Mirrors the profile/post/invite
100
108
  // validators.
101
109
  if (typeof raw !== "string") {
110
+ if (allowEmpty && (raw === undefined || raw === null)) return "";
102
111
  return { error: "Message content is required", status: 400 };
103
112
  }
104
113
  const content = raw.trim();
105
- if (!content) return { error: "Message content is required", status: 400 };
114
+ if (!content) {
115
+ if (allowEmpty) return "";
116
+ return { error: "Message content is required", status: 400 };
117
+ }
106
118
  if (content.length > MAX_DM_CONTENT_LENGTH) {
107
119
  return {
108
120
  error: `Message too long (max ${MAX_DM_CONTENT_LENGTH} chars)`,
@@ -334,6 +346,7 @@ function dmNoteInsert(
334
346
  apId: string;
335
347
  actorApId: string;
336
348
  content: string;
349
+ attachments: Attachment[];
337
350
  toJson: string;
338
351
  conversationId: string;
339
352
  published: string;
@@ -344,6 +357,7 @@ function dmNoteInsert(
344
357
  type: "Note",
345
358
  attributedTo: data.actorApId,
346
359
  content: data.content,
360
+ attachmentsJson: JSON.stringify(data.attachments),
347
361
  visibility: "direct",
348
362
  toJson: data.toJson,
349
363
  ccJson: JSON.stringify([]),
@@ -380,10 +394,27 @@ dm.get("/user/:encodedApId/messages", async (c) => {
380
394
  limit,
381
395
  before,
382
396
  );
397
+
398
+ // The partner's read position (LOCAL-ONLY read receipt): the row only exists
399
+ // when the other participant is a local account that opened the thread —
400
+ // read state is never federated, so a remote partner stays null ("unknown")
401
+ // rather than "unread".
402
+ const partnerRead = await db
403
+ .select({ lastReadAt: dmReadStatus.lastReadAt })
404
+ .from(dmReadStatus)
405
+ .where(
406
+ and(
407
+ eq(dmReadStatus.actorApId, otherApId),
408
+ eq(dmReadStatus.conversationId, conversationId),
409
+ ),
410
+ )
411
+ .get();
412
+
383
413
  return c.json({
384
414
  messages,
385
415
  conversation_id: conversationId,
386
416
  has_more: hasMore,
417
+ partner_last_read_at: partnerRead?.lastReadAt ?? null,
387
418
  });
388
419
  });
389
420
 
@@ -394,10 +425,20 @@ dm.post("/user/:encodedApId/messages", async (c) => {
394
425
 
395
426
  const db = c.get("db");
396
427
  const otherApId = decodeURIComponent(c.req.param("encodedApId"));
397
- const body = await c.req.json<{ content: string }>();
428
+ const body = await c.req.json<{
429
+ content?: string;
430
+ attachments?: unknown;
431
+ }>();
398
432
  const baseUrl = c.env.APP_URL;
399
433
 
400
- const contentOrError = validateContent(body.content);
434
+ const attachmentsResult = validateChatAttachments(body.attachments);
435
+ if (!attachmentsResult.ok) {
436
+ return c.json({ error: attachmentsResult.error }, 400);
437
+ }
438
+ const attachments = attachmentsResult.attachments as Attachment[];
439
+
440
+ // An attachment-only message (LINE-style image send) carries no text.
441
+ const contentOrError = validateContent(body.content, attachments.length > 0);
401
442
  if (typeof contentOrError !== "string") {
402
443
  return c.json({ error: contentOrError.error }, contentOrError.status);
403
444
  }
@@ -466,6 +507,9 @@ dm.post("/user/:encodedApId/messages", async (c) => {
466
507
  const mentionTag = [
467
508
  { type: "Mention", href: otherApId, name: recipientName },
468
509
  ];
510
+ // Media is stored as an app-relative /media path; absolutize (and strip the
511
+ // internal r2_key) for the federated copy so the remote can fetch it.
512
+ const apAttachments = toApAttachments(attachments, baseUrl);
469
513
  const remoteCreateActivity = !isRecipientLocal
470
514
  ? {
471
515
  "@context": "https://www.w3.org/ns/activitystreams",
@@ -480,6 +524,7 @@ dm.post("/user/:encodedApId/messages", async (c) => {
480
524
  attributedTo: actor.ap_id,
481
525
  to: [otherApId],
482
526
  content,
527
+ ...(apAttachments.length > 0 ? { attachment: apAttachments } : {}),
483
528
  published: now,
484
529
  conversation: conversationId,
485
530
  tag: mentionTag,
@@ -498,6 +543,7 @@ dm.post("/user/:encodedApId/messages", async (c) => {
498
543
  apId,
499
544
  actorApId: actor.ap_id,
500
545
  content,
546
+ attachments,
501
547
  toJson,
502
548
  conversationId,
503
549
  published: now,
@@ -560,6 +606,7 @@ dm.post("/user/:encodedApId/messages", async (c) => {
560
606
  id: apId,
561
607
  sender: buildSenderFromActor(actor),
562
608
  content,
609
+ attachments,
563
610
  created_at: now,
564
611
  },
565
612
  conversation_id: conversationId,
@@ -0,0 +1,93 @@
1
+ import { Hono } from "hono";
2
+
3
+ import type { Env, Variables } from "../types.ts";
4
+ import { requireActor } from "./actors-helpers.ts";
5
+ import { parseJsonObject } from "../lib/parse-helpers.ts";
6
+ import {
7
+ normalizeGatewayUrl,
8
+ parseNotificationPusherDeleteRequest,
9
+ parseNotificationPusherSetRequest,
10
+ } from "../lib/notification-pusher-contract.ts";
11
+ import {
12
+ deleteNotificationPusher,
13
+ isNotificationGatewayAllowed,
14
+ registerNotificationPusher,
15
+ } from "../lib/notification-push.ts";
16
+
17
+ const pushers = new Hono<{ Bindings: Env; Variables: Variables }>();
18
+
19
+ pushers.get("/config", (c) => {
20
+ const actor = requireActor(c);
21
+ if (actor instanceof Response) return actor;
22
+
23
+ const configuredGateway = normalizeGatewayUrl(
24
+ c.env.YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_URL,
25
+ );
26
+ const gatewayUrl =
27
+ configuredGateway && isNotificationGatewayAllowed(c.env, configuredGateway)
28
+ ? configuredGateway
29
+ : null;
30
+ const configuredPublicKey =
31
+ c.env.YURUCOMMU_NOTIFICATION_PUSH_WEB_PUSH_PUBLIC_KEY?.trim() ?? "";
32
+ const webPushPublicKey = normalizeWebPushPublicKey(configuredPublicKey);
33
+
34
+ return c.json({
35
+ gateway_url: gatewayUrl,
36
+ web_push_public_key: webPushPublicKey,
37
+ });
38
+ });
39
+
40
+ pushers.post("/", async (c) => {
41
+ const actor = requireActor(c);
42
+ if (actor instanceof Response) return actor;
43
+ const body = await parseJsonObject(c);
44
+ if (!body) {
45
+ return c.json({ code: "BAD_REQUEST", error: "Invalid request body" }, 400);
46
+ }
47
+ const parsed = parseNotificationPusherSetRequest(body);
48
+ if (!parsed.ok) return c.json(parsed.error, 400);
49
+ if (!isNotificationGatewayAllowed(c.env, parsed.value.gatewayUrl)) {
50
+ return c.json(
51
+ {
52
+ code: "BAD_REQUEST",
53
+ error: "pusher.data.url is not allowed by this server",
54
+ field: "pusher.data.url",
55
+ },
56
+ 400,
57
+ );
58
+ }
59
+ const pusher = await registerNotificationPusher(
60
+ c.get("db"),
61
+ actor,
62
+ parsed.value,
63
+ );
64
+ return c.json({ pusher });
65
+ });
66
+
67
+ pushers.delete("/", async (c) => {
68
+ const actor = requireActor(c);
69
+ if (actor instanceof Response) return actor;
70
+ const body = await parseJsonObject(c);
71
+ if (!body) {
72
+ return c.json({ code: "BAD_REQUEST", error: "Invalid request body" }, 400);
73
+ }
74
+ const parsed = parseNotificationPusherDeleteRequest(body);
75
+ if (!parsed.ok) return c.json(parsed.error, 400);
76
+ await deleteNotificationPusher(c.get("db"), actor, parsed.value);
77
+ return c.json({ deleted: true as const });
78
+ });
79
+
80
+ export default pushers;
81
+
82
+ function normalizeWebPushPublicKey(value: string): string | null {
83
+ if (!/^[A-Za-z0-9_-]{87}$/.test(value)) return null;
84
+ try {
85
+ const padded = value.replace(/-/g, "+").replace(/_/g, "/") + "=";
86
+ const decoded = Uint8Array.from(atob(padded), (character) =>
87
+ character.charCodeAt(0),
88
+ );
89
+ return decoded.byteLength === 65 && decoded[0] === 0x04 ? value : null;
90
+ } catch {
91
+ return null;
92
+ }
93
+ }