nixamp 0.57.0 → 0.57.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/dist/label-profiles.js +2 -2
- package/dist/oauth.d.ts +36 -3
- package/dist/oauth.js +107 -7
- package/dist/server.js +7 -5
- package/package.json +1 -1
- package/src/label-profiles.ts +2 -2
- package/src/oauth.ts +119 -6
- package/src/server.ts +8 -4
- package/web/dist/assets/{ar-d05d_7Df.js → ar-HteUbQ_m.js} +1 -1
- package/web/dist/assets/{cs-CclrNwa7.js → cs-CjN_4DAJ.js} +1 -1
- package/web/dist/assets/{da-C30KTGl1.js → da-CzsSXVfk.js} +1 -1
- package/web/dist/assets/{de-D2A2RQwi.js → de-D7zVt1UT.js} +1 -1
- package/web/dist/assets/{es-BXFx0WZ5.js → es-D8duNQiq.js} +1 -1
- package/web/dist/assets/{fi-DPA3S8og.js → fi-CSKCYE5E.js} +1 -1
- package/web/dist/assets/{fr-D_0wnr4h.js → fr-DnjuIwaN.js} +1 -1
- package/web/dist/assets/{hi-EfiCQnb9.js → hi-ByZe82O5.js} +1 -1
- package/web/dist/assets/{hu-CK5rnxKO.js → hu-B3iwlAuW.js} +1 -1
- package/web/dist/assets/{id-BgTV_4UK.js → id-B58CS7rH.js} +1 -1
- package/web/dist/assets/{it-DGefapXn.js → it-CaVLbop_.js} +1 -1
- package/web/dist/assets/{main-C-tWhTWo.js → main-yTRNIxIl.js} +2 -2
- package/web/dist/assets/{nl-Cyst29Bp.js → nl-Dk0ss8Tf.js} +1 -1
- package/web/dist/assets/{radio-CtAne8M1.css → radio-Cmg6GPN2.css} +1 -1
- package/web/dist/assets/{radio-yD_-qpS7.js → radio-DV3h4YZS.js} +1 -1
- package/web/dist/assets/{ru-D8G6Wynm.js → ru-CGuA41ji.js} +1 -1
- package/web/dist/assets/{sv-aYnDAFwz.js → sv-B309Td6Z.js} +1 -1
- package/web/dist/assets/{uk-BDtew7z3.js → uk-CCCKvR-U.js} +1 -1
- package/web/dist/assets/{vi-CRlDn2Xh.js → vi-DfVTbK_t.js} +1 -1
- package/web/dist/assets/{zh-BCAIjeQq.js → zh-BU9_CD0f.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/radio.html +2 -2
- package/web/dist/sw.js +21 -21
package/dist/label-profiles.js
CHANGED
|
@@ -98,7 +98,7 @@ export function bandMarkdown(band) {
|
|
|
98
98
|
const MEMBERS_SYSTEM = [
|
|
99
99
|
"You cast the members of a fictional band (or the one person behind a solo act) for an AI record label.",
|
|
100
100
|
"Everyone is invented: never a real person, never a lookalike of one. Give each a full name, their role, and a look described",
|
|
101
|
-
"precisely enough that an illustrator could draw the same person every time
|
|
101
|
+
"precisely enough that an illustrator could draw the same person every time. Start every look with their gender and age (\"woman, 24, ...\"), then face, skin tone, hair, build, signature outfit and accessories.",
|
|
102
102
|
"Answer as JSON: {\"members\": [{\"name\": \"\", \"role\": \"\", \"look\": \"at most 300 characters\", \"voice\": \"how they sing or play, at most 150 characters\",",
|
|
103
103
|
"\"personality\": \"at most 150 characters\", \"influences\": \"styles and eras, never named artists, at most 120 characters\", \"bio\": \"one or two sentences\"}]}.",
|
|
104
104
|
].join(" ");
|
|
@@ -143,7 +143,7 @@ export function castLine(members) {
|
|
|
143
143
|
}
|
|
144
144
|
/** The portrait prompt for a member: the same person every time it is used as a reference. */
|
|
145
145
|
export function portraitPrompt(member, band) {
|
|
146
|
-
return `Photographic portrait of ${member.look}. ${member.role} in the ${band.genre} band ${band.name}. Head and shoulders, three-quarter view, natural expression, soft studio light, plain dark background, sharp focus, 85mm lens. No text, no logos.`;
|
|
146
|
+
return `Photographic portrait of ${member.name}: ${member.look}. ${member.role} in the ${band.genre} band ${band.name}. Head and shoulders, three-quarter view, natural expression, soft studio light, plain dark background, sharp focus, 85mm lens. No text, no logos.`;
|
|
147
147
|
}
|
|
148
148
|
/** Writes the member pages and portraits that are missing; returns the members as the pages now say. */
|
|
149
149
|
export async function writeMembers(folder, members, band, draw) {
|
package/dist/oauth.d.ts
CHANGED
|
@@ -13,22 +13,41 @@ export interface Provider {
|
|
|
13
13
|
scope: string;
|
|
14
14
|
/** Turn the provider's access token into an address it stands behind. */
|
|
15
15
|
identify(accessToken: string, send: typeof fetch): Promise<Identity | null>;
|
|
16
|
+
/** OAuth 2.1: the round trip carries a PKCE S256 challenge and the exchange its verifier. */
|
|
17
|
+
pkce?: boolean;
|
|
16
18
|
}
|
|
17
19
|
/** Who the provider says this is. `subject` is stable when an address is not. */
|
|
18
20
|
export interface Identity {
|
|
19
21
|
provider: string;
|
|
20
22
|
subject: string;
|
|
21
23
|
email: string;
|
|
24
|
+
/**
|
|
25
|
+
* Whether the provider verified the address. Absent means yes (GitHub and
|
|
26
|
+
* Google are only asked for verified ones). An unverified one never links
|
|
27
|
+
* to an existing account and is never the new account's address.
|
|
28
|
+
*/
|
|
29
|
+
verified?: boolean;
|
|
22
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* CoinPay (coinpayportal.com), the house identity: OAuth 2.1, authorization
|
|
33
|
+
* code with PKCE S256 and a client secret. CoinPay does not verify the
|
|
34
|
+
* address it reports, so a CoinPay identity is matched by its `sub` only.
|
|
35
|
+
*/
|
|
36
|
+
export declare function coinpayProvider(clientId: string, clientSecret: string, base?: string): Provider;
|
|
37
|
+
/** A PKCE pair: the verifier kept here, the S256 challenge sent with the browser. */
|
|
38
|
+
export declare function pkcePair(): {
|
|
39
|
+
verifier: string;
|
|
40
|
+
challenge: string;
|
|
41
|
+
};
|
|
23
42
|
export declare function githubProvider(clientId: string, clientSecret: string): Provider;
|
|
24
43
|
export declare function googleProvider(clientId: string, clientSecret: string): Provider;
|
|
25
44
|
/** Whichever providers this deployment has been given both halves of. */
|
|
26
45
|
export declare function providersFrom(env: Record<string, string | undefined>): Provider[];
|
|
27
46
|
export declare function redirectUri(site: string, provider: Provider): string;
|
|
28
47
|
/** Where to send the browser. `state` is the only thing standing between this and CSRF. */
|
|
29
|
-
export declare function authorizeUrl(provider: Provider, site: string, state: string): string;
|
|
48
|
+
export declare function authorizeUrl(provider: Provider, site: string, state: string, challenge?: string): string;
|
|
30
49
|
/** Swap the code for an access token. Empty string means the provider refused. */
|
|
31
|
-
export declare function exchangeCode(provider: Provider, code: string, site: string, send?: typeof fetch): Promise<string>;
|
|
50
|
+
export declare function exchangeCode(provider: Provider, code: string, site: string, send?: typeof fetch, verifier?: string): Promise<string>;
|
|
32
51
|
/** The slice of the auth module's storage adapter that identities need. */
|
|
33
52
|
export interface Users {
|
|
34
53
|
getUserByEmail(email: string): Promise<{
|
|
@@ -60,6 +79,8 @@ export declare class Identities {
|
|
|
60
79
|
constructor(db: Queryable, users: Users);
|
|
61
80
|
private ensure;
|
|
62
81
|
resolve(identity: Identity): Promise<Account | null>;
|
|
82
|
+
/** The account linked to this subject, or a new one whose address is `<provider>-<subject>@<provider>.invalid`. */
|
|
83
|
+
private resolveUnverified;
|
|
63
84
|
}
|
|
64
85
|
/**
|
|
65
86
|
* What a round trip to a provider is for.
|
|
@@ -74,7 +95,19 @@ export interface Pending {
|
|
|
74
95
|
/** Set when this round trip is approving a terminal rather than a browser. */
|
|
75
96
|
userCode: string;
|
|
76
97
|
createdAt: number;
|
|
98
|
+
/** The PKCE verifier, for a provider that takes one. */
|
|
99
|
+
verifier?: string;
|
|
100
|
+
/** Where the browser goes once signed in (a path here, or an allowed site); "/" by default. */
|
|
101
|
+
redirect?: string;
|
|
77
102
|
}
|
|
103
|
+
/** The sites a sign-in may return to: NIXAMP_SIGNIN_RETURN_HOSTS, else the ones that send people here. */
|
|
104
|
+
export declare function signInReturnHosts(env?: Record<string, string | undefined>): string[];
|
|
105
|
+
/**
|
|
106
|
+
* Where a sign-in may send the browser afterwards: a path on this site, or a
|
|
107
|
+
* page on one of the sites that send people here to sign in (live.streaming).
|
|
108
|
+
* Anything else is "/", so a sign-in link cannot be made to bounce elsewhere.
|
|
109
|
+
*/
|
|
110
|
+
export declare function safeRedirect(value: unknown, allowedHosts?: string[]): string;
|
|
78
111
|
/** A state is only ever open for the length of one sign-in. */
|
|
79
112
|
export declare const STATE_TTL_MS = 600000;
|
|
80
113
|
export declare class SignIn {
|
|
@@ -91,7 +124,7 @@ export declare class SignIn {
|
|
|
91
124
|
}[];
|
|
92
125
|
provider(id: unknown): Provider | null;
|
|
93
126
|
/** Start a round trip, and answer the URL the browser should go to. */
|
|
94
|
-
begin(provider: Provider, userCode?: string): string;
|
|
127
|
+
begin(provider: Provider, userCode?: string, redirect?: string): string;
|
|
95
128
|
/** Redeem a state exactly once, so a replayed callback finds nothing. */
|
|
96
129
|
claim(state: unknown): Pending | null;
|
|
97
130
|
private sweep;
|
package/dist/oauth.js
CHANGED
|
@@ -16,7 +16,37 @@
|
|
|
16
16
|
* address, and both are asked. Without that check, anyone who can add an
|
|
17
17
|
* unverified address at a provider could claim somebody else's nixamp account.
|
|
18
18
|
*/
|
|
19
|
-
import { randomBytes } from "node:crypto";
|
|
19
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
20
|
+
/**
|
|
21
|
+
* CoinPay (coinpayportal.com), the house identity: OAuth 2.1, authorization
|
|
22
|
+
* code with PKCE S256 and a client secret. CoinPay does not verify the
|
|
23
|
+
* address it reports, so a CoinPay identity is matched by its `sub` only.
|
|
24
|
+
*/
|
|
25
|
+
export function coinpayProvider(clientId, clientSecret, base = "https://coinpayportal.com") {
|
|
26
|
+
return {
|
|
27
|
+
id: "coinpay",
|
|
28
|
+
name: "CoinPay",
|
|
29
|
+
clientId,
|
|
30
|
+
clientSecret,
|
|
31
|
+
authorizeUrl: `${base}/api/oauth/authorize`,
|
|
32
|
+
tokenUrl: `${base}/api/oauth/token`,
|
|
33
|
+
scope: "openid profile email",
|
|
34
|
+
pkce: true,
|
|
35
|
+
async identify(accessToken, send) {
|
|
36
|
+
const who = await readJson(await send(`${base}/api/oauth/userinfo`, { headers: { authorization: `Bearer ${accessToken}`, ...AGENT } }));
|
|
37
|
+
const subject = typeof who["sub"] === "string" ? who["sub"] : "";
|
|
38
|
+
if (!subject)
|
|
39
|
+
return null;
|
|
40
|
+
const email = typeof who["email"] === "string" ? who["email"].toLowerCase() : "";
|
|
41
|
+
return { provider: "coinpay", subject, email, verified: who["email_verified"] === true };
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** A PKCE pair: the verifier kept here, the S256 challenge sent with the browser. */
|
|
46
|
+
export function pkcePair() {
|
|
47
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
48
|
+
return { verifier, challenge: createHash("sha256").update(verifier).digest("base64url") };
|
|
49
|
+
}
|
|
20
50
|
const AGENT = { "user-agent": "nixamp" };
|
|
21
51
|
async function readJson(answer) {
|
|
22
52
|
if (!answer.ok)
|
|
@@ -86,23 +116,30 @@ export function providersFrom(env) {
|
|
|
86
116
|
if (env["GOOGLE_CLIENT_ID"] && env["GOOGLE_CLIENT_SECRET"]) {
|
|
87
117
|
found.push(googleProvider(env["GOOGLE_CLIENT_ID"], env["GOOGLE_CLIENT_SECRET"]));
|
|
88
118
|
}
|
|
119
|
+
if (env["COINPAY_OAUTH_CLIENT_ID"] && env["COINPAY_OAUTH_CLIENT_SECRET"]) {
|
|
120
|
+
found.push(coinpayProvider(env["COINPAY_OAUTH_CLIENT_ID"], env["COINPAY_OAUTH_CLIENT_SECRET"], env["COINPAY_OAUTH_BASE"] || undefined));
|
|
121
|
+
}
|
|
89
122
|
return found;
|
|
90
123
|
}
|
|
91
124
|
export function redirectUri(site, provider) {
|
|
92
125
|
return `${site.replace(/\/+$/, "")}/api/v1/${provider.id}/oauth/callback`;
|
|
93
126
|
}
|
|
94
127
|
/** Where to send the browser. `state` is the only thing standing between this and CSRF. */
|
|
95
|
-
export function authorizeUrl(provider, site, state) {
|
|
128
|
+
export function authorizeUrl(provider, site, state, challenge = "") {
|
|
96
129
|
const url = new URL(provider.authorizeUrl);
|
|
97
130
|
url.searchParams.set("client_id", provider.clientId);
|
|
98
131
|
url.searchParams.set("redirect_uri", redirectUri(site, provider));
|
|
99
132
|
url.searchParams.set("response_type", "code");
|
|
100
133
|
url.searchParams.set("scope", provider.scope);
|
|
101
134
|
url.searchParams.set("state", state);
|
|
135
|
+
if (challenge) {
|
|
136
|
+
url.searchParams.set("code_challenge", challenge);
|
|
137
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
138
|
+
}
|
|
102
139
|
return url.toString();
|
|
103
140
|
}
|
|
104
141
|
/** Swap the code for an access token. Empty string means the provider refused. */
|
|
105
|
-
export async function exchangeCode(provider, code, site, send = fetch) {
|
|
142
|
+
export async function exchangeCode(provider, code, site, send = fetch, verifier = "") {
|
|
106
143
|
const answer = await send(provider.tokenUrl, {
|
|
107
144
|
method: "POST",
|
|
108
145
|
headers: {
|
|
@@ -118,6 +155,7 @@ export async function exchangeCode(provider, code, site, send = fetch) {
|
|
|
118
155
|
code,
|
|
119
156
|
redirect_uri: redirectUri(site, provider),
|
|
120
157
|
grant_type: "authorization_code",
|
|
158
|
+
...(verifier ? { code_verifier: verifier } : {}),
|
|
121
159
|
}).toString(),
|
|
122
160
|
});
|
|
123
161
|
const body = await readJson(answer);
|
|
@@ -156,7 +194,15 @@ export class Identities {
|
|
|
156
194
|
await this.ready;
|
|
157
195
|
}
|
|
158
196
|
async resolve(identity) {
|
|
159
|
-
if (!identity.subject
|
|
197
|
+
if (!identity.subject)
|
|
198
|
+
return null;
|
|
199
|
+
// An address the provider did not verify (CoinPay's) is a hint, not a
|
|
200
|
+
// claim: the account is found by subject alone, and a new one gets an
|
|
201
|
+
// address nobody can own, so purchases kept by email are never reached
|
|
202
|
+
// through it.
|
|
203
|
+
if (identity.verified === false)
|
|
204
|
+
return this.resolveUnverified(identity);
|
|
205
|
+
if (!identity.email)
|
|
160
206
|
return null;
|
|
161
207
|
await this.ensure();
|
|
162
208
|
// Already linked. The subject is what is matched, not the address: people
|
|
@@ -200,6 +246,59 @@ export class Identities {
|
|
|
200
246
|
ON CONFLICT (provider, subject) DO UPDATE SET email = EXCLUDED.email`, [identity.provider, identity.subject, account.id, identity.email]);
|
|
201
247
|
return account;
|
|
202
248
|
}
|
|
249
|
+
/** The account linked to this subject, or a new one whose address is `<provider>-<subject>@<provider>.invalid`. */
|
|
250
|
+
async resolveUnverified(identity) {
|
|
251
|
+
await this.ensure();
|
|
252
|
+
const { rows } = await this.db.query(`SELECT user_id, email FROM ${TABLE} WHERE provider = $1 AND subject = $2`, [identity.provider, identity.subject]);
|
|
253
|
+
const linked = rows[0];
|
|
254
|
+
const placeholder = `${identity.provider}-${identity.subject.replace(/[^A-Za-z0-9._-]/g, "").slice(0, 60)}@${identity.provider}.invalid`;
|
|
255
|
+
if (linked) {
|
|
256
|
+
const id = String(linked["user_id"] ?? "");
|
|
257
|
+
return id ? { id, email: String(linked["email"] ?? "") || placeholder } : null;
|
|
258
|
+
}
|
|
259
|
+
const existing = await this.users.getUserByEmail(placeholder);
|
|
260
|
+
const account = existing?.id
|
|
261
|
+
? { id: existing.id, email: existing.email ?? placeholder }
|
|
262
|
+
: await (async () => {
|
|
263
|
+
const made = await this.users.createUser({
|
|
264
|
+
email: placeholder,
|
|
265
|
+
password: null,
|
|
266
|
+
emailVerified: false,
|
|
267
|
+
profile: { signedUpWith: identity.provider, ...(identity.email ? { providerEmail: identity.email } : {}) },
|
|
268
|
+
});
|
|
269
|
+
return { id: String(made.id ?? ""), email: made.email ?? placeholder };
|
|
270
|
+
})();
|
|
271
|
+
if (!account.id)
|
|
272
|
+
return null;
|
|
273
|
+
await this.db.query(`INSERT INTO ${TABLE} (provider, subject, user_id, email) VALUES ($1, $2, $3, $4)
|
|
274
|
+
ON CONFLICT (provider, subject) DO NOTHING`, [identity.provider, identity.subject, account.id, placeholder]);
|
|
275
|
+
return account;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/** The sites a sign-in may return to: NIXAMP_SIGNIN_RETURN_HOSTS, else the ones that send people here. */
|
|
279
|
+
export function signInReturnHosts(env = process.env) {
|
|
280
|
+
const set = (env["NIXAMP_SIGNIN_RETURN_HOSTS"] ?? "").split(",").map((one) => one.trim().toLowerCase()).filter(Boolean);
|
|
281
|
+
return set.length ? set : ["live.streaming", "live.streaming.pit.world", "bittorrented.com", "backtoschool.help"];
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Where a sign-in may send the browser afterwards: a path on this site, or a
|
|
285
|
+
* page on one of the sites that send people here to sign in (live.streaming).
|
|
286
|
+
* Anything else is "/", so a sign-in link cannot be made to bounce elsewhere.
|
|
287
|
+
*/
|
|
288
|
+
export function safeRedirect(value, allowedHosts = []) {
|
|
289
|
+
if (typeof value !== "string" || value === "")
|
|
290
|
+
return "/";
|
|
291
|
+
if (value.startsWith("/") && !value.startsWith("//") && !value.startsWith("/\\"))
|
|
292
|
+
return value.slice(0, 500);
|
|
293
|
+
try {
|
|
294
|
+
const url = new URL(value);
|
|
295
|
+
if (url.protocol === "https:" && allowedHosts.includes(url.hostname))
|
|
296
|
+
return url.href.slice(0, 500);
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
// not a URL
|
|
300
|
+
}
|
|
301
|
+
return "/";
|
|
203
302
|
}
|
|
204
303
|
/** A state is only ever open for the length of one sign-in. */
|
|
205
304
|
export const STATE_TTL_MS = 600_000;
|
|
@@ -223,11 +322,12 @@ export class SignIn {
|
|
|
223
322
|
return this.providers.find((candidate) => candidate.id === id) ?? null;
|
|
224
323
|
}
|
|
225
324
|
/** Start a round trip, and answer the URL the browser should go to. */
|
|
226
|
-
begin(provider, userCode = "") {
|
|
325
|
+
begin(provider, userCode = "", redirect = "/") {
|
|
227
326
|
this.sweep();
|
|
228
327
|
const state = randomBytes(24).toString("base64url");
|
|
229
|
-
|
|
230
|
-
|
|
328
|
+
const pair = provider.pkce ? pkcePair() : null;
|
|
329
|
+
this.states.set(state, { provider: provider.id, userCode, createdAt: this.now(), redirect, ...(pair ? { verifier: pair.verifier } : {}) });
|
|
330
|
+
return authorizeUrl(provider, this.site, state, pair?.challenge ?? "");
|
|
231
331
|
}
|
|
232
332
|
/** Redeem a state exactly once, so a replayed callback finds nothing. */
|
|
233
333
|
claim(state) {
|
package/dist/server.js
CHANGED
|
@@ -40,7 +40,7 @@ import { NixampLinks, nixampExchange } from "./nixamp-link.js";
|
|
|
40
40
|
import { handleNixampLinkApi, nixampLinkPath } from "./nixamp-link-api.js";
|
|
41
41
|
import { DeviceGrants } from "./device.js";
|
|
42
42
|
import { BAD_KEY_LIMIT, callerOf, Guard, SIGN_IN_LIMIT } from "./guard.js";
|
|
43
|
-
import { deviceDonePage, devicePage, exchangeCode, providersFrom, signInFailedPage, SignIn, } from "./oauth.js";
|
|
43
|
+
import { deviceDonePage, devicePage, exchangeCode, providersFrom, safeRedirect, signInReturnHosts, signInFailedPage, SignIn, } from "./oauth.js";
|
|
44
44
|
import { AuthorizationServer, BACKTOSCHOOL_CLIENT, SCOPE_NAMES, clientsFrom } from "./oauth-server.js";
|
|
45
45
|
import { handleOAuthApi, oauthApiPath } from "./oauth-api.js";
|
|
46
46
|
import { WatchParties } from "./watch-party.js";
|
|
@@ -4509,7 +4509,9 @@ export function createHandler(engine, options) {
|
|
|
4509
4509
|
// A terminal can link straight here with the code it is showing, which
|
|
4510
4510
|
// is one hop shorter than the page for somebody who followed the link.
|
|
4511
4511
|
const grant = signIn.device.find(url.searchParams.get("device") ?? "");
|
|
4512
|
-
|
|
4512
|
+
// Back where the visit was going: a page here, or a site that sends people here to sign in.
|
|
4513
|
+
const redirect = safeRedirect(url.searchParams.get("redirect"), signInReturnHosts());
|
|
4514
|
+
response.writeHead(302, { location: signIn.begin(provider, grant?.userCode ?? "", redirect) });
|
|
4513
4515
|
response.end();
|
|
4514
4516
|
return;
|
|
4515
4517
|
}
|
|
@@ -4525,10 +4527,10 @@ export function createHandler(engine, options) {
|
|
|
4525
4527
|
html(response, 400, signInFailedPage(url.searchParams.get("error") ?? "The provider sent no code."));
|
|
4526
4528
|
return;
|
|
4527
4529
|
}
|
|
4528
|
-
const access = await exchangeCode(provider, code, signIn.site).catch(() => "");
|
|
4530
|
+
const access = await exchangeCode(provider, code, signIn.site, fetch, pending.verifier ?? "").catch(() => "");
|
|
4529
4531
|
const identity = access ? await provider.identify(access, fetch).catch(() => null) : null;
|
|
4530
4532
|
if (identity === null) {
|
|
4531
|
-
html(response, 401, signInFailedPage(`${provider.name} did not confirm a verified email address.`));
|
|
4533
|
+
html(response, 401, signInFailedPage(provider.pkce ? `${provider.name} did not finish the sign-in. Try it again.` : `${provider.name} did not confirm a verified email address.`));
|
|
4532
4534
|
return;
|
|
4533
4535
|
}
|
|
4534
4536
|
const result = await accounts.signInWith(identity);
|
|
@@ -4548,7 +4550,7 @@ export function createHandler(engine, options) {
|
|
|
4548
4550
|
}
|
|
4549
4551
|
response.writeHead(302, {
|
|
4550
4552
|
"set-cookie": sessionCookie(result.token, secure),
|
|
4551
|
-
location: "/",
|
|
4553
|
+
location: pending.redirect || "/",
|
|
4552
4554
|
});
|
|
4553
4555
|
response.end();
|
|
4554
4556
|
return;
|
package/package.json
CHANGED
package/src/label-profiles.ts
CHANGED
|
@@ -121,7 +121,7 @@ export function bandMarkdown(band: {
|
|
|
121
121
|
const MEMBERS_SYSTEM = [
|
|
122
122
|
"You cast the members of a fictional band (or the one person behind a solo act) for an AI record label.",
|
|
123
123
|
"Everyone is invented: never a real person, never a lookalike of one. Give each a full name, their role, and a look described",
|
|
124
|
-
"precisely enough that an illustrator could draw the same person every time
|
|
124
|
+
"precisely enough that an illustrator could draw the same person every time. Start every look with their gender and age (\"woman, 24, ...\"), then face, skin tone, hair, build, signature outfit and accessories.",
|
|
125
125
|
"Answer as JSON: {\"members\": [{\"name\": \"\", \"role\": \"\", \"look\": \"at most 300 characters\", \"voice\": \"how they sing or play, at most 150 characters\",",
|
|
126
126
|
"\"personality\": \"at most 150 characters\", \"influences\": \"styles and eras, never named artists, at most 120 characters\", \"bio\": \"one or two sentences\"}]}.",
|
|
127
127
|
].join(" ");
|
|
@@ -171,7 +171,7 @@ export function castLine(members: Member[]): string {
|
|
|
171
171
|
|
|
172
172
|
/** The portrait prompt for a member: the same person every time it is used as a reference. */
|
|
173
173
|
export function portraitPrompt(member: Member, band: { name: string; genre: string }): string {
|
|
174
|
-
return `Photographic portrait of ${member.look}. ${member.role} in the ${band.genre} band ${band.name}. Head and shoulders, three-quarter view, natural expression, soft studio light, plain dark background, sharp focus, 85mm lens. No text, no logos.`;
|
|
174
|
+
return `Photographic portrait of ${member.name}: ${member.look}. ${member.role} in the ${band.genre} band ${band.name}. Head and shoulders, three-quarter view, natural expression, soft studio light, plain dark background, sharp focus, 85mm lens. No text, no logos.`;
|
|
175
175
|
}
|
|
176
176
|
|
|
177
177
|
/** Writes the member pages and portraits that are missing; returns the members as the pages now say. */
|
package/src/oauth.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* address, and both are asked. Without that check, anyone who can add an
|
|
17
17
|
* unverified address at a provider could claim somebody else's nixamp account.
|
|
18
18
|
*/
|
|
19
|
-
import { randomBytes } from "node:crypto";
|
|
19
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
20
20
|
import type { Account } from "./accounts.ts";
|
|
21
21
|
import type { DeviceGrants } from "./device.ts";
|
|
22
22
|
import type { Queryable } from "./follows.ts";
|
|
@@ -33,6 +33,8 @@ export interface Provider {
|
|
|
33
33
|
scope: string;
|
|
34
34
|
/** Turn the provider's access token into an address it stands behind. */
|
|
35
35
|
identify(accessToken: string, send: typeof fetch): Promise<Identity | null>;
|
|
36
|
+
/** OAuth 2.1: the round trip carries a PKCE S256 challenge and the exchange its verifier. */
|
|
37
|
+
pkce?: boolean;
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
/** Who the provider says this is. `subject` is stable when an address is not. */
|
|
@@ -40,6 +42,43 @@ export interface Identity {
|
|
|
40
42
|
provider: string;
|
|
41
43
|
subject: string;
|
|
42
44
|
email: string;
|
|
45
|
+
/**
|
|
46
|
+
* Whether the provider verified the address. Absent means yes (GitHub and
|
|
47
|
+
* Google are only asked for verified ones). An unverified one never links
|
|
48
|
+
* to an existing account and is never the new account's address.
|
|
49
|
+
*/
|
|
50
|
+
verified?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* CoinPay (coinpayportal.com), the house identity: OAuth 2.1, authorization
|
|
55
|
+
* code with PKCE S256 and a client secret. CoinPay does not verify the
|
|
56
|
+
* address it reports, so a CoinPay identity is matched by its `sub` only.
|
|
57
|
+
*/
|
|
58
|
+
export function coinpayProvider(clientId: string, clientSecret: string, base = "https://coinpayportal.com"): Provider {
|
|
59
|
+
return {
|
|
60
|
+
id: "coinpay",
|
|
61
|
+
name: "CoinPay",
|
|
62
|
+
clientId,
|
|
63
|
+
clientSecret,
|
|
64
|
+
authorizeUrl: `${base}/api/oauth/authorize`,
|
|
65
|
+
tokenUrl: `${base}/api/oauth/token`,
|
|
66
|
+
scope: "openid profile email",
|
|
67
|
+
pkce: true,
|
|
68
|
+
async identify(accessToken, send) {
|
|
69
|
+
const who = await readJson(await send(`${base}/api/oauth/userinfo`, { headers: { authorization: `Bearer ${accessToken}`, ...AGENT } }));
|
|
70
|
+
const subject = typeof who["sub"] === "string" ? who["sub"] : "";
|
|
71
|
+
if (!subject) return null;
|
|
72
|
+
const email = typeof who["email"] === "string" ? who["email"].toLowerCase() : "";
|
|
73
|
+
return { provider: "coinpay", subject, email, verified: who["email_verified"] === true };
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A PKCE pair: the verifier kept here, the S256 challenge sent with the browser. */
|
|
79
|
+
export function pkcePair(): { verifier: string; challenge: string } {
|
|
80
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
81
|
+
return { verifier, challenge: createHash("sha256").update(verifier).digest("base64url") };
|
|
43
82
|
}
|
|
44
83
|
|
|
45
84
|
const AGENT = { "user-agent": "nixamp" };
|
|
@@ -114,6 +153,9 @@ export function providersFrom(env: Record<string, string | undefined>): Provider
|
|
|
114
153
|
if (env["GOOGLE_CLIENT_ID"] && env["GOOGLE_CLIENT_SECRET"]) {
|
|
115
154
|
found.push(googleProvider(env["GOOGLE_CLIENT_ID"], env["GOOGLE_CLIENT_SECRET"]));
|
|
116
155
|
}
|
|
156
|
+
if (env["COINPAY_OAUTH_CLIENT_ID"] && env["COINPAY_OAUTH_CLIENT_SECRET"]) {
|
|
157
|
+
found.push(coinpayProvider(env["COINPAY_OAUTH_CLIENT_ID"], env["COINPAY_OAUTH_CLIENT_SECRET"], env["COINPAY_OAUTH_BASE"] || undefined));
|
|
158
|
+
}
|
|
117
159
|
return found;
|
|
118
160
|
}
|
|
119
161
|
|
|
@@ -122,13 +164,17 @@ export function redirectUri(site: string, provider: Provider): string {
|
|
|
122
164
|
}
|
|
123
165
|
|
|
124
166
|
/** Where to send the browser. `state` is the only thing standing between this and CSRF. */
|
|
125
|
-
export function authorizeUrl(provider: Provider, site: string, state: string): string {
|
|
167
|
+
export function authorizeUrl(provider: Provider, site: string, state: string, challenge = ""): string {
|
|
126
168
|
const url = new URL(provider.authorizeUrl);
|
|
127
169
|
url.searchParams.set("client_id", provider.clientId);
|
|
128
170
|
url.searchParams.set("redirect_uri", redirectUri(site, provider));
|
|
129
171
|
url.searchParams.set("response_type", "code");
|
|
130
172
|
url.searchParams.set("scope", provider.scope);
|
|
131
173
|
url.searchParams.set("state", state);
|
|
174
|
+
if (challenge) {
|
|
175
|
+
url.searchParams.set("code_challenge", challenge);
|
|
176
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
177
|
+
}
|
|
132
178
|
return url.toString();
|
|
133
179
|
}
|
|
134
180
|
|
|
@@ -138,6 +184,7 @@ export async function exchangeCode(
|
|
|
138
184
|
code: string,
|
|
139
185
|
site: string,
|
|
140
186
|
send: typeof fetch = fetch,
|
|
187
|
+
verifier = "",
|
|
141
188
|
): Promise<string> {
|
|
142
189
|
const answer = await send(provider.tokenUrl, {
|
|
143
190
|
method: "POST",
|
|
@@ -154,6 +201,7 @@ export async function exchangeCode(
|
|
|
154
201
|
code,
|
|
155
202
|
redirect_uri: redirectUri(site, provider),
|
|
156
203
|
grant_type: "authorization_code",
|
|
204
|
+
...(verifier ? { code_verifier: verifier } : {}),
|
|
157
205
|
}).toString(),
|
|
158
206
|
});
|
|
159
207
|
const body = await readJson(answer);
|
|
@@ -208,7 +256,13 @@ export class Identities {
|
|
|
208
256
|
}
|
|
209
257
|
|
|
210
258
|
async resolve(identity: Identity): Promise<Account | null> {
|
|
211
|
-
if (!identity.subject
|
|
259
|
+
if (!identity.subject) return null;
|
|
260
|
+
// An address the provider did not verify (CoinPay's) is a hint, not a
|
|
261
|
+
// claim: the account is found by subject alone, and a new one gets an
|
|
262
|
+
// address nobody can own, so purchases kept by email are never reached
|
|
263
|
+
// through it.
|
|
264
|
+
if (identity.verified === false) return this.resolveUnverified(identity);
|
|
265
|
+
if (!identity.email) return null;
|
|
212
266
|
await this.ensure();
|
|
213
267
|
|
|
214
268
|
// Already linked. The subject is what is matched, not the address: people
|
|
@@ -256,6 +310,37 @@ export class Identities {
|
|
|
256
310
|
);
|
|
257
311
|
return account;
|
|
258
312
|
}
|
|
313
|
+
|
|
314
|
+
/** The account linked to this subject, or a new one whose address is `<provider>-<subject>@<provider>.invalid`. */
|
|
315
|
+
private async resolveUnverified(identity: Identity): Promise<Account | null> {
|
|
316
|
+
await this.ensure();
|
|
317
|
+
const { rows } = await this.db.query(`SELECT user_id, email FROM ${TABLE} WHERE provider = $1 AND subject = $2`, [identity.provider, identity.subject]);
|
|
318
|
+
const linked = rows[0];
|
|
319
|
+
const placeholder = `${identity.provider}-${identity.subject.replace(/[^A-Za-z0-9._-]/g, "").slice(0, 60)}@${identity.provider}.invalid`;
|
|
320
|
+
if (linked) {
|
|
321
|
+
const id = String(linked["user_id"] ?? "");
|
|
322
|
+
return id ? { id, email: String(linked["email"] ?? "") || placeholder } : null;
|
|
323
|
+
}
|
|
324
|
+
const existing = await this.users.getUserByEmail(placeholder);
|
|
325
|
+
const account = existing?.id
|
|
326
|
+
? { id: existing.id, email: existing.email ?? placeholder }
|
|
327
|
+
: await (async () => {
|
|
328
|
+
const made = await this.users.createUser({
|
|
329
|
+
email: placeholder,
|
|
330
|
+
password: null,
|
|
331
|
+
emailVerified: false,
|
|
332
|
+
profile: { signedUpWith: identity.provider, ...(identity.email ? { providerEmail: identity.email } : {}) },
|
|
333
|
+
});
|
|
334
|
+
return { id: String(made.id ?? ""), email: made.email ?? placeholder };
|
|
335
|
+
})();
|
|
336
|
+
if (!account.id) return null;
|
|
337
|
+
await this.db.query(
|
|
338
|
+
`INSERT INTO ${TABLE} (provider, subject, user_id, email) VALUES ($1, $2, $3, $4)
|
|
339
|
+
ON CONFLICT (provider, subject) DO NOTHING`,
|
|
340
|
+
[identity.provider, identity.subject, account.id, placeholder],
|
|
341
|
+
);
|
|
342
|
+
return account;
|
|
343
|
+
}
|
|
259
344
|
}
|
|
260
345
|
|
|
261
346
|
// --- the sign-in surface ----------------------------------------------------
|
|
@@ -273,6 +358,33 @@ export interface Pending {
|
|
|
273
358
|
/** Set when this round trip is approving a terminal rather than a browser. */
|
|
274
359
|
userCode: string;
|
|
275
360
|
createdAt: number;
|
|
361
|
+
/** The PKCE verifier, for a provider that takes one. */
|
|
362
|
+
verifier?: string;
|
|
363
|
+
/** Where the browser goes once signed in (a path here, or an allowed site); "/" by default. */
|
|
364
|
+
redirect?: string;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** The sites a sign-in may return to: NIXAMP_SIGNIN_RETURN_HOSTS, else the ones that send people here. */
|
|
368
|
+
export function signInReturnHosts(env: Record<string, string | undefined> = process.env): string[] {
|
|
369
|
+
const set = (env["NIXAMP_SIGNIN_RETURN_HOSTS"] ?? "").split(",").map((one) => one.trim().toLowerCase()).filter(Boolean);
|
|
370
|
+
return set.length ? set : ["live.streaming", "live.streaming.pit.world", "bittorrented.com", "backtoschool.help"];
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Where a sign-in may send the browser afterwards: a path on this site, or a
|
|
375
|
+
* page on one of the sites that send people here to sign in (live.streaming).
|
|
376
|
+
* Anything else is "/", so a sign-in link cannot be made to bounce elsewhere.
|
|
377
|
+
*/
|
|
378
|
+
export function safeRedirect(value: unknown, allowedHosts: string[] = []): string {
|
|
379
|
+
if (typeof value !== "string" || value === "") return "/";
|
|
380
|
+
if (value.startsWith("/") && !value.startsWith("//") && !value.startsWith("/\\")) return value.slice(0, 500);
|
|
381
|
+
try {
|
|
382
|
+
const url = new URL(value);
|
|
383
|
+
if (url.protocol === "https:" && allowedHosts.includes(url.hostname)) return url.href.slice(0, 500);
|
|
384
|
+
} catch {
|
|
385
|
+
// not a URL
|
|
386
|
+
}
|
|
387
|
+
return "/";
|
|
276
388
|
}
|
|
277
389
|
|
|
278
390
|
/** A state is only ever open for the length of one sign-in. */
|
|
@@ -298,11 +410,12 @@ export class SignIn {
|
|
|
298
410
|
}
|
|
299
411
|
|
|
300
412
|
/** Start a round trip, and answer the URL the browser should go to. */
|
|
301
|
-
begin(provider: Provider, userCode = ""): string {
|
|
413
|
+
begin(provider: Provider, userCode = "", redirect = "/"): string {
|
|
302
414
|
this.sweep();
|
|
303
415
|
const state = randomBytes(24).toString("base64url");
|
|
304
|
-
|
|
305
|
-
|
|
416
|
+
const pair = provider.pkce ? pkcePair() : null;
|
|
417
|
+
this.states.set(state, { provider: provider.id, userCode, createdAt: this.now(), redirect, ...(pair ? { verifier: pair.verifier } : {}) });
|
|
418
|
+
return authorizeUrl(provider, this.site, state, pair?.challenge ?? "");
|
|
306
419
|
}
|
|
307
420
|
|
|
308
421
|
/** Redeem a state exactly once, so a replayed callback finds nothing. */
|
package/src/server.ts
CHANGED
|
@@ -55,6 +55,8 @@ import {
|
|
|
55
55
|
devicePage,
|
|
56
56
|
exchangeCode,
|
|
57
57
|
providersFrom,
|
|
58
|
+
safeRedirect,
|
|
59
|
+
signInReturnHosts,
|
|
58
60
|
signInFailedPage,
|
|
59
61
|
SignIn,
|
|
60
62
|
} from "./oauth.ts";
|
|
@@ -4927,7 +4929,9 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
4927
4929
|
// A terminal can link straight here with the code it is showing, which
|
|
4928
4930
|
// is one hop shorter than the page for somebody who followed the link.
|
|
4929
4931
|
const grant = signIn.device.find(url.searchParams.get("device") ?? "");
|
|
4930
|
-
|
|
4932
|
+
// Back where the visit was going: a page here, or a site that sends people here to sign in.
|
|
4933
|
+
const redirect = safeRedirect(url.searchParams.get("redirect"), signInReturnHosts());
|
|
4934
|
+
response.writeHead(302, { location: signIn.begin(provider, grant?.userCode ?? "", redirect) });
|
|
4931
4935
|
response.end();
|
|
4932
4936
|
return;
|
|
4933
4937
|
}
|
|
@@ -4945,10 +4949,10 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
4945
4949
|
return;
|
|
4946
4950
|
}
|
|
4947
4951
|
|
|
4948
|
-
const access = await exchangeCode(provider, code, signIn.site).catch(() => "");
|
|
4952
|
+
const access = await exchangeCode(provider, code, signIn.site, fetch, pending.verifier ?? "").catch(() => "");
|
|
4949
4953
|
const identity = access ? await provider.identify(access, fetch).catch(() => null) : null;
|
|
4950
4954
|
if (identity === null) {
|
|
4951
|
-
html(response, 401, signInFailedPage(`${provider.name} did not confirm a verified email address.`));
|
|
4955
|
+
html(response, 401, signInFailedPage(provider.pkce ? `${provider.name} did not finish the sign-in. Try it again.` : `${provider.name} did not confirm a verified email address.`));
|
|
4952
4956
|
return;
|
|
4953
4957
|
}
|
|
4954
4958
|
|
|
@@ -4971,7 +4975,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
4971
4975
|
|
|
4972
4976
|
response.writeHead(302, {
|
|
4973
4977
|
"set-cookie": sessionCookie(result.token, secure),
|
|
4974
|
-
location: "/",
|
|
4978
|
+
location: pending.redirect || "/",
|
|
4975
4979
|
});
|
|
4976
4980
|
response.end();
|
|
4977
4981
|
return;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{t as e}from"./main-
|
|
1
|
+
import{t as e}from"./main-yTRNIxIl.js";var t={...e,"No parties live right now.":`لا توجد جلسات مباشرة الآن.`,"Could not refresh parties.":`تعذر تحديث الجلسات.`,"Pick a server in the directory, or paste its link here.":`اختر خادمًا من الدليل أو الصق رابطه هنا.`,"Interface language":`لغة الواجهة`,Language:`اللغة`,"Translation language":`لغة الترجمة`,"Original (auto-detect)":`الأصل (اكتشاف تلقائي)`,Play:`تشغيل`,Pause:`إيقاف مؤقت`,Stop:`إيقاف`,Next:`التالي`,Previous:`السابق`,Select:`اختيار`,"Next/Prev":`التالي/السابق`,Quit:`خروج`,"Now Playing":`قيد التشغيل`,"Spectrum Analyser":`محلل الطيف`,Playlist:`قائمة التشغيل`,Files:`الملفات`,"Open files":`فتح ملفات`,"Open folder":`فتح مجلد`,"Add to library":`إضافة إلى المكتبة`,"Replace playlist":`استبدال قائمة التشغيل`,Filter:`تصفية`,Search:`بحث`,"Nothing loaded.":`لم يُحمّل أي محتوى.`,"Empty.":`فارغ.`,"Loading…":`جارٍ التحميل…`,"Connecting…":`جارٍ الاتصال…`,Connected:`متصل`,"Not connected":`غير متصل`,"No source":`لا يوجد مصدر`,Playing:`يعمل`,Stopped:`متوقف`,Volume:`مستوى الصوت`,Seek:`موضع التشغيل`,"Full screen":`ملء الشاشة`,Download:`تنزيل`,Share:`مشاركة`,Copy:`نسخ`,"Copy link":`نسخ الرابط`,Copied:`تم النسخ`,Save:`حفظ`,Cancel:`إلغاء`,Close:`إغلاق`,Remove:`إزالة`,Delete:`حذف`,Edit:`تعديل`,Add:`إضافة`,Send:`إرسال`,Retry:`إعادة المحاولة`,Back:`رجوع`,"Next page":`الصفحة التالية`,"Previous page":`الصفحة السابقة`,"Show more":`عرض المزيد`,Panels:`اللوحات`,"Reset layout":`إعادة ضبط التخطيط`,Show:`إظهار`,Hide:`إخفاء`,Collapse:`طي`,Expand:`توسيع`,"Move up":`نقل لأعلى`,"Move down":`نقل لأسفل`,Install:`تثبيت`,"TV mode":`وضع التلفاز`,On:`مفعّل`,Off:`معطّل`,Account:`الحساب`,"Sign in":`تسجيل الدخول`,"Sign out":`تسجيل الخروج`,"Create account":`إنشاء حساب`,Email:`البريد الإلكتروني`,Password:`كلمة المرور`,"Forgot password?":`نسيت كلمة المرور؟`,"Welcome back":`مرحبًا بعودتك`,Profile:`الملف الشخصي`,Handle:`اسم المستخدم`,"Your servers":`خوادمك`,Server:`الخادم`,Connect:`اتصال`,Disconnect:`قطع الاتصال`,Directory:`الدليل`,Favourites:`المفضلة`,Follow:`متابعة`,Unfollow:`إلغاء المتابعة`,Notifications:`الإشعارات`,"Phone number":`رقم الهاتف`,"Save number":`حفظ الرقم`,Transcript:`النص المفرغ`,Captions:`الترجمة النصية`,"Translate audio":`ترجمة الصوت`,"Audio language":`لغة الصوت`,"Audio options":`خيارات الصوت`,"Keep background sound":`الاحتفاظ بأصوات الخلفية`,"Background level":`مستوى صوت الخلفية`,"Speaker voices":`أصوات المتحدثين`,"Any voice":`أي صوت`,"Male voice":`صوت رجالي`,"Female voice":`صوت نسائي`,"Original audio.":`الصوت الأصلي.`,"Buy translated audio":`شراء الصوت المترجم`,"Audio credit":`رصيد الصوت`,"Buy pass":`شراء تصريح`,Pass:`تصريح`,"Pay with":`الدفع باستخدام`,"Check payment":`التحقق من الدفع`,"Open checkout":`الانتقال للدفع`,"Free sessions":`جلسات مجانية`,"Usage rates and expiry":`الأسعار ومدة الصلاحية`,Catalogs:`الكتالوجات`,"Add catalog":`إضافة كتالوج`,"Catalog name":`اسم الكتالوج`,Link:`الرابط`,"Play link":`تشغيل الرابط`,"Go live":`بدء بث مباشر`,Publish:`نشر`,Listen:`استماع`,"Live now":`مباشر الآن`,Upcoming:`قريبًا`,Parties:`حفلات المشاهدة`,"Join party":`الانضمام للحفلة`,"Invite code":`رمز الدعوة`,Invite:`دعوة`,Chat:`الدردشة`,"Chat message":`رسالة دردشة`,People:`الأشخاص`,Speaker:`متحدث`,Speakers:`المتحدثون`,Listener:`مستمع`,Moderator:`مشرف`,"Raise hand":`رفع اليد`,"Hand raises":`الأيدي المرفوعة`,Questions:`الأسئلة`,Resources:`الموارد`,Replay:`إعادة العرض`,Schedule:`الجدول`,Event:`الفعالية`,Host:`المضيف`,About:`حول`,Stage:`المنصة`,Title:`العنوان`,Description:`الوصف`,Topic:`الموضوع`,Public:`عام`,"Anyone with the link":`أي شخص لديه الرابط`,"Invited people":`الأشخاص المدعوون`,"Who can join":`من يمكنه الانضمام`,"Date and time":`التاريخ والوقت`,"Expected length":`المدة المتوقعة`,Repeat:`تكرار`,"Does not repeat":`لا يتكرر`,"Every day":`كل يوم`,"Every week":`كل أسبوع`,Homepage:`الموقع الإلكتروني`,"Host name":`اسم المضيف`,"Profile photo URL":`رابط صورة الملف الشخصي`,"Broadcast link":`رابط البث`,"Edit event":`تعديل الفعالية`,"Save changes":`حفظ التغييرات`,"Create live":`إنشاء بث مباشر`,"Schedule class":`جدولة درس`,"Start class":`بدء الدرس`,"End event":`إنهاء الفعالية`,"End session":`إنهاء الجلسة`,"Session ended":`انتهت الجلسة`,"Event ended":`انتهت الفعالية`,Cancelled:`ملغاة`,"Replay available":`إعادة العرض متاحة`,Join:`انضمام`,"Create invite":`إنشاء دعوة`,"Invite as":`الدعوة بصفة`,"Watch now":`شاهد الآن`,"Start a room":`إنشاء غرفة`,"Recent replays":`إعادات العرض الأخيرة`,"Your account":`حسابك`,"Write with AI":`الكتابة بالذكاء الاصطناعي`,"Suggested title":`العنوان المقترح`,"Suggested description":`الوصف المقترح`,"Suggested topic":`الموضوع المقترح`,"Use these details":`استخدام هذه التفاصيل`,"Learn something live.":`تعلّم شيئًا مباشرة.`,"Screen share":`مشاركة الشاشة`,"Upload and broadcast":`رفع وبث`,Help:`مساعدة`,"Reload Player":`إعادة تحميل المشغل`,"Copy Bundled CLI Path":`نسخ مسار أداة سطر الأوامر المضمنة`,"Developer tools":`أدوات المطور`,Undo:`تراجع`,Redo:`إعادة`,Cut:`قص`,Paste:`لصق`,"Select all":`تحديد الكل`,Status:`الحالة`,"Activity log":`سجل النشاط`,Admin:`الإدارة`,"Playback controls":`عناصر التحكم بالتشغيل`,"Tracks and folders":`المقاطع والمجلدات`,"Original-language captions.":`ترجمة نصية باللغة الأصلية.`,"Skip to playback controls":`الانتقال إلى عناصر التحكم بالتشغيل`,"Skip to playlist":`الانتقال إلى قائمة التشغيل`,"Skip to transcript and translated audio":`الانتقال إلى النص المفرغ والصوت المترجم`};export{t as default};
|