mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3542a6aa3 | ||
|
|
5a59db7b6b | ||
|
|
fc4af97885 | ||
|
|
7cec710014 | ||
|
|
48cd4ea355 | ||
|
|
a110be0340 |
No files matched your search
@@ -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 =
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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>[];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.',
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user