diff --git a/src/main/kotlin/work/slhaf/hub/WebHost.kt b/src/main/kotlin/work/slhaf/hub/WebHost.kt index eb48d02..5e36cfd 100644 --- a/src/main/kotlin/work/slhaf/hub/WebHost.kt +++ b/src/main/kotlin/work/slhaf/hub/WebHost.kt @@ -101,12 +101,23 @@ fun Application.webModule(scriptsDir: File, security: HostSecurity, runConcurren call.respondText(tokenTypeJson(auth), contentType = ContentType.Application.Json) } + get("/u/{subAuth}/type") { + val auth = requireSubTokenPathAuth(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("/u/{subAuth}/scripts") { + val auth = requireSubTokenPathAuth(call, security) ?: return@get + val allow = visibleScriptsFor(auth) + call.respondText(renderScriptList(scriptsDir, allow), ContentType.Text.Plain) + } + get("/scripts/{script}") { val auth = requireAuth(call, security) ?: return@get if (!requireRoot(call, auth)) return@get @@ -151,6 +162,26 @@ fun Application.webModule(scriptsDir: File, security: HostSecurity, runConcurren ) } + get("/u/{subAuth}/meta/{script}") { + val auth = requireSubTokenPathAuth(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()) { + return@get call.respondText("script not found: ${script.name}", status = HttpStatusCode.NotFound) + } + + val (metadata, source) = loadMetadata(script) + call.respondText( + metadataJson(name, metadata, source), + contentType = ContentType.Application.Json, + ) + } + get("/run/{script}") { val auth = requireAuth(call, security) ?: return@get val name = call.parameters["script"] @@ -161,6 +192,16 @@ fun Application.webModule(scriptsDir: File, security: HostSecurity, runConcurren } } + get("/u/{subAuth}/run/{script}") { + val auth = requireSubTokenPathAuth(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 + runConcurrencyLimiter.withPermit { + handleRunRequest(call, scriptsDir, consumeBody = false) + } + } + post("/run/{script}") { val auth = requireAuth(call, security) ?: return@post val name = call.parameters["script"] @@ -171,6 +212,16 @@ fun Application.webModule(scriptsDir: File, security: HostSecurity, runConcurren } } + post("/u/{subAuth}/run/{script}") { + val auth = requireSubTokenPathAuth(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 + runConcurrencyLimiter.withPermit { + handleRunRequest(call, scriptsDir, consumeBody = true) + } + } + get("/subtokens") { val auth = requireAuth(call, security) ?: return@get if (!requireRoot(call, auth)) return@get @@ -211,17 +262,22 @@ Usage: Routes: GET /health GET /type + GET /u/{subtoken_name}@{subtoken}/type (subtoken path auth) Authorization: Authorization: Bearer or X-Host-Token: GET /scripts + GET /u/{subtoken_name}@{subtoken}/scripts (subtoken path auth) 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 /u/{subtoken_name}@{subtoken}/meta/{script} GET /run/{script}?k=v (root or allowed subtoken) + GET /u/{subtoken_name}@{subtoken}/run/{script}?k=v POST /run/{script}?k=v (root or allowed subtoken) + POST /u/{subtoken_name}@{subtoken}/run/{script}?k=v GET /subtokens (root only) GET /subtokens/{name} (root only) POST /subtokens/{name} (root only, body: script names list) diff --git a/src/main/kotlin/work/slhaf/hub/WebSecurity.kt b/src/main/kotlin/work/slhaf/hub/WebSecurity.kt index 0fbb101..0921b2e 100644 --- a/src/main/kotlin/work/slhaf/hub/WebSecurity.kt +++ b/src/main/kotlin/work/slhaf/hub/WebSecurity.kt @@ -62,6 +62,13 @@ class SubTokenStore( byName.values.firstOrNull { it.token == token } } + fun findByNameAndToken(name: String, token: String): SubTokenRecord? = + synchronized(lock) { + ensureLoaded() + val record = byName[name] ?: return null + if (record.token == token) record else null + } + fun create(name: String, scripts: Set): SubTokenRecord { synchronized(lock) { ensureLoaded() @@ -198,6 +205,23 @@ private fun authenticateToken(call: ApplicationCall, security: HostSecurity): Au return AuthContext(type = TokenType.SUB, subTokenName = sub.name, allowedScripts = sub.scripts) } +private fun parseSubTokenPathCredential(raw: String?): Pair? { + val value = raw?.trim().orEmpty() + if (value.isBlank()) return null + val at = value.indexOf('@') + if (at <= 0 || at == value.lastIndex) return null + val name = value.substring(0, at).trim() + val token = value.substring(at + 1).trim() + if (!name.matches(SCRIPT_NAME_REGEX) || token.isBlank()) return null + return name to token +} + +fun authenticateSubTokenPath(pathCredential: String?, security: HostSecurity): AuthContext? { + val (name, token) = parseSubTokenPathCredential(pathCredential) ?: return null + val sub = security.subTokens.findByNameAndToken(name, token) ?: 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 @@ -207,6 +231,21 @@ suspend fun requireAuth(call: ApplicationCall, security: HostSecurity): AuthCont return null } +suspend fun requireSubTokenPathAuth( + call: ApplicationCall, + security: HostSecurity, + pathParamName: String = "subAuth", +): AuthContext? { + val context = authenticateSubTokenPath(call.parameters[pathParamName], security) + if (context != null) return context + call.respondText( + "unauthorized subtoken path, expected /u/@/...", + 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) diff --git a/src/test/kotlin/work/slhaf/hub/WebHostApiTest.kt b/src/test/kotlin/work/slhaf/hub/WebHostApiTest.kt index ed9e273..b19746c 100644 --- a/src/test/kotlin/work/slhaf/hub/WebHostApiTest.kt +++ b/src/test/kotlin/work/slhaf/hub/WebHostApiTest.kt @@ -159,6 +159,30 @@ class WebHostApiTest { val listSubTokens = client.get("/subtokens") { bearer(token) } assertEquals(HttpStatusCode.Forbidden, listSubTokens.status) + + val typeByPath = client.get("/u/demo-sub@$token/type") + assertEquals(HttpStatusCode.OK, typeByPath.status) + assertTrue(typeByPath.bodyAsText().contains("\"tokenType\":\"sub\"")) + + val scriptsByPath = client.get("/u/demo-sub@$token/scripts") + assertEquals(HttpStatusCode.OK, scriptsByPath.status) + assertTrue(scriptsByPath.bodyAsText().contains("allowed")) + assertFalse(scriptsByPath.bodyAsText().contains("blocked")) + + val metaByPathAllowed = client.get("/u/demo-sub@$token/meta/allowed") + assertEquals(HttpStatusCode.OK, metaByPathAllowed.status) + + val metaByPathBlocked = client.get("/u/demo-sub@$token/meta/blocked") + assertEquals(HttpStatusCode.Forbidden, metaByPathBlocked.status) + + val runByPathAllowed = client.get("/u/demo-sub@$token/run/allowed") + assertEquals(HttpStatusCode.OK, runByPathAllowed.status) + + val runByPathBlocked = client.get("/u/demo-sub@$token/run/blocked") + assertEquals(HttpStatusCode.Forbidden, runByPathBlocked.status) + + val invalidPathAuth = client.get("/u/demo-sub@invalid-token/scripts") + assertEquals(HttpStatusCode.Unauthorized, invalidPathAuth.status) } @Test