Compare commits

...
Author SHA1 Message Date
Daniel Salazar f3542a6aa3 fix(backend): shutdown hooks don't skip each other, and final flushes wait for in-flight work (#4499)
A throwing prepare/shutdown hook stopped the loop, so shutdown() skipped
closeAllConnections and every later hook, including the final metering
flush; each hook now runs and failures are logged. Broadcast, events
forwarding and share digests joined nothing already in flight and could
drop what was queued behind it; their final flushes now wait for it, ship
the tail, and drain once more at shutdown. Local worker disposal and the
OpenAI image metering call are awaited. settleWithin and
FINAL_FLUSH_TIMEOUT_MS bound each final flush.
2026-11-04 19:41:17 -07:00
Daniel Salazar 5a59db7b6b feat(backend): drain-start hooks run concurrently and can hold the listener open
The listener closes once every onServerDrainStart hook settles, and the
drain window now starts at the signal, so time a hook spends holding the
listener open counts against drainMs instead of extending shutdown. A
failing hook no longer skips the others.
2026-11-04 18:38:33 -07:00
Nariman Jelveh fc4af97885 fix(gui): the custom domain empty state holds its add button
With no domains, the only way on was the add button in the footer, which
the full-page popup pins to the bottom edge, far from the empty state the
eye lands on. The button now sits in the empty state under its hint, and
the footer keeps only Cancel until there's a domain to list.
2026-11-04 17:08:06 -07:00
Nariman Jelveh 7cec710014 feat(gui): redesign the custom domain dialog, full page in its popup (#4496)
* feat(gui): redesign the custom domain dialog, full page in its popup

- Domains list as cards with a status badge, trash icon, and inline
  Cancel/Confirm for remove and move
- DNS records as cards with copy buttons that confirm with a check
- Status card on the records step; a green check once the domain serves
  the site, after which the dialog closes
- Spinners on the button that's working, skeleton while loading, empty
  and unavailable states
- Pasted URLs are trimmed to their hostname before adding
- In the attach-custom-domain popup the dialog fills the page, with the
  actions pinned to the bottom

* fix(gui): Escape backs out of removing the chosen custom domain

Escape already put back a list row's pending confirmation. On the records
step, it closed the dialog while Remove was asking to confirm; it now puts
the button back first, like the rows.

* fix(gui): each custom domain step opens scrolled to the top

Switching steps reset the scroll of the dialog's body, which only scrolls
when the dialog fills a popup. The floating modal scrolls itself, so a
step opened from far down the list started scrolled past its status card.

* fix(gui): keyboard focus stays in the custom domain dialog

Focus on a control that got hidden or disabled fell to the page behind the
dialog, so Tab started over from there. Switching steps now focuses the
dialog, backing out of a row's confirmation returns to the button that
asked, a request gives focus back to the button that started it, and
removing a row moves focus to the next one.

* fix(gui): a removed custom domain row takes no input as it fades

Its buttons came back on once the delete finished, so a second click in
the fade sent the delete again, and Cancel brought back Use for a domain
that no longer exists. The row is inert until it's gone.

* fix(gui): DNS record copy buttons work without the clipboard API

On an origin with no navigator.clipboard (a Puter served over plain http)
or when the browser refuses the write, the copy buttons did nothing. They
now select the value and try the legacy copy, like the dashboard's form
dialog, and leave it selected to copy by hand if that fails too.

* fix(gui): custom domain rows keep the icon beside the name on phones

The name was sized to the row less 46px for the icon and gap, but the icon
is 36px wide with its border, so the row overflowed by 2px and every name
dropped below its icon. Only the actions wrap now; the name takes the rest
of the first line.

* fix(gui): hostnames in the custom domain dialog wrap whole

Every bold hostname broke at any letter, so one that would fit on the
next line was split mid-word at the end of this one ("puter.lo / calhost").
It now moves down whole and breaks only when it's wider than the line.

* fix(gui): readable Remove and Confirm buttons in dark mode

The red fill under their white labels used the danger text color, which
dark mode lightens to #f87171: 2.8:1 against white. Fills now use their
own token that stays #dc2626 (4.8:1); light mode is unchanged.

* fix(gui): the switch-to-www link shows when it's working

The link that switches a root domain to www had no disabled or loading
style, so while its request ran it still looked clickable and showed
nothing, while every other control dimmed. It now dims like them when
waiting and shows a spinner when it's the one working.
2026-11-04 16:45:56 -07:00
Daniel Salazar 48cd4ea355 fix: oidc toggle (#4494) 2026-11-04 13:13:15 -07:00
Neal ShahandClaude Opus 5.5 a110be0340 feat(gui): remove a custom domain from the attach dialog (#4493)
Each domain in the list gets a Remove button, confirmed in place like Move,
and the records pane can remove the domain just added. A removed domain is
never handed back as the dialog's answer.

Also fixes two strings that went stale: the claim prompt (an active domain
can be claimed too now) and the redirect note (root domains get two A
records).

PUT-2338

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 13:23:04 -05:00
32 changed files with 2852 additions and 285 deletions

No files matched your search

+19 -5
View File
@@ -200,6 +200,16 @@ export type EventMap = {
};
// ---- Auth / signup ----
/**
* Which signup methods are open, emitted via `emitAndWait` before every
* signup and whenever the GUI reads the provider list. A listener sets
* `oidcOnly` to refuse form and temp signups, leaving OIDC and team
* provisioning open; `message` is what a refused form signup is told.
*/
'puter.signup.policy': {
oidcOnly: boolean;
message: string | null;
};
'puter.signup.validate': {
allow: boolean;
email?: string;
@@ -1041,10 +1051,11 @@ export type EventKey = keyof EventMap & string;
// Generates a wildcard for every non-final dot-separated prefix of K.
export type WildcardPrefixes<K extends string> =
K extends `${infer Head}.${infer Tail}`
? | `${Head}.*`
| (Tail extends `${string}.${string}`
? `${Head}.${WildcardPrefixes<Tail>}`
: never)
?
| `${Head}.*`
| (Tail extends `${string}.${string}`
? `${Head}.${WildcardPrefixes<Tail>}`
: never)
: never;
export type ListenKey = EventKey | WildcardPrefixes<EventKey>;
@@ -1055,7 +1066,10 @@ export type MatchingEvents<P extends ListenKey> = P extends `${infer Prefix}.*`
/** Acked, not acked, no socket anywhere, or sockets only in a peer region. */
export type NotifDeliveryOutcome =
'received' | 'unreceived' | 'no-socket' | 'remote-only';
| 'received'
| 'unreceived'
| 'no-socket'
| 'remote-only';
/** One socket's receipt for a live notification. */
export interface NotifDeliveryResponder {
@@ -244,7 +244,10 @@ describe('OIDCController GET /auth/oidc/providers', () => {
await callRoute('get', '/auth/oidc/providers', makeReq({}), res);
// We configured a single `custom` provider; the live service
// walks the config and returns its id.
expect(captured.body).toEqual({ providers: ['custom'] });
expect(captured.body).toEqual({
providers: ['custom'],
signupOidcOnly: false,
});
});
});
@@ -396,9 +396,12 @@ export class OIDCController extends PuterController {
},
},
async (_req: Request, res: Response) => {
const providers =
await this.services.oidc.getEnabledProviderIds();
res.json({ providers });
const [providers, policy] = await Promise.all([
this.services.oidc.getEnabledProviderIds(),
this.services.signup.signupPolicy(),
]);
// The signup window drops its email form when this is set.
res.json({ providers, signupOidcOnly: policy.oidcOnly });
},
);
@@ -235,6 +235,56 @@ describe('OpenAiImageProvider.generate argument validation', () => {
// ── Credit gate ─────────────────────────────────────────────────────
describe('OpenAiImageProvider.generate credit gate', () => {
it('waits for the real usage write before returning an image', async () => {
const actor = makeActor({
user: {
id: 62,
uuid: `image-write-${Date.now()}`,
username: 'metered',
},
});
const redis = server.clients.redis as unknown as {
meterIncr: (...args: string[]) => Promise<unknown>;
};
const realIncr = redis.meterIncr.bind(redis);
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
let blocked = false;
vi.spyOn(redis, 'meterIncr').mockImplementation(async (...args) => {
if (!blocked && args[5].includes(`:actor:${actor.user!.uuid}:`)) {
blocked = true;
await held;
}
return realIncr(...args);
});
generateMock.mockResolvedValueOnce({
data: [{ url: 'https://image.example/result.png' }],
});
const provider = makeProvider();
let finished = false;
const generating = withTestActor(
() =>
provider.generate({
model: 'gpt-image-2',
prompt: 'an image',
ratio: { w: 1024, h: 1024 },
}),
actor,
).then((result) => {
finished = true;
return result;
});
try {
await vi.waitFor(() => expect(blocked).toBe(true));
expect(finished).toBe(false);
} finally {
release();
await generating;
}
});
it('throws 402 BEFORE hitting OpenAI when actor lacks credits', async () => {
const provider = makeProvider();
hasCreditsSpy.mockResolvedValueOnce(false);
@@ -293,7 +293,10 @@ export class OpenAiImageProvider implements IImageProvider {
});
}
if (usageEntries.length) {
this.#meteringService.batchIncrementUsages(actor, usageEntries);
await this.#meteringService.batchIncrementUsages(
actor,
usageEntries,
);
}
const url =
+165
View File
@@ -1492,6 +1492,104 @@ describe('PuterServer keep-alive timeout', () => {
});
describe('PuterServer lifecycle hooks', () => {
it('fails startup before running later hooks when a start hook rejects', async () => {
const failure = new Error('startup failed');
const later = vi.fn();
class FailingStart extends JobScheduler {
override async onServerStart() {
throw failure;
}
}
class LaterStart extends JobScheduler {
override async onServerStart() {
later();
}
}
extension.registerService('failingStart', FailingStart as never);
extension.registerService('laterStart', LaterStart as never);
try {
await expect(setupTestServer()).rejects.toBe(failure);
expect(later).not.toHaveBeenCalled();
} finally {
delete (extensionStore.services as Record<string, unknown>)
.failingStart;
delete (extensionStore.services as Record<string, unknown>)
.laterStart;
}
});
it.each([false, true])(
'continues teardown after failing hooks with listener callback %s',
async (prepareFirst) => {
const port = await allocateEphemeralPort();
const server = await setupTestServer(
{ port } as unknown as IConfig,
{ listen: true },
);
const logged = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const ran: string[] = [];
class FailingHooks {
onServerPrepareShutdown() {
ran.push('prepare:failed');
throw new Error('prepare failed');
}
async onServerShutdown() {
ran.push('shutdown:failed');
throw new Error('shutdown failed');
}
}
(server.services as Record<string, unknown>).fails =
new FailingHooks();
(server.services as Record<string, unknown>).after = {
onServerPrepareShutdown: () => ran.push('prepare:after'),
onServerShutdown: () => ran.push('shutdown:after'),
};
(server.clients as Record<string, unknown>).last = {
onServerShutdown: () => ran.push('shutdown:client'),
};
try {
if (prepareFirst) {
await server.prepareShutdown();
await vi.waitFor(() =>
expect(ran).toContain('prepare:after'),
);
}
await server.shutdown();
expect(ran).toEqual([
'prepare:failed',
'prepare:after',
'shutdown:failed',
'shutdown:after',
'shutdown:client',
]);
expect(logged).toHaveBeenCalledWith(
'[server] onServerPrepareShutdown failed in FailingHooks',
expect.any(Error),
);
expect(logged).toHaveBeenCalledWith(
'[server] onServerShutdown failed in FailingHooks',
expect.any(Error),
);
await expect(
new Promise<void>((resolve, reject) => {
const socket = net.connect(port, '127.0.0.1');
socket.on('connect', () => {
socket.destroy();
resolve();
});
socket.on('error', reject);
}),
).rejects.toMatchObject({ code: 'ECONNREFUSED' });
} finally {
delete (server.services as Record<string, unknown>).fails;
await server.shutdown();
logged.mockRestore();
}
},
);
it('runs drain-start and prepare-shutdown bottom-up, once, before tearing down', async () => {
const port = await allocateEphemeralPort();
const server = await setupTestServer({ port } as unknown as IConfig, {
@@ -1541,6 +1639,73 @@ describe('PuterServer lifecycle hooks', () => {
'shutdown:client',
]);
});
it('keeps accepting connections until every drain-start hook settles, without one holding up another', async () => {
const port = await allocateEphemeralPort();
const server = await setupTestServer({ port } as unknown as IConfig, {
listen: true,
});
let release!: () => void;
const held = new Promise<void>((resolve) => (release = resolve));
let otherRan = false;
(server.services as unknown as Record<string, unknown>).holdsListener =
{ onServerDrainStart: () => held };
(server.drivers as unknown as Record<string, unknown>).other = {
onServerDrainStart: () => {
otherRan = true;
},
};
const connect = () =>
new Promise<void>((resolve, reject) => {
const socket = net.connect(port, '127.0.0.1');
socket.on('connect', () => {
socket.destroy();
resolve();
});
socket.on('error', reject);
});
const prepared = server.prepareShutdown({ drainMs: 1_000 });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(otherRan).toBe(true);
await expect(connect()).resolves.toBeUndefined();
release();
await prepared;
await expect(connect()).rejects.toMatchObject({
code: 'ECONNREFUSED',
});
await server.shutdown();
});
it('runs every drain-start hook when one of them throws', async () => {
const port = await allocateEphemeralPort();
const server = await setupTestServer({ port } as unknown as IConfig, {
listen: true,
});
vi.spyOn(console, 'error').mockImplementation(() => {});
const ran: string[] = [];
(server.stores as unknown as Record<string, unknown>).fails = {
onServerDrainStart: () => {
throw new Error('drain hook failed');
},
};
(server.services as unknown as Record<string, unknown>).after = {
onServerDrainStart: () => {
ran.push('after');
},
};
await server.prepareShutdown({ drainMs: 1_000 });
await server.shutdown();
expect(ran).toEqual(['after']);
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('onServerDrainStart failed'),
expect.any(Error),
);
});
});
describe('PuterServer shutdown during prepare', () => {
+47 -13
View File
@@ -1743,12 +1743,7 @@ export class PuterServer {
*/
async #runHook(
order: 'bottomUp' | 'topDown',
hook:
| 'onServerStart'
| 'onServerDrainStart'
| 'onServerPrepareShutdown'
| 'onServerShutdown',
info?: ServerDrainInfo,
hook: 'onServerStart' | 'onServerPrepareShutdown' | 'onServerShutdown',
) {
const layers = [
this.clients,
@@ -1760,11 +1755,47 @@ export class PuterServer {
if (order === 'topDown') layers.reverse();
for (const layer of layers) {
for (const instance of Object.values(layer) as WithLifecycle[]) {
await instance[hook]?.(info);
try {
await instance[hook]?.();
} catch (err) {
if (hook === 'onServerStart') throw err;
console.error(
`[server] ${hook} failed in ${instance.constructor.name}`,
err,
);
}
}
}
}
/**
* Run every `onServerDrainStart` concurrently, so one hook holding the
* listener open doesn't delay the others, and one failing doesn't skip the
* rest.
*/
async #runDrainStartHooks(info: ServerDrainInfo) {
const instances = [
this.clients,
this.stores,
this.services,
this.controllers,
this.drivers,
].flatMap((layer) => Object.values(layer) as WithLifecycle[]);
const results = await Promise.allSettled(
instances.map(async (instance) =>
instance.onServerDrainStart?.(info),
),
);
results.forEach((result, i) => {
if (result.status === 'rejected') {
console.error(
`[server] onServerDrainStart failed in ${instances[i].constructor.name}`,
result.reason,
);
}
});
}
#prepareShutdownHooks: Promise<void> | null = null;
/**
@@ -1782,16 +1813,19 @@ export class PuterServer {
async prepareShutdown(info: ServerDrainInfo = {}) {
if (this.#server) {
try {
await this.#runHook('bottomUp', 'onServerDrainStart', info);
} catch (err) {
console.error('[server] onServerDrainStart failed', err);
}
await this.#runDrainStartHooks(info);
this.#server.close(async () => {
console.log(
'PuterServer has stopped accepting new connections',
);
await this.#runPrepareShutdownHooks();
try {
await this.#runPrepareShutdownHooks();
} catch (err) {
console.error(
'[server] prepare-shutdown hooks failed',
err,
);
}
});
}
}
@@ -1149,3 +1149,154 @@ describe('BroadcastService — same-cluster pub/sub fan-out', () => {
}
});
});
describe('BroadcastService shutdown drain', () => {
let server: PuterServer;
let broadcast: BroadcastService;
beforeEach(async () => {
axiosRequestMock.mockReset();
axiosRequestMock.mockResolvedValue({ status: 200, data: 'ok' });
server = await setupTestServer({
broadcast: {
webhook: { peerId: SELF_PEER_ID, secret: SELF_SECRET },
peers: [
{
peerId: PEER_ID,
webhook: true,
webhook_url: PEER_URL,
webhook_secret: PEER_SECRET,
},
],
outbound_flush_ms: 10,
},
} as never);
broadcast = server.services.broadcast as unknown as BroadcastService;
});
afterEach(async () => {
vi.useRealTimers();
await broadcast.onServerPrepareShutdown();
await broadcast.onServerShutdown();
await server.shutdown();
});
const emit = (hash: string) =>
server.clients.event.emit(
'outer.fs.write-hash' as never,
{ hash, uuid: hash } as never,
{},
);
it.each(['onServerPrepareShutdown', 'onServerShutdown'] as const)(
'waits for the active flush and sends its tail in %s',
async (hook) => {
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
let releaseTail!: () => void;
const tailHeld = new Promise<void>((resolve) => {
releaseTail = resolve;
});
axiosRequestMock
.mockImplementationOnce(async () => {
await held;
return { status: 200, data: 'ok' };
})
.mockImplementationOnce(async () => {
await tailHeld;
return { status: 200, data: 'ok' };
});
emit('first');
await vi.waitFor(() =>
expect(axiosRequestMock).toHaveBeenCalledTimes(1),
);
emit('tail');
let finished = false;
const draining = broadcast[hook]().then(() => {
finished = true;
});
try {
await new Promise((resolve) => setTimeout(resolve, 20));
expect(finished).toBe(false);
release();
await vi.waitFor(() =>
expect(axiosRequestMock).toHaveBeenCalledTimes(2),
);
expect(finished).toBe(false);
} finally {
release();
releaseTail();
await draining;
}
expect(axiosRequestMock).toHaveBeenCalledTimes(2);
expect(axiosRequestMock.mock.calls[1][0].data).toContain('tail');
},
);
it('keeps normal timer flushing after prepare on an idle server', async () => {
await broadcast.onServerPrepareShutdown();
vi.useFakeTimers();
axiosRequestMock.mockClear();
emit('during-drain');
await vi.advanceTimersByTimeAsync(10);
expect(axiosRequestMock).toHaveBeenCalledTimes(1);
expect(axiosRequestMock.mock.calls[0][0].data).toContain(
'during-drain',
);
});
it('sends events queued after prepare during shutdown and clears the timer', async () => {
await broadcast.onServerPrepareShutdown();
vi.useFakeTimers();
axiosRequestMock.mockClear();
emit('after-prepare');
await broadcast.onServerShutdown();
expect(axiosRequestMock).toHaveBeenCalledTimes(1);
expect(axiosRequestMock.mock.calls[0][0].data).toContain(
'after-prepare',
);
axiosRequestMock.mockClear();
await vi.advanceTimersByTimeAsync(100);
expect(axiosRequestMock).not.toHaveBeenCalled();
});
it.each(['onServerPrepareShutdown', 'onServerShutdown'] as const)(
'bounds a hung flush in %s',
async (hook) => {
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
axiosRequestMock.mockImplementationOnce(async () => {
await held;
return { status: 200, data: 'ok' };
});
emit('hung');
await vi.waitFor(() =>
expect(axiosRequestMock).toHaveBeenCalledTimes(1),
);
vi.useFakeTimers();
emit('tail');
let finished = false;
const draining = broadcast[hook]().then(() => {
finished = true;
});
try {
await vi.advanceTimersByTimeAsync(4_999);
expect(finished).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await draining;
release();
await vi.advanceTimersByTimeAsync(100);
expect(axiosRequestMock).toHaveBeenCalledTimes(
hook === 'onServerPrepareShutdown' ? 2 : 1,
);
} finally {
release();
vi.useRealTimers();
}
},
);
});
@@ -21,6 +21,8 @@ import axios from 'axios';
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
import { Agent as HttpsAgent } from 'node:https';
import { IBroadcastPeerConfig } from '../../types.js';
import { settleWithin } from '../../util/concurrency.js';
import { FINAL_FLUSH_TIMEOUT_MS } from '../../util/gracefulShutdown.js';
import { PuterService } from '../types.js';
// -- Wire types ------------------------------------------------------
@@ -121,7 +123,8 @@ export class BroadcastService extends PuterService {
/** Per peer, serialized events it has not accepted yet, oldest first. */
#outboundBacklogs = new Map<IBroadcastPeerConfig, string[]>();
#outboundFlushTimer: ReturnType<typeof setTimeout> | null = null;
#outboundIsFlushing = false;
#outboundFlush: Promise<void> | null = null;
#outboundDraining = false;
#webhookReplayWindowSeconds = 300;
#outboundFlushMs = 2000;
@@ -165,21 +168,40 @@ export class BroadcastService extends PuterService {
}
override async onServerPrepareShutdown(): Promise<void> {
await this.#drainOutboundEvents();
if (this.#redisSub) {
try {
await this.#redisSub.unsubscribe('pubsub');
this.#redisSub.quit();
} catch (err) {
console.warn('[broadcast] unsubscribe failed', err);
} finally {
this.#redisSub = null;
}
}
}
override async onServerShutdown(): Promise<void> {
this.#outboundDraining = true;
await this.#drainOutboundEvents();
}
async #drainOutboundEvents(): Promise<void> {
if (this.#outboundFlushTimer) {
clearTimeout(this.#outboundFlushTimer);
this.#outboundFlushTimer = null;
}
// Best-effort drain — try one final flush so events queued near
// shutdown make it out.
try {
await this.#flushOutboundEvents();
} catch (err) {
console.warn('[broadcast] final flush failed', err);
}
if (this.#redisSub) {
await this.#redisSub.unsubscribe('pubsub');
this.#redisSub.quit();
this.#redisSub = null;
const deadline = Date.now() + FINAL_FLUSH_TIMEOUT_MS;
const drain = async () => {
try {
await this.#outboundFlush;
if (Date.now() < deadline) await this.#flushOutboundEvents();
} catch (err) {
console.warn('[broadcast] final flush failed', err);
}
};
if (!(await settleWithin(drain(), FINAL_FLUSH_TIMEOUT_MS))) {
console.warn('[broadcast] final flush timed out');
}
}
@@ -453,24 +475,27 @@ export class BroadcastService extends PuterService {
}
#scheduleOutboundFlush(): void {
if (this.#outboundFlushTimer) return;
this.#outboundFlushTimer = setTimeout(() => {
if (this.#outboundDraining || this.#outboundFlushTimer) return;
this.#outboundFlushTimer = setTimeout(async () => {
this.#outboundFlushTimer = null;
void this.#flushOutboundEvents().catch((err) => {
try {
await this.#flushOutboundEvents();
} catch (err) {
console.warn('[broadcast] outbound flush failed', err);
});
}
}, this.#outboundFlushMs);
}
async #flushOutboundEvents(): Promise<void> {
if (
this.#outboundIsFlushing ||
(this.#outboundQueue.size === 0 &&
this.#outboundBacklogs.size === 0)
)
return;
#flushOutboundEvents(): Promise<void> {
if (this.#outboundFlush) return this.#outboundFlush;
if (this.#outboundQueue.size === 0 && this.#outboundBacklogs.size === 0)
return Promise.resolve();
this.#outboundIsFlushing = true;
this.#outboundFlush = this.#sendOutboundEvents();
return this.#outboundFlush;
}
async #sendOutboundEvents(): Promise<void> {
try {
const fresh = this.#outboundQueue;
this.#outboundQueue = new Set();
@@ -504,7 +529,7 @@ export class BroadcastService extends PuterService {
for (const result of flushed)
if (result.status === 'rejected') throw result.reason;
} finally {
this.#outboundIsFlushing = false;
this.#outboundFlush = null;
// Anything that arrived during flush, or is still owed to a peer,
// gets the next tick.
if (
@@ -217,6 +217,97 @@ describe('the addressed peer queue', () => {
).toEqual(['b']);
});
it.each([false, true])(
'waits for an active send and ships its tail after failure %s',
async (failFirst) => {
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
let releaseTail!: () => void;
const tailHeld = new Promise<void>((resolve) => {
releaseTail = resolve;
});
const sent: ForwardItem[][] = [];
const { queue } = makeQueue({
maxItems: 1,
flushMs: 60_000,
send: async (_peerId, items) => {
sent.push(items);
if (sent.length === 1) {
await held;
if (failFirst) throw new Error('peer unavailable');
} else {
await tailHeld;
}
},
});
queue.push('east', delivery('first'));
await vi.waitFor(() => expect(sent).toHaveLength(1));
queue.push('east', delivery('tail'));
let finished = false;
const draining = queue.flushAll().then(() => {
finished = true;
});
try {
await new Promise((resolve) => setTimeout(resolve, 20));
expect(finished).toBe(false);
release();
await vi.waitFor(() => expect(sent).toHaveLength(2));
expect(finished).toBe(false);
} finally {
release();
releaseTail();
await draining;
queue.stop();
}
expect(sent).toEqual([[delivery('first')], [delivery('tail')]]);
},
);
it('releases the peer after a send throws synchronously', async () => {
const send = vi.fn(() => {
throw new Error('send failed');
});
const { queue } = makeQueue({ maxItems: 1, send });
queue.push('east', delivery('first'));
await queue.flushAll();
queue.push('east', delivery('second'));
await queue.flushAll();
expect(send).toHaveBeenCalledTimes(2);
});
it('bounds a final drain while a send is hung', async () => {
vi.useFakeTimers();
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
const send = vi.fn(async () => {
await held;
});
const { queue } = makeQueue({ maxItems: 1, send });
try {
queue.push('east', delivery('first'));
queue.push('east', delivery('tail'));
let finished = false;
const draining = queue.flushAll().then(() => {
finished = true;
});
await vi.advanceTimersByTimeAsync(4_999);
expect(finished).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await draining;
queue.stop();
release();
await vi.advanceTimersByTimeAsync(100);
expect(send).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
} finally {
release();
vi.useRealTimers();
}
});
it('sends nothing more once it is stopped', async () => {
const { queue, sent } = makeQueue();
queue.stop();
+28 -9
View File
@@ -18,6 +18,8 @@
*/
import type { EventKey, KvOp } from '../../clients/event/types.js';
import { settleWithin } from '../../util/concurrency.js';
import { FINAL_FLUSH_TIMEOUT_MS } from '../../util/gracefulShutdown.js';
import type { DeliverableEvent } from './registry.js';
/**
@@ -204,7 +206,7 @@ interface PeerQueue {
items: ForwardItem[];
bytes: number;
timer: ReturnType<typeof setTimeout> | null;
flushing: boolean;
flushing: Promise<void> | null;
}
export class PeerForwardQueue {
@@ -272,19 +274,27 @@ export class PeerForwardQueue {
}
/** Ship whatever is held for one peer. */
async flush(peerId: string): Promise<void> {
flush(peerId: string): Promise<void> {
const queue = this.#peers.get(peerId);
if (!queue || queue.flushing || queue.items.length === 0) return;
if (!queue) return Promise.resolve();
if (queue.flushing) return queue.flushing;
if (queue.items.length === 0) return Promise.resolve();
if (queue.timer) {
clearTimeout(queue.timer);
queue.timer = null;
}
queue.flushing = true;
queue.flushing = this.#send(peerId, queue);
return queue.flushing;
}
async #send(peerId: string, queue: PeerQueue): Promise<void> {
const batch = queue.items;
queue.items = [];
queue.bytes = 0;
try {
// Register the in-flight promise before a send can throw synchronously.
await Promise.resolve();
await this.#options.send(peerId, batch);
} catch (err) {
// A lost batch is a lost at-most-once delivery, and a `single`
@@ -292,7 +302,7 @@ export class PeerForwardQueue {
// hand the client an event whose moment has passed.
console.warn(`[events] forward to ${peerId} failed`, err);
} finally {
queue.flushing = false;
queue.flushing = null;
// Anything that arrived mid-flush gets the next window.
if (queue.items.length > 0 && !queue.timer && !this.#stopped) {
queue.timer = setTimeout(() => {
@@ -305,7 +315,16 @@ export class PeerForwardQueue {
}
async flushAll(): Promise<void> {
await Promise.all([...this.#peers.keys()].map((id) => this.flush(id)));
const deadline = Date.now() + FINAL_FLUSH_TIMEOUT_MS;
const work = Promise.allSettled(
[...this.#peers.entries()].map(async ([id, queue]) => {
await queue.flushing;
if (Date.now() < deadline) await this.flush(id);
}),
);
if (!(await settleWithin(work, FINAL_FLUSH_TIMEOUT_MS))) {
console.warn('[events] final flush timed out');
}
}
stop(): void {
@@ -323,7 +342,7 @@ export class PeerForwardQueue {
items: [],
bytes: 0,
timer: null,
flushing: false,
flushing: null,
};
this.#peers.set(peerId, queue);
return queue;
@@ -349,7 +368,7 @@ const isGapMarker = (item: ForwardItem): boolean =>
*/
const shed = (queue: PeerQueue, count: number): ForwardItem[] => {
const dropped: ForwardItem[] = [];
for (let i = 0; i < queue.items.length && dropped.length < count;) {
for (let i = 0; i < queue.items.length && dropped.length < count; ) {
if (isGapMarker(queue.items[i])) {
i++;
continue;
@@ -372,7 +391,7 @@ const shed = (queue: PeerQueue, count: number): ForwardItem[] => {
const shedBytes = (queue: PeerQueue, maxBytesHeld: number): ForwardItem[] => {
const dropped: ForwardItem[] = [];
let remaining = queue.bytes;
for (let i = 0; i < queue.items.length && remaining > maxBytesHeld;) {
for (let i = 0; i < queue.items.length && remaining > maxBytesHeld; ) {
if (isGapMarker(queue.items[i])) {
i++;
continue;
@@ -0,0 +1,99 @@
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
it,
vi,
} from 'vitest';
import type { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
const { disposals } = vi.hoisted(() => ({
disposals: [] as Array<ReturnType<typeof vi.fn>>,
}));
vi.mock('miniflare', () => ({
Miniflare: class {
dispose = vi.fn(async () => {});
constructor() {
disposals.push(this.dispose);
}
},
}));
describe('LocalWorkerService shutdown disposal', () => {
let server: PuterServer;
beforeAll(async () => {
server = await setupTestServer({
api_base_url: 'http://api.puter.localhost',
} as never);
});
afterAll(async () => {
await server.shutdown();
});
afterEach(() => {
vi.useRealTimers();
disposals.length = 0;
});
it('awaits every disposal even if one fails', async () => {
const service = server.services.localworkerservice;
await service.cfDeployLocal('first', 'auth', 'export default {}');
await service.cfDeployLocal('second', 'auth', 'export default {}');
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
disposals[0].mockRejectedValueOnce(new Error('dispose failed'));
disposals[1].mockImplementationOnce(async () => {
await held;
});
let finished = false;
const draining = Promise.resolve(service.onServerShutdown()).then(
() => {
finished = true;
},
);
try {
await new Promise((resolve) => setTimeout(resolve, 20));
expect(
disposals.every((dispose) => dispose.mock.calls.length === 1),
).toBe(true);
expect(finished).toBe(false);
} finally {
release();
await draining;
}
});
it('bounds disposal of a hung worker to five seconds', async () => {
const service = server.services.localworkerservice;
await service.cfDeployLocal('hung', 'auth', 'export default {}');
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
disposals[0].mockImplementationOnce(async () => {
await held;
});
vi.useFakeTimers();
try {
let finished = false;
const draining = Promise.resolve(service.onServerShutdown()).then(
() => {
finished = true;
},
);
await vi.advanceTimersByTimeAsync(4_999);
expect(finished).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await draining;
expect(vi.getTimerCount()).toBe(0);
} finally {
release();
vi.useRealTimers();
}
});
});
@@ -10,6 +10,8 @@ import {
import { puterStores } from '../../stores';
import type { SubdomainRow } from '../../stores/subdomain/SubdomainStore';
import { LayerInstances } from '../../types';
import { settleWithin } from '../../util/concurrency.js';
import { FINAL_FLUSH_TIMEOUT_MS } from '../../util/gracefulShutdown.js';
import { PuterService } from '../types';
const MAX_SOURCE_SIZE = 10 * 1024 * 1024; // 10 MB
@@ -240,13 +242,16 @@ export class LocalWorkerService extends PuterService {
idleSweepTimer.unref?.();
}
override onServerShutdown(): void {
override async onServerShutdown(): Promise<void> {
if (idleSweepTimer) {
clearInterval(idleSweepTimer);
idleSweepTimer = null;
}
for (const name of [...activeWorkers.keys()]) {
void this.#disposeWorker(name);
const work = Promise.allSettled(
[...activeWorkers.keys()].map((name) => this.#disposeWorker(name)),
);
if (!(await settleWithin(work, FINAL_FLUSH_TIMEOUT_MS))) {
console.warn('[localworker] final flush timed out');
}
}
async reconstructDeployArgs(workerName: string, row: SubdomainRow) {
@@ -0,0 +1,175 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Actor } from '../../core/actor.js';
import type { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import type { ResolvedShare } from './ShareService.js';
describe('ShareNotificationService shutdown drain', () => {
let server: PuterServer;
beforeEach(async () => {
server = await setupTestServer({
email: { from: '[email protected]', host: '127.0.0.1', port: 1 },
share_notify_limits: { emailBatchSeconds: 0.01 },
} as never);
});
afterEach(async () => {
vi.useRealTimers();
await server.services.shareNotification.onServerPrepareShutdown();
await server.services.shareNotification.onServerShutdown();
await server.shutdown();
vi.restoreAllMocks();
});
const queueInvite = async (email: string, issuerId = 7) => {
await server.services.shareNotification.notifyShared(
{
user: { id: issuerId, username: 'sender' },
effectiveApp: null,
} as Actor,
[
{
pending: true,
isNew: true,
recipientEmail: email,
name: 'shared.txt',
} as ResolvedShare,
],
);
};
it.each([false, true])(
'awaits an active timer send and its failure requeue %s',
async (failSend) => {
const email = `active-${failSend}@puter.local`;
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
const send = vi
.spyOn(server.clients.email, 'sendRaw')
.mockImplementation(async () => {
await held;
if (failSend) throw new Error('transport unavailable');
return null;
});
await queueInvite(email);
await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1));
const list = async () =>
server.stores.kv.list({
as: 'keys',
pattern: `share:digest:invite:${email}:`,
});
expect((await list()).res).toEqual([]);
let finished = false;
const draining = server.services.shareNotification
.onServerPrepareShutdown()
.then(() => {
finished = true;
});
try {
await new Promise((resolve) => setTimeout(resolve, 20));
expect(finished).toBe(false);
} finally {
release();
await draining;
}
expect((await list()).res).toHaveLength(failSend ? 1 : 0);
},
);
it('waits for an active send before draining entries queued meanwhile', async () => {
let releaseFirst!: () => void;
let releaseTail!: () => void;
const first = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
const tail = new Promise<void>((resolve) => {
releaseTail = resolve;
});
const send = vi
.spyOn(server.clients.email, 'sendRaw')
.mockImplementationOnce(async () => {
await first;
return null;
})
.mockImplementationOnce(async () => {
await tail;
return null;
});
await queueInvite('[email protected]');
await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1));
(
server.services.shareNotification.config.share_notify_limits as {
emailBatchSeconds: number;
}
).emailBatchSeconds = 600;
await queueInvite('[email protected]', 8);
let finished = false;
const draining = server.services.shareNotification
.onServerPrepareShutdown()
.then(() => {
finished = true;
});
try {
releaseFirst();
await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(2));
expect(finished).toBe(false);
} finally {
releaseFirst();
releaseTail();
await draining;
}
expect(send).toHaveBeenCalledTimes(2);
});
it.each(['onServerPrepareShutdown', 'onServerShutdown'] as const)(
'bounds %s while an active send is hung',
async (hook) => {
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
const send = vi
.spyOn(server.clients.email, 'sendRaw')
.mockImplementationOnce(async () => {
await held;
return null;
});
await queueInvite(`hung-${hook}@puter.local`);
await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1));
vi.useFakeTimers();
try {
let finished = false;
const draining = server.services.shareNotification[hook]().then(
() => {
finished = true;
},
);
await vi.advanceTimersByTimeAsync(4_999);
expect(finished).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await draining;
} finally {
release();
vi.useRealTimers();
}
},
);
it('drains a digest queued after prepare at shutdown', async () => {
await server.services.shareNotification.onServerPrepareShutdown();
const send = vi
.spyOn(server.clients.email, 'sendRaw')
.mockResolvedValue(null);
(
server.services.shareNotification.config.share_notify_limits as {
emailBatchSeconds: number;
}
).emailBatchSeconds = 600;
await queueInvite('[email protected]');
await server.services.shareNotification.onServerShutdown();
expect(send).toHaveBeenCalledTimes(1);
expect(send.mock.calls[0][0].to).toBe('[email protected]');
await new Promise((resolve) => setTimeout(resolve, 30));
expect(send).toHaveBeenCalledTimes(1);
});
});
@@ -20,7 +20,11 @@
import { createHash, randomUUID } from 'node:crypto';
import type { Actor } from '../../core/actor';
import { checkRateLimit } from '../../core/http/middleware/rateLimit.js';
import { runWithConcurrencyLimitSettled } from '../../util/concurrency.js';
import {
runWithConcurrencyLimitSettled,
settleWithin,
} from '../../util/concurrency.js';
import { FINAL_FLUSH_TIMEOUT_MS } from '../../util/gracefulShutdown.js';
import { PuterService } from '../types';
import {
digestItemPaths,
@@ -178,6 +182,7 @@ export class ShareNotificationService extends PuterService {
* queued sends live in KV, where any node's flush can pick them up.
*/
#digestTimers = new Map<string, ReturnType<typeof setTimeout>>();
#digestFlushes = new Map<string, Promise<void>>();
/** The recovery sweep; see `onServerStart`. */
#digestSweep: ReturnType<typeof setInterval> | null = null;
@@ -199,12 +204,46 @@ export class ShareNotificationService extends PuterService {
/** Send what's still waiting while the transport is alive to send it. */
override async onServerPrepareShutdown(): Promise<void> {
await this.#drainDigests();
}
override async onServerShutdown(): Promise<void> {
await this.#drainDigests();
}
async #drainDigests(): Promise<void> {
if (this.#digestSweep) clearInterval(this.#digestSweep);
this.#digestSweep = null;
const keys = [...this.#digestTimers.keys()];
const keys = new Set([
...this.#digestTimers.keys(),
...this.#digestFlushes.keys(),
]);
for (const [, timer] of this.#digestTimers) clearTimeout(timer);
this.#digestTimers.clear();
await Promise.all(keys.map((key) => this.#flushDigest(key)));
const active = [...this.#digestFlushes.values()];
const deadline = Date.now() + FINAL_FLUSH_TIMEOUT_MS;
const drain = async () => {
try {
await Promise.allSettled(active);
if (Date.now() >= deadline) return;
for (const [key, timer] of this.#digestTimers) {
keys.add(key);
clearTimeout(timer);
}
this.#digestTimers.clear();
await Promise.allSettled(
[...keys].map((key) => this.#flushDigest(key)),
);
} catch (err) {
console.warn(
'[share-notify] shutdown digest flush failed:',
err,
);
}
};
if (!(await settleWithin(drain(), FINAL_FLUSH_TIMEOUT_MS))) {
console.warn('[share-notify] final flush timed out');
}
}
/** The recovery sweep, for tests that can't wait out an interval. */
@@ -878,25 +917,37 @@ export class ShareNotificationService extends PuterService {
* everything queued), and across regions the KV `take` is the real claim,
* handing each entry to exactly one flusher.
*/
async #flushDigest(key: string): Promise<void> {
await withRedisLock(
this.clients.redis,
`share:digest:lock:${key}`,
() => this.#flushDigestLocked(key),
{
ttlMs: DIGEST_LOCK_SECONDS * 1000,
// No lock beats no mail; `take` still prevents double sends.
onUnavailable: 'run',
onBusy: () => {
console.log(
'[share-notify] another flush holds the lock:',
{
key,
},
);
#flushDigest(key: string): Promise<void> {
const active = this.#digestFlushes.get(key);
if (active) return active;
const flushing = this.#sendDigest(key);
this.#digestFlushes.set(key, flushing);
return flushing;
}
async #sendDigest(key: string): Promise<void> {
try {
await withRedisLock(
this.clients.redis,
`share:digest:lock:${key}`,
() => this.#flushDigestLocked(key),
{
ttlMs: DIGEST_LOCK_SECONDS * 1000,
// No lock beats no mail; `take` still prevents double sends.
onUnavailable: 'run',
onBusy: () => {
console.log(
'[share-notify] another flush holds the lock:',
{ key },
);
},
},
},
);
);
} catch (err) {
console.warn('[share-notify] digest flush failed:', err);
} finally {
this.#digestFlushes.delete(key);
}
}
async #flushDigestLocked(key: string): Promise<void> {
@@ -122,6 +122,59 @@ describe('SignupService.runSignupGate', () => {
}
});
it('an OIDC-only policy refuses form and temp signups before the validate hook, and lets OIDC and team provisioning through', async () => {
const oidcOnly = (_k: string, data: unknown) => {
Object.assign(data as object, { oidcOnly: true });
};
server.clients.event.on('puter.signup.policy', oidcOnly);
try {
const gate = (input: {
isTemp: boolean;
source?: 'oidc' | 'team_provision';
}) =>
capture('puter.signup.validate', () =>
server.services.signup.runSignupGate({
req,
data: {},
email: `policy-${slug()}@example.com`,
bonusCode: null,
...input,
}),
);
const form = await gate({ isTemp: false });
const temp = await gate({ isTemp: true });
const oidc = await gate({ isTemp: false, source: 'oidc' });
const team = await gate({
isTemp: false,
source: 'team_provision',
});
expect(form.result).toMatchObject({
verdict: 'blocked',
code: 'signup_oidc_only',
});
expect(temp.result).toEqual({
verdict: 'no_temp_user',
message: null,
code: null,
});
expect(form.seen).toHaveLength(0);
expect(temp.seen).toHaveLength(0);
expect(oidc.result.verdict).toBe('allowed');
expect(team.result.verdict).toBe('allowed');
expect(await server.services.signup.signupPolicy()).toEqual({
oidcOnly: true,
message: null,
});
} finally {
server.clients.event.off('puter.signup.policy', oidcOnly);
}
expect(await server.services.signup.signupPolicy()).toEqual({
oidcOnly: false,
message: null,
});
});
it('refuses an unknown bonus code before the validate hook runs', async () => {
const { result, seen } = await capture('puter.signup.validate', () =>
server.services.signup.runSignupGate({
@@ -89,6 +89,9 @@ export interface SignupInput {
export const signupClientIp = (req: Request): string | null =>
req.ip || req.socket?.remoteAddress || null;
const OIDC_ONLY_MESSAGE =
'New accounts can only be created with a sign-in provider right now.';
/**
* Account creation for every signup route (form, temp, OIDC) and email
* confirmation for every confirmation route. Callers parse requests and set
@@ -147,6 +150,30 @@ export class SignupService extends PuterService {
}
}
/**
* The signup methods extensions allow right now; a failing hook restricts
* nothing.
*/
async signupPolicy(): Promise<{
oidcOnly: boolean;
message: string | null;
}> {
const policy = { oidcOnly: false, message: null as string | null };
try {
await this.clients.event?.emitAndWait(
'puter.signup.policy',
policy,
{},
);
} catch (e) {
console.warn('[signup] policy hook failed:', e);
}
return {
oidcOnly: policy.oidcOnly === true,
message: typeof policy.message === 'string' ? policy.message : null,
};
}
/**
* The extension-owned verdict on a signup: a dead bonus code is refused
* first (before `puter.signup.validate` records the attempt), then the
@@ -157,6 +184,23 @@ export class SignupService extends PuterService {
const clientIp = signupClientIp(req);
const fingerprint = input.fingerprint ?? null;
// Only form and temp signups carry no source; OIDC and team
// provisioning are never restricted by the policy.
if (!input.source) {
const policy = await this.signupPolicy();
if (policy.oidcOnly) {
// A refused temp signup sends the GUI to its signup window,
// which then offers only the providers.
return isTemp
? { verdict: 'no_temp_user', message: null, code: null }
: {
verdict: 'blocked',
message: policy.message ?? OIDC_ONLY_MESSAGE,
code: 'signup_oidc_only',
};
}
}
if (
bonusCode &&
!(
+10 -4
View File
@@ -256,7 +256,12 @@ export interface IPreludeConfig {
* an RCS agent provisioned in the Prelude account to actually use RCS.
*/
preferredChannel?:
'sms' | 'rcs' | 'whatsapp' | 'viber' | 'zalo' | 'telegram';
| 'sms'
| 'rcs'
| 'whatsapp'
| 'viber'
| 'zalo'
| 'telegram';
}
/**
@@ -1375,7 +1380,9 @@ export interface WithLifecycle extends Object {
/**
* When the drain starts, before `onServerPrepareShutdown`, which waits for
* every connection (upgraded sockets included) to close or the drain to
* end.
* end. Hooks run concurrently, and the server keeps accepting connections
* until all of them settle, so a hook can hold the listener open (until
* traffic stops arriving, say) by returning a promise.
*/
onServerDrainStart?: (info?: ServerDrainInfo) => Promise<void> | void;
}
@@ -1386,8 +1393,7 @@ export interface ServerDrainInfo {
}
export interface WithCostsReporting extends WithLifecycle {
getReportedCosts?: () =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getReportedCosts?: () => // eslint-disable-next-line @typescript-eslint/no-explicit-any
| Promise<Record<string, any>[]>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
| Record<string, any>[];
+63 -10
View File
@@ -3,23 +3,25 @@
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
awaitLater,
settleWithin,
runWithConcurrencyLimit,
runWithConcurrencyLimitSettled,
} from './concurrency.ts';
@@ -221,3 +223,54 @@ describe('awaitLater', () => {
expect(await awaitLater(Promise.resolve(42))).toBe(42);
});
});
describe('settleWithin', () => {
afterEach(() => vi.useRealTimers());
it('returns true and clears the timer when work settles in time', async () => {
vi.useFakeTimers();
const work = Promise.withResolvers<void>();
const waiting = settleWithin(work.promise, 5000);
expect(vi.getTimerCount()).toBe(1);
work.resolve();
await expect(waiting).resolves.toBe(true);
expect(vi.getTimerCount()).toBe(0);
});
it('returns false and clears the timer when time runs out', async () => {
vi.useFakeTimers();
const cleared = vi.spyOn(globalThis, 'clearTimeout');
try {
const waiting = settleWithin(new Promise(() => {}), 5000);
await vi.advanceTimersByTimeAsync(5000);
await expect(waiting).resolves.toBe(false);
expect(cleared).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
} finally {
cleared.mockRestore();
}
});
it.each([false, true])(
'handles a rejection after timeout %s',
async (afterTimeout) => {
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown) => unhandled.push(reason);
process.on('unhandledRejection', onUnhandled);
try {
const work = Promise.withResolvers<void>();
const waiting = settleWithin(
work.promise,
afterTimeout ? 0 : 5000,
);
if (afterTimeout) await expect(waiting).resolves.toBe(false);
work.reject(new Error('work failed'));
await expect(waiting).resolves.toBe(!afterTimeout);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(unhandled).toEqual([]);
} finally {
process.off('unhandledRejection', onUnhandled);
}
},
);
});
+29
View File
@@ -110,3 +110,32 @@ export function awaitLater<T>(promise: Promise<T>): Promise<T> {
})();
return promise;
}
/**
* Waits for `promise` for at most `ms`, without rejecting. Resolves `true` if
* it settled in time, `false` if the time ran out first.
*/
export async function settleWithin(
promise: Promise<unknown>,
ms: number,
): Promise<boolean> {
let timer: ReturnType<typeof setTimeout> | undefined;
const settled = async () => {
try {
await promise;
} catch {
// Callers report work failures.
}
return true;
};
try {
return await Promise.race([
settled(),
new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(false), ms);
}),
]);
} finally {
clearTimeout(timer);
}
}
+46
View File
@@ -83,6 +83,52 @@ describe('createGracefulShutdown', () => {
expect(drainInfo).toEqual([{ drainMs: 90_000 }]);
});
it('counts time spent in prepareShutdown against the drain window', async () => {
const exit = vi.fn((code: number) => events.push(`exit:${code}`));
const target = makeTarget();
const trigger = createGracefulShutdown(
{
...target,
prepareShutdown: async (info) => {
await target.prepareShutdown(info);
await new Promise((resolve) => setTimeout(resolve, 15_000));
},
},
{ drainMs: 90_000, exit },
);
const done = trigger('SIGTERM');
await vi.advanceTimersByTimeAsync(89_999);
expect(events).toEqual(['prepare']);
await vi.advanceTimersByTimeAsync(1);
await done;
expect(events).toEqual(['prepare', 'shutdown', 'telemetry', 'exit:0']);
});
it('waits for prepareShutdown when it outlasts the drain window', async () => {
const exit = vi.fn((code: number) => events.push(`exit:${code}`));
const target = makeTarget();
const trigger = createGracefulShutdown(
{
...target,
prepareShutdown: async (info) => {
await target.prepareShutdown(info);
await new Promise((resolve) => setTimeout(resolve, 2_000));
},
},
{ drainMs: 1_000, exit },
);
const done = trigger('SIGTERM');
await vi.advanceTimersByTimeAsync(1_999);
expect(events).toEqual(['prepare']);
await vi.advanceTimersByTimeAsync(1);
await done;
expect(events).toEqual(['prepare', 'shutdown', 'telemetry', 'exit:0']);
});
it('SIGINT with a drain function returning 0 for SIGINT exits without waiting', async () => {
const exit = vi.fn((code: number) => events.push(`exit:${code}`));
const drainMs = (signal: string) => (signal === 'SIGTERM' ? 90_000 : 0);
+12 -6
View File
@@ -27,9 +27,9 @@ export interface ShutdownTarget {
export interface GracefulShutdownOptions {
/**
* How long to keep serving after new connections stop, before teardown. A
* function is called with the signal that triggered shutdown, so a
* deployment can drain on `SIGTERM` but exit `SIGINT` (Ctrl-C) instantly.
* How long to keep serving after the signal, before teardown. A function is
* called with the signal that triggered shutdown, so a deployment can drain
* on `SIGTERM` but exit `SIGINT` (Ctrl-C) instantly.
*/
drainMs: number | ((signal: string) => number);
/** Cap on the final telemetry flush. */
@@ -45,6 +45,8 @@ export interface GracefulShutdownOptions {
export const DEFAULT_TELEMETRY_SHUTDOWN_TIMEOUT_MS = 5_000;
export const DEFAULT_SHUTDOWN_HARD_LIMIT_MS = 25_000;
/** Cap on one component's final flush, so teardown fits the hard limit. */
export const FINAL_FLUSH_TIMEOUT_MS = 5_000;
/**
* The process's one shutdown sequence: stop accepting connections, drain, tear
@@ -79,14 +81,18 @@ export const createGracefulShutdown = (
exitOnce(1);
}, drain + hardLimitMs);
let exitCode = 0;
// The drain window starts at the signal, so time `prepareShutdown`
// spends holding the listener open counts against it.
const drained =
drain > 0
? new Promise<void>((resolve) => setTimeout(resolve, drain))
: Promise.resolve();
try {
await target.prepareShutdown({ drainMs: drain });
} catch (err) {
console.error('[shutdown] prepareShutdown failed', err);
}
if (drain > 0) {
await new Promise<void>((resolve) => setTimeout(resolve, drain));
}
await drained;
try {
await target.shutdown();
} catch (err) {
+408 -127
View File
@@ -17,8 +17,13 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { isPlausibleHostname, normalizeHostnameInput } from '../helpers/customDomainHostname.js';
let attach_modal_seq = 0;
const SUCCESS_AUTOCLOSE_MS = 1800;
const COPIED_RESET_MS = 1400;
/** API error code → message key; anything unlisted gets the generic error. */
const ERROR_KEYS = {
invalid_hostname: 'custom_domain_error_invalid',
@@ -31,6 +36,25 @@ const ERROR_KEYS = {
custom_domains_permission_required: 'custom_domain_error_generic',
};
/** Domain status → the badge shown for it; anything else reads as pending. */
const STATUS_BADGE_KEYS = {
active: 'custom_domain_badge_active',
pending: 'custom_domain_badge_pending',
failed: 'custom_domain_badge_failed',
};
const statusOf = (domain) => (domain?.status in STATUS_BADGE_KEYS ? domain.status : 'pending');
const svg = (body, strokeWidth = 2) =>
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
const ICON_GLOBE = svg('<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a14 14 0 0 1 0 18a14 14 0 0 1 0-18z"/>', 1.8);
const ICON_TRASH = svg('<path d="M4 7h16"/><path d="M10 11v6M14 11v6"/><path d="M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12"/><path d="M9 7V4h6v3"/>', 1.8);
const ICON_COPY = svg('<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V6a2 2 0 0 1 2-2h8"/>', 1.8);
const ICON_CHECK = svg('<path d="M20 6 9 17l-5-5"/>', 2.4);
const ICON_CLOCK = svg('<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>');
const ICON_ALERT = svg('<circle cx="12" cy="12" r="9"/><path d="M12 7.5v5"/><path d="M12 16.5h.01"/>', 2.2);
const ICON_PLUS = svg('<path d="M12 5v14M5 12h14"/>', 2.2);
const ICON_X = svg('<line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/>', 2.2);
const format_price = (pricing) => {
if ( ! pricing || typeof pricing.amount !== 'number' ) return null;
try {
@@ -50,13 +74,14 @@ const format_price = (pricing) => {
* from another site if need be) or adds one; the caller learns only the
* domain that ends up chosen.
*
* Built on the app feedback modal's shell (`.app-feedback-*`) so it renders
* the same on the desktop and in the standalone popup. Talks to the
* Built on the app feedback modal's shell (`.app-feedback-*`). Talks to the
* `/custom-domains` API with the GUI's own session: the requesting app never
* holds a token that can manage domains.
*
* @param {{ subdomain: string, requester?: string }} options `requester` is
* the app title or opener origin shown to the user, as the GUI resolved it.
* @param {{ subdomain: string, requester?: string, fullpage?: boolean }} options
* `requester` is the app title or opener origin shown to the user, as the
* GUI resolved it. `fullpage` fills the viewport instead of floating over
* it, for a popup window that exists only to host this dialog.
* @returns {Promise<object|null>} The chosen domain as the API returns it, or
* null when the user closes the dialog first. Never rejects.
*/
@@ -75,22 +100,28 @@ async function UIWindowAttachCustomDomain (options) {
let chosen = null;
const titleId = `${modal_id}-title`;
const closeLabel = html_encode(i18n('close'));
const h = `
<div class="app-feedback-overlay custom-domain-overlay" role="dialog" aria-modal="true" aria-labelledby="${titleId}">
<div class="app-feedback-overlay custom-domain-overlay${options.fullpage ? ' custom-domain-fullpage' : ''}" role="dialog" aria-modal="true" aria-labelledby="${titleId}">
<div class="app-feedback-modal custom-domain-modal" tabindex="-1">
<div class="app-feedback-head">
<h2 class="app-feedback-title" id="${titleId}">${i18n('custom_domain_title')}</h2>
<button type="button" class="app-feedback-x" aria-label="${html_encode(i18n('close'))}" title="${html_encode(i18n('close'))}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
</button>
<div class="app-feedback-head custom-domain-head">
<div class="custom-domain-head-icon">${ICON_GLOBE}</div>
<div class="custom-domain-head-text">
<h2 class="app-feedback-title" id="${titleId}">${i18n('custom_domain_title')}</h2>
<p class="custom-domain-intro">${i18n('custom_domain_intro', [site_host])}</p>
${options.requester ? `<p class="custom-domain-requester">${i18n('custom_domain_requested_by', [options.requester])}</p>` : ''}
</div>
<button type="button" class="app-feedback-x" aria-label="${closeLabel}" title="${closeLabel}">${ICON_X}</button>
</div>
<div class="app-feedback-body">
<p class="custom-domain-intro">${i18n('custom_domain_intro', [site_host])}</p>
${options.requester ? `<p class="custom-domain-requester">${i18n('custom_domain_requested_by', [options.requester])}</p>` : ''}
<div class="app-feedback-body custom-domain-body">
<div class="custom-domain-pane" data-pane="loading" aria-busy="true">
<span class="custom-domain-sr">${i18n('loading')}…</span>
<div class="custom-domain-skeleton"><span></span><span></span></div>
<div class="custom-domain-skeleton"><span></span><span></span></div>
</div>
<div class="app-feedback-loading custom-domain-pane" data-pane="loading">${i18n('loading')}…</div>
<div class="custom-domain-pane" data-pane="unavailable" style="display:none;">
<div class="custom-domain-pane custom-domain-state" data-pane="unavailable" style="display:none;">
<div class="custom-domain-state-icon">${ICON_ALERT}</div>
<p class="app-feedback-unavailable-message"></p>
<div class="app-feedback-actions">
<button type="button" class="app-feedback-btn app-feedback-btn-primary custom-domain-close">${i18n('close')}</button>
@@ -99,28 +130,30 @@ async function UIWindowAttachCustomDomain (options) {
<div class="custom-domain-pane" data-pane="list" style="display:none;">
<ul class="custom-domain-list"></ul>
<p class="custom-domain-empty" style="display:none;">${i18n('custom_domain_none_yet')}</p>
<div class="custom-domain-state custom-domain-empty" style="display:none;">
<div class="custom-domain-state-icon">${ICON_GLOBE}</div>
<p class="custom-domain-state-title">${i18n('custom_domain_none_yet')}</p>
<p class="custom-domain-state-text">${i18n('custom_domain_empty_hint')}</p>
<button type="button" class="app-feedback-btn app-feedback-btn-primary custom-domain-to-add custom-domain-empty-add">${ICON_PLUS}<span>${i18n('custom_domain_add_new')}</span></button>
</div>
<p class="app-feedback-error" role="alert" style="display:none;"></p>
<div class="app-feedback-actions">
<button type="button" class="app-feedback-btn custom-domain-close">${i18n('cancel')}</button>
<button type="button" class="app-feedback-btn app-feedback-btn-primary custom-domain-to-add">${i18n('custom_domain_add_new')}</button>
<button type="button" class="app-feedback-btn app-feedback-btn-primary custom-domain-to-add custom-domain-list-add">${ICON_PLUS}<span>${i18n('custom_domain_add_new')}</span></button>
</div>
</div>
<form class="custom-domain-pane" data-pane="add" style="display:none;">
<form class="custom-domain-pane" data-pane="add" style="display:none;" novalidate>
<label class="custom-domain-label" for="${modal_id}-hostname">${i18n('custom_domain_hostname_label')}</label>
<input type="text" class="custom-domain-input" id="${modal_id}-hostname" placeholder="www.example.com" autocomplete="off" autocapitalize="off" spellcheck="false">
<p class="app-feedback-note custom-domain-price" style="display:none;"></p>
<div class="custom-domain-field">
${ICON_GLOBE}
<input type="text" class="custom-domain-input" id="${modal_id}-hostname" placeholder="www.example.com" autocomplete="off" autocapitalize="off" spellcheck="false" inputmode="url" enterkeyhint="go">
</div>
<p class="app-feedback-note custom-domain-hint">${i18n('custom_domain_hostname_hint')}</p>
<p class="custom-domain-callout custom-domain-price" style="display:none;"></p>
<div class="custom-domain-claim" style="display:none;">
<p class="app-feedback-note">${i18n('custom_domain_claim_c2a')}</p>
<table class="custom-domain-records">
<thead><tr><th>${i18n('custom_domain_record_type')}</th><th>${i18n('name')}</th><th>${i18n('custom_domain_record_value')}</th></tr></thead>
<tbody><tr>
<td>TXT</td>
<td><code class="custom-domain-copy custom-domain-claim-name"></code></td>
<td><code class="custom-domain-copy custom-domain-claim-value"></code></td>
</tr></tbody>
</table>
<p class="custom-domain-callout custom-domain-callout-warn">${i18n('custom_domain_claim_c2a')}</p>
<div class="custom-domain-records"></div>
</div>
<p class="app-feedback-error" role="alert" style="display:none;"></p>
<div class="app-feedback-actions">
@@ -130,21 +163,32 @@ async function UIWindowAttachCustomDomain (options) {
</form>
<div class="custom-domain-pane" data-pane="records" style="display:none;">
<p class="custom-domain-status"></p>
<p class="app-feedback-note custom-domain-records-c2a">${i18n('custom_domain_records_c2a')}</p>
<table class="custom-domain-records">
<thead><tr><th>${i18n('custom_domain_record_type')}</th><th>${i18n('name')}</th><th>${i18n('custom_domain_record_value')}</th></tr></thead>
<tbody></tbody>
</table>
<div class="custom-domain-status-card">
<div class="custom-domain-status-icon"></div>
<div class="custom-domain-status-main">
<p class="custom-domain-status"></p>
<p class="custom-domain-status-detail" style="display:none;"></p>
</div>
</div>
<p class="custom-domain-records-c2a">${i18n('custom_domain_records_c2a')}</p>
<div class="custom-domain-records"></div>
<p class="app-feedback-note custom-domain-redirect" style="display:none;"></p>
<p class="app-feedback-note custom-domain-apex" style="display:none;">${i18n('custom_domain_apex_hint')}</p>
<button type="button" class="app-feedback-btn custom-domain-use-www" style="display:none;">${i18n('custom_domain_no_root_alias')}</button>
<button type="button" class="custom-domain-link-btn custom-domain-use-www" style="display:none;">${i18n('custom_domain_no_root_alias')}</button>
<p class="app-feedback-error" role="alert" style="display:none;"></p>
<p class="custom-domain-callout custom-domain-callout-danger custom-domain-remove-note" style="display:none;">${i18n('custom_domain_remove_confirm')}</p>
<div class="app-feedback-actions">
<button type="button" class="app-feedback-btn custom-domain-btn-danger-text custom-domain-remove-chosen">${i18n('custom_domain_remove')}</button>
<button type="button" class="app-feedback-btn custom-domain-check">${i18n('custom_domain_check_status')}</button>
<button type="button" class="app-feedback-btn app-feedback-btn-primary custom-domain-done">${i18n('done')}</button>
</div>
</div>
<div class="custom-domain-pane app-feedback-success custom-domain-success" data-pane="success" role="status" style="display:none;">
<div class="app-feedback-success-check">${ICON_CHECK}</div>
<p class="app-feedback-success-text custom-domain-success-text"></p>
<p class="app-feedback-note custom-domain-success-note"></p>
</div>
</div>
</div>
</div>
@@ -153,6 +197,9 @@ async function UIWindowAttachCustomDomain (options) {
const $overlay = $(h);
$('body').append($overlay);
requestAnimationFrame(() => $overlay.addClass('app-feedback-open'));
const modalEl = $overlay.find('.custom-domain-modal').get(0);
const focusOn = (el) => el?.focus({ preventScroll: true });
focusOn(modalEl);
const close = () => {
if ( closed ) return;
@@ -162,19 +209,25 @@ async function UIWindowAttachCustomDomain (options) {
setTimeout(() => $overlay.remove(), 160);
resolve(chosen);
};
const finish = (domain) => {
chosen = domain;
close();
};
let currentPane = 'loading';
const paneOf = (el) => $(el).closest('.custom-domain-pane').attr('data-pane');
// A focused control that gets hidden or disabled hands focus to the
// page behind the dialog; keep it in the dialog instead.
const focusLost = () => ! document.activeElement || document.activeElement === document.body;
const showPane = (pane) => {
const from = paneOf(document.activeElement);
$overlay.find('.custom-domain-pane').hide();
$overlay.find('.app-feedback-error').hide();
$overlay.find(`.custom-domain-pane[data-pane="${pane}"]`).show();
currentPane = pane;
// The modal scrolls when it floats, the body when it fills the page.
$overlay.find('.custom-domain-modal, .custom-domain-body').scrollTop(0);
if ( focusLost() || (from && from !== pane) ) focusOn(modalEl);
};
// Plain strings into `.text()`: i18n's HTML encoding would show up
// literally.
const text = (key) => i18n(key, [], false);
const text = (key, args = []) => i18n(key, args, false);
const showUnavailable = (key) => {
$overlay.find('.app-feedback-unavailable-message').text(text(key));
showPane('unavailable');
@@ -183,10 +236,22 @@ async function UIWindowAttachCustomDomain (options) {
$overlay.find(`.custom-domain-pane[data-pane="${pane}"] .app-feedback-error`)
.text(message).show();
};
const setBusy = (value) => {
/** `$trigger` shows the spinner; every other control just waits. */
let focusedBeforeBusy = null;
const setBusy = (value, $trigger) => {
busy = value;
if ( value ) focusedBeforeBusy = document.activeElement;
$overlay.find('button').prop('disabled', value);
if ( ! value ) updateAddButton();
$overlay.find('.custom-domain-input').prop('readonly', value);
$overlay.find('.is-loading').removeClass('is-loading');
if ( value && $trigger ) $trigger.addClass('is-loading');
if ( value ) return;
updateAddButton();
if ( focusLost() && focusedBeforeBusy?.isConnected && paneOf(focusedBeforeBusy) === currentPane ) {
focusOn(focusedBeforeBusy);
}
if ( focusLost() ) focusOn(modalEl);
focusedBeforeBusy = null;
};
const api = async (method, path = '', body) => {
@@ -219,11 +284,21 @@ async function UIWindowAttachCustomDomain (options) {
return `${text('custom_domain_error_generic')} (HTTP ${res.status})`;
};
// Escape, the backdrop and the close buttons all dismiss, except
// while a request is in flight: closing then would report no domain
// for one that may still land server-side.
// Escape backs out of a pending confirmation first. Escape, the
// backdrop and the close buttons all dismiss, except while a request
// is in flight: closing then would report no domain for one that may
// still land server-side.
$(document).on(`keydown.${modal_id}`, (e) => {
if ( e.key === 'Escape' && ! busy ) close();
if ( e.key !== 'Escape' || busy ) return;
if ( $overlay.find('.custom-domain-row[data-confirming]').length ) {
resetConfirming();
return;
}
if ( $overlay.find('.custom-domain-remove-chosen.custom-domain-confirming').length ) {
resetRemoveChosen();
return;
}
close();
});
$overlay.on('mousedown', (e) => {
// Same reason as the feedback modal: keep the press from focusing
@@ -235,57 +310,203 @@ async function UIWindowAttachCustomDomain (options) {
if ( ! busy ) close();
});
/**
* True once `el`'s text is on the clipboard. Without clipboard access
* (an insecure origin has no `navigator.clipboard` at all), the text
* is selected for the legacy copy and stays selected to copy by hand.
*/
const copyText = async (el) => {
try {
await navigator.clipboard.writeText(el.textContent);
return true;
} catch {
const range = document.createRange();
range.selectNodeContents(el);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
try {
return document.execCommand('copy');
} catch {
return false;
}
}
};
// Copy buttons flip to a check for a moment once the value is copied.
$overlay.on('click', '.custom-domain-copy', async function () {
const $btn = $(this);
if ( ! await copyText($btn.siblings('code').get(0)) ) return;
clearTimeout($btn.data('reset'));
$btn.addClass('is-copied').attr('aria-label', text('copied')).attr('title', text('copied'));
$btn.data('reset', setTimeout(() => {
$btn.removeClass('is-copied').attr('aria-label', text('copy')).attr('title', text('copy'));
}, COPIED_RESET_MS));
});
/**
* One DNS record as a card: its type, then the name and value, each
* with a copy button.
*/
const recordCard = (record, typeLabel) => {
const $card = $(`
<div class="custom-domain-record">
<div class="custom-domain-record-head">
<span class="custom-domain-record-type"></span>
<span class="custom-domain-record-optional" style="display:none;">${i18n('custom_domain_optional')}</span>
</div>
<div class="custom-domain-record-field">
<span class="custom-domain-record-label">${i18n('name')}</span>
<code></code>
<button type="button" class="custom-domain-copy">${ICON_COPY}${ICON_CHECK}</button>
</div>
<div class="custom-domain-record-field">
<span class="custom-domain-record-label">${i18n('custom_domain_record_value')}</span>
<code></code>
<button type="button" class="custom-domain-copy">${ICON_COPY}${ICON_CHECK}</button>
</div>
</div>
`);
$card.find('.custom-domain-record-type').text(typeLabel ?? record.type);
$card.find('.custom-domain-record-optional').toggle(record.required === false);
const [$name, $value] = $card.find('code').toArray().map((el) => $(el));
$name.text(record.name);
$value.text(record.value);
$card.find('.custom-domain-copy').attr({ 'aria-label': text('copy'), title: text('copy') });
return $card;
};
// -- Success --------------------------------------------------------
// The check is the last thing the user sees: the domain serves this
// site, so there's nothing left to do here.
const showSuccess = (domain) => {
chosen = domain;
$overlay.find('.custom-domain-success-text').html(i18n('custom_domain_status_active', [domain.hostname]));
$overlay.find('.custom-domain-success-note').html(i18n('custom_domain_success_note', [domain.hostname, site_host]));
showPane('success');
setTimeout(close, SUCCESS_AUTOCLOSE_MS);
};
/** Where to go once `domain` serves this site. */
const settleOn = (domain) => {
if ( statusOf(domain) === 'active' ) {
showSuccess(domain);
return;
}
chosen = domain;
renderRecords(domain);
showPane('records');
};
// -- List ---------------------------------------------------------
let listed = [];
const metaFor = (item) => item.site !== subdomain
? text('custom_domain_serves', [`${item.site}.${window.hosting_domain}`])
: text('custom_domain_serves_this');
// With no domains, adding one is the only way on, so the button sits
// in the empty state where the eye lands instead of the footer.
const toggleEmpty = () => {
$overlay.find('.custom-domain-empty').toggle(listed.length === 0);
$overlay.find('.custom-domain-list-add').toggle(listed.length > 0);
};
// One row asks for confirmation at a time; asking on another puts the
// first back.
const resetConfirming = () => {
$overlay.find('.custom-domain-row[data-confirming]').each(function () {
const $row = $(this);
const kind = $row.attr('data-confirming');
const hadFocus = $.contains($row.find('.custom-domain-row-confirm').get(0), document.activeElement);
$row.removeAttr('data-confirming');
$row.find('.custom-domain-row-meta').text(metaFor($row.data('item')));
// Back to the button that asked, rather than one now hidden.
if ( hadFocus ) {
focusOn($row.find(kind === 'move' ? '.custom-domain-use' : '.custom-domain-remove').get(0));
}
});
};
const confirmOn = ($row, kind, message) => {
resetConfirming();
$row.attr('data-confirming', kind);
$row.find('.custom-domain-row-meta').text(message);
$row.find('.custom-domain-confirm-go')
.text(text(kind === 'move' ? 'custom_domain_move' : 'custom_domain_remove'))
.get(0)?.focus({ preventScroll: true });
};
const renderList = (items) => {
listed = items;
const $list = $overlay.find('.custom-domain-list').empty();
$overlay.find('.custom-domain-empty').toggle(items.length === 0);
toggleEmpty();
for ( const item of items ) {
const elsewhere = item.site !== subdomain;
const status = statusOf(item);
const $row = $(`
<li class="custom-domain-row">
<span class="custom-domain-row-icon">${ICON_GLOBE}</span>
<div class="custom-domain-row-main">
<div class="custom-domain-row-host"></div>
<div class="custom-domain-row-title">
<span class="custom-domain-row-host"></span>
<span class="custom-domain-badge" data-status="${status}">${i18n(STATUS_BADGE_KEYS[status])}</span>
</div>
<div class="custom-domain-row-meta"></div>
</div>
<button type="button" class="app-feedback-btn custom-domain-use">${i18n('custom_domain_use')}</button>
<div class="custom-domain-row-actions">
<button type="button" class="custom-domain-icon-btn custom-domain-remove">${ICON_TRASH}</button>
<button type="button" class="app-feedback-btn custom-domain-use">${i18n('custom_domain_use')}</button>
</div>
<div class="custom-domain-row-confirm">
<button type="button" class="app-feedback-btn custom-domain-confirm-cancel">${i18n('cancel')}</button>
<button type="button" class="app-feedback-btn custom-domain-confirm-go"></button>
</div>
</li>
`);
$row.data('item', item);
$row.toggleClass('is-current', item.site === subdomain);
$row.find('.custom-domain-row-host').text(item.hostname);
$row.find('.custom-domain-row-meta').text(elsewhere
? i18n('custom_domain_serves', [`${item.site}.${window.hosting_domain}`], false)
: i18n('custom_domain_serves_this', [], false));
$row.find('.custom-domain-row-meta').text(metaFor(item));
$row.find('.custom-domain-remove').attr({
'aria-label': text('custom_domain_remove_named', [item.hostname]),
title: text('custom_domain_remove'),
});
$row.find('.custom-domain-use').on('click', () => use(item, $row));
$row.find('.custom-domain-remove').on('click', () => {
if ( ! busy ) confirmOn($row, 'remove', text('custom_domain_remove_confirm'));
});
$row.find('.custom-domain-confirm-cancel').on('click', () => {
if ( ! busy ) resetConfirming();
});
$row.find('.custom-domain-confirm-go').on('click', (e) => {
const $btn = $(e.currentTarget);
if ( $row.attr('data-confirming') === 'move' ) move(item, $btn);
else remove(item, $row, $btn);
});
$list.append($row);
}
};
const use = async (item, $row) => {
const use = (item, $row) => {
if ( busy ) return;
if ( item.site === subdomain ) {
finish(item);
settleOn(item);
return;
}
// Moving takes the domain off another site, so say so first.
if ( ! $row.hasClass('custom-domain-confirming') ) {
$overlay.find('.custom-domain-confirming').removeClass('custom-domain-confirming')
.find('.custom-domain-use').text(text('custom_domain_use'));
$row.addClass('custom-domain-confirming');
$row.find('.custom-domain-row-meta').text(i18n('custom_domain_move_confirm', [
`${item.site}.${window.hosting_domain}`,
site_host,
], false));
$row.find('.custom-domain-use').text(text('custom_domain_move'));
return;
}
setBusy(true);
confirmOn($row, 'move', text('custom_domain_move_confirm', [
`${item.site}.${window.hosting_domain}`,
site_host,
]));
};
const move = async (item, $btn) => {
if ( busy ) return;
setBusy(true, $btn);
try {
const res = await api('PATCH', `/${encodeURIComponent(item.uuid)}`, { site: subdomain });
if ( ! res.ok ) {
showError('list', errorFor(res));
return;
}
finish(res.data);
settleOn(res.data);
} catch ( e ) {
console.error('custom-domain: move failed', e);
showError('list', text('custom_domain_error_generic'));
@@ -294,11 +515,57 @@ async function UIWindowAttachCustomDomain (options) {
}
};
/**
* Removes the domain from the account, which also stops its rent.
* True once it's gone, including when it already was.
*/
const deleteDomain = async (item, pane, $btn) => {
setBusy(true, $btn);
try {
const res = await api('DELETE', `/${encodeURIComponent(item.uuid)}`);
if ( ! res.ok && res.status !== 404 ) {
showError(pane, errorFor(res));
return false;
}
// A removed domain is no answer to hand the caller.
if ( chosen?.uuid === item.uuid ) chosen = null;
return true;
} catch ( e ) {
console.error('custom-domain: remove failed', e);
showError(pane, text('custom_domain_error_generic'));
return false;
} finally {
setBusy(false);
}
};
const remove = async (item, $row, $btn) => {
if ( busy ) return;
if ( ! await deleteDomain(item, 'list', $btn) ) return;
listed = listed.filter((i) => i.uuid !== item.uuid);
// Focus moves to the next row, else the previous one, else to
// adding a domain once the empty state shows.
const hadFocus = $.contains($row.get(0), document.activeElement);
const $next = $row.nextAll('.custom-domain-row:not(.is-leaving)').first();
const $other = $next.length ? $next : $row.prevAll('.custom-domain-row:not(.is-leaving)').first();
if ( hadFocus ) focusOn($other.find('.custom-domain-remove').get(0));
// Gone server-side: its buttons can't act on it again as it fades.
$row.addClass('is-leaving').attr('inert', '');
setTimeout(() => {
$row.remove();
toggleEmpty();
if ( hadFocus && focusLost() ) {
focusOn($overlay.find(listed.length ? '.custom-domain-list-add' : '.custom-domain-empty-add').get(0));
}
}, 180);
};
// -- Add ----------------------------------------------------------
const $input = $overlay.find('.custom-domain-input');
const typedHostname = () => normalizeHostnameInput($input.val());
const updateAddButton = () => {
$overlay.find('.custom-domain-add').prop('disabled', busy || String($input.val() || '').trim() === '');
$overlay.find('.custom-domain-add').prop('disabled', busy || ! isPlausibleHostname(typedHostname()));
};
// A claim is for one hostname; editing it starts over.
const hideClaim = () => {
@@ -306,22 +573,20 @@ async function UIWindowAttachCustomDomain (options) {
$overlay.find('.custom-domain-add').text(text('custom_domain_add'));
};
const showClaim = (record) => {
$overlay.find('.custom-domain-claim-name').text(record.name);
$overlay.find('.custom-domain-claim-value').text(record.value);
$overlay.find('.custom-domain-claim .custom-domain-records')
.empty()
.append(recordCard({ type: 'TXT', name: record.name, value: record.value, required: true }));
$overlay.find('.custom-domain-claim').show();
$overlay.find('.custom-domain-add').text(text('custom_domain_verify_and_add'));
};
$input.on('input', () => {
hideClaim();
$overlay.find('[data-pane="add"] .app-feedback-error').hide();
updateAddButton();
});
$overlay.find('.custom-domain-claim .custom-domain-copy')
.attr('title', text('copy'))
.on('click', function () {
navigator.clipboard?.writeText($(this).text()).catch(() => {});
});
$overlay.find('.custom-domain-to-add').on('click', () => {
resetConfirming();
showPane('add');
$input.get(0)?.focus({ preventScroll: true });
});
@@ -331,9 +596,10 @@ async function UIWindowAttachCustomDomain (options) {
$overlay.find('form[data-pane="add"]').on('submit', async (e) => {
e.preventDefault();
const hostname = String($input.val() || '').trim();
if ( ! hostname || busy ) return;
setBusy(true);
const hostname = typedHostname();
if ( ! isPlausibleHostname(hostname) || busy ) return;
$input.val(hostname);
setBusy(true, $overlay.find('.custom-domain-add'));
try {
const res = await api('POST', '', { site: subdomain, hostname });
if ( res.data?.code === 'custom_domain_claim_required' && res.data.verification ) {
@@ -349,9 +615,7 @@ async function UIWindowAttachCustomDomain (options) {
return;
}
hideClaim();
chosen = res.data;
renderRecords(res.data);
showPane('records');
settleOn(res.data);
} catch ( err ) {
console.error('custom-domain: add failed', err);
showError('add', text('custom_domain_error_generic'));
@@ -362,52 +626,47 @@ async function UIWindowAttachCustomDomain (options) {
// -- Records --------------------------------------------------------
const STATUS_ICONS = { active: ICON_CHECK, pending: ICON_CLOCK, failed: ICON_ALERT };
const STATUS_TEXT_KEYS = {
active: 'custom_domain_status_active',
pending: 'custom_domain_status_pending',
failed: 'custom_domain_status_failed',
};
const resetRemoveChosen = () => {
$overlay.find('.custom-domain-remove-chosen')
.removeClass('custom-domain-confirming').text(text('custom_domain_remove'));
$overlay.find('.custom-domain-remove-note').hide();
};
const renderRecords = (domain) => {
const key = domain.status === 'active'
? 'custom_domain_status_active'
: domain.status === 'failed'
? 'custom_domain_status_failed'
: 'custom_domain_status_pending';
const $status = $overlay.find('.custom-domain-status')
.html(i18n(key, [domain.hostname]));
if ( domain.statusDetail && domain.status !== 'active' ) {
$status.append($('<span class="custom-domain-status-detail"></span>').text(domain.statusDetail));
}
resetRemoveChosen();
const status = statusOf(domain);
$overlay.find('.custom-domain-status-card').attr('data-status', status);
$overlay.find('.custom-domain-status-icon').html(STATUS_ICONS[status]);
$overlay.find('.custom-domain-status').html(i18n(STATUS_TEXT_KEYS[status], [domain.hostname]));
$overlay.find('.custom-domain-status-detail')
.text(domain.statusDetail ?? '')
.toggle(!!domain.statusDetail && status !== 'active');
const $redirect = $overlay.find('.custom-domain-redirect').toggle(!!domain.redirectFrom);
if ( domain.redirectFrom ) {
$redirect.html(i18n('custom_domain_redirect_note', [domain.redirectFrom, domain.hostname]));
}
$overlay.find('.custom-domain-apex').toggle(domain.isApex === true);
$overlay.find('.custom-domain-use-www').toggle(domain.isApex === true && domain.status !== 'active');
$overlay.find('.custom-domain-use-www').toggle(domain.isApex === true && status !== 'active');
const $tbody = $overlay.find('.custom-domain-records tbody').empty();
const $records = $overlay.find('[data-pane="records"] .custom-domain-records').empty();
for ( const record of domain.records ?? [] ) {
const $tr = $(`
<tr>
<td class="custom-domain-record-type"></td>
<td><code class="custom-domain-copy"></code></td>
<td><code class="custom-domain-copy"></code></td>
</tr>
`);
const type = domain.isApex && record.purpose === 'routing'
? text('custom_domain_record_type_apex')
: record.type;
$tr.find('.custom-domain-record-type').text(record.required
? type
: `${type} ${text('custom_domain_optional')}`);
const [$name, $value] = $tr.find('code').toArray().map((el) => $(el));
$name.text(record.name);
$value.text(record.value);
$tbody.append($tr);
$records.append(recordCard(record, type));
}
$tbody.find('.custom-domain-copy').attr('title', i18n('copy', [], false)).on('click', function () {
navigator.clipboard?.writeText($(this).text()).catch(() => {});
});
};
$overlay.find('.custom-domain-check').on('click', async () => {
$overlay.find('.custom-domain-check').on('click', async (e) => {
if ( busy || ! chosen ) return;
setBusy(true);
setBusy(true, $(e.currentTarget));
try {
const res = await api('GET', `/${encodeURIComponent(chosen.uuid)}`);
if ( ! res.ok ) {
@@ -417,27 +676,33 @@ async function UIWindowAttachCustomDomain (options) {
// A domain that is no longer pending comes back with only its
// routing record, and a GET never repeats the provider or the
// redirect; keep what was shown when it was added.
const redirect = chosen.records.filter((r) => r.purpose === 'redirect');
const fresh = (res.data.records?.length > 1 ? res.data.records : chosen.records)
const known = chosen.records ?? [];
const redirect = known.filter((r) => r.purpose === 'redirect');
const fresh = (res.data.records?.length > 1 ? res.data.records : known)
.filter((r) => r.purpose !== 'redirect');
chosen = {
const next = {
...res.data,
dnsProvider: chosen.dnsProvider,
redirectFrom: chosen.redirectFrom,
records: [...fresh.slice(0, 1), ...redirect, ...fresh.slice(1)],
};
if ( statusOf(next) === 'active' ) {
showSuccess(next);
return;
}
chosen = next;
renderRecords(chosen);
} catch ( e ) {
console.error('custom-domain: status check failed', e);
} catch ( err ) {
console.error('custom-domain: status check failed', err);
showError('records', text('custom_domain_error_generic'));
} finally {
setBusy(false);
}
});
// Same row, so the month already paid covers the www hostname.
$overlay.find('.custom-domain-use-www').on('click', async () => {
$overlay.find('.custom-domain-use-www').on('click', async (e) => {
if ( busy || ! chosen ) return;
setBusy(true);
setBusy(true, $(e.currentTarget));
try {
const res = await api('POST', `/${encodeURIComponent(chosen.uuid)}/use-www`);
if ( ! res.ok ) {
@@ -446,20 +711,35 @@ async function UIWindowAttachCustomDomain (options) {
}
chosen = res.data;
renderRecords(chosen);
} catch ( e ) {
console.error('custom-domain: switch to www failed', e);
} catch ( err ) {
console.error('custom-domain: switch to www failed', err);
showError('records', text('custom_domain_error_generic'));
} finally {
setBusy(false);
}
});
// Undoes an add made by mistake, or drops a domain the user is done
// with, without leaving the dialog.
$overlay.find('.custom-domain-remove-chosen').on('click', async function () {
if ( busy || ! chosen ) return;
const $btn = $(this);
if ( ! $btn.hasClass('custom-domain-confirming') ) {
$btn.addClass('custom-domain-confirming').text(text('confirm'));
$overlay.find('.custom-domain-remove-note').show();
return;
}
if ( ! await deleteDomain(chosen, 'records', $btn) ) return;
resetRemoveChosen();
showPane('loading');
await loadList();
});
$overlay.find('.custom-domain-done').on('click', () => {
if ( ! busy ) close();
});
// -- Load -------------------------------------------------------------
(async () => {
const loadList = async () => {
try {
const res = await api('GET');
if ( closed ) return;
@@ -480,14 +760,15 @@ async function UIWindowAttachCustomDomain (options) {
renderList(res.data?.items ?? []);
const price = format_price(res.data?.pricing);
if ( price ) {
$overlay.find('.custom-domain-price').text(i18n('custom_domain_price_note', [price], false)).show();
$overlay.find('.custom-domain-price').text(text('custom_domain_price_note', [price])).show();
}
showPane('list');
} catch ( e ) {
console.error('custom-domain: list failed', e);
if ( ! closed ) showUnavailable('custom_domain_error_generic');
}
})();
};
loadList();
});
}
@@ -0,0 +1,257 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
const { default: UIWindowAttachCustomDomain } = await import('./UIWindowAttachCustomDomain.js');
const pendingDomain = (overrides = {}) => ({
uuid: 'd1',
hostname: 'www.example.com',
site: 'mysite',
status: 'pending',
isApex: false,
records: [{ type: 'CNAME', name: 'www.example.com', value: 'mysite.puter.site', purpose: 'routing', required: true }],
...overrides,
});
const json = (status, data) => new Response(JSON.stringify(data), {
status,
headers: { 'Content-Type': 'application/json' },
});
/** Answers the list call with `items`; anything else 200s with `{}`. */
const serve = (items) => vi.fn(async (url, opts = {}) => {
const path = String(url).split('/custom-domains')[1];
if ( (opts.method ?? 'GET') === 'GET' && ! path ) return json(200, { items });
return json(200, {});
});
const flush = () => new Promise((r) => setTimeout(r, 0));
const $ = (sel) => jQuery(sel);
const visiblePane = () => $('.custom-domain-pane').filter((_, el) => el.style.display !== 'none').attr('data-pane');
const pressEscape = () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
/** Opens the dialog and waits for the list to load. */
const open = async (items = [pendingDomain()]) => {
globalThis.fetch = serve(items);
let result = 'open';
UIWindowAttachCustomDomain({ subdomain: 'mysite' }).then((r) => {
result = r;
});
await flush();
await flush();
return { result: () => result };
};
beforeEach(() => {
document.body.innerHTML = '';
globalThis.$ = jQuery;
globalThis.i18n = (key) => key;
globalThis.html_encode = (value) => jQuery('<div>').text(value).html();
globalThis.puter = { authToken: 'token' };
window.api_origin = 'https://api.puter.test';
window.hosting_domain = 'puter.site';
});
afterEach(() => {
$(document).off('keydown');
vi.useRealTimers();
});
describe('UIWindowAttachCustomDomain', () => {
it('backs out of the records pane remove confirmation on Escape', async () => {
const dialog = await open();
$('.custom-domain-row .custom-domain-use').trigger('click');
expect(visiblePane()).toBe('records');
$('.custom-domain-remove-chosen').trigger('click');
expect($('.custom-domain-remove-chosen').hasClass('custom-domain-confirming')).toBe(true);
pressEscape();
expect($('.custom-domain-remove-chosen').hasClass('custom-domain-confirming')).toBe(false);
expect($('.custom-domain-remove-note').css('display')).toBe('none');
expect(dialog.result()).toBe('open');
pressEscape();
await flush();
expect(dialog.result()).toMatchObject({ uuid: 'd1' });
});
it('starts each step scrolled to the top of the floating modal', async () => {
await open();
// jsdom has no layout, so stand in for a modal scrolled down the list.
const modal = document.querySelector('.custom-domain-modal');
let scrollTop = 240;
Object.defineProperty(modal, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value) => {
scrollTop = value;
},
});
$('.custom-domain-row .custom-domain-use').trigger('click');
expect(visiblePane()).toBe('records');
expect(scrollTop).toBe(0);
});
describe('keyboard focus', () => {
const otherSite = (uuid, hostname) => pendingDomain({ uuid, hostname, site: 'other' });
it('moves into the dialog when the focused control\'s step is hidden', async () => {
await open();
const use = document.querySelector('.custom-domain-use');
use.focus();
$(use).trigger('click');
expect(visiblePane()).toBe('records');
expect(document.activeElement).toBe(document.querySelector('.custom-domain-modal'));
});
it('returns to the button that asked when a row confirmation is backed out', async () => {
await open([otherSite('a', 'a.example.com')]);
const $row = $('.custom-domain-row');
$row.find('.custom-domain-use').trigger('click');
expect(document.activeElement).toBe($row.find('.custom-domain-confirm-go').get(0));
pressEscape();
expect(document.activeElement).toBe($row.find('.custom-domain-use').get(0));
$row.find('.custom-domain-remove').trigger('click');
$row.find('.custom-domain-confirm-cancel').trigger('click');
expect(document.activeElement).toBe($row.find('.custom-domain-remove').get(0));
});
it('comes back to the clicked button once a request finishes', async () => {
await open();
$('.custom-domain-row .custom-domain-use').trigger('click');
const check = document.querySelector('.custom-domain-check');
check.focus();
// A browser drops focus to the page once the focused control is
// disabled; jsdom doesn't, so drop it by hand.
globalThis.fetch = vi.fn(async () => {
const elsewhere = document.createElement('input');
document.body.append(elsewhere);
elsewhere.focus();
elsewhere.remove();
return json(200, pendingDomain());
});
$(check).trigger('click');
await flush();
await flush();
expect(check.disabled).toBe(false);
expect(document.activeElement).toBe(check);
});
it('moves to the next row when a row is removed', async () => {
await open([otherSite('a', 'a.example.com'), otherSite('b', 'b.example.com')]);
const [rowA, rowB] = $('.custom-domain-row').toArray();
$(rowA).find('.custom-domain-remove').trigger('click');
$(rowA).find('.custom-domain-confirm-go').trigger('click');
await flush();
await flush();
expect(document.activeElement).toBe($(rowB).find('.custom-domain-remove').get(0));
});
});
describe('copy buttons', () => {
afterEach(() => {
delete navigator.clipboard;
delete document.execCommand;
});
const copyCnameValue = async () => {
await open();
$('.custom-domain-row .custom-domain-use').trigger('click');
const $btn = $('[data-pane="records"] .custom-domain-copy').eq(1);
$btn.trigger('click');
await flush();
return $btn;
};
it('copy with the clipboard API', async () => {
const writeText = vi.fn(async () => {});
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } });
const $btn = await copyCnameValue();
expect(writeText).toHaveBeenCalledWith('mysite.puter.site');
expect($btn.hasClass('is-copied')).toBe(true);
});
it('fall back to selecting and copying when there is no clipboard API', async () => {
document.execCommand = vi.fn(() => true);
const $btn = await copyCnameValue();
expect(document.execCommand).toHaveBeenCalledWith('copy');
expect(window.getSelection().toString()).toBe('mysite.puter.site');
expect($btn.hasClass('is-copied')).toBe(true);
});
it('leave the value selected when nothing can copy it', async () => {
const $btn = await copyCnameValue();
expect(window.getSelection().toString()).toBe('mysite.puter.site');
expect($btn.hasClass('is-copied')).toBe(false);
});
});
describe('empty state', () => {
const shown = (sel) => $(sel).get(0).style.display !== 'none';
it('puts the add button in the empty state instead of the footer', async () => {
await open([]);
expect(shown('.custom-domain-empty')).toBe(true);
expect(shown('.custom-domain-list-add')).toBe(false);
$('.custom-domain-empty-add').trigger('click');
expect(visiblePane()).toBe('add');
});
it('keeps the add button in the footer while there are domains', async () => {
await open();
expect(shown('.custom-domain-empty')).toBe(false);
expect(shown('.custom-domain-list-add')).toBe(true);
});
it('moves the button and focus to the empty state when the last domain is removed', async () => {
vi.useFakeTimers({ toFake: ['setTimeout'] });
const ready = open([pendingDomain({ site: 'other' })]);
await vi.runAllTimersAsync();
await ready;
const row = document.querySelector('.custom-domain-row');
$(row).find('.custom-domain-remove').trigger('click');
$(row).find('.custom-domain-confirm-go').trigger('click');
await vi.runAllTimersAsync();
expect(row.isConnected).toBe(false);
expect(shown('.custom-domain-empty')).toBe(true);
expect(shown('.custom-domain-list-add')).toBe(false);
expect(document.activeElement).toBe($('.custom-domain-empty-add').get(0));
});
});
it('takes no more input on a removed row while it fades out', async () => {
await open([pendingDomain({ site: 'other' })]);
const row = document.querySelector('.custom-domain-row');
$(row).find('.custom-domain-remove').trigger('click');
$(row).find('.custom-domain-confirm-go').trigger('click');
await flush();
await flush();
expect(row.isConnected).toBe(true);
expect(row.hasAttribute('inert')).toBe(true);
});
});
+26
View File
@@ -68,6 +68,15 @@ function UIWindowSignup(options) {
return new Promise(async (resolve) => {
const internal_id = window.uuidv4();
let offersProviders = false;
// Form signups are closed, so the providers are the only way in.
const showOidcOnly = (el_window) => {
$(el_window).find('.signup-form').hide();
$(el_window).find('.signup-with-email-wrapper').hide();
$(el_window).find('.oidc-providers-wrapper').show();
$(el_window).find('.signup-oidc-only-notice').show();
};
const logo_clickable =
!!options.window_options?.cover_page && !window.embedded_in_popup;
@@ -149,6 +158,7 @@ function UIWindowSignup(options) {
h += '</form>';
h +=
'<div class="oidc-providers-wrapper" style="display:none; padding: 10px 0;">';
h += `<p class="signup-oidc-only-notice" style="display:none;">${i18n('signup_oidc_only')}</p>`;
h += `<button type="button" class="oidc-google-btn oidc-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px;"><img style="width: 20px; height: 20px;" src="data:image/webp;base64,UklGRu4GAABXRUJQVlA4WAoAAAAQAAAAXwAAXwAAQUxQSAUDAAABoATJtmnbGs+23+vZtm3btm3btnHxjG/btm3bnI197tljzjXjtSNiAnAjmbNs8x5DR40e2qtFhTzuVJ2e+rrE/ODKwsZe5J90n9CfXVw6vLEvifIH87OHVOK0mLxSLpSyd4vZhyuGkP+amL45j7lVYn6+rQpfSYDvFzO0QgIdYeZJCTbNRpnfJeDXLTSQsH/X6yiBj1TrIoEPgnZzCXwwtMtL4AOhnfP/wPpC/X0Jux/Uj4jySzsGVskJADkq9dn4JKEP1NuJ6gMDELvrrTF6QP9fjX0FQc2xLZme0E8Tfko20LOmZdAD+tWF/lVlqNb7I9IFBt+hpUD9CZGuMNhT2ONh8GJvWPyK1QZedhdyS7j5Bqkf3Kwo3A3w8yTneTgq3NyOdOZMhqPplM/h6Z+Urp6UF+ZX8HQGZZIrVylw9SvGnRqP33ev2QdSEwlzpIaYTlCOUtSLQpGujN/hRePIFMZDbgyOrGUcdGNe5CBjoRtbIimMMW4ciVxgDHQjxb0LkRTGGF8OMha6kRZZyzjkxrHIFMbDbmyPdGX84cbiSDmGFPNiZASU0V60SvAV4y4vyiS4yhCNSuXZxb5lIOEMyngFRSF+nqg85YsAOjNuSoQ/GdLJXhpjfgbplE/tCbN2Bp0pMtHaBAoy5khOY78yHkjiJOdZW3OEOSmJihxZaymPUPMlgTc40sfQy5RnkWx3kjQ3c0ioI5LClyRpZWSRcJF8T5aMNbFKuHti4B2WnDVwUcj541SnyVeVlCp/KeQ0xE6jiZzNppD1jNDzxcO/PJG9BUhF9gl/B4jtNEQeGBAv55gnRfFfUI+oiMhLOwZWyRnJXKbltLOfim4nDt5XMn0N5Jz/e/Eb6OW9KMtDcx8GQrOLB6ug2zG8fdBuENoB6Jf5PaiVMPlkQP1hdEUov5aH2QpfBXETTK+y91dnGM9/zdh2BFj2bkNn8iHMEqeN7MiJgMe+pPZwP4Sef9J9vL8vD8oMH6tOT309zo+P7BlcAs7mLNu8x9BRY4b1aVO9MG4cAQBWUDggwgMAABAVAJ0BKmAAYAA+bSyTRqQiIaEtVEyQgA2JbAC++hZa3jl94/Jj2rrA/Tvv9y7x+OzH+L5o/8p7APMA/TPpD+YD+bf4T9bPfW/mfqo/wHqAf0v/hdYB6AH7Aemp+5HwS/uN+6XtXdQB1N/Rj+m/RWYp6GESYsQNCm+TZ3BvtEgBK+isF38GQzHNMrw+tkbAm5lUM4rWqU9srOS+RuAUa8nFYoFrGH73Fzd++s+LPyEGAAD+Yg+gQRuJCapqteWj86DdkHmfHdS+mWVn7Ue8RiK5AaWCIe7RXaDKn0baNe80expR5tTBclgnd+SEzNZ6N3NL1suPhGwRqKzB5wNnWUXD7R0IkT5uIwUhTsO/1ucVEMKvTZRe4j7WwrA+e53P8J1/cdIvKtrtZv9AH5u1heZ1dGQELuxMxbQUnrw6IUh0YLBKEjNnR3JIsFNwkC+WLw4wkCMati73lcMjT6U2KIYsr0g5bPzSmo3ir9qRjtIqthpPIvuvYt8AbMewSj96NIqTM14M6ABtOaY87d8HOU7baFGGQjU1f8vf7SV5AjDrfW0VVzQcBez+PYiPApyQUAozEmZFKBDvX9ifFFacoulG9NCPDXa1mRPVwzrKuN3VfwUfv8Bx8QJ88prsOCDd8UvNDnUzbRqBXOmQ4co+/xYy/iQV89GHs4pb0D2LYwv6aY8yPLCrYyAPUe4dKEh/FBqRBJ/716CJVMTCrvB+gSgPwNjN5LlnNQqkBsHnSqdUFddjvBKNdm6zm8ggLFJl14S37sJW1fAEbiB0IqxcqlJOWol+ecJy9xdESchmlROmo/4/9V34VN2cw8NUB5POPUNzivyMFfgF27QTFXgya/LF3OsfHMF6Itejgy4ab38VPFjez5f8Z/b/dhYV21SflE4KtUj8LZDrQXRp+Pzdf/gWfvm2oLh0x+Fr+ggo2jjG3DFn95Wj1P3ra1RtzYE8ffXBeR2lDhL+GPJiiZaVinGMkjVYup0AghAhIfVgFtsL8lhMyJY84ccksHz92o1/J3NfrGSM8NwOv7ieeBjq2eqCOmrUHpG3mlr8Hj/fR4d+orLlpipLTh3+cRY7suXQXw9SPYZieoKS/JE9y0f+BcWCzdNshWl2oVpeK2qm36iFltfGZUvXdfBKufE9RkZcyOBDUVeONlBchjuaLPNd6fSlk41Pp57nF5MmES2Kl7HqY+f2pvS71eIgUXpqtAFIY94JSmNre+g49tL8VnZlJNqgVhj/De//5zH/84h//5uJfunlawY99GgDkm8cYFbIAAAA" />${i18n('sign_up_with_google')}</button>`;
h += `<button type="button" class="oidc-microsoft-btn oidc-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px; margin-top:8px;"><svg style="width:20px; height:20px;" viewBox="0 0 23 23" fill="none"><rect x="1" y="1" width="10" height="10" fill="#f25022"/><rect x="12" y="1" width="10" height="10" fill="#7fba00"/><rect x="1" y="12" width="10" height="10" fill="#00a4ef"/><rect x="12" y="12" width="10" height="10" fill="#ffb900"/></svg>${i18n('sign_up_with_microsoft')}</button>`;
h += `<button type="button" class="oidc-apple-btn oidc-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px; margin-top:8px;"><svg style="width:20px; height:20px;" viewBox="0 0 384 512" fill="currentColor"><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/></svg>${i18n('sign_up_with_apple')}</button>`;
@@ -372,6 +382,7 @@ function UIWindowSignup(options) {
});
if (hasProvider) {
offersProviders = true;
$(el_window).find('.oidc-providers-wrapper').show();
// Hide the signup form and show the "Sign up with email" button
$(el_window).find('.signup-form').hide();
@@ -395,6 +406,10 @@ function UIWindowSignup(options) {
?.focus({ preventScroll: true });
});
}
if (data.signupOidcOnly === true && hasProvider) {
showOidcOnly(el_window);
}
} catch (_) {}
})();
},
@@ -743,6 +758,17 @@ function UIWindowSignup(options) {
return;
}
// Form signups closed after this window loaded.
// Without provider buttons (some popups), the
// server's message below says it instead.
if (
errorJson?.code === 'signup_oidc_only' &&
offersProviders
) {
showOidcOnly(el_window);
return;
}
// Drop the code so the next attempt signs up without it.
if (errorJson?.code === 'bonus_code_invalid') {
window.signup_bonus_code = null;
@@ -0,0 +1,100 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
const state = vi.hoisted(() => ({ el: null }));
vi.mock('./UIWindowLogin.js', () => ({ default: vi.fn() }));
vi.mock('./UIWindowEmailConfirmationRequired.js', () => ({ default: vi.fn() }));
vi.mock('./UIWindowPhoneVerificationRequired.js', () => ({ default: vi.fn() }));
vi.mock('./UIWindowCardVerificationRequired.js', () => ({ default: vi.fn() }));
vi.mock('../helpers/authLogoHeader.js', () => ({
authLogoHeader: () => '',
wireAuthLogoHeader: vi.fn(),
}));
vi.mock('../helpers/checkPasswordStrength.js', () => ({
default: () => ({ overallPass: true }),
}));
vi.mock('./UIWindow.js', () => ({
default: async (options) => {
state.el = document.createElement('div');
state.el.innerHTML = options.body_content;
document.body.append(state.el);
options.onAppend?.(state.el);
return state.el;
},
}));
globalThis.def = globalThis.def ?? ((value) => value);
const { default: UIWindowSignup } = await import('./UIWindowSignup.js');
const providers = (body) => {
globalThis.fetch = vi.fn(async () => new Response(JSON.stringify(body)));
};
const find = (selector) => jQuery(state.el).find(selector);
const shown = (selector) => find(selector).css('display') !== 'none';
const open = async () => {
void UIWindowSignup({ reload_on_success: false, redirect_url: '/' });
await vi.waitFor(() => expect(find('.oidc-google-btn').css('display')).toBe('flex'));
};
beforeEach(() => {
document.body.innerHTML = '';
globalThis.$ = jQuery;
jQuery.fn.close = vi.fn();
globalThis.i18n = (key) => key;
globalThis.html_encode = (value) => jQuery('<div>').text(value).html();
window.gui_origin = 'https://puter.test';
window.api_origin = 'https://api.puter.test';
window.gui_params = {};
window.icons = {};
window.uuidv4 = () => 'oidc-only-test';
window.is_email = () => true;
window.embedded_in_popup = false;
window.url_query_params = new URLSearchParams('');
state.el = null;
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('signup window when only OIDC signups are open', () => {
it('offers the email form behind a button normally', async () => {
providers({ providers: ['google'], signupOidcOnly: false });
await open();
expect(shown('.signup-with-email-wrapper')).toBe(true);
expect(shown('.signup-oidc-only-notice')).toBe(false);
});
it('drops the email form and says why', async () => {
providers({ providers: ['google'], signupOidcOnly: true });
await open();
await vi.waitFor(() => expect(shown('.signup-oidc-only-notice')).toBe(true));
expect(shown('.signup-form')).toBe(false);
expect(shown('.signup-with-email-wrapper')).toBe(false);
});
it('switches to the providers when a form signup is refused for it', async () => {
providers({ providers: ['google'], signupOidcOnly: false });
await open();
jQuery.ajax = vi.fn((opts) =>
opts.error({
responseText: JSON.stringify({
code: 'signup_oidc_only',
message: 'closed',
}),
}));
find('.signup-with-email-btn').trigger('click');
find('.username').val('someone');
find('.email').val('[email protected]');
find('.password').val('a-Strong-pass-1');
find('.confirm-password').val('a-Strong-pass-1');
find('.signup-btn').trigger('click');
await vi.waitFor(() => expect(shown('.signup-oidc-only-notice')).toBe(true));
expect(shown('.signup-form')).toBe(false);
expect(shown('.signup-with-email-wrapper')).toBe(false);
});
});
+710 -53
View File
@@ -2007,28 +2007,174 @@ input.myapps-group-name:focus {
}
/* Custom domain modal (puter.hosting.attach), on the app feedback shell. */
.custom-domain-overlay {
--cd-accent: var(--dashboard-button-primary, #4a67f5);
--cd-subtle: rgba(15, 23, 42, 0.035);
--cd-success: #16a34a;
--cd-success-bg: rgba(34, 197, 94, 0.14);
--cd-warn: #b45309;
--cd-warn-bg: rgba(245, 158, 11, 0.14);
--cd-danger: #dc2626;
/* Under white text, so it stays this dark in both themes (4.8:1). */
--cd-danger-fill: #dc2626;
--cd-danger-bg: rgba(220, 38, 38, 0.08);
--cd-skeleton: rgba(15, 23, 42, 0.07);
}
@media (prefers-color-scheme: dark) {
.custom-domain-overlay {
--cd-subtle: rgba(255, 255, 255, 0.04);
--cd-success: #4ade80;
--cd-success-bg: rgba(74, 222, 128, 0.14);
--cd-warn: #fbbf24;
--cd-warn-bg: rgba(251, 191, 36, 0.14);
--cd-danger: #f87171;
--cd-danger-bg: rgba(248, 113, 113, 0.12);
--cd-skeleton: rgba(255, 255, 255, 0.08);
}
}
.custom-domain-modal {
width: min(520px, 100%);
display: flex;
flex-direction: column;
}
.custom-domain-modal svg {
flex: none;
}
/* A hostname moves to the next line whole, and breaks only when it's wider
than the line. */
.custom-domain-modal strong {
font-weight: 600;
overflow-wrap: anywhere;
}
.custom-domain-sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
/* -- Header -- */
.custom-domain-head {
align-items: flex-start;
gap: 12px;
padding: 20px 16px 4px 20px;
}
.custom-domain-head-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
flex: none;
border-radius: 11px;
background: color-mix(in srgb, var(--cd-accent) 12%, transparent);
color: var(--cd-accent);
}
.custom-domain-head-icon svg {
width: 22px;
height: 22px;
}
.custom-domain-head-text {
flex: 1;
min-width: 0;
padding-top: 1px;
}
.custom-domain-intro,
.custom-domain-requester {
margin: 0 0 12px;
font-size: 14px;
margin: 3px 0 0;
font-size: 13px;
line-height: 1.45;
color: var(--afb-muted);
word-break: break-word;
}
.custom-domain-intro strong {
color: var(--afb-fg);
}
.custom-domain-requester {
margin-top: -6px;
margin-top: 2px;
font-size: 12px;
}
.custom-domain-body {
padding-top: 16px;
}
/* -- Loading -- */
.custom-domain-skeleton {
padding: 14px 12px;
border: 1px solid var(--afb-border);
border-radius: 12px;
}
.custom-domain-skeleton + .custom-domain-skeleton {
margin-top: 8px;
}
.custom-domain-skeleton span {
display: block;
height: 10px;
width: 46%;
border-radius: 5px;
background: var(--cd-skeleton);
animation: custom-domain-pulse 1.2s ease-in-out infinite;
}
.custom-domain-skeleton span + span {
width: 30%;
margin-top: 8px;
height: 8px;
}
@keyframes custom-domain-pulse {
50% { opacity: 0.45; }
}
/* -- Empty, unavailable -- */
.custom-domain-state {
text-align: center;
padding: 18px 12px 6px;
}
.custom-domain-state-icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
margin: 0 auto 12px;
border-radius: 50%;
background: var(--cd-subtle);
border: 1px solid var(--afb-border);
color: var(--afb-muted);
}
.custom-domain-state-icon svg {
width: 24px;
height: 24px;
}
.custom-domain-state-title {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.custom-domain-state-text {
margin: 4px 0 0;
font-size: 13px;
line-height: 1.45;
color: var(--afb-muted);
}
.custom-domain-empty {
border: 1px dashed var(--afb-field-border);
border-radius: 12px;
padding: 24px 16px;
}
.custom-domain-empty-add {
margin-top: 16px;
}
/* -- Domain list -- */
.custom-domain-list {
list-style: none;
margin: 0;
padding: 0;
border: 1px solid var(--afb-border);
border-radius: 10px;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 8px;
}
.custom-domain-list:empty {
display: none;
@@ -2037,113 +2183,624 @@ input.myapps-group-name:focus {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
padding: 10px 10px 10px 12px;
border: 1px solid var(--afb-border);
border-radius: 12px;
transition: border-color 0.12s ease, background-color 0.12s ease, opacity 0.18s ease, transform 0.18s ease;
}
.custom-domain-row + .custom-domain-row {
border-top: 1px solid var(--afb-border);
@media (hover: hover) {
.custom-domain-row:hover {
border-color: var(--afb-field-border);
background: var(--cd-subtle);
}
}
.custom-domain-row.is-current {
border-color: color-mix(in srgb, var(--cd-accent) 45%, transparent);
}
.custom-domain-row.is-leaving {
opacity: 0;
transform: scale(0.98);
}
.custom-domain-row-icon {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
flex: none;
border-radius: 9px;
background: var(--cd-subtle);
border: 1px solid var(--afb-border);
color: var(--afb-muted);
}
.custom-domain-row-icon svg {
width: 18px;
height: 18px;
}
.custom-domain-row-main {
flex: 1;
min-width: 0;
}
.custom-domain-row-title {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px 8px;
}
.custom-domain-row-host {
min-width: 0;
font-size: 14px;
font-weight: 600;
word-break: break-all;
}
.custom-domain-row-meta {
font-size: 12px;
line-height: 1.4;
color: var(--afb-muted);
margin-top: 2px;
word-break: break-word;
}
.custom-domain-confirming .custom-domain-row-meta {
.custom-domain-row-actions,
.custom-domain-row-confirm {
display: flex;
align-items: center;
gap: 6px;
flex: none;
}
.custom-domain-row-confirm,
.custom-domain-row[data-confirming] .custom-domain-row-actions {
display: none;
}
.custom-domain-row[data-confirming] .custom-domain-row-confirm {
display: flex;
}
.custom-domain-row[data-confirming] .custom-domain-row-meta {
color: var(--afb-fg);
}
.custom-domain-row[data-confirming="remove"] {
border-color: color-mix(in srgb, var(--cd-danger) 40%, transparent);
background: var(--cd-danger-bg);
}
.custom-domain-row[data-confirming="move"] {
border-color: color-mix(in srgb, var(--cd-accent) 45%, transparent);
background: color-mix(in srgb, var(--cd-accent) 6%, transparent);
}
.custom-domain-row .app-feedback-btn {
flex: none;
padding: 6px 14px;
font-size: 13px;
}
.custom-domain-empty {
margin: 4px 0 0;
font-size: 14px;
.custom-domain-row[data-confirming="move"] .custom-domain-confirm-go {
border-color: var(--cd-accent);
background: var(--cd-accent);
color: #fff;
}
.custom-domain-row[data-confirming="remove"] .custom-domain-confirm-go {
border-color: var(--cd-danger-fill);
background: var(--cd-danger-fill);
color: #fff;
}
@media (hover: hover) {
.custom-domain-row[data-confirming] .custom-domain-confirm-go:hover:not(:disabled) {
filter: brightness(0.92);
}
}
/* Status pill next to a hostname. */
.custom-domain-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 1px 8px 1px 7px;
font-size: 11px;
font-weight: 600;
line-height: 18px;
border-radius: 999px;
white-space: nowrap;
color: var(--cd-warn);
background: var(--cd-warn-bg);
}
.custom-domain-badge::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.custom-domain-badge[data-status="active"] {
color: var(--cd-success);
background: var(--cd-success-bg);
}
.custom-domain-badge[data-status="failed"] {
color: var(--cd-danger);
background: var(--cd-danger-bg);
}
/* Icon-only button: trash on a row. */
.custom-domain-icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: none;
border-radius: 8px;
background: none;
color: var(--afb-muted);
cursor: pointer;
transition: background-color 0.12s ease, color 0.12s ease;
}
.custom-domain-icon-btn svg {
width: 16px;
height: 16px;
}
@media (hover: hover) {
.custom-domain-icon-btn:hover:not(:disabled) {
background: var(--cd-danger-bg);
color: var(--cd-danger);
}
}
.custom-domain-icon-btn:disabled {
opacity: 0.5;
cursor: default;
}
/* -- Buttons -- */
.custom-domain-modal .app-feedback-btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.custom-domain-modal .app-feedback-btn svg {
width: 15px;
height: 15px;
}
.custom-domain-modal .app-feedback-btn:focus-visible,
.custom-domain-icon-btn:focus-visible,
.custom-domain-copy:focus-visible,
.custom-domain-link-btn:focus-visible {
outline: 2px solid var(--cd-accent);
outline-offset: 2px;
}
/* The clicked button holds its size and shows a spinner while it works. */
.custom-domain-modal .app-feedback-btn.is-loading {
color: transparent !important;
opacity: 1;
}
.custom-domain-modal .app-feedback-btn.is-loading::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 14px;
height: 14px;
margin: -9px 0 0 -9px;
border: 2px solid var(--afb-muted);
border-right-color: transparent;
border-radius: 50%;
animation: share-modal-spin 0.7s linear infinite;
}
.custom-domain-modal .app-feedback-btn-primary.is-loading::after,
.custom-domain-row[data-confirming] .custom-domain-confirm-go.is-loading::after {
border-color: #fff;
border-right-color: transparent;
}
.custom-domain-modal .app-feedback-btn.is-loading svg {
visibility: hidden;
}
.custom-domain-btn-danger-text {
margin-right: auto;
border-color: transparent;
background: none;
color: var(--cd-danger);
padding-left: 10px;
padding-right: 10px;
}
@media (hover: hover) {
.custom-domain-modal .app-feedback-btn.custom-domain-btn-danger-text:hover:not(:disabled) {
background: var(--cd-danger-bg);
}
}
.custom-domain-btn-danger-text.custom-domain-confirming {
border-color: var(--cd-danger-fill);
background: var(--cd-danger-fill);
color: #fff;
}
@media (hover: hover) {
.custom-domain-modal .app-feedback-btn.custom-domain-btn-danger-text.custom-domain-confirming:hover:not(:disabled) {
background: var(--cd-danger-fill);
filter: brightness(0.92);
}
}
.custom-domain-link-btn {
display: block;
margin: 10px 0 0;
padding: 0;
border: none;
background: none;
font: inherit;
font-size: 12px;
text-align: left;
color: var(--cd-accent);
cursor: pointer;
}
@media (hover: hover) {
.custom-domain-link-btn:hover:not(:disabled) {
text-decoration: underline;
}
}
.custom-domain-link-btn:disabled {
opacity: 0.5;
cursor: default;
}
/* Working: full strength, with a spinner after the label. */
.custom-domain-link-btn.is-loading {
opacity: 1;
}
.custom-domain-link-btn.is-loading::after {
content: '';
display: inline-block;
width: 8px;
height: 8px;
margin-left: 6px;
vertical-align: -1px;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: share-modal-spin 0.7s linear infinite;
}
/* -- Add form -- */
.custom-domain-label {
display: block;
font-size: 13px;
font-weight: 500;
font-weight: 600;
margin-bottom: 6px;
}
.custom-domain-input {
.custom-domain-field {
position: relative;
display: flex;
align-items: center;
}
.custom-domain-field svg {
position: absolute;
left: 12px;
width: 16px;
height: 16px;
color: var(--afb-muted);
pointer-events: none;
}
.custom-domain-field .custom-domain-input {
display: block;
width: 100%;
box-sizing: border-box;
padding: 9px 12px;
height: 42px;
padding: 0 12px 0 36px;
font: inherit;
font-size: 14px;
font-size: 15px;
color: var(--afb-fg);
background: var(--afb-field-bg);
border: 1px solid var(--afb-field-border);
border-radius: 8px;
border-radius: 10px;
transition: border-color 0.12s ease, box-shadow 0.12s ease;
}
.custom-domain-input:focus {
.custom-domain-field .custom-domain-input::placeholder {
color: var(--afb-muted);
opacity: 0.7;
}
.custom-domain-field .custom-domain-input:focus {
outline: none;
border-color: #088ef0;
box-shadow: 0 0 0 3px rgba(8, 142, 240, 0.18);
padding: 0 12px 0 36px;
border: 1px solid var(--cd-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--cd-accent) 20%, transparent);
}
.custom-domain-hint {
margin-top: 6px;
}
.custom-domain-callout {
margin: 12px 0 0;
padding: 10px 12px;
font-size: 12.5px;
line-height: 1.45;
border-radius: 10px;
background: var(--cd-subtle);
border: 1px solid var(--afb-border);
color: var(--afb-muted);
}
.custom-domain-callout-warn {
color: var(--afb-fg);
background: var(--cd-warn-bg);
border-color: color-mix(in srgb, var(--cd-warn) 30%, transparent);
}
.custom-domain-callout-danger {
color: var(--afb-fg);
background: var(--cd-danger-bg);
border-color: color-mix(in srgb, var(--cd-danger) 30%, transparent);
}
/* -- Status and DNS records -- */
.custom-domain-status-card {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 14px;
border-radius: 12px;
background: var(--cd-warn-bg);
border: 1px solid color-mix(in srgb, var(--cd-warn) 25%, transparent);
}
.custom-domain-status-card[data-status="active"] {
background: var(--cd-success-bg);
border-color: color-mix(in srgb, var(--cd-success) 25%, transparent);
}
.custom-domain-status-card[data-status="failed"] {
background: var(--cd-danger-bg);
border-color: color-mix(in srgb, var(--cd-danger) 25%, transparent);
}
.custom-domain-status-icon {
display: flex;
color: var(--cd-warn);
padding-top: 1px;
}
.custom-domain-status-card[data-status="active"] .custom-domain-status-icon {
color: var(--cd-success);
}
.custom-domain-status-card[data-status="failed"] .custom-domain-status-icon {
color: var(--cd-danger);
}
.custom-domain-status-icon svg {
width: 20px;
height: 20px;
}
.custom-domain-status-main {
flex: 1;
min-width: 0;
}
.custom-domain-status {
margin: 0 0 8px;
margin: 0;
font-size: 14px;
line-height: 1.45;
word-break: break-word;
}
.custom-domain-status-detail {
display: block;
margin-top: 4px;
margin: 3px 0 0;
font-size: 12px;
line-height: 1.4;
color: var(--afb-muted);
word-break: break-word;
}
.custom-domain-records-c2a {
margin: 16px 0 0;
font-size: 13px;
line-height: 1.45;
}
.custom-domain-records {
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 10px;
border-collapse: collapse;
font-size: 12px;
table-layout: fixed;
}
.custom-domain-records th {
text-align: left;
.custom-domain-records:empty {
display: none;
}
.custom-domain-record {
padding: 10px 12px;
border: 1px solid var(--afb-border);
border-radius: 12px;
background: var(--cd-subtle);
}
.custom-domain-record-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.custom-domain-record-type {
padding: 1px 7px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px;
font-weight: 600;
line-height: 18px;
border-radius: 6px;
color: var(--cd-accent);
background: color-mix(in srgb, var(--cd-accent) 12%, transparent);
}
.custom-domain-record-optional {
font-size: 11px;
color: var(--afb-muted);
}
.custom-domain-record-field {
display: grid;
grid-template-columns: 48px 1fr auto;
align-items: center;
gap: 8px;
min-height: 30px;
}
.custom-domain-record-label {
font-size: 11px;
font-weight: 500;
color: var(--afb-muted);
padding: 0 6px 6px 0;
}
.custom-domain-records th:first-child {
width: 86px;
}
.custom-domain-records td {
padding: 6px 6px 6px 0;
border-top: 1px solid var(--afb-border);
vertical-align: top;
}
.custom-domain-redirect strong {
color: var(--afb-fg);
.custom-domain-record-field code {
min-width: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12.5px;
word-break: break-all;
}
.custom-domain-use-www {
display: block;
width: 100%;
margin-top: 10px;
font-size: 13px;
user-select: all;
}
.custom-domain-copy {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid transparent;
border-radius: 7px;
background: none;
color: var(--afb-muted);
cursor: pointer;
transition: background-color 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}
.custom-domain-copy svg {
width: 15px;
height: 15px;
}
.custom-domain-copy svg + svg,
.custom-domain-copy.is-copied svg:first-child {
display: none;
}
.custom-domain-copy.is-copied svg + svg {
display: block;
word-break: break-all;
cursor: copy;
border-radius: 4px;
animation: custom-domain-pop 0.2s ease-out;
}
.custom-domain-copy.is-copied {
color: var(--cd-success);
}
@media (hover: hover) {
.custom-domain-copy:hover {
background: var(--afb-hover);
.custom-domain-copy:hover:not(:disabled) {
border-color: var(--afb-border);
background: var(--afb-bg);
color: var(--afb-fg);
}
.custom-domain-copy.is-copied:hover:not(:disabled) {
color: var(--cd-success);
}
}
.custom-domain-claim .custom-domain-records {
margin-top: 8px;
}
/* -- Success -- */
.custom-domain-success {
padding: 28px 10px 16px;
}
.custom-domain-success .app-feedback-success-check {
width: 60px;
height: 60px;
background: var(--cd-success-bg);
color: var(--cd-success);
animation: custom-domain-pop 0.32s cubic-bezier(0.2, 0.9, 0.3, 1.3);
}
.custom-domain-success .app-feedback-success-check svg {
width: 30px;
height: 30px;
stroke-dasharray: 24;
stroke-dashoffset: 24;
animation: custom-domain-draw 0.32s 0.14s ease-out forwards;
}
.custom-domain-success-note {
margin-top: 6px;
font-size: 13px;
word-break: break-word;
}
@keyframes custom-domain-pop {
from { transform: scale(0.6); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@keyframes custom-domain-draw {
to { stroke-dashoffset: 0; }
}
/* -- Panes -- */
.custom-domain-pane {
animation: custom-domain-fade 0.16s ease-out;
}
@keyframes custom-domain-fade {
from { opacity: 0; transform: translateY(4px); }
}
@media (prefers-reduced-motion: reduce) {
.custom-domain-pane,
.custom-domain-success .app-feedback-success-check,
.custom-domain-copy.is-copied svg + svg {
animation: none;
}
.custom-domain-success .app-feedback-success-check svg {
animation: none;
stroke-dashoffset: 0;
}
.custom-domain-skeleton span {
animation: none;
}
}
@media (max-width: 500px) {
/* The full-width actions wrap below; the name fills the line beside
the icon. */
.custom-domain-row {
flex-wrap: wrap;
}
.custom-domain-row-actions,
.custom-domain-row-confirm {
width: 100%;
justify-content: flex-end;
}
}
/* In a popup that exists only for this dialog, the dialog is the page: a
centered column, with the actions pinned to the bottom edge. */
.custom-domain-fullpage {
--cd-column: min(552px, 100% - 48px);
padding: 0;
align-items: stretch;
background: var(--afb-bg);
}
.custom-domain-fullpage .custom-domain-modal {
width: 100%;
height: 100dvh;
max-height: none;
border: none;
border-radius: 0;
box-shadow: none;
transform: none;
padding-bottom: 0;
overflow: hidden;
}
.custom-domain-fullpage .app-feedback-x {
display: none;
}
.custom-domain-fullpage .custom-domain-head {
width: var(--cd-column);
margin: 0 auto;
padding: 28px 0 4px;
}
.custom-domain-fullpage .custom-domain-body {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 20px 0 0;
}
.custom-domain-fullpage .custom-domain-pane {
flex: 1 0 auto;
display: flex;
flex-direction: column;
}
.custom-domain-fullpage .custom-domain-pane > :not(.app-feedback-actions, .app-feedback-success-check, .custom-domain-state-icon) {
box-sizing: border-box;
width: var(--cd-column);
margin-left: auto;
margin-right: auto;
}
/* Keeps the actions off the content when it fills the page. */
.custom-domain-fullpage .custom-domain-pane::after {
content: '';
flex: 1 0 20px;
order: 1;
}
.custom-domain-fullpage .app-feedback-actions {
order: 2;
position: sticky;
bottom: 0;
margin: 0;
padding: 14px max(24px, calc((100% - 552px) / 2)) calc(14px + env(safe-area-inset-bottom, 0px));
background: var(--afb-bg);
border-top: 1px solid var(--afb-border);
}
.custom-domain-fullpage .custom-domain-state .app-feedback-actions {
justify-content: center;
}
/* Add-an-app chooser (see showAddAppModal): the three ways to get an app, as
+7
View File
@@ -2260,6 +2260,13 @@ label {
color: #611a15;
}
.signup-oidc-only-notice {
text-align: center;
margin: 0 0 10px;
font-size: 14px;
color: #5f6368;
}
/* -- Logo strip above the login / signup forms -- */
.auth-logo-header {
display: flex;
@@ -0,0 +1,45 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Turns what a user typed or pasted into a bare hostname: a URL like
* `https://www.example.com/about` becomes `www.example.com`. The server
* still validates the result.
*
* @param {unknown} raw
* @returns {string} The hostname, or '' when nothing is left.
*/
export const normalizeHostnameInput = (raw) => String(raw ?? '')
.trim()
.toLowerCase()
.replace(/^[a-z][a-z0-9+.-]*:\/\//, '')
.replace(/^[^@/?#]*@/, '')
.replace(/[/?#].*$/, '')
.replace(/:\d*$/, '')
.replace(/\.+$/, '');
/**
* Whether a normalized hostname is worth sending: something with a dot and
* no whitespace. Anything subtler is the server's call.
*
* @param {string} hostname
* @returns {boolean}
*/
export const isPlausibleHostname = (hostname) =>
/^[^\s.]+(\.[^\s.]+)+$/.test(hostname);
@@ -0,0 +1,59 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { describe, it, expect } from 'vitest';
import { isPlausibleHostname, normalizeHostnameInput } from './customDomainHostname.js';
describe('normalizeHostnameInput', () => {
it('keeps a bare hostname, lowercased and trimmed', () => {
expect(normalizeHostnameInput(' WWW.Example.com ')).toBe('www.example.com');
});
it('strips a pasted URL down to its hostname', () => {
expect(normalizeHostnameInput('https://www.example.com/about?x=1#top')).toBe('www.example.com');
expect(normalizeHostnameInput('http://example.com:8080')).toBe('example.com');
expect(normalizeHostnameInput('https://user:[email protected]/')).toBe('example.com');
});
it('drops a trailing slash or dot', () => {
expect(normalizeHostnameInput('example.com/')).toBe('example.com');
expect(normalizeHostnameInput('example.com.')).toBe('example.com');
});
it('returns an empty string for nothing', () => {
expect(normalizeHostnameInput(undefined)).toBe('');
expect(normalizeHostnameInput(' ')).toBe('');
expect(normalizeHostnameInput('https://')).toBe('');
});
});
describe('isPlausibleHostname', () => {
it('accepts names with at least one dot', () => {
expect(isPlausibleHostname('example.com')).toBe(true);
expect(isPlausibleHostname('www.example.co.uk')).toBe(true);
});
it('rejects names without a dot, with spaces or with empty labels', () => {
expect(isPlausibleHostname('')).toBe(false);
expect(isPlausibleHostname('localhost')).toBe(false);
expect(isPlausibleHostname('www.exa mple.com')).toBe(false);
expect(isPlausibleHostname('www..example.com')).toBe(false);
expect(isPlausibleHostname('.example.com')).toBe(false);
});
});
+13 -4
View File
@@ -161,9 +161,13 @@ const en = {
custom_domain_add: 'Add Domain',
custom_domain_add_new: 'Add a New Domain',
custom_domain_apex_hint: 'This is a root domain, so your DNS provider needs to support a CNAME, ALIAS or ANAME record at the root (sometimes called CNAME flattening).',
custom_domain_claim_c2a: 'Someone else added this domain but never connected it. If it\'s yours, add this record at your DNS provider, then verify. The domain moves to your account.',
custom_domain_badge_active: 'Connected',
custom_domain_badge_failed: 'Failed',
custom_domain_badge_pending: 'Pending',
custom_domain_claim_c2a: 'This domain is on another Puter account. If it\'s yours, add this record at your DNS provider, then verify. The domain moves to your account.',
custom_domain_claim_not_found: 'The record isn\'t visible yet. DNS changes can take a few minutes; try again shortly.',
custom_domain_check_status: 'Check Status',
custom_domain_empty_hint: 'Add a domain you own and point it at this website.',
custom_domain_error_generic: 'Something went wrong. Please try again.',
custom_domain_error_invalid: 'Enter a domain name like www.example.com.',
custom_domain_error_limit: 'Your account has reached the maximum number of custom domains.',
@@ -174,25 +178,29 @@ const en = {
custom_domain_error_site: 'This website isn\'t on your account.',
custom_domain_error_taken: 'That domain is already connected to a Puter account.',
custom_domain_hostname_label: 'Domain',
custom_domain_hostname_hint: 'Use a domain you own, like www.example.com or example.com.',
custom_domain_intro: 'Choose a domain for %strong%.',
custom_domain_move: 'Move',
custom_domain_move_confirm: 'Moves it from %% to %%.',
custom_domain_not_enabled: 'Custom domains aren\'t available on your account yet.',
custom_domain_none_yet: 'You don\'t have any custom domains yet.',
custom_domain_optional: '(optional)',
custom_domain_optional: 'Optional',
custom_domain_price_note: 'Each domain costs %% per month. example.com and www.example.com count as two domains.',
custom_domain_no_root_alias: 'My DNS provider doesn\'t support CNAME, ALIAS or ANAME at the root',
custom_domain_record_type: 'Type',
custom_domain_record_type_apex: 'CNAME / ALIAS / ANAME',
custom_domain_redirect_note: 'Your website is served on %strong%. The A record forwards visitors from %strong% to it.',
custom_domain_redirect_note: 'Your website is served on %strong%. The A records forward visitors from %strong% to it.',
custom_domain_record_value: 'Value',
custom_domain_remove: 'Remove',
custom_domain_remove_confirm: 'The domain stops working right away and is removed from your account. This month isn\'t refunded.',
custom_domain_records_c2a: 'Add these records at your DNS provider. The domain starts working a few minutes after they\'re in place.',
custom_domain_remove_named: 'Remove %%',
custom_domain_requested_by: 'Requested by %strong%',
custom_domain_serves: 'Serving %%',
custom_domain_serves_this: 'Serving this website',
custom_domain_status_active: '%strong% is connected.',
custom_domain_status_failed: '%strong% couldn\'t be verified.',
custom_domain_status_pending: '%strong% is waiting for its DNS records.',
custom_domain_success_note: 'Visitors to %strong% now see %strong%.',
custom_domain_title: 'Connect a Custom Domain',
custom_domain_unavailable: 'Custom domains aren\'t available on this Puter.',
custom_domain_use: 'Use',
@@ -987,6 +995,7 @@ const en = {
sign_in_with_provider: 'Sign in with %%',
sign_up_with_provider: 'Sign up with %%',
sign_up_with_email: 'Sign up using email',
signup_oidc_only: 'New accounts can only be created with one of the options below.',
popup_opener_uses_puter: '%strong% is powered by Puter for its AI and cloud features.\nUse your Puter account to continue.',
popup_opener_origin_unsupported: 'Puter cannot sign you in to this page because the browser gives it no identifiable address. Pages opened straight from a file (file://) and iframes sandboxed without allow-same-origin are not supported — serve the page over http://localhost or a real domain and try again.',
oidc_switched_to_login_message: 'You have been logged in to an existing account.',
+1
View File
@@ -1001,6 +1001,7 @@ const postAuthActions = async (action) => {
domain = await UIWindowAttachCustomDomain({
subdomain,
requester: target_origin,
fullpage: window.embedded_in_popup === true,
});
} catch (e) {
console.error('attach-custom-domain action failed', e);