Loopback Fastify auth-broker + esbuild SPA. Operator password login (constant-time, signed HttpOnly session cookie, per-forwarded-IP rate-limit) → session-gated proxy that mints a fresh 60s manage capability token per call to the control-plane admin API: list hosts, mint pairing codes (with QR + pair command), revoke hosts. Security headers + CSP, CP_URL pinned loopback (anti-SSRF), hostId dot-segment guard. 55 tests pass; security-reviewed. Deployed behind nginx panel.terminal.yaojia.wang.
61 lines
2.3 KiB
TypeScript
61 lines
2.3 KiB
TypeScript
/**
|
|
* Tiny DOM builder. ALL text and attribute values are set via `textContent` / `setAttribute` — this
|
|
* module NEVER assigns `innerHTML`, so untrusted server/host data (host subdomains, etc.) can never
|
|
* inject markup. Children passed as strings become text nodes (also safe).
|
|
*/
|
|
export type Child = Node | string
|
|
|
|
export interface ElProps {
|
|
class?: string
|
|
text?: string
|
|
type?: string
|
|
name?: string
|
|
placeholder?: string
|
|
value?: string
|
|
disabled?: boolean
|
|
autocomplete?: string
|
|
title?: string
|
|
src?: string
|
|
alt?: string
|
|
role?: string
|
|
ariaLabel?: string
|
|
onClick?: (e: MouseEvent) => void
|
|
onSubmit?: (e: SubmitEvent) => void
|
|
}
|
|
|
|
export function el<K extends keyof HTMLElementTagNameMap>(
|
|
tag: K,
|
|
props: ElProps = {},
|
|
children: Child[] = [],
|
|
): HTMLElementTagNameMap[K] {
|
|
const node = document.createElement(tag)
|
|
if (props.class !== undefined) node.className = props.class
|
|
if (props.text !== undefined) node.textContent = props.text
|
|
if (props.type !== undefined) node.setAttribute('type', props.type)
|
|
if (props.name !== undefined) node.setAttribute('name', props.name)
|
|
if (props.placeholder !== undefined) node.setAttribute('placeholder', props.placeholder)
|
|
if (props.value !== undefined) (node as HTMLInputElement).value = props.value
|
|
if (props.disabled) node.setAttribute('disabled', 'true')
|
|
if (props.autocomplete !== undefined) node.setAttribute('autocomplete', props.autocomplete)
|
|
if (props.title !== undefined) node.setAttribute('title', props.title)
|
|
if (props.src !== undefined) node.setAttribute('src', props.src)
|
|
if (props.alt !== undefined) node.setAttribute('alt', props.alt)
|
|
if (props.role !== undefined) node.setAttribute('role', props.role)
|
|
if (props.ariaLabel !== undefined) node.setAttribute('aria-label', props.ariaLabel)
|
|
if (props.onClick !== undefined) node.addEventListener('click', props.onClick as EventListener)
|
|
if (props.onSubmit !== undefined) node.addEventListener('submit', props.onSubmit as EventListener)
|
|
for (const child of children) node.append(child)
|
|
return node
|
|
}
|
|
|
|
/** Remove all children of a node. */
|
|
export function clear(node: Element): void {
|
|
while (node.firstChild) node.removeChild(node.firstChild)
|
|
}
|
|
|
|
/** Replace a node's content with a single new child. */
|
|
export function mount(root: Element, child: Node): void {
|
|
clear(root)
|
|
root.append(child)
|
|
}
|