@takosjp/yurucommu-core 3.2.0 → 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/migrations/0019_notification_push_delivery.sql +10 -7
- package/package.json +5 -2
- package/packages/api/package.json +1 -1
- package/packages/api/src/lib/api/browser-push.ts +11 -0
- 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/push-config.ts +132 -0
- package/packages/api/src/lib/api.ts +2 -0
- package/packages/api/src/social-server.ts +7 -0
- package/packages/api/src/types/index.ts +13 -4
- package/src/backend/index.ts +44 -9
- package/src/backend/lib/attachments.ts +52 -0
- package/src/backend/lib/delivery/queue.ts +55 -0
- package/src/backend/lib/notification-eligibility.ts +150 -0
- package/src/backend/lib/notification-push.ts +159 -146
- 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 +16 -33
- package/src/backend/routes/dm/contacts.ts +6 -44
- package/src/backend/routes/dm/messages.ts +5 -39
- package/src/backend/routes/notifications.ts +27 -70
- package/src/backend/routes/posts/transformers.ts +8 -10
- package/src/db/index.ts +11 -10
- package/src/db/schema/mobile.ts +7 -9
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Yurume unread totals.
|
|
3
|
+
*
|
|
4
|
+
* SINGLE owner of the DM + community-chat unread COUNT(*) SQL. It is consumed
|
|
5
|
+
* by BOTH:
|
|
6
|
+
* - GET /api/dm/unread/count (the Messages nav badge), and
|
|
7
|
+
* - the notification push payload's `counts.unread`
|
|
8
|
+
* so the app badge a push sets can never drift from the badge the client
|
|
9
|
+
* computes when it opens. A parity test pins this helper to the endpoint.
|
|
10
|
+
*
|
|
11
|
+
* - DM unread: direct Notes addressed TO the actor (via the object_recipients
|
|
12
|
+
* `to` index), not authored by the actor, published after the actor's
|
|
13
|
+
* per-conversation read time (epoch if never read), excluding archived
|
|
14
|
+
* conversations.
|
|
15
|
+
* - Community unread: group-CHAT Notes (audience-linked, communityApId IS NULL
|
|
16
|
+
* — NOT feed posts) in communities the actor belongs to, not the actor's
|
|
17
|
+
* own, after the later of the per-community read time and the join time.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { sql } from "drizzle-orm";
|
|
21
|
+
import type { Database } from "../../db/index.ts";
|
|
22
|
+
|
|
23
|
+
export interface YurumeUnreadCounts {
|
|
24
|
+
readonly dm: number;
|
|
25
|
+
readonly community: number;
|
|
26
|
+
readonly total: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function yurumeUnreadCounts(
|
|
30
|
+
db: Database,
|
|
31
|
+
actorApId: string,
|
|
32
|
+
): Promise<YurumeUnreadCounts> {
|
|
33
|
+
const dmRow = await db.get<{ c: number }>(sql`
|
|
34
|
+
SELECT COUNT(*) AS c
|
|
35
|
+
FROM objects o
|
|
36
|
+
JOIN object_recipients orp
|
|
37
|
+
ON orp.object_ap_id = o.ap_id
|
|
38
|
+
AND orp.recipient_ap_id = ${actorApId}
|
|
39
|
+
AND orp.type = 'to'
|
|
40
|
+
LEFT JOIN dm_read_status r
|
|
41
|
+
ON r.conversation_id = o.conversation
|
|
42
|
+
AND r.actor_ap_id = ${actorApId}
|
|
43
|
+
WHERE o.visibility = 'direct'
|
|
44
|
+
AND o.type = 'Note'
|
|
45
|
+
AND o.conversation IS NOT NULL
|
|
46
|
+
AND o.attributed_to != ${actorApId}
|
|
47
|
+
AND o.published > COALESCE(r.last_read_at, '1970-01-01T00:00:00Z')
|
|
48
|
+
AND o.conversation NOT IN (
|
|
49
|
+
SELECT conversation_id FROM dm_archived_conversations
|
|
50
|
+
WHERE actor_ap_id = ${actorApId}
|
|
51
|
+
)
|
|
52
|
+
`);
|
|
53
|
+
|
|
54
|
+
const communityRow = await db.get<{ c: number }>(sql`
|
|
55
|
+
SELECT COUNT(*) AS c
|
|
56
|
+
FROM community_members cm
|
|
57
|
+
JOIN object_recipients orp
|
|
58
|
+
ON orp.recipient_ap_id = cm.community_ap_id
|
|
59
|
+
AND orp.type = 'audience'
|
|
60
|
+
JOIN objects o
|
|
61
|
+
ON o.ap_id = orp.object_ap_id
|
|
62
|
+
AND o.type = 'Note'
|
|
63
|
+
AND o.community_ap_id IS NULL
|
|
64
|
+
AND o.attributed_to != ${actorApId}
|
|
65
|
+
LEFT JOIN dm_community_read_status r
|
|
66
|
+
ON r.community_ap_id = cm.community_ap_id
|
|
67
|
+
AND r.actor_ap_id = ${actorApId}
|
|
68
|
+
WHERE cm.actor_ap_id = ${actorApId}
|
|
69
|
+
AND o.published > COALESCE(
|
|
70
|
+
r.last_read_at,
|
|
71
|
+
cm.joined_at,
|
|
72
|
+
'1970-01-01T00:00:00Z'
|
|
73
|
+
)
|
|
74
|
+
`);
|
|
75
|
+
|
|
76
|
+
const dm = Number(dmRow?.c ?? 0);
|
|
77
|
+
const community = Number(communityRow?.c ?? 0);
|
|
78
|
+
return { dm, community, total: dm + community };
|
|
79
|
+
}
|
|
@@ -69,6 +69,16 @@ function isBearerApiRequest(
|
|
|
69
69
|
return !c.req.header("Cookie");
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
function isCookieLessNativeAuthRequest(
|
|
73
|
+
c: Context<{ Bindings: Env; Variables: Variables }>,
|
|
74
|
+
) {
|
|
75
|
+
return (
|
|
76
|
+
!c.req.header("Cookie") &&
|
|
77
|
+
(c.req.path === "/api/auth/mobile/login" ||
|
|
78
|
+
c.req.path === "/api/auth/mobile/oidc")
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
72
82
|
/**
|
|
73
83
|
* CSRF protection middleware.
|
|
74
84
|
* Validates Origin/Referer for state-changing requests as defense-in-depth
|
|
@@ -82,6 +92,7 @@ export function csrfProtection() {
|
|
|
82
92
|
if (!STATE_CHANGING_METHODS.has(c.req.method.toUpperCase())) return next();
|
|
83
93
|
if (isActivityPubInbox(c.req.path)) return next();
|
|
84
94
|
if (isBearerApiRequest(c)) return next();
|
|
95
|
+
if (isCookieLessNativeAuthRequest(c)) return next();
|
|
85
96
|
|
|
86
97
|
const appUrl = c.env.APP_URL;
|
|
87
98
|
const allowedOrigins = buildAllowedOrigins(c.env);
|
|
@@ -19,6 +19,8 @@ import {
|
|
|
19
19
|
mediaUploads,
|
|
20
20
|
mutes,
|
|
21
21
|
notificationArchived,
|
|
22
|
+
notificationPushers,
|
|
23
|
+
notificationPushJobs,
|
|
22
24
|
nowIso,
|
|
23
25
|
objectRecipients,
|
|
24
26
|
objects,
|
|
@@ -244,6 +246,17 @@ export async function teardownActor(
|
|
|
244
246
|
.delete(notificationArchived)
|
|
245
247
|
.where(eq(notificationArchived.actorApId, apId));
|
|
246
248
|
|
|
249
|
+
// Notification push state (no FK cascade — these tables intentionally declare
|
|
250
|
+
// no actors FK; see migrations/0019). Remove the actor's registered pushers
|
|
251
|
+
// (their pushkey is an external push endpoint that must stop being woken) and
|
|
252
|
+
// any durable outbox rows keyed to the actor.
|
|
253
|
+
await db
|
|
254
|
+
.delete(notificationPushers)
|
|
255
|
+
.where(eq(notificationPushers.actorApId, apId));
|
|
256
|
+
await db
|
|
257
|
+
.delete(notificationPushJobs)
|
|
258
|
+
.where(eq(notificationPushJobs.actorApId, apId));
|
|
259
|
+
|
|
247
260
|
// Media: hard-delete the actor's uploads + best-effort purge backing R2.
|
|
248
261
|
await purgeActorMediaUploads(db, env.MEDIA, apId);
|
|
249
262
|
|
|
@@ -139,6 +139,7 @@ export async function rotateSession(
|
|
|
139
139
|
tokens: OAuthTokens | null,
|
|
140
140
|
encryptionKey: string | undefined,
|
|
141
141
|
rotationContext: string,
|
|
142
|
+
options: { setCookie?: boolean } = {},
|
|
142
143
|
): Promise<string> {
|
|
143
144
|
const db = c.get("db");
|
|
144
145
|
|
|
@@ -184,13 +185,15 @@ export async function rotateSession(
|
|
|
184
185
|
// served over plain http:// — a hardcoded Secure made an http self-host
|
|
185
186
|
// un-loginnable (the browser never sends a Secure cookie over http), so honour
|
|
186
187
|
// the operator's APP_URL protocol while defaulting to Secure for https/unknown.
|
|
187
|
-
setCookie
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
188
|
+
if (options.setCookie !== false) {
|
|
189
|
+
setCookie(c, "session", sessionId, {
|
|
190
|
+
httpOnly: true,
|
|
191
|
+
secure: !(c.env.APP_URL ?? "").startsWith("http://"),
|
|
192
|
+
sameSite: "Strict",
|
|
193
|
+
path: "/",
|
|
194
|
+
maxAge: SESSION_MAX_AGE_SECONDS,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
194
197
|
|
|
195
198
|
return sessionId;
|
|
196
199
|
}
|
|
@@ -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");
|
|
@@ -18,9 +18,9 @@ import {
|
|
|
18
18
|
} from "../../federation-helpers.ts";
|
|
19
19
|
import { feedCursorWhere } from "../../lib/feed-cursor.ts";
|
|
20
20
|
import {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
} from "
|
|
21
|
+
type ChatAttachment,
|
|
22
|
+
validateChatAttachments,
|
|
23
|
+
} from "../../lib/attachments.ts";
|
|
24
24
|
import { communityRequiresMembership } from "../../lib/community-visibility.ts";
|
|
25
25
|
import { rateLimit, RateLimitConfigs } from "../../middleware/rate-limit.ts";
|
|
26
26
|
import {
|
|
@@ -38,30 +38,9 @@ import {
|
|
|
38
38
|
|
|
39
39
|
const MAX_COMMUNITY_MESSAGE_LENGTH = 5000;
|
|
40
40
|
const MAX_COMMUNITY_MESSAGES_LIMIT = 100;
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
}
|
|
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;
|
|
65
44
|
|
|
66
45
|
// D1's batch() (atomic multi-statement) is only on the concrete D1/libsql
|
|
67
46
|
// driver, not the shared `Database` union; reach it through a narrow cast.
|
|
@@ -220,7 +199,9 @@ messagesRouter.get("/:identifier/messages", async (c) => {
|
|
|
220
199
|
// Per-member read positions (LOCAL-ONLY read receipts): rows exist only for
|
|
221
200
|
// local members that marked the chat read — read state is never federated,
|
|
222
201
|
// 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
|
|
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.
|
|
224
205
|
const readStates = await db
|
|
225
206
|
.select({
|
|
226
207
|
actorApId: dmCommunityReadStatus.actorApId,
|
|
@@ -234,7 +215,9 @@ messagesRouter.get("/:identifier/messages", async (c) => {
|
|
|
234
215
|
eq(communityMembers.actorApId, dmCommunityReadStatus.actorApId),
|
|
235
216
|
),
|
|
236
217
|
)
|
|
237
|
-
.where(eq(dmCommunityReadStatus.communityApId, community.apId))
|
|
218
|
+
.where(eq(dmCommunityReadStatus.communityApId, community.apId))
|
|
219
|
+
.orderBy(desc(dmCommunityReadStatus.lastReadAt))
|
|
220
|
+
.limit(MAX_COMMUNITY_READ_STATES);
|
|
238
221
|
|
|
239
222
|
return c.json({
|
|
240
223
|
messages: result,
|
|
@@ -264,11 +247,11 @@ messagesRouter.post(
|
|
|
264
247
|
attachments?: unknown;
|
|
265
248
|
}>();
|
|
266
249
|
|
|
267
|
-
const
|
|
268
|
-
if (!
|
|
269
|
-
return c.json({ error:
|
|
250
|
+
const attachmentsResult = validateChatAttachments(body.attachments);
|
|
251
|
+
if (!attachmentsResult.ok) {
|
|
252
|
+
return c.json({ error: attachmentsResult.error }, 400);
|
|
270
253
|
}
|
|
271
|
-
const attachments =
|
|
254
|
+
const attachments = attachmentsResult.attachments;
|
|
272
255
|
|
|
273
256
|
// Guard non-string content before .trim() (else TypeError → 500). An
|
|
274
257
|
// attachment-only message (image send) carries no text.
|
|
@@ -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;
|
|
@@ -37,10 +37,7 @@ import {
|
|
|
37
37
|
import { enqueueDeliveryToActor } from "../../lib/delivery/queue.ts";
|
|
38
38
|
import { feedCursorWhere } from "../../lib/feed-cursor.ts";
|
|
39
39
|
import { toApAttachments } from "../../lib/activitypub-helpers.ts";
|
|
40
|
-
import {
|
|
41
|
-
MAX_ATTACHMENTS,
|
|
42
|
-
MAX_ATTACHMENTS_JSON_LENGTH,
|
|
43
|
-
} from "../posts/transformers.ts";
|
|
40
|
+
import { validateChatAttachments } from "../../lib/attachments.ts";
|
|
44
41
|
import { logger } from "../../lib/logger.ts";
|
|
45
42
|
|
|
46
43
|
const log = logger.child({ component: "dm.messages" });
|
|
@@ -127,34 +124,6 @@ function validateContent(
|
|
|
127
124
|
return content;
|
|
128
125
|
}
|
|
129
126
|
|
|
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
|
-
|
|
158
127
|
/**
|
|
159
128
|
* Resolve a `user@domain` handle for a DM recipient. Prefers the stored
|
|
160
129
|
* preferredUsername paired with the recipient's host, falling back to
|
|
@@ -462,14 +431,11 @@ dm.post("/user/:encodedApId/messages", async (c) => {
|
|
|
462
431
|
}>();
|
|
463
432
|
const baseUrl = c.env.APP_URL;
|
|
464
433
|
|
|
465
|
-
const
|
|
466
|
-
if (!
|
|
467
|
-
return c.json(
|
|
468
|
-
{ error: attachmentsOrError.error },
|
|
469
|
-
attachmentsOrError.status,
|
|
470
|
-
);
|
|
434
|
+
const attachmentsResult = validateChatAttachments(body.attachments);
|
|
435
|
+
if (!attachmentsResult.ok) {
|
|
436
|
+
return c.json({ error: attachmentsResult.error }, 400);
|
|
471
437
|
}
|
|
472
|
-
const attachments =
|
|
438
|
+
const attachments = attachmentsResult.attachments as Attachment[];
|
|
473
439
|
|
|
474
440
|
// An attachment-only message (LINE-style image send) carries no text.
|
|
475
441
|
const contentOrError = validateContent(body.content, attachments.length > 0);
|