@takosjp/yurucommu-core 3.0.3 → 3.2.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.
@@ -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,11 @@ 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 {
41
+ MAX_ATTACHMENTS,
42
+ MAX_ATTACHMENTS_JSON_LENGTH,
43
+ } from "../posts/transformers.ts";
38
44
  import { logger } from "../../lib/logger.ts";
39
45
 
40
46
  const log = logger.child({ component: "dm.messages" });
@@ -90,19 +96,28 @@ type DmMessageResponse = {
90
96
  created_at: string | null;
91
97
  };
92
98
 
93
- /** Validate trimmed DM content; returns the trimmed string or an error response. */
99
+ /**
100
+ * Validate trimmed DM content; returns the trimmed string or an error response.
101
+ * With `allowEmpty` (an attachment-only message) an empty/absent content is
102
+ * accepted and normalized to "".
103
+ */
94
104
  function validateContent(
95
105
  raw: unknown,
106
+ allowEmpty = false,
96
107
  ): string | { error: string; status: 400 } {
97
108
  // The json<{content:string}>() cast is compile-time only; a client can send a
98
109
  // non-string. Guard before .trim() else TypeError → 500 (the global handler
99
110
  // deliberately does not mask TypeError as 400). Mirrors the profile/post/invite
100
111
  // validators.
101
112
  if (typeof raw !== "string") {
113
+ if (allowEmpty && (raw === undefined || raw === null)) return "";
102
114
  return { error: "Message content is required", status: 400 };
103
115
  }
104
116
  const content = raw.trim();
105
- if (!content) return { error: "Message content is required", status: 400 };
117
+ if (!content) {
118
+ if (allowEmpty) return "";
119
+ return { error: "Message content is required", status: 400 };
120
+ }
106
121
  if (content.length > MAX_DM_CONTENT_LENGTH) {
107
122
  return {
108
123
  error: `Message too long (max ${MAX_DM_CONTENT_LENGTH} chars)`,
@@ -112,6 +127,34 @@ function validateContent(
112
127
  return content;
113
128
  }
114
129
 
130
+ /**
131
+ * Validate a chat message's attachments array (mirrors the post-create bounds:
132
+ * records only, capped count + serialized size — the size cap bounds row and
133
+ * federated-doc bloat regardless of internal shape). Returns the validated
134
+ * array ([] when absent) or an error response.
135
+ */
136
+ function validateAttachments(
137
+ raw: unknown,
138
+ ): Attachment[] | { error: string; status: 400 } {
139
+ if (raw === undefined || raw === null) return [];
140
+ if (!Array.isArray(raw)) {
141
+ return { error: "attachments must be an array", status: 400 };
142
+ }
143
+ if (raw.some((a) => !a || typeof a !== "object" || Array.isArray(a))) {
144
+ return { error: "attachments must be objects", status: 400 };
145
+ }
146
+ if (raw.length > MAX_ATTACHMENTS) {
147
+ return {
148
+ error: `Too many attachments (max ${MAX_ATTACHMENTS})`,
149
+ status: 400,
150
+ };
151
+ }
152
+ if (JSON.stringify(raw).length > MAX_ATTACHMENTS_JSON_LENGTH) {
153
+ return { error: "attachments payload too large", status: 400 };
154
+ }
155
+ return raw as Attachment[];
156
+ }
157
+
115
158
  /**
116
159
  * Resolve a `user@domain` handle for a DM recipient. Prefers the stored
117
160
  * preferredUsername paired with the recipient's host, falling back to
@@ -334,6 +377,7 @@ function dmNoteInsert(
334
377
  apId: string;
335
378
  actorApId: string;
336
379
  content: string;
380
+ attachments: Attachment[];
337
381
  toJson: string;
338
382
  conversationId: string;
339
383
  published: string;
@@ -344,6 +388,7 @@ function dmNoteInsert(
344
388
  type: "Note",
345
389
  attributedTo: data.actorApId,
346
390
  content: data.content,
391
+ attachmentsJson: JSON.stringify(data.attachments),
347
392
  visibility: "direct",
348
393
  toJson: data.toJson,
349
394
  ccJson: JSON.stringify([]),
@@ -380,10 +425,27 @@ dm.get("/user/:encodedApId/messages", async (c) => {
380
425
  limit,
381
426
  before,
382
427
  );
428
+
429
+ // The partner's read position (LOCAL-ONLY read receipt): the row only exists
430
+ // when the other participant is a local account that opened the thread —
431
+ // read state is never federated, so a remote partner stays null ("unknown")
432
+ // rather than "unread".
433
+ const partnerRead = await db
434
+ .select({ lastReadAt: dmReadStatus.lastReadAt })
435
+ .from(dmReadStatus)
436
+ .where(
437
+ and(
438
+ eq(dmReadStatus.actorApId, otherApId),
439
+ eq(dmReadStatus.conversationId, conversationId),
440
+ ),
441
+ )
442
+ .get();
443
+
383
444
  return c.json({
384
445
  messages,
385
446
  conversation_id: conversationId,
386
447
  has_more: hasMore,
448
+ partner_last_read_at: partnerRead?.lastReadAt ?? null,
387
449
  });
388
450
  });
389
451
 
@@ -394,10 +456,23 @@ dm.post("/user/:encodedApId/messages", async (c) => {
394
456
 
395
457
  const db = c.get("db");
396
458
  const otherApId = decodeURIComponent(c.req.param("encodedApId"));
397
- const body = await c.req.json<{ content: string }>();
459
+ const body = await c.req.json<{
460
+ content?: string;
461
+ attachments?: unknown;
462
+ }>();
398
463
  const baseUrl = c.env.APP_URL;
399
464
 
400
- const contentOrError = validateContent(body.content);
465
+ const attachmentsOrError = validateAttachments(body.attachments);
466
+ if (!Array.isArray(attachmentsOrError)) {
467
+ return c.json(
468
+ { error: attachmentsOrError.error },
469
+ attachmentsOrError.status,
470
+ );
471
+ }
472
+ const attachments = attachmentsOrError;
473
+
474
+ // An attachment-only message (LINE-style image send) carries no text.
475
+ const contentOrError = validateContent(body.content, attachments.length > 0);
401
476
  if (typeof contentOrError !== "string") {
402
477
  return c.json({ error: contentOrError.error }, contentOrError.status);
403
478
  }
@@ -466,6 +541,9 @@ dm.post("/user/:encodedApId/messages", async (c) => {
466
541
  const mentionTag = [
467
542
  { type: "Mention", href: otherApId, name: recipientName },
468
543
  ];
544
+ // Media is stored as an app-relative /media path; absolutize (and strip the
545
+ // internal r2_key) for the federated copy so the remote can fetch it.
546
+ const apAttachments = toApAttachments(attachments, baseUrl);
469
547
  const remoteCreateActivity = !isRecipientLocal
470
548
  ? {
471
549
  "@context": "https://www.w3.org/ns/activitystreams",
@@ -480,6 +558,7 @@ dm.post("/user/:encodedApId/messages", async (c) => {
480
558
  attributedTo: actor.ap_id,
481
559
  to: [otherApId],
482
560
  content,
561
+ ...(apAttachments.length > 0 ? { attachment: apAttachments } : {}),
483
562
  published: now,
484
563
  conversation: conversationId,
485
564
  tag: mentionTag,
@@ -498,6 +577,7 @@ dm.post("/user/:encodedApId/messages", async (c) => {
498
577
  apId,
499
578
  actorApId: actor.ap_id,
500
579
  content,
580
+ attachments,
501
581
  toJson,
502
582
  conversationId,
503
583
  published: now,
@@ -560,6 +640,7 @@ dm.post("/user/:encodedApId/messages", async (c) => {
560
640
  id: apId,
561
641
  sender: buildSenderFromActor(actor),
562
642
  content,
643
+ attachments,
563
644
  created_at: now,
564
645
  },
565
646
  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
+ }
@@ -233,6 +233,44 @@ function encodeNotifCursor(row: { created_at: string; id: string }): string {
233
233
  return `${row.created_at}${NOTIF_CURSOR_SEP}${row.id}`;
234
234
  }
235
235
 
236
+ function notificationTarget(
237
+ type: string | null,
238
+ activityActorApId: string,
239
+ objectApId: string | null,
240
+ objectType: string | null,
241
+ ): {
242
+ target_kind: "post" | "story" | "profile" | "notifications";
243
+ target_id: string | null;
244
+ target_url: string;
245
+ } {
246
+ if (type === "follow" || type === "follow_request") {
247
+ return {
248
+ target_kind: "profile",
249
+ target_id: activityActorApId,
250
+ target_url: `/profile/${encodeURIComponent(activityActorApId)}`,
251
+ };
252
+ }
253
+ if (objectApId && objectType === "Story") {
254
+ return {
255
+ target_kind: "story",
256
+ target_id: objectApId,
257
+ target_url: `/?story=${encodeURIComponent(objectApId)}`,
258
+ };
259
+ }
260
+ if (objectApId) {
261
+ return {
262
+ target_kind: "post",
263
+ target_id: objectApId,
264
+ target_url: `/post/${encodeURIComponent(objectApId)}`,
265
+ };
266
+ }
267
+ return {
268
+ target_kind: "notifications",
269
+ target_id: null,
270
+ target_url: "/notifications",
271
+ };
272
+ }
273
+
236
274
  // ---------------------------------------------------------------------------
237
275
  // Routes
238
276
  // ---------------------------------------------------------------------------
@@ -358,6 +396,7 @@ notifications.get("/", async (c) => {
358
396
  ? db
359
397
  .select({
360
398
  apId: objects.apId,
399
+ type: objects.type,
361
400
  content: objects.content,
362
401
  inReplyTo: objects.inReplyTo,
363
402
  audienceJson: objects.audienceJson,
@@ -438,6 +477,7 @@ notifications.get("/", async (c) => {
438
477
  objectRows.map((o) => [
439
478
  o.apId,
440
479
  {
480
+ type: o.type,
441
481
  content: readableObjectIds.has(o.apId) ? o.content : "",
442
482
  inReplyTo: o.inReplyTo,
443
483
  },
@@ -464,6 +504,9 @@ notifications.get("/", async (c) => {
464
504
  icon_url: string | null;
465
505
  };
466
506
  object_content: string;
507
+ target_kind: "post" | "story" | "profile" | "community" | "notifications";
508
+ target_id: string | null;
509
+ target_url: string;
467
510
  }> = [];
468
511
 
469
512
  for (const entry of inboxEntries) {
@@ -484,6 +527,12 @@ notifications.get("/", async (c) => {
484
527
  followStatus,
485
528
  );
486
529
  const actorInfo = actorMap.get(entry.activityActorApId);
530
+ const target = notificationTarget(
531
+ notifType,
532
+ entry.activityActorApId,
533
+ entry.activityObjectApId,
534
+ objectData?.type ?? null,
535
+ );
487
536
 
488
537
  notifications_list.push({
489
538
  id: entry.activityApId,
@@ -499,6 +548,7 @@ notifications.get("/", async (c) => {
499
548
  icon_url: actorInfo?.iconUrl ?? null,
500
549
  },
501
550
  object_content: objectData?.content ?? "",
551
+ ...target,
502
552
  });
503
553
  }
504
554
 
@@ -96,6 +96,12 @@ const ENV_PASSTHROUGH_KEYS = [
96
96
  "DELIVERY_DLQ_NAME",
97
97
  "YURUCOMMU_ENABLE_LOCAL_SUBSTRATE_REMOTE_FETCHES",
98
98
  "YURUCOMMU_ENABLE_LOCAL_DELIVERY_QUEUE",
99
+ "YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_ALLOWED_HOSTS",
100
+ "YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_URL",
101
+ "YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_TOKEN",
102
+ "YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_TIMEOUT_MS",
103
+ "YURUCOMMU_NOTIFICATION_PUSH_ALLOW_INSECURE_LOOPBACK",
104
+ "YURUCOMMU_NOTIFICATION_PUSH_WEB_PUSH_PUBLIC_KEY",
99
105
  ] as const;
100
106
 
101
107
  function isTruthyEnv(value: string | undefined): boolean {
@@ -67,6 +67,18 @@ export interface EnvVars {
67
67
  // app falls back to the YURUCOMMU_VERSION default constant.
68
68
  YURUCOMMU_SOFTWARE_VERSION?: string;
69
69
 
70
+ // Product-neutral notification pusher gateway. Public gateway registration
71
+ // is fail-closed until its hostname is explicitly allowlisted. The bearer is
72
+ // attached only when data.url exactly matches the canonical URL.
73
+ YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_ALLOWED_HOSTS?: string;
74
+ YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_URL?: string;
75
+ YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_TOKEN?: string;
76
+ YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_TIMEOUT_MS?: string;
77
+ YURUCOMMU_NOTIFICATION_PUSH_ALLOW_INSECURE_LOOPBACK?: string;
78
+ // Public VAPID application-server key exposed to authenticated browser
79
+ // clients. The corresponding private key remains gateway-owned.
80
+ YURUCOMMU_NOTIFICATION_PUSH_WEB_PUSH_PUBLIC_KEY?: string;
81
+
70
82
  // CSRF allowed origins (comma-separated). APP_URL の origin に加えて
71
83
  // 受け付ける追加 origin (= dev hostname (`https://yurucommu.test`) を
72
84
  // production-equivalent な strict CSRF check 経由で踏むため)。 未設定なら
@@ -2,7 +2,13 @@
2
2
  * Mobile client tables.
3
3
  */
4
4
 
5
- import { index, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
5
+ import {
6
+ index,
7
+ integer,
8
+ sqliteTable,
9
+ text,
10
+ uniqueIndex,
11
+ } from "drizzle-orm/sqlite-core";
6
12
  import { nowIsoUtc } from "./date-utils.ts";
7
13
  import { actors } from "./actors.ts";
8
14
 
@@ -35,3 +41,97 @@ export const mobilePushRegistrations = sqliteTable(
35
41
  index("mobile_push_registrations_last_seen_idx").on(t.lastSeenAt),
36
42
  ],
37
43
  );
44
+
45
+ /**
46
+ * Product-neutral notification pushers shared by yurucommu-family clients.
47
+ *
48
+ * `pushkey` is an opaque downstream-provider identifier. Provider credentials
49
+ * never live in this table; they stay in the configured stateless gateway.
50
+ */
51
+ export const notificationPushers = sqliteTable(
52
+ "notification_pushers",
53
+ {
54
+ id: text("id").primaryKey(),
55
+ actorApId: text("actor_ap_id")
56
+ .notNull()
57
+ .references(() => actors.apId),
58
+ product: text("product").notNull(),
59
+ scope: text("scope"),
60
+ kind: text("kind").notNull().default("http"),
61
+ appId: text("app_id").notNull(),
62
+ pushkey: text("pushkey").notNull(),
63
+ pushkeyHash: text("pushkey_hash").notNull(),
64
+ appDisplayName: text("app_display_name"),
65
+ deviceDisplayName: text("device_display_name"),
66
+ profileTag: text("profile_tag"),
67
+ lang: text("lang"),
68
+ dataJson: text("data_json").notNull().default("{}"),
69
+ gatewayUrl: text("gateway_url").notNull(),
70
+ createdAt: text("created_at").notNull().$defaultFn(nowIsoUtc),
71
+ updatedAt: text("updated_at")
72
+ .notNull()
73
+ .$defaultFn(nowIsoUtc)
74
+ .$onUpdateFn(nowIsoUtc),
75
+ lastSeenAt: text("last_seen_at").notNull().$defaultFn(nowIsoUtc),
76
+ },
77
+ (t) => [
78
+ uniqueIndex("notification_pushers_actor_product_app_pushkey_idx").on(
79
+ t.actorApId,
80
+ t.product,
81
+ t.appId,
82
+ t.pushkeyHash,
83
+ ),
84
+ index("notification_pushers_actor_product_idx").on(t.actorApId, t.product),
85
+ uniqueIndex("notification_pushers_device_idx").on(
86
+ t.product,
87
+ t.appId,
88
+ t.pushkeyHash,
89
+ ),
90
+ index("notification_pushers_last_seen_idx").on(t.lastSeenAt),
91
+ ],
92
+ );
93
+
94
+ /**
95
+ * Durable outbox for push delivery. A migration-owned trigger writes one row
96
+ * whenever an unread inbox entry is created. Queue messages contain only `id`.
97
+ */
98
+ export const notificationPushJobs = sqliteTable(
99
+ "notification_push_jobs",
100
+ {
101
+ id: text("id").primaryKey(),
102
+ actorApId: text("actor_ap_id").notNull(),
103
+ activityApId: text("activity_ap_id").notNull(),
104
+ // Explicit for notification sources that do not create an inbox row (for
105
+ // example community talk). NULL means infer social/direct from the object.
106
+ product: text("product"),
107
+ status: text("status").notNull().default("pending"),
108
+ // Per-claim fencing token. Every processing-state mutation must match this
109
+ // value so an expired worker cannot overwrite a reclaimed job.
110
+ processingToken: text("processing_token"),
111
+ attempts: integer("attempts").notNull().default(0),
112
+ pendingPusherIdsJson: text("pending_pusher_ids_json"),
113
+ nextAttemptAt: text("next_attempt_at").notNull().$defaultFn(nowIsoUtc),
114
+ lastError: text("last_error"),
115
+ createdAt: text("created_at").notNull().$defaultFn(nowIsoUtc),
116
+ updatedAt: text("updated_at")
117
+ .notNull()
118
+ .$defaultFn(nowIsoUtc)
119
+ .$onUpdateFn(nowIsoUtc),
120
+ deliveredAt: text("delivered_at"),
121
+ },
122
+ (t) => [
123
+ uniqueIndex("notification_push_jobs_actor_activity_idx").on(
124
+ t.actorApId,
125
+ t.activityApId,
126
+ ),
127
+ index("notification_push_jobs_status_next_idx").on(
128
+ t.status,
129
+ t.nextAttemptAt,
130
+ ),
131
+ index("notification_push_jobs_terminal_retention_idx").on(
132
+ t.status,
133
+ t.updatedAt,
134
+ ),
135
+ index("notification_push_jobs_actor_idx").on(t.actorApId),
136
+ ],
137
+ );