From 279663831182d228cc7652d73048e2193145c1dd Mon Sep 17 00:00:00 2001 From: slhafzjw Date: Tue, 24 Feb 2026 21:14:53 +0800 Subject: [PATCH] feat: add token-aware API TUI with subtoken actions and row-based focus navigation --- README.md | 22 +- src/main/kotlin/work/slhaf/hub/WebHost.kt | 176 +++++- .../kotlin/work/slhaf/hub/WebScriptService.kt | 7 +- src/main/kotlin/work/slhaf/hub/WebSecurity.kt | 219 ++++++- tools/api-cli.kts | 109 ++-- tools/api-cli.main.kts | 186 ++++-- tools/api-tui.main.kts | 594 ++++++++++++------ 7 files changed, 991 insertions(+), 322 deletions(-) mode change 100644 => 100755 tools/api-tui.main.kts diff --git a/README.md b/README.md index 32380b4..d2a6da1 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,13 @@ Auth: - Token source: - Preferred: set env `HOST_API_TOKEN`. - Otherwise host auto-generates a token and stores it at `scripts/.host-api-token`. +- Token types: + - `root`: full access to all endpoints. + - `sub`: only `health`, filtered `scripts`, and allowed-script `meta`/`run`. Routes: - `GET /health` +- `GET /type` - `GET /scripts` - `GET /scripts/{script}` (raw script content) - `POST /scripts/{script}` @@ -35,11 +39,17 @@ Routes: - `GET /meta/{script}` - `GET /run/{script}?k=v` - `POST /run/{script}?k=v` +- `GET /subtokens` (root only) +- `GET /subtokens/{name}` (root only) +- `POST /subtokens/{name}` (root only, body = script names list) +- `PUT /subtokens/{name}` (root only, body = script names list) +- `DELETE /subtokens/{name}` (root only) Examples: ```bash curl 'http://127.0.0.1:8080/health' TOKEN="$(cat scripts/.host-api-token)" +curl -H "Authorization: Bearer $TOKEN" 'http://127.0.0.1:8080/type' curl -H "Authorization: Bearer $TOKEN" 'http://127.0.0.1:8080/scripts' curl -H "Authorization: Bearer $TOKEN" 'http://127.0.0.1:8080/scripts/hello' curl -H "Authorization: Bearer $TOKEN" -X POST 'http://127.0.0.1:8080/scripts/new-api' --data-binary $'// @desc: new api\nval args: Array = emptyArray()\nprintln("ok")' @@ -48,6 +58,8 @@ curl -H "Authorization: Bearer $TOKEN" -X DELETE 'http://127.0.0.1:8080/scripts/ curl -H "Authorization: Bearer $TOKEN" 'http://127.0.0.1:8080/meta/hello' curl -H "Authorization: Bearer $TOKEN" 'http://127.0.0.1:8080/run/hello?name=Alice&upper=true' curl -H "Authorization: Bearer $TOKEN" -X POST 'http://127.0.0.1:8080/run/hello?name=Alice' -d 'from-body' +curl -H "Authorization: Bearer $TOKEN" -X POST 'http://127.0.0.1:8080/subtokens/demo-sub' --data-binary $'hello\ntime' +curl -H "Authorization: Bearer $TOKEN" 'http://127.0.0.1:8080/subtokens' ``` ## Script Metadata & Args (`*.hub.kts`) @@ -84,9 +96,12 @@ A standalone CLI script is available at `tools/api-cli.main.kts` (independent fr Examples: ```bash kotlin tools/api-cli.main.kts --base-url=http://127.0.0.1:8080 --token-file=./scripts/.host-api-token list +kotlin tools/api-cli.main.kts --token-file=./scripts/.host-api-token type kotlin tools/api-cli.main.kts --token-file=./scripts/.host-api-token show hello kotlin tools/api-cli.main.kts --token-file=./scripts/.host-api-token run hello --arg=name=Alice --arg=upper=true kotlin tools/api-cli.main.kts --token-file=./scripts/.host-api-token create demo --text='// @desc: demo\nval args: Array = emptyArray()\nprintln("ok")' +kotlin tools/api-cli.main.kts --token-file=./scripts/.host-api-token sub-create demo-sub --scripts=hello,time +kotlin tools/api-cli.main.kts --token-file=./scripts/.host-api-token sub-list ``` Note: @@ -102,10 +117,15 @@ kotlin tools/api-tui.main.kts --base-url=http://127.0.0.1:8080 --token-file=./sc Keys: - `Up/Down` or `j/k`: switch script -- `Left/Right` or `h/l`: switch action (`Refresh/Show/Run/Meta/Create/Edit/Delete/Quit`) +- `Left/Right` or `h/l`: switch action (grouped by `Script` / `SubToken` / `System`) - `Enter`: execute selected action - `q`: quit +Action model: +- `root` token: full grouped actions including `Subtokens` +- `sub` token: reduced action set (`Refresh/Run/Meta/Type/Quit`) +- `Subtokens` opens a keyboard sub-menu (`List/Show/Create/Update/Delete/Back`) + Create/Edit/Delete behavior: - `Create`: prompt script name, then choose source mode: - `e` (default): create temp file, open terminal editor, then upload via API diff --git a/src/main/kotlin/work/slhaf/hub/WebHost.kt b/src/main/kotlin/work/slhaf/hub/WebHost.kt index 932ab45..6fc9ef6 100644 --- a/src/main/kotlin/work/slhaf/hub/WebHost.kt +++ b/src/main/kotlin/work/slhaf/hub/WebHost.kt @@ -6,6 +6,7 @@ import io.ktor.server.application.Application import io.ktor.server.application.call import io.ktor.server.engine.embeddedServer import io.ktor.server.netty.Netty +import io.ktor.server.request.receiveText import io.ktor.server.response.respondText import io.ktor.server.routing.delete import io.ktor.server.routing.get @@ -18,35 +19,122 @@ private const val DEFAULT_PORT = 8080 private const val DEFAULT_SCRIPTS_DIR = "scripts" private const val DEFAULT_HOST = "0.0.0.0" -private fun Application.module(scriptsDir: File, apiToken: String) { +private suspend fun handleSubTokenCreate(call: io.ktor.server.application.ApplicationCall, security: HostSecurity) { + val name = call.parameters["name"] + ?: return call.respondText("missing subtoken name", status = HttpStatusCode.BadRequest) + + if (!name.matches(Regex("[A-Za-z0-9._-]+"))) { + return call.respondText("invalid subtoken name", status = HttpStatusCode.BadRequest) + } + + val scriptsRaw = call.receiveText() + val scripts = + try { + parseScriptNameSet(scriptsRaw) + } catch (t: Throwable) { + return call.respondText(t.message ?: "invalid script names", status = HttpStatusCode.BadRequest) + } + + val created = + try { + security.subTokens.create(name, scripts) + } catch (t: Throwable) { + return call.respondText(t.message ?: "failed to create subtoken", status = HttpStatusCode.Conflict) + } + + call.respondText(subTokenItemJson(created, includeToken = true), contentType = ContentType.Application.Json, status = HttpStatusCode.Created) +} + +private suspend fun handleSubTokenUpdate(call: io.ktor.server.application.ApplicationCall, security: HostSecurity) { + val name = call.parameters["name"] + ?: return call.respondText("missing subtoken name", status = HttpStatusCode.BadRequest) + + val scriptsRaw = call.receiveText() + val scripts = + try { + parseScriptNameSet(scriptsRaw) + } catch (t: Throwable) { + return call.respondText(t.message ?: "invalid script names", status = HttpStatusCode.BadRequest) + } + + val updated = + try { + security.subTokens.update(name, scripts) + } catch (t: Throwable) { + return call.respondText(t.message ?: "failed to update subtoken", status = HttpStatusCode.NotFound) + } + + call.respondText(subTokenItemJson(updated, includeToken = true), contentType = ContentType.Application.Json) +} + +private suspend fun handleSubTokenGet(call: io.ktor.server.application.ApplicationCall, security: HostSecurity) { + val name = call.parameters["name"] + ?: return call.respondText("missing subtoken name", status = HttpStatusCode.BadRequest) + + val item = security.subTokens.get(name) + ?: return call.respondText("subtoken not found: $name", status = HttpStatusCode.NotFound) + + call.respondText(subTokenItemJson(item, includeToken = true), contentType = ContentType.Application.Json) +} + +private suspend fun handleSubTokenDelete(call: io.ktor.server.application.ApplicationCall, security: HostSecurity) { + val name = call.parameters["name"] + ?: return call.respondText("missing subtoken name", status = HttpStatusCode.BadRequest) + + val deleted = security.subTokens.delete(name) + if (!deleted) return call.respondText("subtoken not found: $name", status = HttpStatusCode.NotFound) + + call.respondText("deleted subtoken: $name") +} + +private fun Application.module(scriptsDir: File, security: HostSecurity) { routing { get("/health") { call.respondText("OK") } - get("/scripts") { - if (!requireAuth(call, apiToken)) return@get - call.respondText(renderScriptList(scriptsDir), ContentType.Text.Plain) + + get("/type") { + val auth = requireAuth(call, security) ?: return@get + call.respondText(tokenTypeJson(auth), contentType = ContentType.Application.Json) } + + get("/scripts") { + val auth = requireAuth(call, security) ?: return@get + val allow = visibleScriptsFor(auth) + call.respondText(renderScriptList(scriptsDir, allow), ContentType.Text.Plain) + } + get("/scripts/{script}") { - if (!requireAuth(call, apiToken)) return@get + val auth = requireAuth(call, security) ?: return@get + if (!requireRoot(call, auth)) return@get handleGetScriptContent(call, scriptsDir) } + post("/scripts/{script}") { - if (!requireAuth(call, apiToken)) return@post + val auth = requireAuth(call, security) ?: return@post + if (!requireRoot(call, auth)) return@post handleCreateScript(call, scriptsDir) } + put("/scripts/{script}") { - if (!requireAuth(call, apiToken)) return@put + val auth = requireAuth(call, security) ?: return@put + if (!requireRoot(call, auth)) return@put handleUpdateScript(call, scriptsDir) } + delete("/scripts/{script}") { - if (!requireAuth(call, apiToken)) return@delete + val auth = requireAuth(call, security) ?: return@delete + if (!requireRoot(call, auth)) return@delete handleDeleteScript(call, scriptsDir) } + get("/meta/{script}") { - if (!requireAuth(call, apiToken)) return@get + val auth = requireAuth(call, security) ?: return@get val name = call.parameters["script"] ?: return@get call.respondText("missing route name", status = HttpStatusCode.BadRequest) + + if (!requireScriptAccess(call, auth, name)) return@get + val script = resolveScriptFile(scriptsDir, name) ?: return@get call.respondText("invalid script name", status = HttpStatusCode.BadRequest) if (!script.exists()) { @@ -56,17 +144,55 @@ private fun Application.module(scriptsDir: File, apiToken: String) { val (metadata, source) = loadMetadata(script) call.respondText( metadataJson(name, metadata, source), - contentType = ContentType.Application.Json + contentType = ContentType.Application.Json, ) } + get("/run/{script}") { - if (!requireAuth(call, apiToken)) return@get + val auth = requireAuth(call, security) ?: return@get + val name = call.parameters["script"] + ?: return@get call.respondText("missing route name", status = HttpStatusCode.BadRequest) + if (!requireScriptAccess(call, auth, name)) return@get handleRunRequest(call, scriptsDir, consumeBody = false) } + post("/run/{script}") { - if (!requireAuth(call, apiToken)) return@post + val auth = requireAuth(call, security) ?: return@post + val name = call.parameters["script"] + ?: return@post call.respondText("missing route name", status = HttpStatusCode.BadRequest) + if (!requireScriptAccess(call, auth, name)) return@post handleRunRequest(call, scriptsDir, consumeBody = true) } + + get("/subtokens") { + val auth = requireAuth(call, security) ?: return@get + if (!requireRoot(call, auth)) return@get + call.respondText(subTokenListJson(security.subTokens.list()), contentType = ContentType.Application.Json) + } + + get("/subtokens/{name}") { + val auth = requireAuth(call, security) ?: return@get + if (!requireRoot(call, auth)) return@get + handleSubTokenGet(call, security) + } + + post("/subtokens/{name}") { + val auth = requireAuth(call, security) ?: return@post + if (!requireRoot(call, auth)) return@post + handleSubTokenCreate(call, security) + } + + put("/subtokens/{name}") { + val auth = requireAuth(call, security) ?: return@put + if (!requireRoot(call, auth)) return@put + handleSubTokenUpdate(call, security) + } + + delete("/subtokens/{name}") { + val auth = requireAuth(call, security) ?: return@delete + if (!requireRoot(call, auth)) return@delete + handleSubTokenDelete(call, security) + } } } @@ -77,18 +203,24 @@ Usage: ./gradlew runWeb --args='[--host=0.0.0.0] [--port=8080] [--scripts-dir=./scripts]' Routes: GET /health + GET /type Authorization: Authorization: Bearer or X-Host-Token: GET /scripts - GET /scripts/{script} - POST /scripts/{script} - PUT /scripts/{script} - DELETE /scripts/{script} - GET /meta/{script} - GET /run/{script}?k=v - POST /run/{script}?k=v - """.trimIndent() + GET /scripts/{script} (root only) + POST /scripts/{script} (root only) + PUT /scripts/{script} (root only) + DELETE /scripts/{script} (root only) + GET /meta/{script} (root or allowed subtoken) + GET /run/{script}?k=v (root or allowed subtoken) + POST /run/{script}?k=v (root or allowed subtoken) + GET /subtokens (root only) + GET /subtokens/{name} (root only) + POST /subtokens/{name} (root only, body: script names list) + PUT /subtokens/{name} (root only, body: script names list) + DELETE /subtokens/{name} (root only) + """.trimIndent(), ) } @@ -109,6 +241,7 @@ fun main(args: Array) { if (!scriptsDir.exists()) scriptsDir.mkdirs() val auth = loadOrCreateApiToken(scriptsDir) + val security = createHostSecurity(scriptsDir, auth.token) println("Starting script web host on http://$host:$port") println("Scripts directory: ${scriptsDir.absolutePath}") @@ -117,10 +250,11 @@ fun main(args: Array) { auth.source.startsWith("env:") -> println("Auth token loaded from environment variable.") auth.source.startsWith("generated:") -> println("Auth token generated and saved to: ${auth.tokenFile?.absolutePath}") + else -> println("Auth token loaded from file: ${auth.tokenFile?.absolutePath}") } embeddedServer(Netty, port = port, host = host) { - module(scriptsDir, auth.token) + module(scriptsDir, security) }.start(wait = true) } diff --git a/src/main/kotlin/work/slhaf/hub/WebScriptService.kt b/src/main/kotlin/work/slhaf/hub/WebScriptService.kt index abfa679..b293f0e 100644 --- a/src/main/kotlin/work/slhaf/hub/WebScriptService.kt +++ b/src/main/kotlin/work/slhaf/hub/WebScriptService.kt @@ -29,8 +29,11 @@ private fun listScriptNames(scriptsDir: File): List = ?.toList() ?: emptyList() -fun renderScriptList(scriptsDir: File): String = - listScriptNames(scriptsDir).joinToString("\n") { name -> +fun renderScriptList(scriptsDir: File, allowNames: Set? = null): String = + listScriptNames(scriptsDir) + .asSequence() + .filter { allowNames == null || allowNames.contains(it) } + .joinToString("\n") { name -> val file = resolveScriptFile(scriptsDir, name) val description = file?.let(::cachedMetadata)?.description if (description.isNullOrBlank()) name else "$name\t$description" diff --git a/src/main/kotlin/work/slhaf/hub/WebSecurity.kt b/src/main/kotlin/work/slhaf/hub/WebSecurity.kt index 50615cf..0fbb101 100644 --- a/src/main/kotlin/work/slhaf/hub/WebSecurity.kt +++ b/src/main/kotlin/work/slhaf/hub/WebSecurity.kt @@ -10,15 +10,151 @@ import java.security.SecureRandom private const val ENV_API_TOKEN = "HOST_API_TOKEN" private const val TOKEN_FILE_NAME = ".host-api-token" +private const val SUBTOKEN_FILE_NAME = ".host-subtokens.db" private const val ALT_TOKEN_HEADER = "X-Host-Token" +private val SCRIPT_NAME_REGEX = Regex("[A-Za-z0-9._-]+") data class ApiTokenConfig( val token: String, val source: String, - val tokenFile: File? + val tokenFile: File?, ) -private fun randomTokenHex(bytes: Int = 32): String { +enum class TokenType { + ROOT, + SUB, +} + +data class SubTokenRecord( + val name: String, + val token: String, + val scripts: Set, +) + +data class AuthContext( + val type: TokenType, + val subTokenName: String? = null, + val allowedScripts: Set = emptySet(), +) + +class SubTokenStore( + private val storageFile: File, +) { + private val lock = Any() + private var loaded = false + private val byName = linkedMapOf() + + fun list(): List = + synchronized(lock) { + ensureLoaded() + byName.values.sortedBy { it.name } + } + + fun get(name: String): SubTokenRecord? = + synchronized(lock) { + ensureLoaded() + byName[name] + } + + fun findByToken(token: String): SubTokenRecord? = + synchronized(lock) { + ensureLoaded() + byName.values.firstOrNull { it.token == token } + } + + fun create(name: String, scripts: Set): SubTokenRecord { + synchronized(lock) { + ensureLoaded() + require(name.matches(SCRIPT_NAME_REGEX)) { "invalid subtoken name" } + if (byName.containsKey(name)) error("subtoken already exists: $name") + + val record = SubTokenRecord(name = name, token = randomTokenHex(), scripts = scripts.sorted().toSet()) + byName[name] = record + persist() + return record + } + } + + fun update(name: String, scripts: Set): SubTokenRecord { + synchronized(lock) { + ensureLoaded() + val existing = byName[name] ?: error("subtoken not found: $name") + val updated = existing.copy(scripts = scripts.sorted().toSet()) + byName[name] = updated + persist() + return updated + } + } + + fun delete(name: String): Boolean = + synchronized(lock) { + ensureLoaded() + val removed = byName.remove(name) ?: return false + persist() + removed.name.isNotBlank() + } + + private fun ensureLoaded() { + if (loaded) return + loaded = true + + if (!storageFile.exists()) return + storageFile.readLines().forEach { line -> + val trimmed = line.trim() + if (trimmed.isBlank() || trimmed.startsWith("#")) return@forEach + + val parts = trimmed.split('\t') + if (parts.size < 2) return@forEach + + val name = parts[0].trim() + val token = parts[1].trim() + if (!name.matches(SCRIPT_NAME_REGEX) || token.isBlank()) return@forEach + + val scripts = + parts.getOrNull(2) + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotBlank() && it.matches(SCRIPT_NAME_REGEX) } + ?.toSet() + ?: emptySet() + + byName[name] = SubTokenRecord(name, token, scripts) + } + } + + private fun persist() { + storageFile.parentFile?.mkdirs() + val text = + buildString { + appendLine("# name\\ttoken\\tscript1,script2,...") + byName.values.sortedBy { it.name }.forEach { record -> + val scripts = record.scripts.sorted().joinToString(",") + append(record.name) + append('\t') + append(record.token) + append('\t') + append(scripts) + appendLine() + } + } + + storageFile.writeText(text) + storageFile.setReadable(false, false) + storageFile.setReadable(true, true) + storageFile.setWritable(false, false) + storageFile.setWritable(true, true) + } +} + +data class HostSecurity( + val rootToken: String, + val subTokens: SubTokenStore, +) + +fun createHostSecurity(scriptsDir: File, rootToken: String): HostSecurity = + HostSecurity(rootToken = rootToken, subTokens = SubTokenStore(File(scriptsDir, SUBTOKEN_FILE_NAME))) + +fun randomTokenHex(bytes: Int = 32): String { val random = ByteArray(bytes) SecureRandom().nextBytes(random) return random.joinToString("") { "%02x".format(it) } @@ -53,11 +189,86 @@ private fun extractProvidedToken(call: ApplicationCall): String? { return call.request.headers[ALT_TOKEN_HEADER]?.trim() } -suspend fun requireAuth(call: ApplicationCall, expectedToken: String): Boolean { +private fun authenticateToken(call: ApplicationCall, security: HostSecurity): AuthContext? { val provided = extractProvidedToken(call) - if (provided == expectedToken) return true + if (provided.isNullOrBlank()) return null + if (provided == security.rootToken) return AuthContext(type = TokenType.ROOT) + + val sub = security.subTokens.findByToken(provided) ?: return null + return AuthContext(type = TokenType.SUB, subTokenName = sub.name, allowedScripts = sub.scripts) +} + +suspend fun requireAuth(call: ApplicationCall, security: HostSecurity): AuthContext? { + val context = authenticateToken(call, security) + if (context != null) return context call.response.headers.append(HttpHeaders.WWWAuthenticate, "Bearer realm=\"script-host\"") call.respondText("unauthorized", status = HttpStatusCode.Unauthorized, contentType = ContentType.Text.Plain) + return null +} + +suspend fun requireRoot(call: ApplicationCall, context: AuthContext): Boolean { + if (context.type == TokenType.ROOT) return true + call.respondText("forbidden: root token required", status = HttpStatusCode.Forbidden, contentType = ContentType.Text.Plain) return false } + +suspend fun requireScriptAccess(call: ApplicationCall, context: AuthContext, scriptName: String): Boolean { + if (context.type == TokenType.ROOT) return true + if (context.allowedScripts.contains(scriptName)) return true + call.respondText("forbidden: subtoken has no access to script '$scriptName'", status = HttpStatusCode.Forbidden, contentType = ContentType.Text.Plain) + return false +} + +fun visibleScriptsFor(context: AuthContext): Set? = + when (context.type) { + TokenType.ROOT -> null + TokenType.SUB -> context.allowedScripts + } + +private fun String.jsonEscaped(): String = buildString(length) { + for (ch in this@jsonEscaped) { + when (ch) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> append(ch) + } + } +} + +fun tokenTypeJson(context: AuthContext): String { + val scripts = context.allowedScripts.sorted().joinToString(",") { "\"${it.jsonEscaped()}\"" } + val name = context.subTokenName?.let { "\"${it.jsonEscaped()}\"" } ?: "null" + return """{"tokenType":"${context.type.name.lowercase()}","subTokenName":$name,"scripts":[${scripts}]}""" +} + +fun parseScriptNameSet(raw: String): Set { + val values = + raw.split(Regex("[,\\n\\r\\t\\s]+")) + .map { it.trim() } + .filter { it.isNotBlank() } + .toSet() + + require(values.all { it.matches(SCRIPT_NAME_REGEX) }) { + "invalid script names, only [A-Za-z0-9._-] is allowed" + } + return values +} + +fun subTokenListJson(records: List): String { + val items = + records.joinToString(",") { record -> + val scripts = record.scripts.sorted().joinToString(",") { "\"${it.jsonEscaped()}\"" } + """{"name":"${record.name.jsonEscaped()}","token":"${record.token.jsonEscaped()}","scripts":[${scripts}]}""" + } + return """{"items":[${items}]}""" +} + +fun subTokenItemJson(record: SubTokenRecord, includeToken: Boolean): String { + val scripts = record.scripts.sorted().joinToString(",") { "\"${it.jsonEscaped()}\"" } + val tokenPart = if (includeToken) "\"${record.token.jsonEscaped()}\"" else "null" + return """{"name":"${record.name.jsonEscaped()}","token":$tokenPart,"scripts":[${scripts}]}""" +} diff --git a/tools/api-cli.kts b/tools/api-cli.kts index 5f6d9bc..40385c4 100755 --- a/tools/api-cli.kts +++ b/tools/api-cli.kts @@ -33,6 +33,7 @@ Global options: Commands: health + type list show