Files
technitium-console/src/api/client.ts
T
DenozordecandClaude Opus 5.5 48b5d9fb15 Hub stage 3: the console in hub mode
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 <noreply@anthropic.com>
2026-10-30 19:18:58 +07:00

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)
}