@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
|
@@ -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) {
|
|
@@ -17,7 +17,7 @@ function isExpired(expiresAt: string): boolean {
|
|
|
17
17
|
export async function extractActorFromSession(
|
|
18
18
|
c: Context<{ Bindings: Env; Variables: Variables }>,
|
|
19
19
|
): Promise<void> {
|
|
20
|
-
const sessionId =
|
|
20
|
+
const sessionId = rawSessionCredential(c);
|
|
21
21
|
if (!sessionId) return;
|
|
22
22
|
|
|
23
23
|
const db = c.get("db");
|
|
@@ -59,3 +59,18 @@ export async function extractActorFromSession(
|
|
|
59
59
|
};
|
|
60
60
|
c.set("actor", actor);
|
|
61
61
|
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolve the host-owned session credential used by browser and native clients.
|
|
65
|
+
* Cookie auth wins when both are present so adding an Authorization header to a
|
|
66
|
+
* browser request never changes its CSRF/session identity semantics.
|
|
67
|
+
*/
|
|
68
|
+
export function rawSessionCredential(
|
|
69
|
+
c: Context<{ Bindings: Env; Variables: Variables }>,
|
|
70
|
+
): string | undefined {
|
|
71
|
+
const cookie = getCookie(c, "session")?.trim();
|
|
72
|
+
if (cookie) return cookie;
|
|
73
|
+
const authorization = c.req.header("Authorization")?.trim();
|
|
74
|
+
const match = authorization?.match(/^Bearer\s+([^\s]+)$/i);
|
|
75
|
+
return match?.[1];
|
|
76
|
+
}
|
|
@@ -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
|
}
|