@takosjp/yurucommu-core 3.0.2 → 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 -17
- 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/stories.ts +16 -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 +51 -1
- 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/routes/stories/interactions.ts +65 -1
- package/src/backend/routes/stories/query-helpers.ts +48 -1
- package/src/backend/routes/stories/routes.ts +2 -30
- 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
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
|
-
import { and, eq, gt, sql } from "drizzle-orm";
|
|
2
|
+
import { and, count, desc, eq, gt, sql } from "drizzle-orm";
|
|
3
3
|
import type { Env, Variables } from "../../types.ts";
|
|
4
4
|
import {
|
|
5
5
|
activities,
|
|
@@ -18,12 +18,14 @@ import {
|
|
|
18
18
|
safeJsonParse,
|
|
19
19
|
} from "../../federation-helpers.ts";
|
|
20
20
|
import {
|
|
21
|
+
buildAuthor,
|
|
21
22
|
canViewerReadStory,
|
|
22
23
|
findStory,
|
|
23
24
|
getVoteCounts,
|
|
24
25
|
resolveStoryApId,
|
|
25
26
|
sumVotes,
|
|
26
27
|
} from "./query-helpers.ts";
|
|
28
|
+
import { loadActorInfoMap } from "../actors-helpers.ts";
|
|
27
29
|
import { enqueueDeliveryToActor } from "../../lib/delivery/queue.ts";
|
|
28
30
|
import { actorIsBlockedBy } from "../../lib/post-visibility.ts";
|
|
29
31
|
import { rateLimit, RateLimitConfigs } from "../../middleware/rate-limit.ts";
|
|
@@ -573,4 +575,66 @@ stories.get("/:id/votes", async (c) => {
|
|
|
573
575
|
return c.json({ votes, total: sumVotes(votes), user_vote });
|
|
574
576
|
});
|
|
575
577
|
|
|
578
|
+
// Cap the returned viewer list most-recent-first. `view_count` stays the true
|
|
579
|
+
// total (the author sees the real number even when the list is truncated).
|
|
580
|
+
const STORY_VIEWERS_LIMIT = 200;
|
|
581
|
+
|
|
582
|
+
// Get the "seen by" viewer list for a story (author-only).
|
|
583
|
+
stories.get("/:id/views", async (c) => {
|
|
584
|
+
const db = c.get("db");
|
|
585
|
+
const actor = c.get("actor");
|
|
586
|
+
const baseUrl = c.env.APP_URL;
|
|
587
|
+
// Resolve the id the same way every sibling /:id/* route does.
|
|
588
|
+
const apId = resolveStoryApId(c.req.param("id"), baseUrl);
|
|
589
|
+
|
|
590
|
+
const story = await findStory(db, apId);
|
|
591
|
+
if (!story) return c.json({ error: "Story not found" }, 404);
|
|
592
|
+
|
|
593
|
+
// Author-only: only the story author may see WHO viewed. Anyone else
|
|
594
|
+
// (including anonymous) gets 404 so the viewer list isn't disclosed and the
|
|
595
|
+
// endpoint isn't a story-existence oracle for non-authors.
|
|
596
|
+
if (!actor || actor.ap_id !== story.attributedTo) {
|
|
597
|
+
return c.json({ error: "Story not found" }, 404);
|
|
598
|
+
}
|
|
599
|
+
// Mirror the /:id/votes expiry gate: don't serve the list for an expired
|
|
600
|
+
// story before the reaper runs.
|
|
601
|
+
if (story.endTime && story.endTime < new Date().toISOString()) {
|
|
602
|
+
return c.json({ error: "Story has expired" }, 410);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// True total (uncapped) — stays accurate even when the list below is capped.
|
|
606
|
+
const totalRow = await db
|
|
607
|
+
.select({ value: count() })
|
|
608
|
+
.from(storyViews)
|
|
609
|
+
.where(eq(storyViews.storyApId, apId))
|
|
610
|
+
.get();
|
|
611
|
+
const view_count = totalRow?.value ?? 0;
|
|
612
|
+
|
|
613
|
+
// Most-recent-first page of viewers, capped.
|
|
614
|
+
const rows = await db
|
|
615
|
+
.select({
|
|
616
|
+
actorApId: storyViews.actorApId,
|
|
617
|
+
viewedAt: storyViews.viewedAt,
|
|
618
|
+
})
|
|
619
|
+
.from(storyViews)
|
|
620
|
+
.where(eq(storyViews.storyApId, apId))
|
|
621
|
+
.orderBy(desc(storyViews.viewedAt))
|
|
622
|
+
.limit(STORY_VIEWERS_LIMIT);
|
|
623
|
+
|
|
624
|
+
// Hydrate ap_id → PostAuthor via the same batch loader the follower/story
|
|
625
|
+
// lists use (local `actors` + `actor_cache`, local wins). A remote viewer
|
|
626
|
+
// absent from both degrades to a best-effort author in `buildAuthor`.
|
|
627
|
+
const infoMap = await loadActorInfoMap(
|
|
628
|
+
db,
|
|
629
|
+
rows.map((r) => r.actorApId),
|
|
630
|
+
"author",
|
|
631
|
+
);
|
|
632
|
+
const viewers = rows.map((r) => ({
|
|
633
|
+
actor: buildAuthor(r.actorApId, infoMap.get(r.actorApId)),
|
|
634
|
+
viewed_at: r.viewedAt,
|
|
635
|
+
}));
|
|
636
|
+
|
|
637
|
+
return c.json({ view_count, viewers });
|
|
638
|
+
});
|
|
639
|
+
|
|
576
640
|
export default stories;
|
|
@@ -11,7 +11,11 @@ import {
|
|
|
11
11
|
storyVotes,
|
|
12
12
|
} from "../../../db/index.ts";
|
|
13
13
|
import type { IObjectStorage } from "../../runtime/types.ts";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
formatUsername,
|
|
16
|
+
objectApId,
|
|
17
|
+
safeJsonParse,
|
|
18
|
+
} from "../../federation-helpers.ts";
|
|
15
19
|
import {
|
|
16
20
|
deleteObjectCascade,
|
|
17
21
|
purgeMediaBlobs,
|
|
@@ -271,6 +275,49 @@ export async function fetchActorCache(
|
|
|
271
275
|
);
|
|
272
276
|
}
|
|
273
277
|
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
// Author projection
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Public author projection for a story author / viewer (the `PostAuthor` shape
|
|
284
|
+
* used across the feed / story surfaces). Shared by the story feed and the
|
|
285
|
+
* viewer ("seen by") list so both hydrate an ap_id → author identically.
|
|
286
|
+
*/
|
|
287
|
+
export type StoryAuthor = {
|
|
288
|
+
ap_id: string;
|
|
289
|
+
username: string;
|
|
290
|
+
preferred_username: string | null;
|
|
291
|
+
name: string | null;
|
|
292
|
+
icon_url: string | null;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Build a StoryAuthor from available data sources. A remote actor with no local
|
|
297
|
+
* `actors` / `actor_cache` row (`data` undefined) degrades gracefully to a
|
|
298
|
+
* best-effort `username` derived from the ap_id plus null fields, so a viewer is
|
|
299
|
+
* never dropped just because its profile isn't cached locally.
|
|
300
|
+
*/
|
|
301
|
+
export function buildAuthor(
|
|
302
|
+
apId: string,
|
|
303
|
+
data:
|
|
304
|
+
| {
|
|
305
|
+
preferredUsername?: string | null;
|
|
306
|
+
name?: string | null;
|
|
307
|
+
iconUrl?: string | null;
|
|
308
|
+
}
|
|
309
|
+
| null
|
|
310
|
+
| undefined,
|
|
311
|
+
): StoryAuthor {
|
|
312
|
+
return {
|
|
313
|
+
ap_id: apId,
|
|
314
|
+
username: formatUsername(apId),
|
|
315
|
+
preferred_username: data?.preferredUsername || null,
|
|
316
|
+
name: data?.name || null,
|
|
317
|
+
icon_url: data?.iconUrl || null,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
274
321
|
// ---------------------------------------------------------------------------
|
|
275
322
|
// Story data cleanup & transformation
|
|
276
323
|
// ---------------------------------------------------------------------------
|
|
@@ -21,7 +21,6 @@ import type { IObjectStorage } from "../../runtime/types.ts";
|
|
|
21
21
|
import {
|
|
22
22
|
activityApId,
|
|
23
23
|
actorApId,
|
|
24
|
-
formatUsername,
|
|
25
24
|
generateId,
|
|
26
25
|
objectApId,
|
|
27
26
|
} from "../../federation-helpers.ts";
|
|
@@ -31,10 +30,12 @@ import { maybeReapDrainedTombstones } from "../actors.ts";
|
|
|
31
30
|
import { checkCommunityPostPermission } from "../posts/post-helpers.ts";
|
|
32
31
|
import { rateLimit, RateLimitConfigs } from "../../middleware/rate-limit.ts";
|
|
33
32
|
import {
|
|
33
|
+
buildAuthor,
|
|
34
34
|
cleanupExpiredStories,
|
|
35
35
|
fetchActorCache,
|
|
36
36
|
fetchBatchVotes,
|
|
37
37
|
fetchBlockedAndMutedIds,
|
|
38
|
+
type StoryAuthor,
|
|
38
39
|
sumVotes,
|
|
39
40
|
transformStoryData,
|
|
40
41
|
validateOverlays,
|
|
@@ -101,14 +102,6 @@ stories.post("/delete", storyWriteLimiter);
|
|
|
101
102
|
|
|
102
103
|
type VoteResults = Record<number, number>;
|
|
103
104
|
|
|
104
|
-
type StoryAuthor = {
|
|
105
|
-
ap_id: string;
|
|
106
|
-
username: string;
|
|
107
|
-
preferred_username: string | null;
|
|
108
|
-
name: string | null;
|
|
109
|
-
icon_url: string | null;
|
|
110
|
-
};
|
|
111
|
-
|
|
112
105
|
type StoryResponse = {
|
|
113
106
|
ap_id: string;
|
|
114
107
|
author: StoryAuthor;
|
|
@@ -160,27 +153,6 @@ const MAX_STORY_CAPTION_LENGTH = 500;
|
|
|
160
153
|
// feed page; a busy instance simply shows the 90 most recent.
|
|
161
154
|
const MAX_STORY_FEED_ITEMS = 90;
|
|
162
155
|
|
|
163
|
-
/** Build a StoryAuthor from available data sources. */
|
|
164
|
-
function buildAuthor(
|
|
165
|
-
apId: string,
|
|
166
|
-
data:
|
|
167
|
-
| {
|
|
168
|
-
preferredUsername?: string | null;
|
|
169
|
-
name?: string | null;
|
|
170
|
-
iconUrl?: string | null;
|
|
171
|
-
}
|
|
172
|
-
| null
|
|
173
|
-
| undefined,
|
|
174
|
-
): StoryAuthor {
|
|
175
|
-
return {
|
|
176
|
-
ap_id: apId,
|
|
177
|
-
username: formatUsername(apId),
|
|
178
|
-
preferred_username: data?.preferredUsername || null,
|
|
179
|
-
name: data?.name || null,
|
|
180
|
-
icon_url: data?.iconUrl || null,
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
|
|
184
156
|
/** Build a StoryResponse from a story object row and pre-fetched data. */
|
|
185
157
|
function buildStoryResponse(
|
|
186
158
|
s: {
|
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 経由で踏むため)。 未設定なら
|