With info.hub the node selector reads Server / All servers, the default-credentials password prompt and the account actions the hub cannot serve are gone, the header status reports the servers an aggregate could not reach (hubWarnings, kept until the next aggregate), and merged Query Logs show a Server column. Without the flag nothing changes. The hub sends hubWarnings on every aggregate answer, empty when all servers answered, and never on a single-node one. Co-Authored-By: Claude Opus 5.5 <[email protected]>
181 lines
6.7 KiB
TypeScript
181 lines
6.7 KiB
TypeScript
import { urlApi } from '../app/base'
|
|
import { setHubWarnings } from './hub-warnings'
|
|
/*
|
|
The console's HTTP client. A replica of the old console's `HTTPRequest` helper
|
|
(upstream/master:DnsServerCore/www/js/common.js:28).
|
|
|
|
Three things that are the server's contract and not our preferences:
|
|
|
|
1. Paths go RELATIVE and without a leading slash. The server honours
|
|
X-Forwarded-Prefix and mounts a PathBase (DnsWebService.cs:1943-1945), so a
|
|
leading slash breaks any install behind a proxy with a prefix.
|
|
|
|
2. The response is NOT unwrapped. `user/login` and `user/session/get` return
|
|
the session FLAT; every other endpoint wraps it in `response`. Verified
|
|
against a v15.4 instance. Just like upstream, the JSON is handed over as it
|
|
came and the caller decides.
|
|
|
|
3. The four values of `status` are `ok`, `2fa-required`, `invalid-token` and
|
|
`error`. Verified in DnsWebService.cs:2475-2543.
|
|
*/
|
|
|
|
export type ApiOutcome<T = unknown> =
|
|
| { kind: 'ok'; data: T }
|
|
| { kind: 'error'; message: string }
|
|
| { kind: 'invalid-token' }
|
|
| { kind: 'two-factor-required' }
|
|
|
|
export interface ApiOptions {
|
|
method?: 'GET' | 'POST'
|
|
body?: Record<string, string>
|
|
token?: string | null
|
|
/*
|
|
Which cluster node answers this request.
|
|
|
|
It is not per-endpoint: the server reads `node` centrally in
|
|
`DnsWebService.cs:2355` and proxies the whole request to that node, so any call
|
|
can carry it. The empty string means "this one" and is not sent.
|
|
|
|
`cluster`, the aggregate, IS sent, as upstream sends it (main.js:2622, 2935):
|
|
the proxy skips it (`DnsWebService.cs:2356`), and `dashboard/stats/get` and
|
|
`dashboard/stats/getTop` read it themselves to add every node up
|
|
(`WebServiceDashboardApi.cs:281-282, 801-802`). Dropping it, as this client did
|
|
until v1.2.1, made the Dashboard's "Cluster" show the local node alone.
|
|
|
|
Upstream offers the choice on eight screens and remembers it per screen
|
|
(`ui/ClusterNodeSelect`).
|
|
*/
|
|
node?: string | null
|
|
/*
|
|
Multipart upload. The console has five: install and update app, import zone,
|
|
import records and restore settings (apps.js:348,392 · zone.js:1273,3039 ·
|
|
main.js:3169). They go with FormData, not with an encoded form, so
|
|
Content-Type must NOT be set by hand: the browser sets it with its boundary.
|
|
The fields of `body` travel inside the FormData too.
|
|
*/
|
|
file?: { field: string; file: File }
|
|
/** Alternative to `file` when the caller already built the FormData. */
|
|
form?: FormData
|
|
/*
|
|
Plain-text body. One single screen asks for it: importing a zone by pasting
|
|
the file into a textarea sends the raw text with `Content-Type: text/plain`
|
|
instead of multipart (zone.js:1281). The server tells them apart by that type.
|
|
*/
|
|
text?: string
|
|
}
|
|
|
|
interface Envelope {
|
|
status?: string
|
|
errorMessage?: string
|
|
/** Only from the hub: servers that did not answer an aggregate call. */
|
|
hubWarnings?: unknown
|
|
}
|
|
|
|
/*
|
|
What to do when the server says the session is no longer valid.
|
|
|
|
Upstream ALWAYS ends the session: `invalid-token` calls `showPageLogin()` (which
|
|
clears the token and shows the login) in the sixty-four calls that declare it,
|
|
and in the ones that do not, it falls through to the `window.location = "/"` of
|
|
`common.js:147`.
|
|
|
|
Here nobody did. Every screen showed "Invalid token or session expired." and
|
|
stayed where it was, with the console apparently usable and every action failing
|
|
one after another; to get back in you had to know that a reload was due. Beyond
|
|
being awkward, this is an administration console: it must not stay standing with
|
|
a session the server has already rejected.
|
|
|
|
It is solved in a single place, here, and not in the thirty screens, because the
|
|
rule is one: `SessionProvider` registers it, since it is the one that holds the
|
|
session.
|
|
*/
|
|
let onExpired: ((token?: string | null) => void) | null = null
|
|
|
|
export function onSessionExpired(fn: ((token?: string | null) => void) | null): void {
|
|
onExpired = fn
|
|
}
|
|
|
|
/*
|
|
The envelope read in ONE place. `apiRequest` uses it, and so do the few calls that
|
|
cannot go through it because their good answer is not JSON (`allowed/export`,
|
|
`blocked/export`): when those fail they answer this same envelope, and an
|
|
`invalid-token` there has to end the session like anywhere else.
|
|
*/
|
|
export function envelopeOutcome<T>(payload: Envelope, token?: string | null): ApiOutcome<T> {
|
|
switch (payload.status) {
|
|
case 'ok':
|
|
// Only an aggregate answer carries the field; a per-node call must not clear it.
|
|
if (payload.hubWarnings !== undefined) setHubWarnings(payload.hubWarnings)
|
|
return { kind: 'ok', data: payload as T }
|
|
case 'invalid-token':
|
|
onExpired?.(token)
|
|
return { kind: 'invalid-token' }
|
|
case '2fa-required':
|
|
return { kind: 'two-factor-required' }
|
|
default:
|
|
return { kind: 'error', message: payload.errorMessage ?? 'Unknown error.' }
|
|
}
|
|
}
|
|
|
|
export async function apiRequest<T = unknown>(
|
|
path: string,
|
|
opts: ApiOptions = {},
|
|
): Promise<ApiOutcome<T>> {
|
|
const { method = 'GET', body, token } = opts
|
|
|
|
const headers: Record<string, string> = {}
|
|
if (token) headers.Authorization = `Bearer ${token}`
|
|
|
|
let url = urlApi(`api/${path}`)
|
|
if (opts.node) {
|
|
url += (url.includes('?') ? '&' : '?') + 'node=' + encodeURIComponent(opts.node)
|
|
}
|
|
const init: RequestInit & { headers: Record<string, string> } = { method, headers }
|
|
|
|
if (opts.form) {
|
|
init.method = 'POST'
|
|
init.body = opts.form
|
|
} else if (opts.text != null) {
|
|
init.method = 'POST'
|
|
headers['Content-Type'] = 'text/plain'
|
|
init.body = opts.text
|
|
} else if (opts.file) {
|
|
const fd = new FormData()
|
|
for (const [k, v] of Object.entries(body ?? {})) fd.append(k, v)
|
|
fd.append(opts.file.field, opts.file.file)
|
|
init.method = 'POST'
|
|
init.body = fd
|
|
// No Content-Type by hand: the browser adds the boundary.
|
|
} else if (body) {
|
|
const encoded = new URLSearchParams(body).toString()
|
|
if (method === 'POST') {
|
|
headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
|
init.body = encoded
|
|
} else {
|
|
url += (url.includes('?') ? '&' : '?') + encoded
|
|
}
|
|
}
|
|
|
|
/*
|
|
common.js:182-196. A request that never gets an answer is jQuery's
|
|
`textStatus === "error"` with an empty `errorThrown`, and upstream says so with
|
|
its own sentence; any other failure (an answer that is not JSON) is shown as
|
|
jQuery's `textStatus - errorThrown`. The sentence had been replaced with one of
|
|
this console's own until 2026-09-30.
|
|
*/
|
|
let res: Response
|
|
try {
|
|
res = await fetch(url, init)
|
|
} catch {
|
|
return { kind: 'error', message: 'Unable to connect to the server. Please try again.' }
|
|
}
|
|
let payload: Envelope
|
|
try {
|
|
payload = (await res.json()) as Envelope
|
|
} catch (e) {
|
|
return { kind: 'error', message: `parsererror - ${String(e)}` }
|
|
}
|
|
|
|
return envelopeOutcome<T>(payload, token)
|
|
}
|