kanna-code 0.30.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/dist/client/assets/index-BxEBJJ_f.css +32 -0
- package/dist/client/assets/index-CoeDwJZ0.js +2593 -0
- package/dist/client/index.html +2 -2
- package/package.json +2 -1
- package/src/server/auth.test.ts +207 -0
- package/src/server/auth.ts +304 -0
- package/src/server/cli-runtime.test.ts +28 -0
- package/src/server/cli-runtime.ts +11 -0
- package/src/server/event-store.test.ts +18 -0
- package/src/server/event-store.ts +32 -0
- package/src/server/events.ts +8 -0
- package/src/server/generate-commit-message.test.ts +20 -0
- package/src/server/generate-commit-message.ts +1 -1
- package/src/server/generate-title.ts +1 -1
- package/src/server/llm-provider.test.ts +134 -0
- package/src/server/llm-provider.ts +149 -0
- package/src/server/quick-response.test.ts +202 -0
- package/src/server/quick-response.ts +56 -2
- package/src/server/read-models.test.ts +78 -1
- package/src/server/read-models.ts +54 -4
- package/src/server/server.ts +47 -0
- package/src/server/ws-router.test.ts +274 -10
- package/src/server/ws-router.ts +111 -21
- package/src/shared/branding.ts +8 -0
- package/src/shared/protocol.ts +11 -0
- package/src/shared/types.ts +24 -0
- package/dist/client/assets/index-Bnur1EEv.css +0 -32
- package/dist/client/assets/index-DNENqajG.js +0 -2593
package/dist/client/index.html
CHANGED
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
})()
|
|
31
31
|
</script>
|
|
32
32
|
<title>Kanna</title>
|
|
33
|
-
<script type="module" crossorigin src="/assets/index-
|
|
34
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
33
|
+
<script type="module" crossorigin src="/assets/index-CoeDwJZ0.js"></script>
|
|
34
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BxEBJJ_f.css">
|
|
35
35
|
</head>
|
|
36
36
|
<body>
|
|
37
37
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kanna-code",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.32.0",
|
|
5
5
|
"description": "A beautiful web UI for Claude Code",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"cloudflared": "^0.7.1",
|
|
58
58
|
"default-shell": "^2.2.0",
|
|
59
59
|
"file-type": "^22.0.0",
|
|
60
|
+
"openai": "^6.34.0",
|
|
60
61
|
"react-resizable-panels": "^4.7.3",
|
|
61
62
|
"uqr": "^0.1.3"
|
|
62
63
|
},
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
import { persistProjectUpload } from "./uploads"
|
|
6
|
+
import { startKannaServer } from "./server"
|
|
7
|
+
|
|
8
|
+
const tempDirs: string[] = []
|
|
9
|
+
|
|
10
|
+
afterEach(async () => {
|
|
11
|
+
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
async function startPasswordServer() {
|
|
15
|
+
const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-auth-test-"))
|
|
16
|
+
tempDirs.push(projectDir)
|
|
17
|
+
const server = await startKannaServer({ port: 4320, strictPort: true, password: "secret" })
|
|
18
|
+
const project = await server.store.openProject(projectDir, "Project")
|
|
19
|
+
return { server, projectDir, project }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function extractCookie(response: Response) {
|
|
23
|
+
const header = response.headers.get("set-cookie")
|
|
24
|
+
expect(header).toBeTruthy()
|
|
25
|
+
return header!.split(";", 1)[0]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("password auth", () => {
|
|
29
|
+
test("redirects unauthenticated html requests to the login page", async () => {
|
|
30
|
+
const { server } = await startPasswordServer()
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const response = await fetch(`http://localhost:${server.port}/`, { redirect: "manual", headers: { Accept: "text/html" } })
|
|
34
|
+
expect(response.status).toBe(302)
|
|
35
|
+
expect(response.headers.get("location")).toBe(`http://localhost:${server.port}/auth/login?next=%2F`)
|
|
36
|
+
} finally {
|
|
37
|
+
await server.stop()
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test("blocks unauthenticated api requests", async () => {
|
|
42
|
+
const { server } = await startPasswordServer()
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const response = await fetch(`http://localhost:${server.port}/health`, { redirect: "manual" })
|
|
46
|
+
expect(response.status).toBe(401)
|
|
47
|
+
} finally {
|
|
48
|
+
await server.stop()
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test("serves the login page without authentication", async () => {
|
|
53
|
+
const { server } = await startPasswordServer()
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const response = await fetch(`http://localhost:${server.port}/auth/login`)
|
|
57
|
+
expect(response.status).toBe(200)
|
|
58
|
+
expect(response.headers.get("content-type")).toContain("text/html")
|
|
59
|
+
expect(await response.text()).toContain("This server is password protected")
|
|
60
|
+
} finally {
|
|
61
|
+
await server.stop()
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test("sets a session cookie after a successful login", async () => {
|
|
66
|
+
const { server } = await startPasswordServer()
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const formData = new FormData()
|
|
70
|
+
formData.append("password", "secret")
|
|
71
|
+
formData.append("next", "/")
|
|
72
|
+
const response = await fetch(`http://localhost:${server.port}/auth/login`, {
|
|
73
|
+
method: "POST",
|
|
74
|
+
body: formData,
|
|
75
|
+
redirect: "manual",
|
|
76
|
+
headers: {
|
|
77
|
+
Origin: `http://localhost:${server.port}`,
|
|
78
|
+
},
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
expect(response.status).toBe(302)
|
|
82
|
+
expect(response.headers.get("location")).toBe(`http://localhost:${server.port}/`)
|
|
83
|
+
expect(extractCookie(response)).toContain("kanna_session=")
|
|
84
|
+
} finally {
|
|
85
|
+
await server.stop()
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test("rejects an invalid password", async () => {
|
|
90
|
+
const { server } = await startPasswordServer()
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const formData = new FormData()
|
|
94
|
+
formData.append("password", "wrong")
|
|
95
|
+
const response = await fetch(`http://localhost:${server.port}/auth/login`, {
|
|
96
|
+
method: "POST",
|
|
97
|
+
body: formData,
|
|
98
|
+
redirect: "manual",
|
|
99
|
+
headers: {
|
|
100
|
+
Origin: `http://localhost:${server.port}`,
|
|
101
|
+
},
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
expect(response.status).toBe(302)
|
|
105
|
+
expect(response.headers.get("location")).toContain("/auth/login?error=1")
|
|
106
|
+
expect(response.headers.get("set-cookie")).toBeNull()
|
|
107
|
+
} finally {
|
|
108
|
+
await server.stop()
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test("rejects cross-origin login attempts", async () => {
|
|
113
|
+
const { server } = await startPasswordServer()
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const response = await fetch(`http://localhost:${server.port}/auth/login`, {
|
|
117
|
+
method: "POST",
|
|
118
|
+
body: JSON.stringify({ password: "secret" }),
|
|
119
|
+
headers: {
|
|
120
|
+
"Content-Type": "application/json",
|
|
121
|
+
Origin: "http://evil.test",
|
|
122
|
+
},
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
expect(response.status).toBe(403)
|
|
126
|
+
} finally {
|
|
127
|
+
await server.stop()
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test("allows authenticated access to protected routes", async () => {
|
|
132
|
+
const { server, project, projectDir } = await startPasswordServer()
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const attachment = await persistProjectUpload({
|
|
136
|
+
projectId: project.id,
|
|
137
|
+
localPath: projectDir,
|
|
138
|
+
fileName: "hello.txt",
|
|
139
|
+
bytes: new TextEncoder().encode("hello from upload"),
|
|
140
|
+
fallbackMimeType: "text/plain",
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
const loginResponse = await fetch(`http://localhost:${server.port}/auth/login`, {
|
|
144
|
+
method: "POST",
|
|
145
|
+
body: JSON.stringify({ password: "secret", next: "/" }),
|
|
146
|
+
headers: {
|
|
147
|
+
"Content-Type": "application/json",
|
|
148
|
+
Origin: `http://localhost:${server.port}`,
|
|
149
|
+
},
|
|
150
|
+
})
|
|
151
|
+
const cookie = extractCookie(loginResponse)
|
|
152
|
+
|
|
153
|
+
const healthResponse = await fetch(`http://localhost:${server.port}/health`, {
|
|
154
|
+
headers: {
|
|
155
|
+
Cookie: cookie,
|
|
156
|
+
},
|
|
157
|
+
})
|
|
158
|
+
expect(healthResponse.status).toBe(200)
|
|
159
|
+
|
|
160
|
+
const contentResponse = await fetch(`http://localhost:${server.port}${attachment.contentUrl}`, {
|
|
161
|
+
headers: {
|
|
162
|
+
Cookie: cookie,
|
|
163
|
+
},
|
|
164
|
+
})
|
|
165
|
+
expect(contentResponse.status).toBe(200)
|
|
166
|
+
expect(await contentResponse.text()).toBe("hello from upload")
|
|
167
|
+
} finally {
|
|
168
|
+
await server.stop()
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
test("clears the session cookie on logout", async () => {
|
|
173
|
+
const { server } = await startPasswordServer()
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
const loginResponse = await fetch(`http://localhost:${server.port}/auth/login`, {
|
|
177
|
+
method: "POST",
|
|
178
|
+
body: JSON.stringify({ password: "secret", next: "/" }),
|
|
179
|
+
headers: {
|
|
180
|
+
"Content-Type": "application/json",
|
|
181
|
+
Origin: `http://localhost:${server.port}`,
|
|
182
|
+
},
|
|
183
|
+
})
|
|
184
|
+
const cookie = extractCookie(loginResponse)
|
|
185
|
+
|
|
186
|
+
const logoutResponse = await fetch(`http://localhost:${server.port}/auth/logout`, {
|
|
187
|
+
method: "POST",
|
|
188
|
+
headers: {
|
|
189
|
+
Cookie: cookie,
|
|
190
|
+
Origin: `http://localhost:${server.port}`,
|
|
191
|
+
},
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
expect(logoutResponse.status).toBe(200)
|
|
195
|
+
expect(logoutResponse.headers.get("set-cookie")).toContain("Max-Age=0")
|
|
196
|
+
|
|
197
|
+
const healthResponse = await fetch(`http://localhost:${server.port}/health`, {
|
|
198
|
+
headers: {
|
|
199
|
+
Cookie: cookie,
|
|
200
|
+
},
|
|
201
|
+
})
|
|
202
|
+
expect(healthResponse.status).toBe(401)
|
|
203
|
+
} finally {
|
|
204
|
+
await server.stop()
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
})
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from "node:crypto"
|
|
2
|
+
import { APP_NAME } from "../shared/branding"
|
|
3
|
+
|
|
4
|
+
const SESSION_COOKIE_NAME = "kanna_session"
|
|
5
|
+
|
|
6
|
+
export interface AuthStatusPayload {
|
|
7
|
+
enabled: boolean
|
|
8
|
+
authenticated: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface AuthManager {
|
|
12
|
+
readonly enabled: true
|
|
13
|
+
isAuthenticated(req: Request): boolean
|
|
14
|
+
validateOrigin(req: Request): boolean
|
|
15
|
+
createSessionCookie(req: Request): string
|
|
16
|
+
clearSessionCookie(req: Request): string
|
|
17
|
+
verifyPassword(candidate: string): boolean
|
|
18
|
+
handleLogin(req: Request, nextPath: string): Promise<Response>
|
|
19
|
+
handleLogout(req: Request): Response
|
|
20
|
+
handleStatus(req: Request): Response
|
|
21
|
+
renderLoginPage(req: Request): Response
|
|
22
|
+
unauthorizedResponse(req: Request): Response
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseCookies(header: string | null) {
|
|
26
|
+
const cookies = new Map<string, string>()
|
|
27
|
+
if (!header) return cookies
|
|
28
|
+
|
|
29
|
+
for (const segment of header.split(";")) {
|
|
30
|
+
const trimmed = segment.trim()
|
|
31
|
+
if (!trimmed) continue
|
|
32
|
+
const separator = trimmed.indexOf("=")
|
|
33
|
+
if (separator <= 0) continue
|
|
34
|
+
const key = trimmed.slice(0, separator).trim()
|
|
35
|
+
const value = trimmed.slice(separator + 1).trim()
|
|
36
|
+
cookies.set(key, decodeURIComponent(value))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return cookies
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function sanitizeNextPath(nextPath: string | null | undefined) {
|
|
43
|
+
if (!nextPath || typeof nextPath !== "string") return "/"
|
|
44
|
+
if (!nextPath.startsWith("/")) return "/"
|
|
45
|
+
if (nextPath.startsWith("//")) return "/"
|
|
46
|
+
if (nextPath.startsWith("/auth/login")) return "/"
|
|
47
|
+
return nextPath
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function shouldUseSecureCookie(req: Request) {
|
|
51
|
+
return new URL(req.url).protocol === "https:"
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function buildCookie(name: string, value: string, req: Request, extras: string[] = []) {
|
|
55
|
+
const parts = [
|
|
56
|
+
`${name}=${encodeURIComponent(value)}`,
|
|
57
|
+
"Path=/",
|
|
58
|
+
"HttpOnly",
|
|
59
|
+
"SameSite=Strict",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
if (shouldUseSecureCookie(req)) {
|
|
63
|
+
parts.push("Secure")
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
parts.push(...extras)
|
|
67
|
+
return parts.join("; ")
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function readLoginForm(req: Request) {
|
|
71
|
+
const contentType = req.headers.get("content-type") ?? ""
|
|
72
|
+
|
|
73
|
+
if (contentType.includes("application/json")) {
|
|
74
|
+
const payload = await req.json() as { password?: unknown; next?: unknown }
|
|
75
|
+
return {
|
|
76
|
+
password: typeof payload.password === "string" ? payload.password : "",
|
|
77
|
+
nextPath: sanitizeNextPath(typeof payload.next === "string" ? payload.next : "/"),
|
|
78
|
+
wantsJson: true,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const formData = await req.formData()
|
|
83
|
+
return {
|
|
84
|
+
password: String(formData.get("password") ?? ""),
|
|
85
|
+
nextPath: sanitizeNextPath(String(formData.get("next") ?? "/")),
|
|
86
|
+
wantsJson: false,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function requestWantsHtml(req: Request) {
|
|
91
|
+
const accept = req.headers.get("accept") ?? ""
|
|
92
|
+
return accept.includes("text/html")
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function escapeHtml(value: string) {
|
|
96
|
+
return value
|
|
97
|
+
.replaceAll("&", "&")
|
|
98
|
+
.replaceAll("<", "<")
|
|
99
|
+
.replaceAll(">", ">")
|
|
100
|
+
.replaceAll("\"", """)
|
|
101
|
+
.replaceAll("'", "'")
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function createAuthManager(password: string): AuthManager {
|
|
105
|
+
const sessions = new Set<string>()
|
|
106
|
+
const expectedPassword = Buffer.from(password)
|
|
107
|
+
|
|
108
|
+
function getSessionToken(req: Request) {
|
|
109
|
+
return parseCookies(req.headers.get("cookie")).get(SESSION_COOKIE_NAME) ?? null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isAuthenticated(req: Request) {
|
|
113
|
+
const sessionToken = getSessionToken(req)
|
|
114
|
+
return Boolean(sessionToken && sessions.has(sessionToken))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function validateOrigin(req: Request) {
|
|
118
|
+
const origin = req.headers.get("origin")
|
|
119
|
+
if (!origin) return true
|
|
120
|
+
return origin === new URL(req.url).origin
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function createSessionCookie(req: Request) {
|
|
124
|
+
const sessionToken = randomBytes(32).toString("base64url")
|
|
125
|
+
sessions.add(sessionToken)
|
|
126
|
+
return buildCookie(SESSION_COOKIE_NAME, sessionToken, req)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function clearSessionCookie(req: Request) {
|
|
130
|
+
const sessionToken = getSessionToken(req)
|
|
131
|
+
if (sessionToken) {
|
|
132
|
+
sessions.delete(sessionToken)
|
|
133
|
+
}
|
|
134
|
+
return buildCookie(SESSION_COOKIE_NAME, "", req, ["Max-Age=0"])
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function verifyPassword(candidate: string) {
|
|
138
|
+
const actual = Buffer.from(candidate)
|
|
139
|
+
if (actual.length !== expectedPassword.length) {
|
|
140
|
+
return false
|
|
141
|
+
}
|
|
142
|
+
return timingSafeEqual(actual, expectedPassword)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function handleStatus(req: Request) {
|
|
146
|
+
return Response.json({
|
|
147
|
+
enabled: true,
|
|
148
|
+
authenticated: isAuthenticated(req),
|
|
149
|
+
} satisfies AuthStatusPayload)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function unauthorizedResponse(req: Request) {
|
|
153
|
+
if (req.method === "GET" && requestWantsHtml(req)) {
|
|
154
|
+
const url = new URL(req.url)
|
|
155
|
+
const loginUrl = new URL("/auth/login", req.url)
|
|
156
|
+
loginUrl.searchParams.set("next", sanitizeNextPath(`${url.pathname}${url.search}`))
|
|
157
|
+
return Response.redirect(loginUrl, 302)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return Response.json({ error: "Unauthorized" }, { status: 401 })
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function renderLoginPage(req: Request) {
|
|
164
|
+
if (isAuthenticated(req)) {
|
|
165
|
+
const currentUrl = new URL(req.url)
|
|
166
|
+
return Response.redirect(new URL(sanitizeNextPath(currentUrl.searchParams.get("next")), req.url), 302)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const currentUrl = new URL(req.url)
|
|
170
|
+
const nextPath = sanitizeNextPath(currentUrl.searchParams.get("next"))
|
|
171
|
+
const showError = currentUrl.searchParams.get("error") === "1"
|
|
172
|
+
const html = `<!doctype html>
|
|
173
|
+
<html lang="en">
|
|
174
|
+
<head>
|
|
175
|
+
<meta charset="utf-8" />
|
|
176
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
177
|
+
<title>${escapeHtml(APP_NAME)} Login</title>
|
|
178
|
+
<style>
|
|
179
|
+
:root { color-scheme: dark; }
|
|
180
|
+
* { box-sizing: border-box; }
|
|
181
|
+
body {
|
|
182
|
+
margin: 0;
|
|
183
|
+
min-height: 100vh;
|
|
184
|
+
font-family: ui-sans-serif, system-ui, sans-serif;
|
|
185
|
+
background:
|
|
186
|
+
radial-gradient(circle at top, rgba(255,255,255,0.10), transparent 28%),
|
|
187
|
+
linear-gradient(160deg, #16181d 0%, #0b0d10 55%, #050608 100%);
|
|
188
|
+
color: #f4f7fb;
|
|
189
|
+
display: grid;
|
|
190
|
+
place-items: center;
|
|
191
|
+
padding: 24px;
|
|
192
|
+
}
|
|
193
|
+
.card {
|
|
194
|
+
width: min(420px, 100%);
|
|
195
|
+
border: 1px solid rgba(255,255,255,0.12);
|
|
196
|
+
background: rgba(12, 15, 20, 0.88);
|
|
197
|
+
backdrop-filter: blur(10px);
|
|
198
|
+
border-radius: 24px;
|
|
199
|
+
padding: 28px;
|
|
200
|
+
box-shadow: 0 24px 70px rgba(0,0,0,0.35);
|
|
201
|
+
}
|
|
202
|
+
h1 { margin: 0 0 8px; font-size: 28px; }
|
|
203
|
+
p { margin: 0 0 20px; color: #a6b0bd; line-height: 1.5; }
|
|
204
|
+
label { display: block; font-size: 13px; margin-bottom: 8px; color: #d7dce4; }
|
|
205
|
+
input {
|
|
206
|
+
width: 100%;
|
|
207
|
+
border-radius: 14px;
|
|
208
|
+
border: 1px solid rgba(255,255,255,0.16);
|
|
209
|
+
background: rgba(255,255,255,0.04);
|
|
210
|
+
color: inherit;
|
|
211
|
+
padding: 14px 16px;
|
|
212
|
+
font-size: 15px;
|
|
213
|
+
margin-bottom: 16px;
|
|
214
|
+
}
|
|
215
|
+
button {
|
|
216
|
+
width: 100%;
|
|
217
|
+
border: 0;
|
|
218
|
+
border-radius: 14px;
|
|
219
|
+
padding: 14px 16px;
|
|
220
|
+
background: linear-gradient(135deg, #f4ede0 0%, #d9c4a1 100%);
|
|
221
|
+
color: #16181d;
|
|
222
|
+
font-size: 15px;
|
|
223
|
+
font-weight: 700;
|
|
224
|
+
cursor: pointer;
|
|
225
|
+
}
|
|
226
|
+
.error {
|
|
227
|
+
margin-bottom: 16px;
|
|
228
|
+
border-radius: 14px;
|
|
229
|
+
background: rgba(255, 106, 106, 0.12);
|
|
230
|
+
border: 1px solid rgba(255, 106, 106, 0.24);
|
|
231
|
+
color: #ffb8b8;
|
|
232
|
+
padding: 12px 14px;
|
|
233
|
+
font-size: 14px;
|
|
234
|
+
}
|
|
235
|
+
</style>
|
|
236
|
+
</head>
|
|
237
|
+
<body>
|
|
238
|
+
<form class="card" method="post" action="/auth/login">
|
|
239
|
+
<h1>${escapeHtml(APP_NAME)}</h1>
|
|
240
|
+
<p>This server is password protected. Enter the launch password to continue.</p>
|
|
241
|
+
${showError ? '<div class="error">Incorrect password. Try again.</div>' : ""}
|
|
242
|
+
<input type="hidden" name="next" value="${escapeHtml(nextPath)}" />
|
|
243
|
+
<label for="password">Password</label>
|
|
244
|
+
<input id="password" name="password" type="password" autocomplete="current-password" autofocus required />
|
|
245
|
+
<button type="submit">Unlock</button>
|
|
246
|
+
</form>
|
|
247
|
+
</body>
|
|
248
|
+
</html>`
|
|
249
|
+
return new Response(html, {
|
|
250
|
+
headers: {
|
|
251
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
252
|
+
},
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function handleLogin(req: Request, fallbackNextPath: string) {
|
|
257
|
+
if (!validateOrigin(req)) {
|
|
258
|
+
return Response.json({ error: "Forbidden" }, { status: 403 })
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const { password: candidate, nextPath, wantsJson } = await readLoginForm(req)
|
|
262
|
+
if (!verifyPassword(candidate)) {
|
|
263
|
+
if (wantsJson) {
|
|
264
|
+
return Response.json({ error: "Invalid password" }, { status: 401 })
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const redirectUrl = new URL("/auth/login", req.url)
|
|
268
|
+
redirectUrl.searchParams.set("error", "1")
|
|
269
|
+
redirectUrl.searchParams.set("next", sanitizeNextPath(nextPath || fallbackNextPath))
|
|
270
|
+
return Response.redirect(redirectUrl, 302)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const response = wantsJson
|
|
274
|
+
? Response.json({ ok: true, nextPath: sanitizeNextPath(nextPath || fallbackNextPath) })
|
|
275
|
+
: Response.redirect(new URL(sanitizeNextPath(nextPath || fallbackNextPath), req.url), 302)
|
|
276
|
+
|
|
277
|
+
response.headers.set("Set-Cookie", createSessionCookie(req))
|
|
278
|
+
return response
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function handleLogout(req: Request) {
|
|
282
|
+
if (!validateOrigin(req)) {
|
|
283
|
+
return Response.json({ error: "Forbidden" }, { status: 403 })
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const response = Response.json({ ok: true })
|
|
287
|
+
response.headers.set("Set-Cookie", clearSessionCookie(req))
|
|
288
|
+
return response
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
enabled: true,
|
|
293
|
+
isAuthenticated,
|
|
294
|
+
validateOrigin,
|
|
295
|
+
createSessionCookie,
|
|
296
|
+
clearSessionCookie,
|
|
297
|
+
verifyPassword,
|
|
298
|
+
handleLogin,
|
|
299
|
+
handleLogout,
|
|
300
|
+
handleStatus,
|
|
301
|
+
renderLoginPage,
|
|
302
|
+
unauthorizedResponse,
|
|
303
|
+
}
|
|
304
|
+
}
|
|
@@ -25,6 +25,7 @@ function createDeps(overrides: Partial<Parameters<typeof runCli>[1]> = {}) {
|
|
|
25
25
|
host: string
|
|
26
26
|
openBrowser: boolean
|
|
27
27
|
share: false | "quick" | { kind: "token"; token: string }
|
|
28
|
+
password: string | null
|
|
28
29
|
strictPort: boolean
|
|
29
30
|
update: {
|
|
30
31
|
version: string
|
|
@@ -102,6 +103,7 @@ describe("parseArgs", () => {
|
|
|
102
103
|
host: "127.0.0.1",
|
|
103
104
|
openBrowser: false,
|
|
104
105
|
share: false,
|
|
106
|
+
password: null,
|
|
105
107
|
strictPort: false,
|
|
106
108
|
},
|
|
107
109
|
})
|
|
@@ -115,6 +117,7 @@ describe("parseArgs", () => {
|
|
|
115
117
|
host: "127.0.0.1",
|
|
116
118
|
openBrowser: true,
|
|
117
119
|
share: false,
|
|
120
|
+
password: null,
|
|
118
121
|
strictPort: true,
|
|
119
122
|
},
|
|
120
123
|
})
|
|
@@ -128,6 +131,7 @@ describe("parseArgs", () => {
|
|
|
128
131
|
host: "0.0.0.0",
|
|
129
132
|
openBrowser: true,
|
|
130
133
|
share: false,
|
|
134
|
+
password: null,
|
|
131
135
|
strictPort: false,
|
|
132
136
|
},
|
|
133
137
|
})
|
|
@@ -141,6 +145,7 @@ describe("parseArgs", () => {
|
|
|
141
145
|
host: "127.0.0.1",
|
|
142
146
|
openBrowser: true,
|
|
143
147
|
share: "quick",
|
|
148
|
+
password: null,
|
|
144
149
|
strictPort: false,
|
|
145
150
|
},
|
|
146
151
|
})
|
|
@@ -154,11 +159,31 @@ describe("parseArgs", () => {
|
|
|
154
159
|
host: "127.0.0.1",
|
|
155
160
|
openBrowser: true,
|
|
156
161
|
share: { kind: "token", token: "secret-token" },
|
|
162
|
+
password: null,
|
|
157
163
|
strictPort: false,
|
|
158
164
|
},
|
|
159
165
|
})
|
|
160
166
|
})
|
|
161
167
|
|
|
168
|
+
test("--password accepts a secret", () => {
|
|
169
|
+
expect(parseArgs(["--password", "secret"])).toEqual({
|
|
170
|
+
kind: "run",
|
|
171
|
+
options: {
|
|
172
|
+
port: 3210,
|
|
173
|
+
host: "127.0.0.1",
|
|
174
|
+
openBrowser: true,
|
|
175
|
+
share: false,
|
|
176
|
+
password: "secret",
|
|
177
|
+
strictPort: false,
|
|
178
|
+
},
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test("--password without a value throws", () => {
|
|
183
|
+
expect(() => parseArgs(["--password"])).toThrow("Missing value for --password")
|
|
184
|
+
expect(() => parseArgs(["--password", "--no-open"])).toThrow("Missing value for --password")
|
|
185
|
+
})
|
|
186
|
+
|
|
162
187
|
test("--cloudflared without a token throws", () => {
|
|
163
188
|
expect(() => parseArgs(["--cloudflared"])).toThrow("Missing value for --cloudflared")
|
|
164
189
|
expect(() => parseArgs(["--cloudflared", "--no-open"])).toThrow("Missing value for --cloudflared")
|
|
@@ -172,6 +197,7 @@ describe("parseArgs", () => {
|
|
|
172
197
|
host: "100.64.0.1",
|
|
173
198
|
openBrowser: true,
|
|
174
199
|
share: false,
|
|
200
|
+
password: null,
|
|
175
201
|
strictPort: false,
|
|
176
202
|
},
|
|
177
203
|
})
|
|
@@ -185,6 +211,7 @@ describe("parseArgs", () => {
|
|
|
185
211
|
host: "dev-box",
|
|
186
212
|
openBrowser: true,
|
|
187
213
|
share: false,
|
|
214
|
+
password: null,
|
|
188
215
|
strictPort: false,
|
|
189
216
|
},
|
|
190
217
|
})
|
|
@@ -261,6 +288,7 @@ describe("runCli", () => {
|
|
|
261
288
|
host: "127.0.0.1",
|
|
262
289
|
openBrowser: false,
|
|
263
290
|
share: false,
|
|
291
|
+
password: null,
|
|
264
292
|
strictPort: false,
|
|
265
293
|
update: {
|
|
266
294
|
version: "0.3.0",
|
|
@@ -14,6 +14,7 @@ export interface CliOptions {
|
|
|
14
14
|
host: string
|
|
15
15
|
openBrowser: boolean
|
|
16
16
|
share: ShareMode
|
|
17
|
+
password: string | null
|
|
17
18
|
strictPort: boolean
|
|
18
19
|
}
|
|
19
20
|
|
|
@@ -85,6 +86,7 @@ Options:
|
|
|
85
86
|
--share Create a public Cloudflare quick tunnel with terminal QR
|
|
86
87
|
--cloudflared <token>
|
|
87
88
|
Run a named Cloudflare tunnel from a token
|
|
89
|
+
--password <secret> Require a password before loading the app
|
|
88
90
|
--strict-port Fail instead of trying another port
|
|
89
91
|
--no-open Don't open browser automatically
|
|
90
92
|
--version Print version and exit
|
|
@@ -96,6 +98,7 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
|
|
96
98
|
let host = "127.0.0.1"
|
|
97
99
|
let openBrowser = true
|
|
98
100
|
let share: ShareMode = false
|
|
101
|
+
let password: string | null = null
|
|
99
102
|
let sawHost = false
|
|
100
103
|
let sawRemote = false
|
|
101
104
|
let strictPort = false
|
|
@@ -153,6 +156,13 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
|
|
153
156
|
openBrowser = false
|
|
154
157
|
continue
|
|
155
158
|
}
|
|
159
|
+
if (arg === "--password") {
|
|
160
|
+
const next = argv[index + 1]
|
|
161
|
+
if (!next || next.startsWith("-")) throw new Error("Missing value for --password")
|
|
162
|
+
password = next
|
|
163
|
+
index += 1
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
156
166
|
if (arg === "--strict-port") {
|
|
157
167
|
strictPort = true
|
|
158
168
|
continue
|
|
@@ -167,6 +177,7 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
|
|
167
177
|
host,
|
|
168
178
|
openBrowser,
|
|
169
179
|
share,
|
|
180
|
+
password,
|
|
170
181
|
strictPort,
|
|
171
182
|
},
|
|
172
183
|
}
|
|
@@ -345,6 +345,24 @@ describe("EventStore", () => {
|
|
|
345
345
|
expect(store.getChat("chat-1")?.unread).toBe(false)
|
|
346
346
|
})
|
|
347
347
|
|
|
348
|
+
test("persists sidebar project order across restart and compaction", async () => {
|
|
349
|
+
const dataDir = await createTempDataDir()
|
|
350
|
+
const store = new EventStore(dataDir)
|
|
351
|
+
await store.initialize()
|
|
352
|
+
|
|
353
|
+
const first = await store.openProject("/tmp/project-a")
|
|
354
|
+
const second = await store.openProject("/tmp/project-b")
|
|
355
|
+
|
|
356
|
+
await store.setSidebarProjectOrder([second.id, first.id])
|
|
357
|
+
expect(store.state.sidebarProjectOrder).toEqual([second.id, first.id])
|
|
358
|
+
|
|
359
|
+
await store.compact()
|
|
360
|
+
|
|
361
|
+
const reloaded = new EventStore(dataDir)
|
|
362
|
+
await reloaded.initialize()
|
|
363
|
+
expect(reloaded.state.sidebarProjectOrder).toEqual([second.id, first.id])
|
|
364
|
+
})
|
|
365
|
+
|
|
348
366
|
test("prunes stale empty chats after thirty minutes", async () => {
|
|
349
367
|
const dataDir = await createTempDataDir()
|
|
350
368
|
const store = new EventStore(dataDir)
|