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