nixamp 0.27.1 → 0.27.2
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.md +14 -4
- package/backtoschool/dist/assets/{index-6vtCMzi1.css → index-OE2xWZUH.css} +1 -1
- package/backtoschool/dist/assets/index-pQ_uH4bZ.js +93 -0
- package/backtoschool/dist/index.html +25 -9
- package/dist/accounts.d.ts +2 -0
- package/dist/accounts.js +9 -2
- package/dist/classroom.d.ts +15 -0
- package/dist/classroom.js +54 -0
- package/dist/live-events.d.ts +7 -1
- package/dist/live-events.js +88 -12
- package/dist/live-voice.d.ts +1 -1
- package/dist/live-voice.js +7 -7
- package/dist/password-reset-page.d.ts +2 -0
- package/dist/password-reset-page.js +106 -0
- package/dist/password-reset.d.ts +35 -0
- package/dist/password-reset.js +119 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +96 -2
- package/dist/shared-translation.js +25 -17
- package/dist/tokens.d.ts +2 -0
- package/dist/tokens.js +2 -0
- package/dist/translation-passes.d.ts +13 -8
- package/dist/translation-passes.js +31 -20
- package/dist/upgrade-allowance.d.ts +23 -0
- package/dist/upgrade-allowance.js +70 -0
- package/package.json +1 -1
- package/src/accounts.ts +8 -2
- package/src/classroom.ts +52 -0
- package/src/live-events.ts +81 -13
- package/src/live-voice.ts +7 -7
- package/src/password-reset-page.ts +108 -0
- package/src/password-reset.ts +134 -0
- package/src/server.ts +77 -2
- package/src/shared-translation.ts +20 -14
- package/src/tokens.ts +3 -0
- package/src/translation-passes.ts +35 -25
- package/src/types/auth-system.d.ts +10 -0
- package/src/upgrade-allowance.ts +69 -0
- package/web/dist/assets/{hls-3VKVEQE3-C1ttVP7K.js → hls-3VKVEQE3-CAL4qBEq.js} +1 -1
- package/web/dist/assets/index-CLeEWlms.js +3 -0
- package/web/dist/assets/{index-BmR7T0zX.css → index-D34dTY18.css} +1 -1
- package/web/dist/assets/{mpegts-CVYPgepU.js → mpegts-H9oC2fAK.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-Dw2p8qGf.js → mpegts-LO6RVLD6-Bkd9Sqr6.js} +1 -1
- package/web/dist/index.html +6 -5
- package/web/dist/sw.js +6 -6
- package/backtoschool/dist/assets/index-D7Oas1m5.js +0 -88
- package/web/dist/assets/index-3WZwGw04.js +0 -3
package/dist/live-events.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { classroomBroadcast, publicWebUrl } from "./classroom.js";
|
|
1
2
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
3
|
export const LIVE_EVENT_STATUSES = [
|
|
3
4
|
"draft",
|
|
@@ -85,6 +86,7 @@ const EVENT_SCHEMA = `
|
|
|
85
86
|
ALTER TABLE live_events ADD COLUMN IF NOT EXISTS ticket_currency TEXT NOT NULL DEFAULT 'USD';
|
|
86
87
|
ALTER TABLE live_events ADD COLUMN IF NOT EXISTS ticket_minutes INTEGER NOT NULL DEFAULT 1440;
|
|
87
88
|
ALTER TABLE live_events ADD COLUMN IF NOT EXISTS pay_to TEXT;
|
|
89
|
+
ALTER TABLE live_events ADD COLUMN IF NOT EXISTS classroom JSONB NOT NULL DEFAULT '{}'::jsonb;
|
|
88
90
|
CREATE INDEX IF NOT EXISTS live_events_kind
|
|
89
91
|
ON live_events (kind, status, starts_at);
|
|
90
92
|
|
|
@@ -101,7 +103,7 @@ const EVENT_SCHEMA = `
|
|
|
101
103
|
END LOOP;
|
|
102
104
|
FOR stale IN
|
|
103
105
|
SELECT conname FROM pg_constraint
|
|
104
|
-
WHERE conrelid = 'live_event_invitations'
|
|
106
|
+
WHERE conrelid = to_regclass('live_event_invitations') AND contype = 'c'
|
|
105
107
|
AND conname <> 'live_event_invitations_role_allowed'
|
|
106
108
|
AND pg_get_constraintdef(oid) LIKE '%moderator%'
|
|
107
109
|
LOOP
|
|
@@ -190,6 +192,45 @@ function text(value, name, limit, required = false) {
|
|
|
190
192
|
throw new LiveEventError(`${name} is too long`, 422);
|
|
191
193
|
return cleaned;
|
|
192
194
|
}
|
|
195
|
+
function classroomSettings(input, current = {}) {
|
|
196
|
+
const settings = {};
|
|
197
|
+
for (const key of ["hostName", "homepageUrl", "avatarUrl", "broadcastUrl"]) {
|
|
198
|
+
const value = input[key] === undefined ? current[key] ?? "" : text(input[key], key, key === "hostName" ? 100 : 2048);
|
|
199
|
+
if (!value)
|
|
200
|
+
continue;
|
|
201
|
+
if (key === "hostName")
|
|
202
|
+
settings[key] = value;
|
|
203
|
+
else if (key === "broadcastUrl") {
|
|
204
|
+
const broadcast = classroomBroadcast(value);
|
|
205
|
+
if (!broadcast)
|
|
206
|
+
throw new LiveEventError("Use a Pairux live/join link or a Nixamp Share link with a stream selected. Admin links cannot be shared.", 422);
|
|
207
|
+
settings[key] = broadcast.url;
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
const safe = publicWebUrl(value);
|
|
211
|
+
if (!safe)
|
|
212
|
+
throw new LiveEventError(`${key} must be an HTTPS link without credentials`, 422);
|
|
213
|
+
settings[key] = safe;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const recurrence = input.recurrence === undefined ? current.recurrence ?? "none" : input.recurrence;
|
|
217
|
+
if (recurrence !== "none" && recurrence !== "daily" && recurrence !== "weekly") {
|
|
218
|
+
throw new LiveEventError("recurrence must be none, daily, or weekly", 422);
|
|
219
|
+
}
|
|
220
|
+
if (recurrence !== "none")
|
|
221
|
+
settings.recurrence = recurrence;
|
|
222
|
+
return settings;
|
|
223
|
+
}
|
|
224
|
+
function classroomTimezone(value) {
|
|
225
|
+
const zone = text(value, "timezone", 100, true);
|
|
226
|
+
try {
|
|
227
|
+
new Intl.DateTimeFormat("en", { timeZone: zone }).format();
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
throw new LiveEventError("timezone must be a valid time zone", 422);
|
|
231
|
+
}
|
|
232
|
+
return zone;
|
|
233
|
+
}
|
|
193
234
|
function timestamp(value, name) {
|
|
194
235
|
if (value === undefined || value === null || value === "")
|
|
195
236
|
return null;
|
|
@@ -271,6 +312,7 @@ export function payToAddress(value, name = "payTo") {
|
|
|
271
312
|
}
|
|
272
313
|
function eventFrom(row) {
|
|
273
314
|
return {
|
|
315
|
+
...((row["classroom"] && typeof row["classroom"] === "object") ? row["classroom"] : {}),
|
|
274
316
|
id: String(row["id"] ?? ""),
|
|
275
317
|
slug: String(row["slug"] ?? ""),
|
|
276
318
|
ownerId: String(row["owner_id"] ?? ""),
|
|
@@ -432,6 +474,9 @@ export class LiveEvents {
|
|
|
432
474
|
if (ticketPriceCents > 0 && !payTo) {
|
|
433
475
|
throw new LiveEventError("a ticketed event needs a payTo address", 422);
|
|
434
476
|
}
|
|
477
|
+
const classroom = classroomSettings(input);
|
|
478
|
+
if (classroom.recurrence && !startsAt)
|
|
479
|
+
throw new LiveEventError("a recurring class needs a start time", 422);
|
|
435
480
|
const baseSlug = eventSlug(title);
|
|
436
481
|
const values = [
|
|
437
482
|
id,
|
|
@@ -448,7 +493,7 @@ export class LiveEvents {
|
|
|
448
493
|
payTo,
|
|
449
494
|
startsAt,
|
|
450
495
|
endsAt,
|
|
451
|
-
|
|
496
|
+
classroomTimezone(input.timezone ?? "UTC"),
|
|
452
497
|
optionalInteger(input.expectedDurationMinutes, "expectedDurationMinutes"),
|
|
453
498
|
startsAt ? "scheduled" : "draft",
|
|
454
499
|
enumValue(input.visibility, LIVE_EVENT_VISIBILITIES, "public", "visibility"),
|
|
@@ -457,6 +502,7 @@ export class LiveEvents {
|
|
|
457
502
|
boolean(input.handRaiseEnabled, true, "handRaiseEnabled"),
|
|
458
503
|
boolean(input.recordingEnabled, false, "recordingEnabled"),
|
|
459
504
|
text(input.layoutId, "layoutId", 100),
|
|
505
|
+
JSON.stringify(classroom),
|
|
460
506
|
];
|
|
461
507
|
await this.ensure();
|
|
462
508
|
let rows;
|
|
@@ -466,10 +512,10 @@ export class LiveEvents {
|
|
|
466
512
|
ticket_price_cents, ticket_currency, ticket_minutes, pay_to,
|
|
467
513
|
starts_at, ends_at, timezone,
|
|
468
514
|
expected_duration_minutes, status, visibility, room_id, chat_enabled,
|
|
469
|
-
hand_raise_enabled, recording_enabled, layout_id
|
|
515
|
+
hand_raise_enabled, recording_enabled, layout_id, classroom
|
|
470
516
|
) VALUES (
|
|
471
517
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NULLIF($12, ''), $13, $14, $15,
|
|
472
|
-
$16, $17, $18, $19, $20, $21, $22, NULLIF($23, '')
|
|
518
|
+
$16, $17, $18, $19, $20, $21, $22, NULLIF($23, ''), $24::jsonb
|
|
473
519
|
) RETURNING *`, values));
|
|
474
520
|
}
|
|
475
521
|
catch (error) {
|
|
@@ -481,10 +527,10 @@ export class LiveEvents {
|
|
|
481
527
|
ticket_price_cents, ticket_currency, ticket_minutes, pay_to,
|
|
482
528
|
starts_at, ends_at, timezone,
|
|
483
529
|
expected_duration_minutes, status, visibility, room_id, chat_enabled,
|
|
484
|
-
hand_raise_enabled, recording_enabled, layout_id
|
|
530
|
+
hand_raise_enabled, recording_enabled, layout_id, classroom
|
|
485
531
|
) VALUES (
|
|
486
532
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NULLIF($12, ''), $13, $14, $15,
|
|
487
|
-
$16, $17, $18, $19, $20, $21, $22, NULLIF($23, '')
|
|
533
|
+
$16, $17, $18, $19, $20, $21, $22, NULLIF($23, ''), $24::jsonb
|
|
488
534
|
) RETURNING *`, values));
|
|
489
535
|
}
|
|
490
536
|
const row = rows[0];
|
|
@@ -595,9 +641,9 @@ export class LiveEvents {
|
|
|
595
641
|
throw new LiveEventError("the event changed; reload it and try again", 409);
|
|
596
642
|
}
|
|
597
643
|
const title = input.title === undefined ? current.title : text(input.title, "title", 160, true);
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
644
|
+
let startsAt = input.startsAt === undefined ? current.startsAt ?? null : timestamp(input.startsAt, "startsAt");
|
|
645
|
+
let endsAt = input.endsAt === undefined ? current.endsAt ?? null : timestamp(input.endsAt, "endsAt");
|
|
646
|
+
let doorsOpenAt = input.doorsOpenAt === undefined
|
|
601
647
|
? current.doorsOpenAt ?? null
|
|
602
648
|
: timestamp(input.doorsOpenAt, "doorsOpenAt");
|
|
603
649
|
if (startsAt && endsAt && endsAt <= startsAt) {
|
|
@@ -611,13 +657,41 @@ export class LiveEvents {
|
|
|
611
657
|
if (ticketPriceCents > 0 && !payTo) {
|
|
612
658
|
throw new LiveEventError("a ticketed event needs a payTo address", 422);
|
|
613
659
|
}
|
|
614
|
-
|
|
660
|
+
let nextStatus = enumValue(input.status, LIVE_EVENT_STATUSES, current.status, "status");
|
|
615
661
|
if (!canTransition(current.status, nextStatus)) {
|
|
616
662
|
throw new LiveEventError(`an event cannot move from ${current.status} to ${nextStatus}`, 409);
|
|
617
663
|
}
|
|
618
664
|
if (nextStatus === "scheduled" && !startsAt) {
|
|
619
665
|
throw new LiveEventError("a scheduled event needs startsAt", 422);
|
|
620
666
|
}
|
|
667
|
+
const classroom = classroomSettings(input, current);
|
|
668
|
+
const timezone = input.timezone === undefined ? current.timezone : classroomTimezone(input.timezone);
|
|
669
|
+
if (classroom.recurrence && !startsAt)
|
|
670
|
+
throw new LiveEventError("a recurring class needs a start time", 422);
|
|
671
|
+
let recurringNext = false;
|
|
672
|
+
if (nextStatus === "ended" && classroom.recurrence && startsAt) {
|
|
673
|
+
// Calendar arithmetic in Postgres preserves the host's local time over DST.
|
|
674
|
+
// Consider only the next three candidates, even if many sessions were missed.
|
|
675
|
+
const days = classroom.recurrence === "daily" ? 1 : 7;
|
|
676
|
+
const next = await this.db.query(`WITH anchor AS (
|
|
677
|
+
SELECT $1::timestamptz AT TIME ZONE $2 AS local_start
|
|
678
|
+
), candidates AS (
|
|
679
|
+
SELECT (local_start + n * $3::int * interval '1 day') AT TIME ZONE $2 AS starts_at
|
|
680
|
+
FROM anchor CROSS JOIN LATERAL generate_series(
|
|
681
|
+
GREATEST(1, ((now() AT TIME ZONE $2)::date - local_start::date) / $3::int),
|
|
682
|
+
GREATEST(1, ((now() AT TIME ZONE $2)::date - local_start::date) / $3::int) + 2
|
|
683
|
+
) n
|
|
684
|
+
) SELECT starts_at FROM candidates
|
|
685
|
+
WHERE starts_at > GREATEST(now(), $1::timestamptz) ORDER BY starts_at LIMIT 1`, [startsAt, timezone, days]);
|
|
686
|
+
const nextStart = iso(next.rows[0]?.["starts_at"]);
|
|
687
|
+
if (!nextStart)
|
|
688
|
+
throw new LiveEventError("could not schedule the next class", 503);
|
|
689
|
+
startsAt = nextStart;
|
|
690
|
+
endsAt = null;
|
|
691
|
+
doorsOpenAt = null;
|
|
692
|
+
nextStatus = "scheduled";
|
|
693
|
+
recurringNext = true;
|
|
694
|
+
}
|
|
621
695
|
const { rows } = await this.db.query(`UPDATE live_events SET
|
|
622
696
|
title = $4,
|
|
623
697
|
description = $5,
|
|
@@ -639,6 +713,7 @@ export class LiveEvents {
|
|
|
639
713
|
ticket_currency = $21,
|
|
640
714
|
ticket_minutes = $22,
|
|
641
715
|
pay_to = NULLIF($23, ''),
|
|
716
|
+
classroom = $24::jsonb,
|
|
642
717
|
version = version + 1,
|
|
643
718
|
updated_at = now()
|
|
644
719
|
WHERE (id = $1 OR slug = $1) AND owner_id = $2 AND version = $3
|
|
@@ -651,7 +726,7 @@ export class LiveEvents {
|
|
|
651
726
|
input.topic === undefined ? current.topic ?? "" : text(input.topic, "topic", 100),
|
|
652
727
|
startsAt,
|
|
653
728
|
endsAt,
|
|
654
|
-
|
|
729
|
+
timezone,
|
|
655
730
|
input.expectedDurationMinutes === undefined
|
|
656
731
|
? current.expectedDurationMinutes ?? null
|
|
657
732
|
: optionalInteger(input.expectedDurationMinutes, "expectedDurationMinutes"),
|
|
@@ -660,7 +735,7 @@ export class LiveEvents {
|
|
|
660
735
|
boolean(input.chatEnabled, current.chatEnabled, "chatEnabled"),
|
|
661
736
|
boolean(input.handRaiseEnabled, current.handRaiseEnabled, "handRaiseEnabled"),
|
|
662
737
|
boolean(input.recordingEnabled, current.recordingEnabled, "recordingEnabled"),
|
|
663
|
-
input.recordingId === undefined ? current.recordingId ?? null : text(input.recordingId, "recordingId", 160) || null,
|
|
738
|
+
recurringNext ? null : input.recordingId === undefined ? current.recordingId ?? null : text(input.recordingId, "recordingId", 160) || null,
|
|
664
739
|
input.layoutId === undefined ? current.layoutId ?? null : text(input.layoutId, "layoutId", 100) || null,
|
|
665
740
|
enumValue(input.kind, LIVE_EVENT_KINDS, current.kind, "kind"),
|
|
666
741
|
doorsOpenAt,
|
|
@@ -670,6 +745,7 @@ export class LiveEvents {
|
|
|
670
745
|
: text(input.ticketCurrency, "ticketCurrency", 8, true).toUpperCase(),
|
|
671
746
|
minutes(input.ticketMinutes, current.ticketMinutes, "ticketMinutes"),
|
|
672
747
|
payTo,
|
|
748
|
+
JSON.stringify(classroom),
|
|
673
749
|
]);
|
|
674
750
|
const row = rows[0];
|
|
675
751
|
if (!row)
|
package/dist/live-voice.d.ts
CHANGED
|
@@ -57,7 +57,7 @@ export declare class LiveVoice {
|
|
|
57
57
|
/** Optional diarization, billed only while a signed-in listener requests it.
|
|
58
58
|
* Rolling audio is bounded to 15 seconds, including overlap. Every second
|
|
59
59
|
* submitted (also repeated context) consumes the persistent provider budget. */
|
|
60
|
-
hear(bytes: Uint8Array, by: string, signal?: AbortSignal, meter?: TranslationMeter | undefined): Promise<SpeakerTranscript>;
|
|
60
|
+
hear(bytes: Uint8Array, by: string, signal?: AbortSignal, meter?: TranslationMeter | undefined, resource?: string): Promise<SpeakerTranscript>;
|
|
61
61
|
voices(): Promise<LiveVoiceChoice[]>;
|
|
62
62
|
/** A 90-second capability for one channel, never the viewer's account credential. */
|
|
63
63
|
grant(by: string, channel: string): Promise<{
|
package/dist/live-voice.js
CHANGED
|
@@ -47,10 +47,10 @@ export class LiveVoice {
|
|
|
47
47
|
/** Optional diarization, billed only while a signed-in listener requests it.
|
|
48
48
|
* Rolling audio is bounded to 15 seconds, including overlap. Every second
|
|
49
49
|
* submitted (also repeated context) consumes the persistent provider budget. */
|
|
50
|
-
async hear(bytes, by, signal, meter = this.billing) {
|
|
50
|
+
async hear(bytes, by, signal, meter = this.billing, resource = "") {
|
|
51
51
|
if (!this.available())
|
|
52
52
|
throw new SpeechError("speaker voices are unavailable", 503);
|
|
53
|
-
await meter?.require(by);
|
|
53
|
+
await meter?.require(by, resource);
|
|
54
54
|
const wav = decodeWav(bytes);
|
|
55
55
|
const seconds = wav.samples.length / wav.rate;
|
|
56
56
|
if (wav.rate !== 16000 || wav.channels !== 1 || seconds < 0.2 || seconds > 15.1)
|
|
@@ -82,7 +82,7 @@ export class LiveVoice {
|
|
|
82
82
|
form.set("tag_audio_events", "false");
|
|
83
83
|
form.set("timestamps_granularity", "word");
|
|
84
84
|
// No language_code: preserve the source language, including Spanish.
|
|
85
|
-
reservation = await meter?.reserve(by, "transcription", wav.samples.length);
|
|
85
|
+
reservation = await meter?.reserve(by, "transcription", wav.samples.length, resource);
|
|
86
86
|
const answer = await this.fetcher("https://api.elevenlabs.io/v1/speech-to-text", {
|
|
87
87
|
method: "POST", headers: { "xi-api-key": this.key }, body: form,
|
|
88
88
|
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000),
|
|
@@ -127,7 +127,7 @@ export class LiveVoice {
|
|
|
127
127
|
throw new SpeechError("choose a playback session or live channel", 400);
|
|
128
128
|
if (!this.requests.check(`grant:${by}`, { allowed: 10, windowMs: 60_000 }).ok)
|
|
129
129
|
throw new SpeechError("too many audio authorization requests", 429);
|
|
130
|
-
await this.billing?.require(by);
|
|
130
|
+
await this.billing?.require(by, channel);
|
|
131
131
|
for (const [token, grant] of this.grants)
|
|
132
132
|
if (grant.expires <= this.now())
|
|
133
133
|
this.grants.delete(token);
|
|
@@ -217,7 +217,7 @@ export class LiveVoice {
|
|
|
217
217
|
/** Stream the first request immediately; concurrent listeners share its cached result. */
|
|
218
218
|
async stream(ask, by, signal, meter = this.billing) {
|
|
219
219
|
this.checkRequest(by);
|
|
220
|
-
await meter?.require(by);
|
|
220
|
+
await meter?.require(by, ask.channel);
|
|
221
221
|
const text = typeof ask.text === "string" ? ask.text.trim() : "";
|
|
222
222
|
if (!text || text.length > 600)
|
|
223
223
|
throw new SpeechError("translated audio needs a caption of 1–600 characters", 400);
|
|
@@ -242,7 +242,7 @@ export class LiveVoice {
|
|
|
242
242
|
if (cached || pending) {
|
|
243
243
|
const bytes = cached?.bytes ?? await pending;
|
|
244
244
|
signal?.throwIfAborted();
|
|
245
|
-
const paid = await meter?.reserve(by, "voice", text.length);
|
|
245
|
+
const paid = await meter?.reserve(by, "voice", text.length, ask.channel);
|
|
246
246
|
if (paid)
|
|
247
247
|
await meter.commit(paid);
|
|
248
248
|
return new Response(new Uint8Array(bytes), { headers: HEADERS });
|
|
@@ -265,7 +265,7 @@ export class LiveVoice {
|
|
|
265
265
|
try {
|
|
266
266
|
await this.charge(by, ask.channel ?? "direct", text.length);
|
|
267
267
|
signal?.throwIfAborted();
|
|
268
|
-
reservation = await meter?.reserve(by, "voice", text.length);
|
|
268
|
+
reservation = await meter?.reserve(by, "voice", text.length, ask.channel);
|
|
269
269
|
const response = await this.fetcher(`https://api.elevenlabs.io/v1/text-to-speech/${voice.id}/stream?output_format=pcm_16000`, {
|
|
270
270
|
method: "POST",
|
|
271
271
|
headers: { "xi-api-key": this.key, "content-type": "application/json" },
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
function escape(value) {
|
|
2
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll('"', """);
|
|
3
|
+
}
|
|
4
|
+
/** A small recovery page shared by every branded client on this account server. */
|
|
5
|
+
export function passwordResetPage(site, nonce) {
|
|
6
|
+
const host = new URL(site).hostname;
|
|
7
|
+
const school = host === "backtoschool.help" || host === "www.backtoschool.help";
|
|
8
|
+
const name = school ? "BackToSchool.help" : host;
|
|
9
|
+
const home = school ? "/#live" : "/";
|
|
10
|
+
return `<!doctype html><html lang="en"><head>
|
|
11
|
+
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
12
|
+
<meta name="referrer" content="no-referrer"><meta name="robots" content="noindex, nofollow">
|
|
13
|
+
<title>Reset your password — ${escape(name)}</title>
|
|
14
|
+
<style>
|
|
15
|
+
:root{font-family:system-ui,sans-serif;color:#34246d;background:#f7f3e9;color-scheme:light}
|
|
16
|
+
*{box-sizing:border-box}body{margin:0}header{padding:1.25rem max(1rem,calc((100% - 960px)/2));border-bottom:1px solid #ddd5e6}
|
|
17
|
+
a{color:#34246d;text-underline-offset:3px}header a{font-weight:800;text-decoration:none}
|
|
18
|
+
main{max-width:480px;margin:clamp(2rem,8vh,5rem) auto;padding:0 1rem 3rem}
|
|
19
|
+
.card{background:#fffdf8;border:1px solid #ddd5e6;border-radius:20px;padding:clamp(1.25rem,5vw,2rem)}
|
|
20
|
+
h1{font-family:Georgia,serif;font-size:2.25rem;line-height:1.1;margin:0 0 1rem}p{line-height:1.55}
|
|
21
|
+
label{display:block;font-weight:650;margin:1.25rem 0 .5rem}input{width:100%;font:inherit;padding:.85rem;border:1px solid #8a829f;border-radius:10px;background:white;color:#211747}
|
|
22
|
+
button{width:100%;font:inherit;font-weight:700;border:0;border-radius:30px;background:#34246d;color:white;padding:.9rem 1.1rem;margin-top:1.25rem;cursor:pointer}
|
|
23
|
+
button[aria-disabled=true]{opacity:.65;cursor:wait}a:focus-visible,input:focus-visible,button:focus-visible{outline:3px solid #b14b29;outline-offset:3px}
|
|
24
|
+
.help{font-size:.9rem;color:#514961}.message:empty{display:none}.error{color:#9b202a}.success{color:#215d3d}
|
|
25
|
+
.back{display:inline-block;margin-top:1.5rem}[hidden]{display:none!important}
|
|
26
|
+
@media(forced-colors:active){.card,button{border:1px solid CanvasText}}
|
|
27
|
+
</style></head><body>
|
|
28
|
+
<header><a href="${home}">${escape(name)}</a></header>
|
|
29
|
+
<main><div class="card">
|
|
30
|
+
<h1 id="title">Forgot your password?</h1>
|
|
31
|
+
<p id="intro">Enter your account’s email address and we’ll send you a link to choose a new password.</p>
|
|
32
|
+
<form id="request-form">
|
|
33
|
+
<label for="email">Email</label><input id="email" name="email" type="email" autocomplete="email" maxlength="320" required>
|
|
34
|
+
<button type="submit">Send reset link</button>
|
|
35
|
+
</form>
|
|
36
|
+
<form id="reset-form" hidden>
|
|
37
|
+
<label for="password">New password</label><input id="password" name="password" type="password" autocomplete="new-password" minlength="8" maxlength="200" aria-describedby="password-help" required>
|
|
38
|
+
<p class="help" id="password-help">Use at least 8 characters, including a number.</p>
|
|
39
|
+
<label for="confirm">Confirm new password</label><input id="confirm" name="confirm" type="password" autocomplete="new-password" minlength="8" maxlength="200" required>
|
|
40
|
+
<button type="submit">Save new password</button>
|
|
41
|
+
</form>
|
|
42
|
+
<p id="status" class="message success" role="status" aria-live="polite"></p>
|
|
43
|
+
<p id="error" class="message error" role="alert"></p>
|
|
44
|
+
<p id="another" hidden><a href="/reset-password">Request a new reset link</a></p>
|
|
45
|
+
<a class="back" id="back" href="${home}">Back to sign in</a>
|
|
46
|
+
<noscript><p>Enable JavaScript to reset your password.</p></noscript>
|
|
47
|
+
</div></main>
|
|
48
|
+
<script nonce="${nonce}">${SCRIPT}</script></body></html>`;
|
|
49
|
+
}
|
|
50
|
+
const SCRIPT = String.raw `
|
|
51
|
+
(() => {
|
|
52
|
+
const requestForm = document.querySelector('#request-form');
|
|
53
|
+
const resetForm = document.querySelector('#reset-form');
|
|
54
|
+
const status = document.querySelector('#status');
|
|
55
|
+
const error = document.querySelector('#error');
|
|
56
|
+
let token = ''; let pending = false; let done = false;
|
|
57
|
+
function readLink() {
|
|
58
|
+
const incoming = new URLSearchParams(location.hash.slice(1)).get('token');
|
|
59
|
+
if (location.hash) history.replaceState(null, '', location.pathname);
|
|
60
|
+
if (!incoming) return;
|
|
61
|
+
token = incoming; done = false;
|
|
62
|
+
status.textContent = ''; error.textContent = '';
|
|
63
|
+
document.querySelector('#another').hidden = true;
|
|
64
|
+
for (const field of resetForm.querySelectorAll('input')) {field.value = ''; field.readOnly = false;}
|
|
65
|
+
const button = resetForm.querySelector('button');
|
|
66
|
+
button.textContent = 'Save new password'; button.removeAttribute('aria-disabled');
|
|
67
|
+
requestForm.hidden = true; resetForm.hidden = false;
|
|
68
|
+
document.querySelector('#title').textContent = 'Choose a new password';
|
|
69
|
+
document.querySelector('#intro').textContent = 'Your new password works here and on NixAmp. Existing sessions will be signed out.';
|
|
70
|
+
}
|
|
71
|
+
readLink(); window.addEventListener('hashchange', readLink);
|
|
72
|
+
async function submit(form, path, input) {
|
|
73
|
+
if (pending || done) return;
|
|
74
|
+
pending = true; error.textContent = ''; status.textContent = '';
|
|
75
|
+
const button = form.querySelector('button');
|
|
76
|
+
button.setAttribute('aria-disabled', 'true'); form.setAttribute('aria-busy', 'true');
|
|
77
|
+
try {
|
|
78
|
+
const response = await fetch(path, {method:'POST', credentials:'same-origin', headers:{'content-type':'application/json'}, body:JSON.stringify(input)});
|
|
79
|
+
const result = await response.json();
|
|
80
|
+
if (!response.ok) {
|
|
81
|
+
if (form === resetForm && response.status === 400) document.querySelector('#another').hidden = false;
|
|
82
|
+
throw new Error(result.error || 'That did not work. Please try again.');
|
|
83
|
+
}
|
|
84
|
+
status.textContent = result.message;
|
|
85
|
+
if (form === resetForm) {
|
|
86
|
+
done = true; token = '';
|
|
87
|
+
for (const field of form.querySelectorAll('input')) {field.value = ''; field.readOnly = true;}
|
|
88
|
+
button.textContent = 'Password saved';
|
|
89
|
+
document.querySelector('#back').textContent = 'Sign in with your new password';
|
|
90
|
+
}
|
|
91
|
+
} catch (problem) {error.textContent = problem instanceof TypeError ? 'Could not connect. Please try again.' : problem.message;}
|
|
92
|
+
finally {pending = false; form.removeAttribute('aria-busy'); if (!done) button.removeAttribute('aria-disabled');}
|
|
93
|
+
}
|
|
94
|
+
requestForm.addEventListener('submit', event => {
|
|
95
|
+
event.preventDefault();
|
|
96
|
+
void submit(requestForm, '/api/v1/auth/password-reset/request', {email:requestForm.elements.email.value.trim()});
|
|
97
|
+
});
|
|
98
|
+
resetForm.addEventListener('submit', event => {
|
|
99
|
+
event.preventDefault();
|
|
100
|
+
if (pending || done) return;
|
|
101
|
+
const password = resetForm.elements.password.value;
|
|
102
|
+
if (password !== resetForm.elements.confirm.value) {error.textContent = 'The passwords do not match.'; return;}
|
|
103
|
+
if (!/[0-9]/.test(password)) {error.textContent = 'Include at least one number in your password.'; return;}
|
|
104
|
+
void submit(resetForm, '/api/v1/auth/password-reset/confirm', {token, password});
|
|
105
|
+
});
|
|
106
|
+
})();`;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Queryable } from "./follows.ts";
|
|
2
|
+
import type { Tokens } from "./tokens.ts";
|
|
3
|
+
export declare const RESET_MESSAGE = "If an account exists for that email, you\u2019ll receive a password reset link shortly.";
|
|
4
|
+
export declare const RESET_INVALID = "This reset link is invalid, expired, or already used. Request a new link.";
|
|
5
|
+
interface Passwords {
|
|
6
|
+
hashPassword(password: string): Promise<string>;
|
|
7
|
+
validatePassword(password: string): {
|
|
8
|
+
valid: boolean;
|
|
9
|
+
message: string;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export type ResetMail = (email: string, link: string) => Promise<boolean>;
|
|
13
|
+
export declare class PasswordResets {
|
|
14
|
+
private readonly db;
|
|
15
|
+
private readonly tokens;
|
|
16
|
+
private readonly passwords;
|
|
17
|
+
private ready;
|
|
18
|
+
constructor(db: Queryable & {
|
|
19
|
+
initialize?(): Promise<void>;
|
|
20
|
+
}, tokens: Tokens, passwords: Passwords);
|
|
21
|
+
private ensure;
|
|
22
|
+
/** The public response is identical for registered and unknown addresses. */
|
|
23
|
+
request(email: string, site: string, deliver: ResetMail): Promise<void>;
|
|
24
|
+
/** Claim the link, change the password, and revoke sessions in one statement. */
|
|
25
|
+
confirm(token: unknown, password: unknown): Promise<string | null>;
|
|
26
|
+
/** Older JWT sessions also stop working after recovery, across restarts. */
|
|
27
|
+
acceptsLegacyToken(userId: string, token: string): Promise<boolean>;
|
|
28
|
+
}
|
|
29
|
+
/** Transactional recovery mail has no follower footer and never logs a token. */
|
|
30
|
+
export declare function resendPasswordReset(options: {
|
|
31
|
+
apiKey: string;
|
|
32
|
+
from: string;
|
|
33
|
+
fetch?: typeof globalThis.fetch;
|
|
34
|
+
}): ResetMail;
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
export const RESET_MESSAGE = "If an account exists for that email, you’ll receive a password reset link shortly.";
|
|
3
|
+
export const RESET_INVALID = "This reset link is invalid, expired, or already used. Request a new link.";
|
|
4
|
+
const LIFETIME_MS = 30 * 60_000;
|
|
5
|
+
const SCHEMA = `
|
|
6
|
+
CREATE TABLE IF NOT EXISTS nixamp_password_resets (
|
|
7
|
+
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
|
8
|
+
token_hash TEXT UNIQUE,
|
|
9
|
+
password_hash TEXT,
|
|
10
|
+
expires_at TIMESTAMPTZ,
|
|
11
|
+
reset_at TIMESTAMPTZ
|
|
12
|
+
);
|
|
13
|
+
`;
|
|
14
|
+
function digest(token) {
|
|
15
|
+
return createHash("sha256").update(token).digest("hex");
|
|
16
|
+
}
|
|
17
|
+
export class PasswordResets {
|
|
18
|
+
db;
|
|
19
|
+
tokens;
|
|
20
|
+
passwords;
|
|
21
|
+
ready = null;
|
|
22
|
+
constructor(db, tokens, passwords) {
|
|
23
|
+
this.db = db;
|
|
24
|
+
this.tokens = tokens;
|
|
25
|
+
this.passwords = passwords;
|
|
26
|
+
}
|
|
27
|
+
async ensure() {
|
|
28
|
+
this.ready ??= (async () => {
|
|
29
|
+
await this.db.initialize?.();
|
|
30
|
+
await this.tokens.initialize();
|
|
31
|
+
await this.db.query(SCHEMA);
|
|
32
|
+
})().catch((error) => { this.ready = null; throw error; });
|
|
33
|
+
await this.ready;
|
|
34
|
+
}
|
|
35
|
+
/** The public response is identical for registered and unknown addresses. */
|
|
36
|
+
async request(email, site, deliver) {
|
|
37
|
+
await this.ensure();
|
|
38
|
+
const token = randomBytes(32).toString("base64url");
|
|
39
|
+
const { rows } = await this.db.query(`INSERT INTO nixamp_password_resets (user_id, token_hash, password_hash, expires_at)
|
|
40
|
+
SELECT id, $2, password, now() + interval '30 minutes' FROM users WHERE lower(email) = $1
|
|
41
|
+
ON CONFLICT (user_id) DO UPDATE SET token_hash = EXCLUDED.token_hash,
|
|
42
|
+
password_hash = EXCLUDED.password_hash, expires_at = EXCLUDED.expires_at
|
|
43
|
+
RETURNING user_id`, [email.trim().toLowerCase(), digest(token)]);
|
|
44
|
+
if (!rows.length)
|
|
45
|
+
return;
|
|
46
|
+
// Fragments never reach HTTP logs or referrers. Email delivery also never
|
|
47
|
+
// changes the HTTP response (or its timing) according to account existence.
|
|
48
|
+
const link = `${site.replace(/\/+$/, "")}/reset-password#token=${token}`;
|
|
49
|
+
void Promise.resolve().then(() => deliver(email.trim().toLowerCase(), link)).then((sent) => {
|
|
50
|
+
if (!sent)
|
|
51
|
+
console.error("nixamp: password reset email delivery failed");
|
|
52
|
+
}).catch(() => console.error("nixamp: password reset email delivery failed"));
|
|
53
|
+
}
|
|
54
|
+
/** Claim the link, change the password, and revoke sessions in one statement. */
|
|
55
|
+
async confirm(token, password) {
|
|
56
|
+
if (typeof token !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(token))
|
|
57
|
+
return RESET_INVALID;
|
|
58
|
+
if (typeof password !== "string" || password.length > 200)
|
|
59
|
+
return "Enter a password between 8 and 200 characters.";
|
|
60
|
+
const validation = this.passwords.validatePassword(password);
|
|
61
|
+
if (!validation.valid)
|
|
62
|
+
return validation.message;
|
|
63
|
+
await this.ensure();
|
|
64
|
+
const tokenHash = digest(token);
|
|
65
|
+
// Reject bad tokens before doing the deliberately expensive password hash.
|
|
66
|
+
const candidate = await this.db.query(`SELECT user_id FROM nixamp_password_resets WHERE token_hash = $1 AND expires_at > now()`, [tokenHash]);
|
|
67
|
+
if (!candidate.rows.length)
|
|
68
|
+
return RESET_INVALID;
|
|
69
|
+
const hashed = await this.passwords.hashPassword(password);
|
|
70
|
+
const { rows } = await this.db.query(`WITH consumed AS (
|
|
71
|
+
UPDATE nixamp_password_resets r SET token_hash = NULL, expires_at = NULL,
|
|
72
|
+
password_hash = NULL, reset_at = clock_timestamp()
|
|
73
|
+
FROM users u WHERE r.token_hash = $1 AND r.expires_at > now()
|
|
74
|
+
AND u.id = r.user_id AND r.password_hash IS NOT DISTINCT FROM u.password
|
|
75
|
+
RETURNING r.user_id
|
|
76
|
+
), changed AS (
|
|
77
|
+
UPDATE users u SET password = $2, updated_at = now()
|
|
78
|
+
FROM consumed c WHERE u.id = c.user_id RETURNING u.id
|
|
79
|
+
), revoked AS (
|
|
80
|
+
DELETE FROM nixamp_tokens t USING changed c WHERE t.user_id = c.id RETURNING t.id
|
|
81
|
+
) SELECT id FROM changed`, [tokenHash, hashed]);
|
|
82
|
+
return rows.length ? null : RESET_INVALID;
|
|
83
|
+
}
|
|
84
|
+
/** Older JWT sessions also stop working after recovery, across restarts. */
|
|
85
|
+
async acceptsLegacyToken(userId, token) {
|
|
86
|
+
await this.ensure();
|
|
87
|
+
const { rows } = await this.db.query("SELECT reset_at FROM nixamp_password_resets WHERE user_id = $1", [userId]);
|
|
88
|
+
if (!rows[0]?.["reset_at"])
|
|
89
|
+
return true;
|
|
90
|
+
try {
|
|
91
|
+
// Called only after the auth library has verified the JWT signature.
|
|
92
|
+
const claims = JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
|
|
93
|
+
const issued = claims.iat;
|
|
94
|
+
const reset = rows[0]["reset_at"];
|
|
95
|
+
const resetAt = reset instanceof Date ? reset.getTime() : new Date(String(reset)).getTime();
|
|
96
|
+
return typeof issued === "number" && Number.isFinite(issued)
|
|
97
|
+
&& issued * 1000 >= resetAt;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Transactional recovery mail has no follower footer and never logs a token. */
|
|
105
|
+
export function resendPasswordReset(options) {
|
|
106
|
+
return async (email, link) => {
|
|
107
|
+
const host = new URL(link).hostname;
|
|
108
|
+
const response = await (options.fetch ?? globalThis.fetch)("https://api.resend.com/emails", {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: { authorization: `Bearer ${options.apiKey}`, "content-type": "application/json" },
|
|
111
|
+
signal: AbortSignal.timeout(15_000),
|
|
112
|
+
body: JSON.stringify({
|
|
113
|
+
from: options.from, to: [email], subject: `Reset your ${host} password`,
|
|
114
|
+
text: `Someone requested a password reset for your account on ${host}.\n\nChoose a new password using this link:\n${link}\n\nThis link expires in ${LIFETIME_MS / 60_000} minutes and works once. Resetting your password signs out existing sessions.\n\nIf you did not request this, you can ignore this email. Your password will stay the same.`,
|
|
115
|
+
}),
|
|
116
|
+
});
|
|
117
|
+
return response.ok;
|
|
118
|
+
};
|
|
119
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { Broadcaster, type Destination, type EncoderSettings } from "./broadcast
|
|
|
7
7
|
import { Ingest } from "./ingest.ts";
|
|
8
8
|
import { Channels, type Channel, type ChannelInfo, type RememberedChannel } from "./channels.ts";
|
|
9
9
|
import { Accounts } from "./accounts.ts";
|
|
10
|
+
import { type ResetMail } from "./password-reset.ts";
|
|
10
11
|
import { Handles } from "./handles.ts";
|
|
11
12
|
import { OpenDirs } from "./opendirs.ts";
|
|
12
13
|
import { Servers } from "./servers.ts";
|
|
@@ -659,6 +660,8 @@ export interface HandlerOptions {
|
|
|
659
660
|
}[];
|
|
660
661
|
/** Accounts, on the instance that keeps them. Only nixamp.com passes this. */
|
|
661
662
|
accounts?: Accounts;
|
|
663
|
+
/** Transactional password recovery mail, separate from follow notifications. */
|
|
664
|
+
resetMail?: ResetMail;
|
|
662
665
|
/** Providers to sign in with, and the terminals waiting to be connected. */
|
|
663
666
|
signIn?: SignIn;
|
|
664
667
|
/** The servers each account runs, on the instance that keeps accounts. */
|