@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,106 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Notification,
|
|
3
|
+
NotificationTargetKind,
|
|
4
|
+
} from "../../types/index.ts";
|
|
5
|
+
|
|
6
|
+
export interface NotificationTarget {
|
|
7
|
+
readonly target_kind: NotificationTargetKind;
|
|
8
|
+
readonly target_id: string | null;
|
|
9
|
+
/**
|
|
10
|
+
* Same-origin in-app path shaped for the yurucommu web client's routing.
|
|
11
|
+
* Other clients should prefer `target_kind` + `target_id` and build their own
|
|
12
|
+
* path. Never treat this as an external URL.
|
|
13
|
+
*/
|
|
14
|
+
readonly target_url: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** True if the string contains any C0 control character (0x00-0x1f). */
|
|
18
|
+
function hasControlChar(value: string): boolean {
|
|
19
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
20
|
+
if (value.charCodeAt(index) < 0x20) return true;
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Guard an in-app navigation path against open-redirect / protocol-relative /
|
|
27
|
+
* control-character abuse. Returns the value only if it is a plain same-origin
|
|
28
|
+
* absolute path (`/...`, not `//...`), else null. Promoted from the yurucommu
|
|
29
|
+
* and yurume web clients, which each carried an identical copy.
|
|
30
|
+
*/
|
|
31
|
+
export function safeNotificationPath(
|
|
32
|
+
value: string | null | undefined,
|
|
33
|
+
): string | null {
|
|
34
|
+
if (
|
|
35
|
+
!value ||
|
|
36
|
+
!value.startsWith("/") ||
|
|
37
|
+
value.startsWith("//") ||
|
|
38
|
+
value.includes("\\") ||
|
|
39
|
+
hasControlChar(value)
|
|
40
|
+
) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isStoryNotification(notification: Notification): boolean {
|
|
47
|
+
return (
|
|
48
|
+
notification.target_kind === "story" ||
|
|
49
|
+
!!notification.object_ap_id?.includes("/ap/stories/")
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve the navigation target for a notification. Prefers the server-provided
|
|
55
|
+
* `target_*` fields (3.2.0+) after re-validating `target_url`; when a server
|
|
56
|
+
* older than 3.2.0 omitted them, synthesizes the same mapping the server uses
|
|
57
|
+
* from `type` + `object_ap_id`. Always returns a safe same-origin path.
|
|
58
|
+
*/
|
|
59
|
+
export function resolveNotificationTarget(
|
|
60
|
+
notification: Notification,
|
|
61
|
+
): NotificationTarget {
|
|
62
|
+
const declaredKind = notification.target_kind;
|
|
63
|
+
const declaredUrl = safeNotificationPath(notification.target_url);
|
|
64
|
+
if (declaredKind && declaredUrl) {
|
|
65
|
+
return {
|
|
66
|
+
target_kind: declaredKind,
|
|
67
|
+
target_id: notification.target_id ?? null,
|
|
68
|
+
target_url: declaredUrl,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (
|
|
73
|
+
notification.type === "follow" ||
|
|
74
|
+
notification.type === "follow_request"
|
|
75
|
+
) {
|
|
76
|
+
const actorApId = notification.actor?.ap_id ?? null;
|
|
77
|
+
return {
|
|
78
|
+
target_kind: "profile",
|
|
79
|
+
target_id: actorApId,
|
|
80
|
+
target_url: actorApId
|
|
81
|
+
? `/profile/${encodeURIComponent(actorApId)}`
|
|
82
|
+
: "/notifications",
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const objectApId = notification.object_ap_id;
|
|
87
|
+
if (objectApId && isStoryNotification(notification)) {
|
|
88
|
+
return {
|
|
89
|
+
target_kind: "story",
|
|
90
|
+
target_id: objectApId,
|
|
91
|
+
target_url: `/?story=${encodeURIComponent(objectApId)}`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
if (objectApId) {
|
|
95
|
+
return {
|
|
96
|
+
target_kind: "post",
|
|
97
|
+
target_id: objectApId,
|
|
98
|
+
target_url: `/post/${encodeURIComponent(objectApId)}`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
target_kind: "notifications",
|
|
103
|
+
target_id: null,
|
|
104
|
+
target_url: "/notifications",
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
Notification,
|
|
3
|
+
NotificationPusherInput,
|
|
4
|
+
NotificationPusherProduct,
|
|
5
|
+
NotificationPusherRegistration,
|
|
6
|
+
} from "../../types/index.ts";
|
|
2
7
|
import { normalizeNotification } from "./normalize.ts";
|
|
3
8
|
import { apiDelete, apiFetch, apiPost, assertOk } from "./fetch.ts";
|
|
4
9
|
|
|
@@ -59,3 +64,50 @@ export async function archiveAllNotifications(): Promise<number> {
|
|
|
59
64
|
const data = (await res.json()) as { archived_count?: number };
|
|
60
65
|
return data.archived_count ?? 0;
|
|
61
66
|
}
|
|
67
|
+
|
|
68
|
+
export async function registerNotificationPusher(input: {
|
|
69
|
+
product: NotificationPusherProduct;
|
|
70
|
+
scope?: string;
|
|
71
|
+
pusher: NotificationPusherInput;
|
|
72
|
+
}): Promise<NotificationPusherRegistration> {
|
|
73
|
+
const res = await apiPost("/api/notifications/pushers", input);
|
|
74
|
+
await assertOk(res, "Failed to register notification pusher");
|
|
75
|
+
const data = (await res.json()) as {
|
|
76
|
+
pusher: NotificationPusherRegistration;
|
|
77
|
+
};
|
|
78
|
+
return data.pusher;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function unregisterNotificationPusher(input: {
|
|
82
|
+
product: NotificationPusherProduct;
|
|
83
|
+
scope?: string;
|
|
84
|
+
app_id: string;
|
|
85
|
+
pushkey: string;
|
|
86
|
+
}): Promise<void> {
|
|
87
|
+
const res = await apiDelete("/api/notifications/pushers", input);
|
|
88
|
+
await assertOk(res, "Failed to unregister notification pusher");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface NotificationPusherPublicConfig {
|
|
92
|
+
readonly enabled: boolean;
|
|
93
|
+
readonly gateway_url: string | null;
|
|
94
|
+
readonly web_push_public_key: string | null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Non-secret runtime configuration used by browser/PWA clients. */
|
|
98
|
+
export async function fetchNotificationPusherPublicConfig(): Promise<NotificationPusherPublicConfig> {
|
|
99
|
+
const res = await apiFetch("/api/notifications/pushers/config");
|
|
100
|
+
await assertOk(res, "Failed to load notification pusher configuration");
|
|
101
|
+
const value = (await res.json()) as Partial<NotificationPusherPublicConfig>;
|
|
102
|
+
const gatewayUrl =
|
|
103
|
+
typeof value.gateway_url === "string" ? value.gateway_url : null;
|
|
104
|
+
const publicKey =
|
|
105
|
+
typeof value.web_push_public_key === "string"
|
|
106
|
+
? value.web_push_public_key
|
|
107
|
+
: null;
|
|
108
|
+
return {
|
|
109
|
+
enabled: Boolean(gatewayUrl && publicKey),
|
|
110
|
+
gateway_url: gatewayUrl,
|
|
111
|
+
web_push_public_key: publicKey,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { NotificationPusherProduct } from "../../types/index.ts";
|
|
2
|
+
import {
|
|
3
|
+
clearBrowserNotificationPush,
|
|
4
|
+
disableBrowserNotificationPush,
|
|
5
|
+
type BrowserNotificationPushConfig,
|
|
6
|
+
} from "./browser-push.ts";
|
|
7
|
+
import { fetchNotificationPusherPublicConfig } from "./notifications.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Stable identity of a browser/PWA push client. Everything here is a per-app
|
|
11
|
+
* constant; only the gateway URL, VAPID key, and server origin are resolved at
|
|
12
|
+
* runtime.
|
|
13
|
+
*/
|
|
14
|
+
export interface BrowserPushClientIdentity {
|
|
15
|
+
readonly product: NotificationPusherProduct;
|
|
16
|
+
readonly appId: string;
|
|
17
|
+
readonly appDisplayName: string;
|
|
18
|
+
/** Root-relative service worker script path (e.g. "/notification-push-sw.js"). */
|
|
19
|
+
readonly serviceWorkerPath: string;
|
|
20
|
+
readonly scope?: string;
|
|
21
|
+
readonly lang?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Non-secret runtime gateway values, from a fetch or build-time fallback. */
|
|
25
|
+
export interface BrowserPushRuntimeValues {
|
|
26
|
+
readonly gatewayUrl?: string | null;
|
|
27
|
+
readonly vapidPublicKey?: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface BrowserPushConfigResolverOptions {
|
|
31
|
+
readonly identity: BrowserPushClientIdentity;
|
|
32
|
+
/**
|
|
33
|
+
* Origin of the yurucommu-compatible API that owns the registration. Returns
|
|
34
|
+
* null when it cannot be determined (e.g. SSR, or an unconfigured shell), in
|
|
35
|
+
* which case config resolution yields null.
|
|
36
|
+
*/
|
|
37
|
+
readonly resolveServerOrigin: () => string | null;
|
|
38
|
+
/**
|
|
39
|
+
* Build-time fallback used only while a server rolls forward to 3.2.0; a
|
|
40
|
+
* responding runtime config always wins.
|
|
41
|
+
*/
|
|
42
|
+
readonly buildTimeValues?: () => BrowserPushRuntimeValues | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface BrowserPushConfigResolver {
|
|
46
|
+
/** Build config from build-time values only (no network). */
|
|
47
|
+
readonly buildTimeConfig: () => BrowserNotificationPushConfig | null;
|
|
48
|
+
/**
|
|
49
|
+
* Resolve config from the server's runtime pusher config, falling back to
|
|
50
|
+
* build-time values if the server is unreachable or has push disabled.
|
|
51
|
+
*/
|
|
52
|
+
readonly resolveConfig: () => Promise<BrowserNotificationPushConfig | null>;
|
|
53
|
+
/**
|
|
54
|
+
* Invalidate this device's endpoint before sign-out / account teardown,
|
|
55
|
+
* disabling via the resolved config when possible and always clearing the
|
|
56
|
+
* local subscription so a signed-out device is never woken.
|
|
57
|
+
*/
|
|
58
|
+
readonly clearBeforeSignOut: () => Promise<void>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build a browser push config resolver for a single client identity. Promoted
|
|
63
|
+
* from the near-identical per-app resolvers in the yurucommu and yurume web
|
|
64
|
+
* clients so the fetch/fallback/clear flow lives in one place.
|
|
65
|
+
*/
|
|
66
|
+
export function createBrowserPushConfigResolver(
|
|
67
|
+
options: BrowserPushConfigResolverOptions,
|
|
68
|
+
): BrowserPushConfigResolver {
|
|
69
|
+
const { identity, resolveServerOrigin, buildTimeValues } = options;
|
|
70
|
+
|
|
71
|
+
const build = (
|
|
72
|
+
values: BrowserPushRuntimeValues | null | undefined,
|
|
73
|
+
): BrowserNotificationPushConfig | null => {
|
|
74
|
+
const gatewayUrl = values?.gatewayUrl?.trim();
|
|
75
|
+
const vapidPublicKey = values?.vapidPublicKey?.trim();
|
|
76
|
+
if (!gatewayUrl || !vapidPublicKey) return null;
|
|
77
|
+
const serverOrigin = resolveServerOrigin();
|
|
78
|
+
if (!serverOrigin) return null;
|
|
79
|
+
return {
|
|
80
|
+
product: identity.product,
|
|
81
|
+
appId: identity.appId,
|
|
82
|
+
appDisplayName: identity.appDisplayName,
|
|
83
|
+
serverOrigin,
|
|
84
|
+
gatewayUrl,
|
|
85
|
+
vapidPublicKey,
|
|
86
|
+
serviceWorkerPath: identity.serviceWorkerPath,
|
|
87
|
+
...(identity.scope ? { scope: identity.scope } : {}),
|
|
88
|
+
...(identity.lang ? { lang: identity.lang } : {}),
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const buildTimeConfig = (): BrowserNotificationPushConfig | null =>
|
|
93
|
+
build(buildTimeValues?.() ?? null);
|
|
94
|
+
|
|
95
|
+
const resolveConfig =
|
|
96
|
+
async (): Promise<BrowserNotificationPushConfig | null> => {
|
|
97
|
+
try {
|
|
98
|
+
const runtime = await fetchNotificationPusherPublicConfig();
|
|
99
|
+
if (
|
|
100
|
+
!runtime.enabled ||
|
|
101
|
+
!runtime.gateway_url ||
|
|
102
|
+
!runtime.web_push_public_key
|
|
103
|
+
) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
return build({
|
|
107
|
+
gatewayUrl: runtime.gateway_url,
|
|
108
|
+
vapidPublicKey: runtime.web_push_public_key,
|
|
109
|
+
});
|
|
110
|
+
} catch {
|
|
111
|
+
// Compatibility with older servers while they roll forward. Build-time
|
|
112
|
+
// public values are a fallback only; a responding runtime config is the
|
|
113
|
+
// deployment authority.
|
|
114
|
+
return buildTimeConfig();
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const clearBeforeSignOut = async (): Promise<void> => {
|
|
119
|
+
const config = await resolveConfig().catch(() => null);
|
|
120
|
+
if (config) {
|
|
121
|
+
try {
|
|
122
|
+
await disableBrowserNotificationPush(config);
|
|
123
|
+
return;
|
|
124
|
+
} catch {
|
|
125
|
+
// Fall through to local endpoint invalidation.
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
await clearBrowserNotificationPush(identity).catch(() => undefined);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
return { buildTimeConfig, resolveConfig, clearBeforeSignOut };
|
|
132
|
+
}
|
|
@@ -7,6 +7,9 @@ export * from "./api/posts.ts";
|
|
|
7
7
|
export * from "./api/communities.ts";
|
|
8
8
|
export * from "./api/dm.ts";
|
|
9
9
|
export * from "./api/notifications.ts";
|
|
10
|
+
export * from "./api/notification-target.ts";
|
|
11
|
+
export * from "./api/browser-push.ts";
|
|
12
|
+
export * from "./api/push-config.ts";
|
|
10
13
|
export * from "./api/search.ts";
|
|
11
14
|
export * from "./api/media.ts";
|
|
12
15
|
export * from "./api/stories.ts";
|
|
@@ -16,6 +16,11 @@ export interface SocialServerDiscovery {
|
|
|
16
16
|
readonly defaultEntry: "feed" | "messages";
|
|
17
17
|
}[];
|
|
18
18
|
readonly issuer: string;
|
|
19
|
+
readonly oidcClientId?: string;
|
|
20
|
+
readonly auth?: {
|
|
21
|
+
readonly oidc: boolean;
|
|
22
|
+
readonly password: boolean;
|
|
23
|
+
};
|
|
19
24
|
readonly apiBaseUrl: string;
|
|
20
25
|
readonly activitypubOrigin: string;
|
|
21
26
|
readonly mediaOrigin: string;
|
|
@@ -24,10 +29,14 @@ export interface SocialServerDiscovery {
|
|
|
24
29
|
readonly endpoints: {
|
|
25
30
|
readonly api: string;
|
|
26
31
|
readonly authProviders: string;
|
|
32
|
+
readonly mobilePasswordLogin: string;
|
|
33
|
+
readonly mobileOidcExchange: string;
|
|
27
34
|
readonly currentUser: string;
|
|
28
35
|
readonly timeline: string;
|
|
29
36
|
readonly conversations: string;
|
|
30
37
|
readonly notifications: string;
|
|
38
|
+
readonly notificationPushers: string;
|
|
39
|
+
/** @deprecated Use notificationPushers. */
|
|
31
40
|
readonly mobilePushRegistrations: string;
|
|
32
41
|
};
|
|
33
42
|
}
|
|
@@ -102,6 +102,8 @@ export interface DMMessage {
|
|
|
102
102
|
id: string;
|
|
103
103
|
sender: DMSender;
|
|
104
104
|
content: string;
|
|
105
|
+
/** Media attachments (image/video), same shape as post attachments. */
|
|
106
|
+
attachments?: MediaAttachment[];
|
|
105
107
|
created_at: string;
|
|
106
108
|
}
|
|
107
109
|
|
|
@@ -120,10 +122,56 @@ export interface Notification {
|
|
|
120
122
|
type: "follow" | "follow_request" | "like" | "announce" | "reply" | "mention";
|
|
121
123
|
actor: NotificationActor;
|
|
122
124
|
object_ap_id: string | null;
|
|
125
|
+
/**
|
|
126
|
+
* Navigation target for the notification. OPTIONAL: a server older than
|
|
127
|
+
* 3.2.0 omits these fields, and `normalizeNotification` then synthesizes them
|
|
128
|
+
* from `object_ap_id`. Prefer `target_kind` + `target_id` and build your own
|
|
129
|
+
* in-app path; `target_url` is shaped for the yurucommu web client's routing
|
|
130
|
+
* and is same-origin only — never treat it as an external URL.
|
|
131
|
+
*/
|
|
132
|
+
target_kind?: NotificationTargetKind;
|
|
133
|
+
target_id?: string | null;
|
|
134
|
+
target_url?: string;
|
|
123
135
|
read: boolean;
|
|
124
136
|
created_at: string;
|
|
125
137
|
}
|
|
126
138
|
|
|
139
|
+
export type NotificationTargetKind =
|
|
140
|
+
"post" | "story" | "profile" | "notifications";
|
|
141
|
+
|
|
142
|
+
export type NotificationPusherProduct = "yurucommu" | "yurume";
|
|
143
|
+
|
|
144
|
+
export interface NotificationPusherInput {
|
|
145
|
+
kind: "http";
|
|
146
|
+
app_id: string;
|
|
147
|
+
pushkey: string;
|
|
148
|
+
app_display_name?: string;
|
|
149
|
+
device_display_name?: string;
|
|
150
|
+
profile_tag?: string;
|
|
151
|
+
lang?: string;
|
|
152
|
+
data: {
|
|
153
|
+
url: string;
|
|
154
|
+
format?: "event_id_only" | "full";
|
|
155
|
+
[key: string]: unknown;
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface NotificationPusherRegistration {
|
|
160
|
+
id: string;
|
|
161
|
+
kind: "http";
|
|
162
|
+
app_id: string;
|
|
163
|
+
app_display_name?: string;
|
|
164
|
+
device_display_name?: string;
|
|
165
|
+
profile_tag?: string;
|
|
166
|
+
lang?: string;
|
|
167
|
+
data: Record<string, unknown>;
|
|
168
|
+
gateway_url: string;
|
|
169
|
+
product: NotificationPusherProduct;
|
|
170
|
+
scope: string | null;
|
|
171
|
+
registered_at: string;
|
|
172
|
+
last_seen_at: string;
|
|
173
|
+
}
|
|
174
|
+
|
|
127
175
|
// Story attachment (image or video)
|
|
128
176
|
export interface StoryAttachment {
|
|
129
177
|
type: string; // "Document" or "Video"
|
package/src/backend/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Hono, type Context } from "hono";
|
|
2
2
|
import { MOBILE_PUSH_REGISTRATION_PATH } from "./lib/mobile-contract.ts";
|
|
3
|
+
import { NOTIFICATION_PUSHER_REGISTRATION_PATH } from "./lib/notification-pusher-contract.ts";
|
|
3
4
|
import type { Env, EnvVars, Variables } from "./types.ts";
|
|
4
5
|
import { extractActorFromSession } from "./lib/session-actor.ts";
|
|
5
6
|
import { isBackendPath } from "./lib/backend-paths.ts";
|
|
@@ -27,6 +28,7 @@ import recommendationsRoutes from "./routes/recommendations.ts";
|
|
|
27
28
|
import { moderationRoutes } from "./routes/moderation.ts";
|
|
28
29
|
import { appsApiRoutes, appsServeRoutes } from "./routes/apps.ts";
|
|
29
30
|
import mobileRoutes from "./routes/mobile.ts";
|
|
31
|
+
import notificationPusherRoutes from "./routes/notification-pushers.ts";
|
|
30
32
|
|
|
31
33
|
import { rateLimit, RateLimitConfigs } from "./middleware/rate-limit.ts";
|
|
32
34
|
import { csrfProtection } from "./middleware/csrf.ts";
|
|
@@ -38,6 +40,7 @@ import {
|
|
|
38
40
|
import { logger } from "./lib/logger.ts";
|
|
39
41
|
|
|
40
42
|
const log = logger.child({ component: "backend.index" });
|
|
43
|
+
let lastNotificationPushRecoverySweep = 0;
|
|
41
44
|
import type { MessageBatch } from "@cloudflare/workers-types";
|
|
42
45
|
import type {
|
|
43
46
|
DeliveryDlqMessageV1,
|
|
@@ -47,6 +50,7 @@ import {
|
|
|
47
50
|
handleDeliveryDlqBatch,
|
|
48
51
|
handleDeliveryQueueBatch,
|
|
49
52
|
} from "./lib/delivery/queue.ts";
|
|
53
|
+
import { enqueuePendingNotificationPushJobs } from "./lib/notification-push.ts";
|
|
50
54
|
|
|
51
55
|
type YurucommuApp = Hono<{ Bindings: Env; Variables: Variables }>;
|
|
52
56
|
|
|
@@ -106,6 +110,7 @@ const DEFAULT_DISCOVERY_OPTIONS = {
|
|
|
106
110
|
"activitypub.server.v1",
|
|
107
111
|
"client.yurucommu.feed.v1",
|
|
108
112
|
"client.yurume.messages.v1",
|
|
113
|
+
"notification.pushers.v1",
|
|
109
114
|
],
|
|
110
115
|
} satisfies Required<YurucommuBackendDiscoveryOptionsV1>;
|
|
111
116
|
|
|
@@ -214,6 +219,7 @@ function buildSocialServerDiscovery(
|
|
|
214
219
|
appUrl: string,
|
|
215
220
|
issuer: string,
|
|
216
221
|
options: YurucommuBackendDiscoveryOptionsV1 = {},
|
|
222
|
+
auth: { oidcClientId?: string; passwordEnabled?: boolean } = {},
|
|
217
223
|
) {
|
|
218
224
|
const discovery = {
|
|
219
225
|
...DEFAULT_DISCOVERY_OPTIONS,
|
|
@@ -233,6 +239,11 @@ function buildSocialServerDiscovery(
|
|
|
233
239
|
},
|
|
234
240
|
clients: discovery.clients,
|
|
235
241
|
issuer,
|
|
242
|
+
oidcClientId: auth.oidcClientId,
|
|
243
|
+
auth: {
|
|
244
|
+
oidc: Boolean(auth.oidcClientId),
|
|
245
|
+
password: Boolean(auth.passwordEnabled),
|
|
246
|
+
},
|
|
236
247
|
apiBaseUrl: appUrl,
|
|
237
248
|
activitypubOrigin: appUrl,
|
|
238
249
|
mediaOrigin: `${appUrl}/media`,
|
|
@@ -241,10 +252,14 @@ function buildSocialServerDiscovery(
|
|
|
241
252
|
endpoints: {
|
|
242
253
|
api: `${appUrl}/api`,
|
|
243
254
|
authProviders: `${appUrl}/api/auth/providers`,
|
|
255
|
+
mobilePasswordLogin: `${appUrl}/api/auth/mobile/login`,
|
|
256
|
+
mobileOidcExchange: `${appUrl}/api/auth/mobile/oidc`,
|
|
244
257
|
currentUser: `${appUrl}/api/auth/me`,
|
|
245
258
|
timeline: `${appUrl}/api/timeline`,
|
|
246
259
|
conversations: `${appUrl}/api/dm/contacts`,
|
|
247
260
|
notifications: `${appUrl}/api/notifications`,
|
|
261
|
+
notificationPushers: `${appUrl}${NOTIFICATION_PUSHER_REGISTRATION_PATH}`,
|
|
262
|
+
// Retained for older mobile clients. New clients use notificationPushers.
|
|
248
263
|
mobilePushRegistrations: `${appUrl}${MOBILE_PUSH_REGISTRATION_PATH}`,
|
|
249
264
|
},
|
|
250
265
|
};
|
|
@@ -324,9 +339,19 @@ function mountReadinessRoutes(
|
|
|
324
339
|
) => {
|
|
325
340
|
const appUrl = normalizeOrigin(c.env.APP_URL, c.req.url);
|
|
326
341
|
const issuer = getOidcIssuerUrl(c.env) ?? appUrl;
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
342
|
+
const { clientId } = getOidcClientCredentials(c.env);
|
|
343
|
+
return c.json(
|
|
344
|
+
buildSocialServerDiscovery(appUrl, issuer, discovery, {
|
|
345
|
+
oidcClientId: getOidcIssuerUrl(c.env)
|
|
346
|
+
? clientId || undefined
|
|
347
|
+
: undefined,
|
|
348
|
+
passwordEnabled: Boolean(c.env.AUTH_PASSWORD_HASH?.trim()),
|
|
349
|
+
}),
|
|
350
|
+
200,
|
|
351
|
+
{
|
|
352
|
+
"Cache-Control": "public, max-age=300",
|
|
353
|
+
},
|
|
354
|
+
);
|
|
330
355
|
};
|
|
331
356
|
|
|
332
357
|
app.get("/.well-known/yurucommu", wellKnownSocialServer);
|
|
@@ -545,6 +570,44 @@ function applyGlobalMiddleware(app: YurucommuApp): void {
|
|
|
545
570
|
app.use("*", async (c, next) => {
|
|
546
571
|
c.set("db", c.env.DB_INSTANCE);
|
|
547
572
|
await next();
|
|
573
|
+
|
|
574
|
+
// Every unread inbox insert is captured by the DB outbox trigger. Flush it
|
|
575
|
+
// after the request instead of wiring every follow/like/story/DM write path
|
|
576
|
+
// separately (which is both leak- and duplicate-prone). Queue binding is
|
|
577
|
+
// optional; when absent this is an immediate no-op and the durable rows stay
|
|
578
|
+
// pending until a correctly configured runtime handles later traffic.
|
|
579
|
+
const method = c.req.method.toUpperCase();
|
|
580
|
+
const now = Date.now();
|
|
581
|
+
const mutating = !["GET", "HEAD", "OPTIONS"].includes(method);
|
|
582
|
+
const recoveryDue = now - lastNotificationPushRecoverySweep >= 60_000;
|
|
583
|
+
if (mutating || recoveryDue) {
|
|
584
|
+
if (recoveryDue) lastNotificationPushRecoverySweep = now;
|
|
585
|
+
const sweep = (async () => {
|
|
586
|
+
try {
|
|
587
|
+
await enqueuePendingNotificationPushJobs(c.env);
|
|
588
|
+
} catch (error) {
|
|
589
|
+
log.error("Failed to enqueue notification push outbox", {
|
|
590
|
+
event: "notification.push.enqueue_failed",
|
|
591
|
+
error,
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
})();
|
|
595
|
+
// Prefer to run the sweep AFTER the response is sent (waitUntil) so its
|
|
596
|
+
// 2-4 D1 round-trips never add latency to the request. `executionCtx`
|
|
597
|
+
// throws when no runtime context exists (tests / plain fetch); fall back
|
|
598
|
+
// to awaiting inline there.
|
|
599
|
+
let deferred = false;
|
|
600
|
+
try {
|
|
601
|
+
const ctx = c.executionCtx;
|
|
602
|
+
if (ctx && typeof ctx.waitUntil === "function") {
|
|
603
|
+
ctx.waitUntil(sweep);
|
|
604
|
+
deferred = true;
|
|
605
|
+
}
|
|
606
|
+
} catch {
|
|
607
|
+
// No execution context; await inline below.
|
|
608
|
+
}
|
|
609
|
+
if (!deferred) await sweep;
|
|
610
|
+
}
|
|
548
611
|
});
|
|
549
612
|
|
|
550
613
|
app.use("/api/*", async (c, next) => {
|
|
@@ -690,6 +753,7 @@ function mountCoreRoutes(app: YurucommuApp): void {
|
|
|
690
753
|
});
|
|
691
754
|
|
|
692
755
|
app.route("/api/notifications", notificationsRoutes);
|
|
756
|
+
app.route("/api/notifications/pushers", notificationPusherRoutes);
|
|
693
757
|
app.route("/api/mobile", mobileRoutes);
|
|
694
758
|
app.route("/api/stories", storiesRoutes);
|
|
695
759
|
app.route("/api/search", searchRoutes);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared attachment bounds + validation.
|
|
3
|
+
*
|
|
4
|
+
* Owner of the attachment payload limits used by every write surface (post
|
|
5
|
+
* create, DM messages, community chat) and by inbound-federation bounding.
|
|
6
|
+
* Route files must import from here instead of copy-pasting the checks or
|
|
7
|
+
* reaching into another route's transformers module.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Bound the attachments payload. An attachment is an open-ended record, so cap
|
|
11
|
+
// both the COUNT and the serialized SIZE — the size cap bounds row/federated-doc
|
|
12
|
+
// bloat regardless of internal shape (key count / field length). 16 KiB is ample
|
|
13
|
+
// for MAX_ATTACHMENTS media descriptors with alt text + blurhash.
|
|
14
|
+
export const MAX_ATTACHMENTS = 8;
|
|
15
|
+
export const MAX_ATTACHMENTS_JSON_LENGTH = 16 * 1024;
|
|
16
|
+
|
|
17
|
+
/** Drop an oversized inbound attachments blob to "[]" rather than store it. */
|
|
18
|
+
export function boundAttachmentsJson(json: string): string {
|
|
19
|
+
return json.length > MAX_ATTACHMENTS_JSON_LENGTH ? "[]" : json;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ChatAttachment = Record<string, unknown>;
|
|
23
|
+
|
|
24
|
+
export type ChatAttachmentsResult =
|
|
25
|
+
| { readonly ok: true; readonly attachments: ChatAttachment[] }
|
|
26
|
+
| { readonly ok: false; readonly error: string };
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Validate a chat message's attachments array (mirrors the post-create bounds:
|
|
30
|
+
* records only, capped count + serialized size). Returns the validated array
|
|
31
|
+
* ([] when absent) or an error message. Used by both the DM and community-chat
|
|
32
|
+
* send routes so the two cannot drift.
|
|
33
|
+
*/
|
|
34
|
+
export function validateChatAttachments(raw: unknown): ChatAttachmentsResult {
|
|
35
|
+
if (raw === undefined || raw === null) return { ok: true, attachments: [] };
|
|
36
|
+
if (!Array.isArray(raw)) {
|
|
37
|
+
return { ok: false, error: "attachments must be an array" };
|
|
38
|
+
}
|
|
39
|
+
if (raw.some((a) => !a || typeof a !== "object" || Array.isArray(a))) {
|
|
40
|
+
return { ok: false, error: "attachments must be objects" };
|
|
41
|
+
}
|
|
42
|
+
if (raw.length > MAX_ATTACHMENTS) {
|
|
43
|
+
return {
|
|
44
|
+
ok: false,
|
|
45
|
+
error: `Too many attachments (max ${MAX_ATTACHMENTS})`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (JSON.stringify(raw).length > MAX_ATTACHMENTS_JSON_LENGTH) {
|
|
49
|
+
return { ok: false, error: "attachments payload too large" };
|
|
50
|
+
}
|
|
51
|
+
return { ok: true, attachments: raw as ChatAttachment[] };
|
|
52
|
+
}
|