@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
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire-compatible subset of Takosumi's product-neutral notification pusher
|
|
3
|
+
* contract. This package is independently published, so it intentionally does
|
|
4
|
+
* not import Takosumi source at runtime.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const NOTIFICATION_PUSHER_REGISTRATION_PATH =
|
|
8
|
+
"/api/notifications/pushers" as const;
|
|
9
|
+
export const MATRIX_PUSH_GATEWAY_NOTIFY_PATH =
|
|
10
|
+
"/_matrix/push/v1/notify" as const;
|
|
11
|
+
export const MAX_NOTIFICATION_PUSHER_DATA_BYTES = 2 * 1024;
|
|
12
|
+
export const SOCIAL_NOTIFICATION_PRODUCTS = ["yurucommu", "yurume"] as const;
|
|
13
|
+
|
|
14
|
+
export type SocialNotificationProduct =
|
|
15
|
+
(typeof SOCIAL_NOTIFICATION_PRODUCTS)[number];
|
|
16
|
+
|
|
17
|
+
export type JsonValue =
|
|
18
|
+
null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
|
|
19
|
+
export type JsonObject = { [key: string]: JsonValue };
|
|
20
|
+
|
|
21
|
+
export interface NotificationPusher {
|
|
22
|
+
readonly kind: "http";
|
|
23
|
+
readonly app_id: string;
|
|
24
|
+
readonly pushkey: string;
|
|
25
|
+
readonly app_display_name?: string;
|
|
26
|
+
readonly device_display_name?: string;
|
|
27
|
+
readonly profile_tag?: string;
|
|
28
|
+
readonly lang?: string;
|
|
29
|
+
readonly data: JsonObject & {
|
|
30
|
+
readonly url: string;
|
|
31
|
+
readonly format?: "event_id_only" | "full";
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ParsedNotificationPusherSetRequest {
|
|
36
|
+
readonly product: SocialNotificationProduct;
|
|
37
|
+
readonly scope: string | null;
|
|
38
|
+
readonly pusher: NotificationPusher;
|
|
39
|
+
readonly gatewayUrl: string;
|
|
40
|
+
readonly storedData: JsonObject;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ParsedNotificationPusherDeleteRequest {
|
|
44
|
+
readonly product: SocialNotificationProduct;
|
|
45
|
+
readonly scope: string | null;
|
|
46
|
+
readonly appId: string;
|
|
47
|
+
readonly pushkey: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type NotificationPusherParseResult<T> =
|
|
51
|
+
| { readonly ok: true; readonly value: T }
|
|
52
|
+
| {
|
|
53
|
+
readonly ok: false;
|
|
54
|
+
readonly error: {
|
|
55
|
+
readonly code: "BAD_REQUEST";
|
|
56
|
+
readonly error: string;
|
|
57
|
+
readonly field?: string;
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export function parseNotificationPusherSetRequest(
|
|
62
|
+
body: unknown,
|
|
63
|
+
): NotificationPusherParseResult<ParsedNotificationPusherSetRequest> {
|
|
64
|
+
if (!isRecord(body)) return bad("body must be an object");
|
|
65
|
+
const product = parseProduct(body.product);
|
|
66
|
+
if (!product) {
|
|
67
|
+
return bad("product must be yurucommu or yurume", "product");
|
|
68
|
+
}
|
|
69
|
+
const scope = parseOptionalIdentifier(body.scope);
|
|
70
|
+
if (scope === undefined) return bad("scope is invalid", "scope");
|
|
71
|
+
if (!isRecord(body.pusher)) return bad("pusher must be an object", "pusher");
|
|
72
|
+
const pusher = body.pusher;
|
|
73
|
+
if (pusher.kind !== "http") {
|
|
74
|
+
return bad("pusher.kind must be http", "pusher.kind");
|
|
75
|
+
}
|
|
76
|
+
const appId = parseBoundedString(pusher.app_id, 255);
|
|
77
|
+
if (!appId || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(appId)) {
|
|
78
|
+
return bad("pusher.app_id is invalid", "pusher.app_id");
|
|
79
|
+
}
|
|
80
|
+
const pushkey = parseBoundedString(pusher.pushkey, 4096);
|
|
81
|
+
if (!pushkey) return bad("pusher.pushkey is invalid", "pusher.pushkey");
|
|
82
|
+
if (!isRecord(pusher.data)) {
|
|
83
|
+
return bad("pusher.data must be an object", "pusher.data");
|
|
84
|
+
}
|
|
85
|
+
const gatewayUrl = normalizeGatewayUrl(pusher.data.url);
|
|
86
|
+
if (!gatewayUrl) {
|
|
87
|
+
return bad("pusher.data.url is invalid", "pusher.data.url");
|
|
88
|
+
}
|
|
89
|
+
if (
|
|
90
|
+
pusher.data.format !== undefined &&
|
|
91
|
+
pusher.data.format !== "event_id_only" &&
|
|
92
|
+
pusher.data.format !== "full"
|
|
93
|
+
) {
|
|
94
|
+
return bad("pusher.data.format is invalid", "pusher.data.format");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const clonedStoredData = cloneJsonObjectWithoutUrl(pusher.data);
|
|
98
|
+
if (!clonedStoredData) {
|
|
99
|
+
return bad("pusher.data must contain only bounded JSON", "pusher.data");
|
|
100
|
+
}
|
|
101
|
+
// The privacy-preserving wire mode is the contract default. Persist it
|
|
102
|
+
// explicitly so legacy/partial clients cannot accidentally opt into the
|
|
103
|
+
// metadata-bearing payload merely by omitting `format`.
|
|
104
|
+
const storedData: JsonObject = {
|
|
105
|
+
...clonedStoredData,
|
|
106
|
+
format: pusher.data.format === "full" ? "full" : "event_id_only",
|
|
107
|
+
};
|
|
108
|
+
if (
|
|
109
|
+
utf8Bytes(JSON.stringify(storedData)) > MAX_NOTIFICATION_PUSHER_DATA_BYTES
|
|
110
|
+
) {
|
|
111
|
+
return bad(
|
|
112
|
+
`pusher.data must be at most ${MAX_NOTIFICATION_PUSHER_DATA_BYTES} bytes without url`,
|
|
113
|
+
"pusher.data",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const optional = {
|
|
118
|
+
app_display_name: parseOptionalBoundedString(pusher.app_display_name, 255),
|
|
119
|
+
device_display_name: parseOptionalBoundedString(
|
|
120
|
+
pusher.device_display_name,
|
|
121
|
+
255,
|
|
122
|
+
),
|
|
123
|
+
profile_tag: parseOptionalBoundedString(pusher.profile_tag, 255),
|
|
124
|
+
lang: parseOptionalBoundedString(pusher.lang, 64),
|
|
125
|
+
};
|
|
126
|
+
for (const [key, value] of Object.entries(optional)) {
|
|
127
|
+
if (value === undefined)
|
|
128
|
+
return bad(`pusher.${key} is invalid`, `pusher.${key}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
ok: true,
|
|
133
|
+
value: {
|
|
134
|
+
product,
|
|
135
|
+
scope,
|
|
136
|
+
gatewayUrl,
|
|
137
|
+
storedData,
|
|
138
|
+
pusher: {
|
|
139
|
+
kind: "http",
|
|
140
|
+
app_id: appId,
|
|
141
|
+
pushkey,
|
|
142
|
+
...(optional.app_display_name
|
|
143
|
+
? { app_display_name: optional.app_display_name }
|
|
144
|
+
: {}),
|
|
145
|
+
...(optional.device_display_name
|
|
146
|
+
? { device_display_name: optional.device_display_name }
|
|
147
|
+
: {}),
|
|
148
|
+
...(optional.profile_tag ? { profile_tag: optional.profile_tag } : {}),
|
|
149
|
+
...(optional.lang ? { lang: optional.lang } : {}),
|
|
150
|
+
data: { ...storedData, url: gatewayUrl },
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function parseNotificationPusherDeleteRequest(
|
|
157
|
+
body: unknown,
|
|
158
|
+
): NotificationPusherParseResult<ParsedNotificationPusherDeleteRequest> {
|
|
159
|
+
if (!isRecord(body)) return bad("body must be an object");
|
|
160
|
+
const product = parseProduct(body.product);
|
|
161
|
+
if (!product) {
|
|
162
|
+
return bad("product must be yurucommu or yurume", "product");
|
|
163
|
+
}
|
|
164
|
+
const scope = parseOptionalIdentifier(body.scope);
|
|
165
|
+
if (scope === undefined) return bad("scope is invalid", "scope");
|
|
166
|
+
const appId = parseBoundedString(body.app_id, 255);
|
|
167
|
+
if (!appId || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(appId)) {
|
|
168
|
+
return bad("app_id is invalid", "app_id");
|
|
169
|
+
}
|
|
170
|
+
const pushkey = parseBoundedString(body.pushkey, 4096);
|
|
171
|
+
if (!pushkey) return bad("pushkey is invalid", "pushkey");
|
|
172
|
+
return { ok: true, value: { product, scope, appId, pushkey } };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function normalizeGatewayUrl(value: unknown): string | null {
|
|
176
|
+
const text = parseBoundedString(value, 2048);
|
|
177
|
+
if (!text) return null;
|
|
178
|
+
try {
|
|
179
|
+
const url = new URL(text);
|
|
180
|
+
if (url.username || url.password || url.hash) return null;
|
|
181
|
+
if (url.protocol === "https:") {
|
|
182
|
+
if (url.port && url.port !== "443") return null;
|
|
183
|
+
if (!isPublicHttpsHostname(url.hostname)) return null;
|
|
184
|
+
return url.toString();
|
|
185
|
+
}
|
|
186
|
+
if (url.protocol !== "http:" || !isLoopbackHostname(url.hostname)) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
return url.toString();
|
|
190
|
+
} catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function isLoopbackGatewayUrl(value: string): boolean {
|
|
196
|
+
try {
|
|
197
|
+
const url = new URL(value);
|
|
198
|
+
return url.protocol === "http:" && isLoopbackHostname(url.hostname);
|
|
199
|
+
} catch {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function parseProduct(value: unknown): SocialNotificationProduct | null {
|
|
205
|
+
return SOCIAL_NOTIFICATION_PRODUCTS.includes(
|
|
206
|
+
value as SocialNotificationProduct,
|
|
207
|
+
)
|
|
208
|
+
? (value as SocialNotificationProduct)
|
|
209
|
+
: null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function parseOptionalIdentifier(value: unknown): string | null | undefined {
|
|
213
|
+
if (value == null) return null;
|
|
214
|
+
const text = parseBoundedString(value, 128);
|
|
215
|
+
if (!text || !/^[A-Za-z0-9._:-]+$/.test(text)) return undefined;
|
|
216
|
+
return text;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function parseOptionalBoundedString(
|
|
220
|
+
value: unknown,
|
|
221
|
+
maxLength: number,
|
|
222
|
+
): string | null | undefined {
|
|
223
|
+
if (value == null) return null;
|
|
224
|
+
return parseBoundedString(value, maxLength) ?? undefined;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function parseBoundedString(value: unknown, maxLength: number): string | null {
|
|
228
|
+
if (typeof value !== "string") return null;
|
|
229
|
+
const text = value.trim();
|
|
230
|
+
return text && text.length <= maxLength ? text : null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function cloneJsonObjectWithoutUrl(
|
|
234
|
+
value: Record<string, unknown>,
|
|
235
|
+
): JsonObject | null {
|
|
236
|
+
const clone = Object.create(null) as JsonObject;
|
|
237
|
+
const budget = { entries: 0 };
|
|
238
|
+
for (const [key, item] of Object.entries(value)) {
|
|
239
|
+
if (key === "url") continue;
|
|
240
|
+
if (utf8Bytes(key) > 128) return null;
|
|
241
|
+
const parsed = cloneJson(item, 1, budget);
|
|
242
|
+
if (parsed === undefined) return null;
|
|
243
|
+
clone[key] = parsed;
|
|
244
|
+
}
|
|
245
|
+
return clone;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function cloneJson(
|
|
249
|
+
value: unknown,
|
|
250
|
+
depth: number,
|
|
251
|
+
budget: { entries: number },
|
|
252
|
+
): JsonValue | undefined {
|
|
253
|
+
if (depth > 8 || budget.entries++ >= 64) return undefined;
|
|
254
|
+
if (value === null || typeof value === "boolean") return value;
|
|
255
|
+
if (typeof value === "number")
|
|
256
|
+
return Number.isFinite(value) ? value : undefined;
|
|
257
|
+
if (typeof value === "string") {
|
|
258
|
+
return utf8Bytes(value) <= 1024 ? value : undefined;
|
|
259
|
+
}
|
|
260
|
+
if (Array.isArray(value)) {
|
|
261
|
+
if (value.length > 64) return undefined;
|
|
262
|
+
const result: JsonValue[] = [];
|
|
263
|
+
for (const item of value) {
|
|
264
|
+
const parsed = cloneJson(item, depth + 1, budget);
|
|
265
|
+
if (parsed === undefined) return undefined;
|
|
266
|
+
result.push(parsed);
|
|
267
|
+
}
|
|
268
|
+
return result;
|
|
269
|
+
}
|
|
270
|
+
if (!isRecord(value)) return undefined;
|
|
271
|
+
const result = Object.create(null) as JsonObject;
|
|
272
|
+
for (const [key, item] of Object.entries(value)) {
|
|
273
|
+
if (utf8Bytes(key) > 128) return undefined;
|
|
274
|
+
const parsed = cloneJson(item, depth + 1, budget);
|
|
275
|
+
if (parsed === undefined) return undefined;
|
|
276
|
+
result[key] = parsed;
|
|
277
|
+
}
|
|
278
|
+
return result;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function bad<T>(
|
|
282
|
+
error: string,
|
|
283
|
+
field?: string,
|
|
284
|
+
): NotificationPusherParseResult<T> {
|
|
285
|
+
return { ok: false, error: { code: "BAD_REQUEST", error, field } };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
289
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function isLoopbackHostname(hostname: string): boolean {
|
|
293
|
+
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
294
|
+
if (
|
|
295
|
+
normalized === "localhost" ||
|
|
296
|
+
normalized.endsWith(".localhost") ||
|
|
297
|
+
normalized === "::1"
|
|
298
|
+
) {
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
const octets = normalized.split(".").map(Number);
|
|
302
|
+
return (
|
|
303
|
+
octets.length === 4 &&
|
|
304
|
+
octets.every(
|
|
305
|
+
(part) => Number.isInteger(part) && part >= 0 && part <= 255,
|
|
306
|
+
) &&
|
|
307
|
+
octets[0] === 127
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function isPublicHttpsHostname(hostname: string): boolean {
|
|
312
|
+
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
313
|
+
if (
|
|
314
|
+
!normalized.includes(".") ||
|
|
315
|
+
normalized.endsWith(".localhost") ||
|
|
316
|
+
normalized.endsWith(".local") ||
|
|
317
|
+
normalized.endsWith(".internal") ||
|
|
318
|
+
normalized.endsWith(".home") ||
|
|
319
|
+
normalized.endsWith(".lan")
|
|
320
|
+
) {
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const ipv4 = normalized.split(".").map(Number);
|
|
325
|
+
if (
|
|
326
|
+
ipv4.length === 4 &&
|
|
327
|
+
ipv4.every((part) => Number.isInteger(part) && part >= 0 && part <= 255)
|
|
328
|
+
) {
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Any colon denotes an IPv6 literal. Reject local/private/non-routable IPv6
|
|
333
|
+
// and public literals alike for v1; operators should use an allowlisted DNS
|
|
334
|
+
// name so HTTPS identity remains meaningful.
|
|
335
|
+
return !normalized.includes(":");
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function utf8Bytes(value: string): number {
|
|
339
|
+
return new TextEncoder().encode(value).byteLength;
|
|
340
|
+
}
|
|
@@ -99,12 +99,13 @@ export function getAuthConfig(env: Env): AuthConfig {
|
|
|
99
99
|
});
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
// Takosumi Accounts OIDC. The client SECRET is optional:
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
// client (secret set) also works. Either way
|
|
107
|
-
// so issuer + client_id are sufficient to
|
|
102
|
+
// Takosumi Accounts OIDC. The client SECRET is optional: a Takosumi-created
|
|
103
|
+
// Capsule client is PUBLIC (token_endpoint_auth_method "none", PKCE-only),
|
|
104
|
+
// because the explicit install mapping publishes issuer/client metadata but
|
|
105
|
+
// deliberately does not project secret-bearing material. A separately
|
|
106
|
+
// configured confidential client (secret set) also works. Either way
|
|
107
|
+
// PKCE-S256 protects the exchange, so issuer + client_id are sufficient to
|
|
108
|
+
// offer the provider.
|
|
108
109
|
const oidcIssuer = getOidcIssuerUrl(env);
|
|
109
110
|
const { clientId: oidcClientId } = getOidcClientCredentials(env);
|
|
110
111
|
if (oidcIssuer && oidcClientId) {
|
|
@@ -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 {
|
|
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
|
+
MAX_ATTACHMENTS,
|
|
22
|
+
MAX_ATTACHMENTS_JSON_LENGTH,
|
|
23
|
+
} from "../posts/transformers.ts";
|
|
13
24
|
import { communityRequiresMembership } from "../../lib/community-visibility.ts";
|
|
14
25
|
import { rateLimit, RateLimitConfigs } from "../../middleware/rate-limit.ts";
|
|
15
26
|
import {
|
|
@@ -28,6 +39,30 @@ import {
|
|
|
28
39
|
const MAX_COMMUNITY_MESSAGE_LENGTH = 5000;
|
|
29
40
|
const MAX_COMMUNITY_MESSAGES_LIMIT = 100;
|
|
30
41
|
|
|
42
|
+
type ChatAttachment = Record<string, unknown>;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Validate a chat message's attachments array (mirrors the post-create and DM
|
|
46
|
+
* bounds: records only, capped count + serialized size). Returns the validated
|
|
47
|
+
* array ([] when absent) or an error message.
|
|
48
|
+
*/
|
|
49
|
+
function validateAttachments(
|
|
50
|
+
raw: unknown,
|
|
51
|
+
): ChatAttachment[] | { error: string } {
|
|
52
|
+
if (raw === undefined || raw === null) return [];
|
|
53
|
+
if (!Array.isArray(raw)) return { error: "attachments must be an array" };
|
|
54
|
+
if (raw.some((a) => !a || typeof a !== "object" || Array.isArray(a))) {
|
|
55
|
+
return { error: "attachments must be objects" };
|
|
56
|
+
}
|
|
57
|
+
if (raw.length > MAX_ATTACHMENTS) {
|
|
58
|
+
return { error: `Too many attachments (max ${MAX_ATTACHMENTS})` };
|
|
59
|
+
}
|
|
60
|
+
if (JSON.stringify(raw).length > MAX_ATTACHMENTS_JSON_LENGTH) {
|
|
61
|
+
return { error: "attachments payload too large" };
|
|
62
|
+
}
|
|
63
|
+
return raw as ChatAttachment[];
|
|
64
|
+
}
|
|
65
|
+
|
|
31
66
|
// D1's batch() (atomic multi-statement) is only on the concrete D1/libsql
|
|
32
67
|
// driver, not the shared `Database` union; reach it through a narrow cast.
|
|
33
68
|
type Batchable = {
|
|
@@ -151,6 +186,7 @@ messagesRouter.get("/:identifier/messages", async (c) => {
|
|
|
151
186
|
apId: objects.apId,
|
|
152
187
|
attributedTo: objects.attributedTo,
|
|
153
188
|
content: objects.content,
|
|
189
|
+
attachmentsJson: objects.attachmentsJson,
|
|
154
190
|
published: objects.published,
|
|
155
191
|
})
|
|
156
192
|
.from(objectRecipients)
|
|
@@ -176,11 +212,38 @@ messagesRouter.get("/:identifier/messages", async (c) => {
|
|
|
176
212
|
icon_url: senderInfo?.iconUrl || null,
|
|
177
213
|
},
|
|
178
214
|
content: msg.content,
|
|
215
|
+
attachments: safeJsonParse<ChatAttachment[]>(msg.attachmentsJson, []),
|
|
179
216
|
created_at: msg.published,
|
|
180
217
|
};
|
|
181
218
|
});
|
|
182
219
|
|
|
183
|
-
|
|
220
|
+
// Per-member read positions (LOCAL-ONLY read receipts): rows exist only for
|
|
221
|
+
// local members that marked the chat read — read state is never federated,
|
|
222
|
+
// so remote members simply never appear here. Restricted to CURRENT members
|
|
223
|
+
// so a kicked member's stale row doesn't leak into the read count.
|
|
224
|
+
const readStates = await db
|
|
225
|
+
.select({
|
|
226
|
+
actorApId: dmCommunityReadStatus.actorApId,
|
|
227
|
+
lastReadAt: dmCommunityReadStatus.lastReadAt,
|
|
228
|
+
})
|
|
229
|
+
.from(dmCommunityReadStatus)
|
|
230
|
+
.innerJoin(
|
|
231
|
+
communityMembers,
|
|
232
|
+
and(
|
|
233
|
+
eq(communityMembers.communityApId, dmCommunityReadStatus.communityApId),
|
|
234
|
+
eq(communityMembers.actorApId, dmCommunityReadStatus.actorApId),
|
|
235
|
+
),
|
|
236
|
+
)
|
|
237
|
+
.where(eq(dmCommunityReadStatus.communityApId, community.apId));
|
|
238
|
+
|
|
239
|
+
return c.json({
|
|
240
|
+
messages: result,
|
|
241
|
+
has_more: hasMore,
|
|
242
|
+
read_states: readStates.map((r) => ({
|
|
243
|
+
actor_ap_id: r.actorApId,
|
|
244
|
+
last_read_at: r.lastReadAt,
|
|
245
|
+
})),
|
|
246
|
+
});
|
|
184
247
|
});
|
|
185
248
|
|
|
186
249
|
// POST /api/communities/:name/messages - Send a chat message
|
|
@@ -196,14 +259,31 @@ messagesRouter.post(
|
|
|
196
259
|
const db = c.get("db");
|
|
197
260
|
const baseUrl = c.env.APP_URL;
|
|
198
261
|
const apId = resolveCommunityApId(baseUrl, identifier);
|
|
199
|
-
const body = await c.req.json<{
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
262
|
+
const body = await c.req.json<{
|
|
263
|
+
content?: string;
|
|
264
|
+
attachments?: unknown;
|
|
265
|
+
}>();
|
|
266
|
+
|
|
267
|
+
const attachmentsOrError = validateAttachments(body.attachments);
|
|
268
|
+
if (!Array.isArray(attachmentsOrError)) {
|
|
269
|
+
return c.json({ error: attachmentsOrError.error }, 400);
|
|
270
|
+
}
|
|
271
|
+
const attachments = attachmentsOrError;
|
|
272
|
+
|
|
273
|
+
// Guard non-string content before .trim() (else TypeError → 500). An
|
|
274
|
+
// attachment-only message (image send) carries no text.
|
|
275
|
+
const rawContent = body.content;
|
|
276
|
+
if (
|
|
277
|
+
typeof rawContent !== "string" &&
|
|
278
|
+
!(
|
|
279
|
+
attachments.length > 0 &&
|
|
280
|
+
(rawContent === undefined || rawContent === null)
|
|
281
|
+
)
|
|
282
|
+
) {
|
|
203
283
|
return c.json({ error: "Message content is required" }, 400);
|
|
204
284
|
}
|
|
205
|
-
const content =
|
|
206
|
-
if (!content) {
|
|
285
|
+
const content = typeof rawContent === "string" ? rawContent.trim() : "";
|
|
286
|
+
if (!content && attachments.length === 0) {
|
|
207
287
|
return c.json({ error: "Message content is required" }, 400);
|
|
208
288
|
}
|
|
209
289
|
if (content.length > MAX_COMMUNITY_MESSAGE_LENGTH) {
|
|
@@ -257,6 +337,54 @@ messagesRouter.post(
|
|
|
257
337
|
const activityId = generateId();
|
|
258
338
|
const activityApIdVal = `${baseUrl}/ap/activities/${activityId}`;
|
|
259
339
|
|
|
340
|
+
// Community talk deliberately has no social-inbox row, so its durable push
|
|
341
|
+
// jobs must commit with the message itself. Resolve only members that own a
|
|
342
|
+
// Yurume pusher at event time; delivery re-checks membership before egress.
|
|
343
|
+
const pushRecipients = await db
|
|
344
|
+
.selectDistinct({ actorApId: notificationPushers.actorApId })
|
|
345
|
+
.from(notificationPushers)
|
|
346
|
+
.innerJoin(
|
|
347
|
+
communityMembers,
|
|
348
|
+
eq(communityMembers.actorApId, notificationPushers.actorApId),
|
|
349
|
+
)
|
|
350
|
+
.where(
|
|
351
|
+
and(
|
|
352
|
+
eq(communityMembers.communityApId, community.apId),
|
|
353
|
+
eq(notificationPushers.product, "yurume"),
|
|
354
|
+
ne(notificationPushers.actorApId, actor.ap_id),
|
|
355
|
+
),
|
|
356
|
+
);
|
|
357
|
+
const pushJobStatements: unknown[] = [];
|
|
358
|
+
// D1 caps bound parameters at 100. Keep each multi-row insert below that
|
|
359
|
+
// limit while retaining every insert in the same atomic batch.
|
|
360
|
+
const pushJobBatchSize = 8;
|
|
361
|
+
for (
|
|
362
|
+
let offset = 0;
|
|
363
|
+
offset < pushRecipients.length;
|
|
364
|
+
offset += pushJobBatchSize
|
|
365
|
+
) {
|
|
366
|
+
pushJobStatements.push(
|
|
367
|
+
db
|
|
368
|
+
.insert(notificationPushJobs)
|
|
369
|
+
.values(
|
|
370
|
+
pushRecipients
|
|
371
|
+
.slice(offset, offset + pushJobBatchSize)
|
|
372
|
+
.map(({ actorApId }) => ({
|
|
373
|
+
id: `${actorApId}\n${activityApIdVal}`,
|
|
374
|
+
actorApId,
|
|
375
|
+
activityApId: activityApIdVal,
|
|
376
|
+
product: "yurume",
|
|
377
|
+
status: "pending",
|
|
378
|
+
attempts: 0,
|
|
379
|
+
nextAttemptAt: now,
|
|
380
|
+
createdAt: now,
|
|
381
|
+
updatedAt: now,
|
|
382
|
+
})),
|
|
383
|
+
)
|
|
384
|
+
.onConflictDoNothing(),
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
260
388
|
// Persist the chat message atomically: the Note, its community-audience
|
|
261
389
|
// recipient row (which the GET-messages reader joins on), the Create
|
|
262
390
|
// activity, and the community's lastMessageAt. D1 has no interactive
|
|
@@ -270,6 +398,7 @@ messagesRouter.post(
|
|
|
270
398
|
type: "Note",
|
|
271
399
|
attributedTo: actor.ap_id,
|
|
272
400
|
content,
|
|
401
|
+
attachmentsJson: JSON.stringify(attachments),
|
|
273
402
|
toJson,
|
|
274
403
|
audienceJson,
|
|
275
404
|
visibility: "unlisted",
|
|
@@ -293,6 +422,7 @@ messagesRouter.post(
|
|
|
293
422
|
.update(communities)
|
|
294
423
|
.set({ lastMessageAt: now })
|
|
295
424
|
.where(eq(communities.apId, community.apId)),
|
|
425
|
+
...pushJobStatements,
|
|
296
426
|
]);
|
|
297
427
|
|
|
298
428
|
return c.json(
|
|
@@ -307,6 +437,7 @@ messagesRouter.post(
|
|
|
307
437
|
icon_url: actor.icon_url,
|
|
308
438
|
},
|
|
309
439
|
content,
|
|
440
|
+
attachments,
|
|
310
441
|
created_at: now,
|
|
311
442
|
},
|
|
312
443
|
},
|