mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
feat(web): protect launcher dashboard with token and SPA login (#1953)
Add token-based authentication for the Launcher's embedded Web Dashboard. - Ephemeral token generated in-memory each run (or via PICOCLAW_LAUNCHER_TOKEN env var) - HMAC-SHA256 session cookie (HttpOnly, SameSite=Lax, Secure when HTTPS) - Bearer token support for API/script access - Rate limiting on login (10 attempts/IP/min) - Referrer-Policy: no-referrer on all responses - POST-only logout with JSON content-type (CSRF-safe) - System tray "Copy dashboard token" action - Login page shows contextual help (console/tray/log file path) - Path traversal protection via path.Clean - X-Forwarded-Host/Port/Proto support for reverse proxy deployments - Full i18n support (English, Chinese) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
// API client for channels navigation and channel-specific config flows.
|
||||
|
||||
import { launcherFetch } from "@/api/http"
|
||||
|
||||
export type ChannelConfig = Record<string, unknown>
|
||||
export type AppConfig = Record<string, unknown>
|
||||
|
||||
@@ -22,7 +24,7 @@ interface ConfigActionResponse {
|
||||
const BASE_URL = ""
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE_URL}${path}`, options)
|
||||
const res = await launcherFetch(`${BASE_URL}${path}`, options)
|
||||
if (!res.ok) {
|
||||
let message = `API error: ${res.status} ${res.statusText}`
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { launcherFetch } from "@/api/http"
|
||||
|
||||
// API client for gateway process management.
|
||||
|
||||
interface GatewayStatusResponse {
|
||||
@@ -27,7 +29,7 @@ interface GatewayActionResponse {
|
||||
const BASE_URL = ""
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE_URL}${path}`, options)
|
||||
const res = await launcherFetch(`${BASE_URL}${path}`, options)
|
||||
if (!res.ok) {
|
||||
throw new Error(`API error: ${res.status} ${res.statusText}`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { isLauncherLoginPathname } from "@/lib/launcher-login-path"
|
||||
|
||||
function isLauncherLoginPath(): boolean {
|
||||
if (typeof globalThis.location === "undefined") {
|
||||
return false
|
||||
}
|
||||
if (isLauncherLoginPathname(globalThis.location.pathname || "/")) {
|
||||
return true
|
||||
}
|
||||
try {
|
||||
return isLauncherLoginPathname(
|
||||
new URL(globalThis.location.href).pathname || "/",
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Same-origin fetch that sends cookies; redirects to launcher login on 401 JSON responses.
|
||||
* Skips redirect while already on the login page to avoid reload loops (e.g. gateway poll).
|
||||
*/
|
||||
export async function launcherFetch(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
const res = await fetch(input, {
|
||||
credentials: "same-origin",
|
||||
...init,
|
||||
})
|
||||
if (res.status === 401) {
|
||||
const ct = res.headers.get("content-type") || ""
|
||||
if (
|
||||
ct.includes("application/json") &&
|
||||
typeof globalThis.location !== "undefined" &&
|
||||
!isLauncherLoginPath()
|
||||
) {
|
||||
globalThis.location.assign("/launcher-login")
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Dashboard launcher token login. Uses plain fetch (not launcherFetch) to avoid
|
||||
* redirect loops on 401 while on the login page.
|
||||
*/
|
||||
export async function postLauncherDashboardLogin(
|
||||
token: string,
|
||||
): Promise<boolean> {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ token: token.trim() }),
|
||||
})
|
||||
return res.ok
|
||||
}
|
||||
|
||||
export type LauncherAuthTokenHelp = {
|
||||
env_var_name: string
|
||||
log_file?: string
|
||||
tray_copy_menu: boolean
|
||||
console_stdout: boolean
|
||||
}
|
||||
|
||||
export type LauncherAuthStatus = {
|
||||
authenticated: boolean
|
||||
token_help?: LauncherAuthTokenHelp
|
||||
}
|
||||
|
||||
export async function getLauncherAuthStatus(): Promise<LauncherAuthStatus> {
|
||||
const res = await fetch("/api/auth/status", {
|
||||
method: "GET",
|
||||
credentials: "same-origin",
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(`status ${res.status}`)
|
||||
}
|
||||
return (await res.json()) as LauncherAuthStatus
|
||||
}
|
||||
|
||||
export async function postLauncherDashboardLogout(): Promise<boolean> {
|
||||
const res = await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: "{}",
|
||||
})
|
||||
return res.ok
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { launcherFetch } from "@/api/http"
|
||||
import { refreshGatewayState } from "@/store/gateway"
|
||||
|
||||
// API client for model list management.
|
||||
@@ -39,7 +40,7 @@ interface ModelActionResponse {
|
||||
const BASE_URL = ""
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE_URL}${path}`, options)
|
||||
const res = await launcherFetch(`${BASE_URL}${path}`, options)
|
||||
if (!res.ok) {
|
||||
throw new Error(`API error: ${res.status} ${res.statusText}`)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { launcherFetch } from "@/api/http"
|
||||
|
||||
export type OAuthProvider = "openai" | "anthropic" | "google-antigravity"
|
||||
export type OAuthMethod = "browser" | "device_code" | "token"
|
||||
|
||||
@@ -51,7 +53,7 @@ interface OAuthProvidersResponse {
|
||||
const BASE_URL = ""
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE_URL}${path}`, options)
|
||||
const res = await launcherFetch(`${BASE_URL}${path}`, options)
|
||||
if (!res.ok) {
|
||||
const message = await res.text()
|
||||
throw new Error(message || `API error: ${res.status} ${res.statusText}`)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { launcherFetch } from "@/api/http"
|
||||
|
||||
// API client for Pico Channel configuration.
|
||||
|
||||
interface PicoTokenResponse {
|
||||
@@ -16,7 +18,7 @@ interface PicoSetupResponse {
|
||||
const BASE_URL = ""
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE_URL}${path}`, options)
|
||||
const res = await launcherFetch(`${BASE_URL}${path}`, options)
|
||||
if (!res.ok) {
|
||||
throw new Error(`API error: ${res.status} ${res.statusText}`)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Sessions API — list and retrieve chat session history
|
||||
|
||||
import { launcherFetch } from "@/api/http"
|
||||
|
||||
export interface SessionSummary {
|
||||
id: string
|
||||
title: string
|
||||
@@ -26,7 +28,7 @@ export async function getSessions(
|
||||
limit: limit.toString(),
|
||||
})
|
||||
|
||||
const res = await fetch(`/api/sessions?${params.toString()}`)
|
||||
const res = await launcherFetch(`/api/sessions?${params.toString()}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch sessions: ${res.status}`)
|
||||
}
|
||||
@@ -34,7 +36,7 @@ export async function getSessions(
|
||||
}
|
||||
|
||||
export async function getSessionHistory(id: string): Promise<SessionDetail> {
|
||||
const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`)
|
||||
const res = await launcherFetch(`/api/sessions/${encodeURIComponent(id)}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch session ${id}: ${res.status}`)
|
||||
}
|
||||
@@ -42,7 +44,7 @@ export async function getSessionHistory(id: string): Promise<SessionDetail> {
|
||||
}
|
||||
|
||||
export async function deleteSession(id: string): Promise<void> {
|
||||
const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`, {
|
||||
const res = await launcherFetch(`/api/sessions/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { launcherFetch } from "@/api/http"
|
||||
|
||||
export interface SkillSupportItem {
|
||||
name: string
|
||||
path: string
|
||||
@@ -22,7 +24,7 @@ interface SkillActionResponse {
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, options)
|
||||
const res = await launcherFetch(path, options)
|
||||
if (!res.ok) {
|
||||
throw new Error(await extractErrorMessage(res))
|
||||
}
|
||||
@@ -41,7 +43,7 @@ export async function importSkill(file: File): Promise<SkillActionResponse> {
|
||||
const formData = new FormData()
|
||||
formData.set("file", file)
|
||||
|
||||
const res = await fetch("/api/skills/import", {
|
||||
const res = await launcherFetch("/api/skills/import", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { launcherFetch } from "@/api/http"
|
||||
|
||||
export interface AutoStartStatus {
|
||||
enabled: boolean
|
||||
supported: boolean
|
||||
@@ -12,7 +14,7 @@ export interface LauncherConfig {
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, options)
|
||||
const res = await launcherFetch(path, options)
|
||||
if (!res.ok) {
|
||||
let message = `API error: ${res.status} ${res.statusText}`
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { launcherFetch } from "@/api/http"
|
||||
|
||||
export interface ToolSupportItem {
|
||||
name: string
|
||||
description: string
|
||||
@@ -16,7 +18,7 @@ interface ToolActionResponse {
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, options)
|
||||
const res = await launcherFetch(path, options)
|
||||
if (!res.ok) {
|
||||
let message = `API error: ${res.status} ${res.statusText}`
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user