@takosjp/yurucommu-core 3.2.0 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/migrations/0019_notification_push_delivery.sql +10 -7
- package/migrations/0020_call_sessions.sql +23 -0
- package/package.json +6 -2
- package/packages/api/package.json +1 -1
- package/packages/api/src/index.ts +1 -0
- 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/lib/rtc-client.ts +542 -0
- package/packages/api/src/social-server.ts +7 -0
- package/packages/api/src/types/call.ts +306 -0
- package/packages/api/src/types/index.ts +16 -4
- package/src/backend/index.ts +65 -10
- 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/rtc/call-store.ts +101 -0
- package/src/backend/lib/rtc/provider.ts +135 -0
- package/src/backend/lib/rtc/signal-transport.ts +125 -0
- 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/public.ts +3 -0
- package/src/backend/routes/account-teardown.ts +13 -0
- package/src/backend/routes/activitypub.ts +5 -1
- package/src/backend/routes/auth-helpers.ts +10 -7
- package/src/backend/routes/auth.ts +131 -3
- 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/backend/routes/rtc/index.ts +147 -0
- package/src/backend/runtime/call-hub-core.ts +470 -0
- package/src/backend/runtime/call-hub-port.ts +78 -0
- package/src/backend/runtime/call-signaling-do.ts +242 -0
- package/src/backend/runtime/signaling-hub.ts +187 -0
- package/src/backend/types.ts +28 -0
- package/src/db/index.ts +11 -10
- package/src/db/schema/calls.ts +46 -0
- package/src/db/schema/index.ts +1 -0
- package/src/db/schema/mobile.ts +7 -9
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RtcProvider — media-plane configuration for the call feature.
|
|
3
|
+
*
|
|
4
|
+
* This is the seam that keeps calling vendor-neutral: 1:1 calls are pure P2P
|
|
5
|
+
* WebRTC over the operator-configured STUN/TURN servers (no SFU, no Cloudflare),
|
|
6
|
+
* and group calls (Phase 3) select a WHIP/WHEP SFU "focus" whose backend is any
|
|
7
|
+
* of `whip` / `livekit` / `cloudflare-realtime` — Cloudflare Realtime is one
|
|
8
|
+
* adapter among equals, never required.
|
|
9
|
+
*
|
|
10
|
+
* TURN credentials, when coturn's REST scheme is configured, are minted
|
|
11
|
+
* per-request as short-lived HMAC creds (RFC 8489 long-term-credential via the
|
|
12
|
+
* `turn-rest` `timestamp:name` username convention) — no static long-lived
|
|
13
|
+
* secret is ever handed to a client.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type {
|
|
17
|
+
CallMediaKind,
|
|
18
|
+
IceServerConfig,
|
|
19
|
+
SfuFocus,
|
|
20
|
+
} from "../../../../packages/api/src/types/call.ts";
|
|
21
|
+
import type { EnvVars } from "../../types.ts";
|
|
22
|
+
import { bufferToBase64 } from "../base64.ts";
|
|
23
|
+
import { logger } from "../logger.ts";
|
|
24
|
+
|
|
25
|
+
const log = logger.child({ component: "rtc.provider" });
|
|
26
|
+
|
|
27
|
+
const DEFAULT_TURN_TTL_SECONDS = 3600;
|
|
28
|
+
|
|
29
|
+
export interface RtcProvider {
|
|
30
|
+
/** ICE (STUN/TURN) servers for a call. Fresh (short-lived) TURN creds. */
|
|
31
|
+
getIceServers(): Promise<IceServerConfig[]>;
|
|
32
|
+
/** Group-call SFU focus, or null for pure P2P (always null for 1:1). */
|
|
33
|
+
getSfuFocus(media: CallMediaKind): Promise<SfuFocus | null>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseStaticIceServers(raw: string | undefined): IceServerConfig[] {
|
|
37
|
+
if (!raw?.trim()) return [];
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(raw);
|
|
40
|
+
if (!Array.isArray(parsed)) return [];
|
|
41
|
+
const out: IceServerConfig[] = [];
|
|
42
|
+
for (const entry of parsed) {
|
|
43
|
+
if (!entry || typeof entry !== "object") continue;
|
|
44
|
+
const urls = (entry as { urls?: unknown }).urls;
|
|
45
|
+
if (typeof urls !== "string" && !Array.isArray(urls)) continue;
|
|
46
|
+
const server: IceServerConfig = { urls: urls as string | string[] };
|
|
47
|
+
const username = (entry as { username?: unknown }).username;
|
|
48
|
+
const credential = (entry as { credential?: unknown }).credential;
|
|
49
|
+
if (typeof username === "string") server.username = username;
|
|
50
|
+
if (typeof credential === "string") server.credential = credential;
|
|
51
|
+
out.push(server);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
} catch (err) {
|
|
55
|
+
log.warn("Invalid YURUCOMMU_RTC_ICE_SERVERS JSON", { error: String(err) });
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function mintTurnCredential(
|
|
61
|
+
uris: string[],
|
|
62
|
+
secret: string,
|
|
63
|
+
ttlSeconds: number,
|
|
64
|
+
): Promise<IceServerConfig | null> {
|
|
65
|
+
if (uris.length === 0 || !secret) return null;
|
|
66
|
+
// coturn REST: username = "<unix-expiry>:<name>", credential = base64(HMAC-
|
|
67
|
+
// SHA1(secret, username)). A random name keeps creds unlinkable per call.
|
|
68
|
+
const expiry = Math.floor(Date.now() / 1000) + ttlSeconds;
|
|
69
|
+
const username = `${expiry}:yurucommu`;
|
|
70
|
+
try {
|
|
71
|
+
const key = await crypto.subtle.importKey(
|
|
72
|
+
"raw",
|
|
73
|
+
new TextEncoder().encode(secret),
|
|
74
|
+
{ name: "HMAC", hash: "SHA-1" },
|
|
75
|
+
false,
|
|
76
|
+
["sign"],
|
|
77
|
+
);
|
|
78
|
+
const mac = await crypto.subtle.sign(
|
|
79
|
+
"HMAC",
|
|
80
|
+
key,
|
|
81
|
+
new TextEncoder().encode(username),
|
|
82
|
+
);
|
|
83
|
+
return { urls: uris, username, credential: bufferToBase64(mac) };
|
|
84
|
+
} catch (err) {
|
|
85
|
+
log.error("Failed to mint TURN credential", { error: String(err) });
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
class ConfiguredRtcProvider implements RtcProvider {
|
|
91
|
+
constructor(private readonly env: EnvVars) {}
|
|
92
|
+
|
|
93
|
+
async getIceServers(): Promise<IceServerConfig[]> {
|
|
94
|
+
const servers = parseStaticIceServers(this.env.YURUCOMMU_RTC_ICE_SERVERS);
|
|
95
|
+
const uris = (this.env.YURUCOMMU_RTC_TURN_URIS ?? "")
|
|
96
|
+
.split(",")
|
|
97
|
+
.map((s) => s.trim())
|
|
98
|
+
.filter(Boolean);
|
|
99
|
+
const secret = this.env.YURUCOMMU_RTC_TURN_SECRET?.trim();
|
|
100
|
+
if (uris.length > 0 && secret) {
|
|
101
|
+
const ttl =
|
|
102
|
+
Number.parseInt(this.env.YURUCOMMU_RTC_TURN_TTL ?? "", 10) ||
|
|
103
|
+
DEFAULT_TURN_TTL_SECONDS;
|
|
104
|
+
const turn = await mintTurnCredential(uris, secret, ttl);
|
|
105
|
+
if (turn) servers.push(turn);
|
|
106
|
+
}
|
|
107
|
+
return servers;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async getSfuFocus(_media: CallMediaKind): Promise<SfuFocus | null> {
|
|
111
|
+
const adapter = (this.env.YURUCOMMU_RTC_SFU_ADAPTER ?? "p2p")
|
|
112
|
+
.trim()
|
|
113
|
+
.toLowerCase();
|
|
114
|
+
if (adapter === "" || adapter === "p2p") return null;
|
|
115
|
+
const url = this.env.YURUCOMMU_RTC_SFU_URL?.trim();
|
|
116
|
+
if (!url) {
|
|
117
|
+
log.warn("SFU adapter selected but YURUCOMMU_RTC_SFU_URL unset", {
|
|
118
|
+
adapter,
|
|
119
|
+
});
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
// WHIP/WHEP focus passthrough. Real per-room token minting (LiveKit JWT,
|
|
123
|
+
// Cloudflare Realtime app tokens) lands with group calls (Phase 3); today
|
|
124
|
+
// the shared/static token (if any) is advertised as-is.
|
|
125
|
+
return {
|
|
126
|
+
kind: adapter,
|
|
127
|
+
url,
|
|
128
|
+
token: this.env.YURUCOMMU_RTC_SFU_TOKEN?.trim() || undefined,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function createRtcProvider(env: EnvVars): RtcProvider {
|
|
134
|
+
return new ConfiguredRtcProvider(env);
|
|
135
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-instance call signaling transport (server-to-server).
|
|
3
|
+
*
|
|
4
|
+
* Signaling for a call between two self-hosted instances travels as an
|
|
5
|
+
* HTTP-Signature-signed POST to the peer instance's `/ap/rtc/signal` endpoint.
|
|
6
|
+
* This deliberately does NOT use the queued federation delivery path
|
|
7
|
+
* (`enqueueDeliveryToActor`, with its circuit-breaker + retry/backoff): an SDP
|
|
8
|
+
* offer or ICE candidate is ephemeral and latency-sensitive, so a stale retry is
|
|
9
|
+
* useless. We reuse the same low-level signing (`signRequest`) + SSRF-guarded
|
|
10
|
+
* fetch (`fetchWithTimeout`) primitives the delivery worker uses, but send
|
|
11
|
+
* synchronously and directly. It also does NOT go through the inbox activity
|
|
12
|
+
* pipeline (`claimActivityForDispatch` / `parseActivity`), which would both strip
|
|
13
|
+
* the SDP/ICE fields and pollute the `activities` ledger with ephemeral frames.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { eq } from "drizzle-orm";
|
|
17
|
+
import type { Database } from "../../../db/index.ts";
|
|
18
|
+
import { actorCache } from "../../../db/index.ts";
|
|
19
|
+
import type { RtcSignalEnvelopeV1 } from "../../../../packages/api/src/types/call.ts";
|
|
20
|
+
import { signRequest } from "../ap-signing.ts";
|
|
21
|
+
import { fetchWithTimeout } from "../federation-fetch.ts";
|
|
22
|
+
import { fetchAndUpsertActorCache } from "../activitypub-actor-cache.ts";
|
|
23
|
+
import { isSafeRemoteUrl } from "../ssrf.ts";
|
|
24
|
+
import { logger } from "../logger.ts";
|
|
25
|
+
|
|
26
|
+
const log = logger.child({ component: "rtc.signal-transport" });
|
|
27
|
+
|
|
28
|
+
const SIGNAL_PATH = "/ap/rtc/signal";
|
|
29
|
+
const SIGNAL_TIMEOUT_MS = 8000;
|
|
30
|
+
|
|
31
|
+
export interface CallSigner {
|
|
32
|
+
apId: string;
|
|
33
|
+
privateKeyPem: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Derive a peer's signaling endpoint from a cached actor row. */
|
|
37
|
+
function endpointFromActorRow(row: {
|
|
38
|
+
inbox: string;
|
|
39
|
+
rawJson: string;
|
|
40
|
+
}): string | null {
|
|
41
|
+
// Prefer an explicitly advertised endpoint (actor `endpoints.rtcSignal`).
|
|
42
|
+
try {
|
|
43
|
+
const doc = JSON.parse(row.rawJson) as {
|
|
44
|
+
endpoints?: { rtcSignal?: unknown };
|
|
45
|
+
};
|
|
46
|
+
const advertised = doc.endpoints?.rtcSignal;
|
|
47
|
+
if (typeof advertised === "string" && isSafeRemoteUrl(advertised)) {
|
|
48
|
+
return advertised;
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// fall through to inbox-origin derivation
|
|
52
|
+
}
|
|
53
|
+
// Fall back to `<inbox-origin>/ap/rtc/signal` (every yurucommu instance
|
|
54
|
+
// serves this path). Peers that are not yurucommu simply won't answer.
|
|
55
|
+
try {
|
|
56
|
+
const origin = new URL(row.inbox).origin;
|
|
57
|
+
const endpoint = `${origin}${SIGNAL_PATH}`;
|
|
58
|
+
return isSafeRemoteUrl(endpoint) ? endpoint : null;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Resolve (and cache) the peer instance's signaling endpoint URL. */
|
|
65
|
+
export async function resolvePeerSignalEndpoint(
|
|
66
|
+
db: Database,
|
|
67
|
+
peerApId: string,
|
|
68
|
+
): Promise<string | null> {
|
|
69
|
+
let row = await db.query.actorCache.findFirst({
|
|
70
|
+
where: eq(actorCache.apId, peerApId),
|
|
71
|
+
columns: { inbox: true, rawJson: true },
|
|
72
|
+
});
|
|
73
|
+
if (!row) {
|
|
74
|
+
const result = await fetchAndUpsertActorCache(db, peerApId, {});
|
|
75
|
+
if (result.ok) {
|
|
76
|
+
row = { inbox: result.row.inbox, rawJson: result.row.rawJson };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!row) return null;
|
|
80
|
+
return endpointFromActorRow(row);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Sign + POST a signaling envelope to the peer instance. Throws on any
|
|
85
|
+
* unreachable / non-2xx outcome so the caller can fail the call fast.
|
|
86
|
+
*/
|
|
87
|
+
export async function sendCallSignal(
|
|
88
|
+
db: Database,
|
|
89
|
+
signer: CallSigner,
|
|
90
|
+
envelope: RtcSignalEnvelopeV1,
|
|
91
|
+
peerSignalEndpoint?: string,
|
|
92
|
+
): Promise<void> {
|
|
93
|
+
const endpoint =
|
|
94
|
+
peerSignalEndpoint ?? (await resolvePeerSignalEndpoint(db, envelope.to));
|
|
95
|
+
if (!endpoint) {
|
|
96
|
+
throw new Error(`no signaling endpoint for ${envelope.to}`);
|
|
97
|
+
}
|
|
98
|
+
const body = JSON.stringify(envelope);
|
|
99
|
+
const keyId = `${signer.apId}#main-key`;
|
|
100
|
+
const signed = await signRequest(
|
|
101
|
+
signer.privateKeyPem,
|
|
102
|
+
keyId,
|
|
103
|
+
"POST",
|
|
104
|
+
endpoint,
|
|
105
|
+
body,
|
|
106
|
+
);
|
|
107
|
+
const res = await fetchWithTimeout(endpoint, {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: {
|
|
110
|
+
...signed,
|
|
111
|
+
"Content-Type": "application/activity+json",
|
|
112
|
+
Accept: "application/json",
|
|
113
|
+
},
|
|
114
|
+
body,
|
|
115
|
+
timeout: SIGNAL_TIMEOUT_MS,
|
|
116
|
+
});
|
|
117
|
+
if (!res.ok) {
|
|
118
|
+
log.warn("Signaling POST rejected", {
|
|
119
|
+
callId: envelope.callId,
|
|
120
|
+
type: envelope.type,
|
|
121
|
+
status: res.status,
|
|
122
|
+
});
|
|
123
|
+
throw new Error(`signal POST ${res.status}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -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);
|
package/src/backend/public.ts
CHANGED
|
@@ -13,6 +13,9 @@ export { default } from "./index.ts";
|
|
|
13
13
|
export { default as app } from "./index.ts";
|
|
14
14
|
export { type Database, getDb, getDbSQLite } from "../db/index.ts";
|
|
15
15
|
export { wrapCloudflareBindings } from "./runtime/cloudflare.ts";
|
|
16
|
+
// Call feature: the signaling Durable Object class each product's generated
|
|
17
|
+
// worker entry must re-export so Wrangler can bind CALL_SIGNALING to it.
|
|
18
|
+
export { CallSignalingDurableObject } from "./runtime/call-signaling-do.ts";
|
|
16
19
|
export type { Env, EnvVars } from "./types.ts";
|
|
17
20
|
export type {
|
|
18
21
|
DeliveryDlqMessageV1,
|
|
@@ -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
|
|
|
@@ -440,9 +440,13 @@ ap.get(
|
|
|
440
440
|
following: showCollections ? actor.followingUrl : undefined,
|
|
441
441
|
// Advertise sharedInbox so remote servers can deduplicate fan-out
|
|
442
442
|
// delivery (Mastodon convention). The endpoint accepts signed
|
|
443
|
-
// activities just like the per-actor inbox.
|
|
443
|
+
// activities just like the per-actor inbox. `rtcSignal` advertises the
|
|
444
|
+
// call-signaling ingest so a caller's instance can reach this user
|
|
445
|
+
// directly (yurucommu extension; peers that don't recognize it just fall
|
|
446
|
+
// back to deriving it from the inbox origin).
|
|
444
447
|
endpoints: {
|
|
445
448
|
sharedInbox: `${baseUrl}/ap/inbox`,
|
|
449
|
+
rtcSignal: `${baseUrl}/ap/rtc/signal`,
|
|
446
450
|
},
|
|
447
451
|
publicKey: buildPublicKey(actor.apId, actor.publicKeyPem),
|
|
448
452
|
discoverable: !actor.isPrivate,
|
|
@@ -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
|
|
|
@@ -62,6 +63,12 @@ const auth = new Hono<{ Bindings: Env; Variables: Variables }>();
|
|
|
62
63
|
// abuse even from the legitimate owner session (#23).
|
|
63
64
|
const MAX_SUB_ACCOUNTS = 20;
|
|
64
65
|
|
|
66
|
+
function parsePassword(value: unknown): string | null {
|
|
67
|
+
// Passwords are opaque credentials. Trimming changes a valid secret and can
|
|
68
|
+
// make the native and browser login behavior diverge from the stored hash.
|
|
69
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
65
72
|
// 認証設定取得
|
|
66
73
|
auth.get("/providers", async (c) => {
|
|
67
74
|
const config = getAuthConfig(c.env);
|
|
@@ -75,12 +82,20 @@ auth.get("/providers", async (c) => {
|
|
|
75
82
|
});
|
|
76
83
|
});
|
|
77
84
|
|
|
85
|
+
function mobileSessionResponse(sessionId: string) {
|
|
86
|
+
return {
|
|
87
|
+
access_token: sessionId,
|
|
88
|
+
token_type: "Bearer",
|
|
89
|
+
expires_in: 30 * 24 * 60 * 60,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
78
93
|
// 現在のユーザー情報
|
|
79
94
|
auth.get("/me", async (c) => {
|
|
80
95
|
const actor = c.get("actor");
|
|
81
96
|
if (!actor) return c.json({ error: "Not authenticated" }, 401);
|
|
82
97
|
|
|
83
|
-
const sessionId =
|
|
98
|
+
const sessionId = rawSessionCredential(c);
|
|
84
99
|
let provider: string | null = null;
|
|
85
100
|
let hasTakosAccess = false;
|
|
86
101
|
|
|
@@ -142,7 +157,7 @@ auth.post("/login", async (c) => {
|
|
|
142
157
|
return c.json({ error: "Invalid request body", code: "BAD_REQUEST" }, 400);
|
|
143
158
|
}
|
|
144
159
|
|
|
145
|
-
const password =
|
|
160
|
+
const password = parsePassword(body.password);
|
|
146
161
|
if (!password) {
|
|
147
162
|
return c.json({ error: "password is required", code: "BAD_REQUEST" }, 400);
|
|
148
163
|
}
|
|
@@ -206,6 +221,119 @@ auth.post("/login", async (c) => {
|
|
|
206
221
|
return c.json({ success: true });
|
|
207
222
|
});
|
|
208
223
|
|
|
224
|
+
// Native password authentication. The credential is a host-owned session,
|
|
225
|
+
// not the password itself and not a browser cookie, so Tauri/native clients can
|
|
226
|
+
// call the same API safely from any app origin.
|
|
227
|
+
auth.post("/mobile/login", async (c) => {
|
|
228
|
+
const config = getAuthConfig(c.env);
|
|
229
|
+
if (!config.passwordEnabled) {
|
|
230
|
+
return c.json({ error: "Password auth not enabled" }, 400);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const clientIp = getClientIP(c);
|
|
234
|
+
const lockoutKey = `password:${clientIp}`;
|
|
235
|
+
const lockoutStatus = await getLoginLockoutStatus(c.env.KV, lockoutKey);
|
|
236
|
+
if (lockoutStatus.locked) {
|
|
237
|
+
c.header("Retry-After", String(lockoutStatus.retryAfterSeconds));
|
|
238
|
+
return c.json(lockoutErrorResponse(lockoutStatus.retryAfterSeconds), 429);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const body = await parseJsonObject(c);
|
|
242
|
+
const password = body ? parsePassword(body.password) : null;
|
|
243
|
+
if (!password) {
|
|
244
|
+
return c.json({ error: "password is required", code: "BAD_REQUEST" }, 400);
|
|
245
|
+
}
|
|
246
|
+
const isValid = c.env.AUTH_PASSWORD_HASH?.trim()
|
|
247
|
+
? await verifyBootstrapOrPassword(password, c.env.AUTH_PASSWORD_HASH)
|
|
248
|
+
: false;
|
|
249
|
+
if (!isValid) {
|
|
250
|
+
const failedStatus = await recordFailedLoginAttempt(c.env.KV, lockoutKey);
|
|
251
|
+
if (failedStatus.locked) {
|
|
252
|
+
c.header("Retry-After", String(failedStatus.retryAfterSeconds));
|
|
253
|
+
return c.json(lockoutErrorResponse(failedStatus.retryAfterSeconds), 429);
|
|
254
|
+
}
|
|
255
|
+
return c.json({ error: "Invalid password" }, 401);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const db = c.get("db");
|
|
259
|
+
const actorData =
|
|
260
|
+
(await db
|
|
261
|
+
.select()
|
|
262
|
+
.from(actors)
|
|
263
|
+
.where(and(eq(actors.role, "owner"), notDeleted(actors)))
|
|
264
|
+
.get()) ??
|
|
265
|
+
(await createActor(db, c.env, {
|
|
266
|
+
username: "tako",
|
|
267
|
+
name: "tako",
|
|
268
|
+
takosUserId: "password:owner",
|
|
269
|
+
role: "owner",
|
|
270
|
+
}));
|
|
271
|
+
const sessionId = await rotateSession(
|
|
272
|
+
c,
|
|
273
|
+
actorData.apId,
|
|
274
|
+
null,
|
|
275
|
+
null,
|
|
276
|
+
c.env.ENCRYPTION_KEY,
|
|
277
|
+
"mobile password login rotation",
|
|
278
|
+
{ setCookie: false },
|
|
279
|
+
);
|
|
280
|
+
await clearLoginLockout(c.env.KV, lockoutKey);
|
|
281
|
+
return c.json(mobileSessionResponse(sessionId));
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// Exchange a verified native OIDC ID token for the same host-owned session
|
|
285
|
+
// used by password login. This keeps product APIs independent from the
|
|
286
|
+
// operator's token format and makes session revocation host-local.
|
|
287
|
+
auth.post("/mobile/oidc", async (c) => {
|
|
288
|
+
const provider = getProvider(c.env, "takos");
|
|
289
|
+
if (!provider?.issuer || !provider.jwksUrl) {
|
|
290
|
+
return c.json({ error: "OIDC auth not enabled" }, 400);
|
|
291
|
+
}
|
|
292
|
+
const body = await parseJsonObject(c);
|
|
293
|
+
const idToken = body ? parseNonEmptyString(body.id_token) : undefined;
|
|
294
|
+
if (!idToken) {
|
|
295
|
+
return c.json({ error: "id_token is required", code: "BAD_REQUEST" }, 400);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
const { clientId } = getClientCredentials(c.env, "takos");
|
|
300
|
+
const claims = await verifyOidcIdToken(idToken, {
|
|
301
|
+
issuer: provider.issuer,
|
|
302
|
+
clientId,
|
|
303
|
+
jwksUrl: provider.jwksUrl,
|
|
304
|
+
});
|
|
305
|
+
const actorData = await findOrCreateOAuthActor(
|
|
306
|
+
c.get("db"),
|
|
307
|
+
c.env,
|
|
308
|
+
"takos",
|
|
309
|
+
{
|
|
310
|
+
id: claims.sub,
|
|
311
|
+
name:
|
|
312
|
+
claims.name ?? claims.preferred_username ?? claims.email ?? "user",
|
|
313
|
+
email: claims.email,
|
|
314
|
+
username: claims.preferred_username,
|
|
315
|
+
},
|
|
316
|
+
);
|
|
317
|
+
if (!actorData) return c.json({ error: "actor_creation_failed" }, 403);
|
|
318
|
+
const sessionId = await rotateSession(
|
|
319
|
+
c,
|
|
320
|
+
actorData.apId,
|
|
321
|
+
"takos",
|
|
322
|
+
null,
|
|
323
|
+
c.env.ENCRYPTION_KEY,
|
|
324
|
+
"mobile oidc login rotation",
|
|
325
|
+
{ setCookie: false },
|
|
326
|
+
);
|
|
327
|
+
return c.json(mobileSessionResponse(sessionId));
|
|
328
|
+
} catch (error) {
|
|
329
|
+
log.warn("Mobile OIDC exchange failed", {
|
|
330
|
+
event: "auth.mobile.oidc_exchange_failed",
|
|
331
|
+
error,
|
|
332
|
+
});
|
|
333
|
+
return c.json({ error: "invalid_id_token" }, 401);
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
|
|
209
337
|
// OAuth: 認証開始
|
|
210
338
|
auth.get("/login/:provider", async (c) => {
|
|
211
339
|
const providerId = c.req.param("provider");
|
|
@@ -388,7 +516,7 @@ auth.get("/callback/:provider", async (c) => {
|
|
|
388
516
|
|
|
389
517
|
// ログアウト
|
|
390
518
|
auth.post("/logout", async (c) => {
|
|
391
|
-
const sessionId =
|
|
519
|
+
const sessionId = rawSessionCredential(c);
|
|
392
520
|
if (sessionId) {
|
|
393
521
|
await deleteSessionSafely(c.get("db"), c.env, sessionId, "logout");
|
|
394
522
|
deleteCookie(c, "session");
|