Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s
- Local accounts with bcrypt password hashing; first-run setup via POST /api/setup - Personal API tokens (dmv_<48hex>, SHA-256 hashed at rest) for scripted access - Server-side in-memory sessions with 32-byte secure cookie (dockmv_session, 7-day TTL, sliding renewal) - Login rate limiting (exponential backoff 1s–30s cap) per IP - Refuse to bind non-loopback while no account exists, unless DOCKMV_TRUST_ADDR=1 (for Docker's port mapping) - Every account can manage every other account (no roles in v1) - Auth middleware: public-path allowlist (/api/setup, /api/login, /api/logout, /api/me, /api/health) + session cookie check + API token (X-Auth-Token or Authorization: Bearer) check - Frontend AuthGate gates app on GET /api/me; shows setup screen or login form or app tree as needed - Account tab for personal token management; sign-out button in topbar - Break: removed --token flag, DOCKMV_TOKEN env var, ?token= query param, /api/health no longer auto-responds when unauthenticated Verified: go build/vet clean, frontend tsc+vite clean. Sandbox cannot execute binaries to test setup→login→session→token flow at runtime; recommend manual pass before merge. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
112 lines
5.4 KiB
TypeScript
112 lines
5.4 KiB
TypeScript
import type {
|
|
APIToken, Connection, Health, HostKeyInfo, JobSnapshot, Me, PackageInfo, Plan,
|
|
Preflight, PreviewResponse, Source, SourceResponse, SourcesResponse, SourceStatus, TargetInventory, User,
|
|
} from './types'
|
|
|
|
/** ApiError carries the server's message plus anything it attached to it. */
|
|
export class ApiError extends Error {
|
|
status: number
|
|
body: Record<string, unknown>
|
|
constructor(status: number, message: string, body: Record<string, unknown> = {}) {
|
|
super(message)
|
|
this.status = status
|
|
this.body = body
|
|
}
|
|
/** True when the target's SSH host key still has to be approved. */
|
|
get needsTrust(): boolean {
|
|
return this.body.needsTrust === true
|
|
}
|
|
}
|
|
|
|
// Paths whose own job is to report or resolve "not authenticated" — a 401
|
|
// from one of these is the expected outcome of a bad login, not a session
|
|
// that dropped out from under the app, so it must not trigger the global
|
|
// handler below.
|
|
const authPaths = new Set(['/api/me', '/api/login', '/api/setup'])
|
|
|
|
// Set by AuthGate so a 401 on any other endpoint (a session that expired or
|
|
// was revoked mid-use) drops the app back to the login screen, without every
|
|
// call site having to check for it.
|
|
let onUnauthorized: (() => void) | null = null
|
|
export function setUnauthorizedHandler(fn: (() => void) | null) {
|
|
onUnauthorized = fn
|
|
}
|
|
|
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
const headers: Record<string, string> = { ...(init?.headers as Record<string, string>) }
|
|
if (init?.body) headers['Content-Type'] = 'application/json'
|
|
|
|
const res = await fetch(path, { ...init, headers, credentials: 'same-origin' })
|
|
if (res.status === 401 && !authPaths.has(path)) onUnauthorized?.()
|
|
if (res.status === 204) return undefined as T
|
|
|
|
const text = await res.text()
|
|
let body: Record<string, unknown> = {}
|
|
if (text) {
|
|
try {
|
|
body = JSON.parse(text)
|
|
} catch {
|
|
if (!res.ok) throw new ApiError(res.status, text.slice(0, 400))
|
|
}
|
|
}
|
|
if (!res.ok) {
|
|
const msg = typeof body.error === 'string' ? body.error : `request failed (${res.status})`
|
|
throw new ApiError(res.status, msg, body)
|
|
}
|
|
return body as T
|
|
}
|
|
|
|
const post = <T>(path: string, body?: unknown) =>
|
|
request<T>(path, { method: 'POST', body: body === undefined ? undefined : JSON.stringify(body) })
|
|
|
|
export const api = {
|
|
health: () => request<Health>('/api/health'),
|
|
source: () => request<SourceResponse>('/api/source'),
|
|
volumeSizes: () => request<{ volumes: Record<string, number> }>('/api/source/sizes'),
|
|
|
|
me: () => request<Me>('/api/me'),
|
|
setup: (username: string, password: string) => post<Me>('/api/setup', { username, password }),
|
|
login: (username: string, password: string) => post<Me>('/api/login', { username, password }),
|
|
logout: () => request<void>('/api/logout', { method: 'POST' }),
|
|
|
|
users: () => request<User[]>('/api/users'),
|
|
createUser: (username: string, password: string) => post<User>('/api/users', { username, password }),
|
|
deleteUser: (id: string) => request<void>(`/api/users/${id}`, { method: 'DELETE' }),
|
|
|
|
tokens: () => request<APIToken[]>('/api/tokens'),
|
|
createToken: (name: string) => post<APIToken & { token: string }>('/api/tokens', { name }),
|
|
revokeToken: (id: string) => request<void>(`/api/tokens/${id}`, { method: 'DELETE' }),
|
|
|
|
sources: () => request<SourcesResponse>('/api/sources'),
|
|
saveSource: (s: Partial<Source>) => post<Source>('/api/sources', s),
|
|
deleteSource: (id: string) => request<void>(`/api/sources/${id}`, { method: 'DELETE' }),
|
|
selectSource: (id: string) => post<SourceStatus>(`/api/sources/${id}/select`),
|
|
probeSource: (id: string) => post<HostKeyInfo>(`/api/sources/${id}/probe`),
|
|
trustSource: (id: string, fingerprint: string) =>
|
|
post<{ trusted: boolean }>(`/api/sources/${id}/trust`, { fingerprint }),
|
|
|
|
connections: () => request<Connection[]>('/api/connections'),
|
|
saveConnection: (c: Partial<Connection>) => post<Connection>('/api/connections', c),
|
|
deleteConnection: (id: string) => request<void>(`/api/connections/${id}`, { method: 'DELETE' }),
|
|
probe: (id: string) => post<HostKeyInfo>(`/api/connections/${id}/probe`),
|
|
trust: (id: string, fingerprint: string) => post<{ trusted: boolean }>(`/api/connections/${id}/trust`, { fingerprint }),
|
|
testConnection: (id: string) => post<Preflight>(`/api/connections/${id}/test`),
|
|
targetInventory: (id: string) => request<TargetInventory>(`/api/connections/${id}/inventory`),
|
|
|
|
preview: (plan: Plan) => post<PreviewResponse>('/api/plan/preview', plan),
|
|
migrateSSH: (connectionId: string, plan: Plan) => post<JobSnapshot>('/api/migrate/ssh', { connectionId, plan }),
|
|
buildPackage: (plan: Plan, format: 'tar' | 'dir') => post<JobSnapshot>('/api/migrate/package', { plan, format }),
|
|
|
|
jobs: () => request<JobSnapshot[]>('/api/jobs'),
|
|
job: (id: string) => request<JobSnapshot>(`/api/jobs/${id}`),
|
|
cancelJob: (id: string) => post<{ canceled: boolean }>(`/api/jobs/${id}/cancel`),
|
|
deleteJob: (id: string) => request<void>(`/api/jobs/${id}`, { method: 'DELETE' }),
|
|
|
|
packages: () => request<PackageInfo[]>('/api/packages'),
|
|
deletePackage: (name: string) => request<void>(`/api/packages/${encodeURIComponent(name)}`, { method: 'DELETE' }),
|
|
downloadUrl: (name: string) => `/api/packages/${encodeURIComponent(name)}/download`,
|
|
|
|
/** Opens the live progress stream for a job. Cookies ride along automatically: it's a same-origin request. */
|
|
jobEvents: (id: string) => new EventSource(`/api/jobs/${id}/events`),
|
|
}
|