@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.
- package/README.en.md +92 -0
- package/README.md +56 -47
- package/migrations/0019_notification_push_delivery.sql +100 -0
- package/migrations/README.md +7 -6
- package/package.json +7 -4
- package/packages/api/package.json +1 -1
- package/packages/api/src/lib/api/browser-push.ts +534 -0
- package/packages/api/src/lib/api/communities.ts +25 -2
- package/packages/api/src/lib/api/dm.ts +14 -2
- package/packages/api/src/lib/api/notifications.ts +53 -1
- package/packages/api/src/lib/api.ts +1 -0
- package/packages/api/src/social-server.ts +2 -0
- package/packages/api/src/types/index.ts +39 -0
- package/src/backend/index.ts +29 -0
- package/src/backend/lib/delivery/queue.ts +18 -0
- package/src/backend/lib/delivery/types.ts +15 -1
- package/src/backend/lib/notification-push.ts +1200 -0
- package/src/backend/lib/notification-pusher-contract.ts +340 -0
- package/src/backend/lib/oauth-providers.ts +7 -6
- package/src/backend/routes/communities/messages.ts +140 -9
- package/src/backend/routes/dm/messages.ts +85 -4
- package/src/backend/routes/notification-pushers.ts +93 -0
- package/src/backend/routes/notifications.ts +50 -0
- package/src/backend/server.ts +6 -0
- package/src/backend/types.ts +12 -0
- package/src/db/schema/mobile.ts +101 -1
|
@@ -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
|
-
/**
|
|
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)
|
|
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<{
|
|
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
|
|
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
|
|
package/src/backend/server.ts
CHANGED
|
@@ -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 {
|
package/src/backend/types.ts
CHANGED
|
@@ -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 経由で踏むため)。 未設定なら
|
package/src/db/schema/mobile.ts
CHANGED
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
* Mobile client tables.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
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
|
+
);
|