Compare commits

...

15 Commits

Author SHA1 Message Date
a3d2ff1cb8 feat(web): support @response metadata (text/json/html) and return matching run content type 2026-02-25 19:30:17 +08:00
f332159217 refactor(docker): use configurable SLHAF_HUB_IMAGE in compose instead of local build 2026-02-25 17:17:14 +08:00
bcf0a316a6 fix(web): validate script create/update by compilation only 2026-02-25 16:49:26 +08:00
ae94615095 refactor(docker): rename runtime artifact and entrypoint to slhaf-hub 2026-02-25 15:30:13 +08:00
c0bb645125 refactor(gradle): centralize plugin/dependency repositories in settings with aliyun mirrors 2026-02-25 15:27:54 +08:00
923479b4cf docs(readme): add compiled script cache to feature list 2026-02-25 14:59:45 +08:00
e3886ff2ed refactor(layout): organize sources into web script cli domains 2026-02-25 14:50:53 +08:00
d79ff57b89 refactor(web): mask subtoken credentials in request audit path logs 2026-02-25 14:48:02 +08:00
ab6d1204e6 refactor(test): split web host api tests by domain 2026-02-25 14:43:23 +08:00
56d0b40dd3 refactor(script): split metadata parsing and cache from script engine 2026-02-25 14:40:23 +08:00
62bd88efc9 refactor(web): split web host bootstrap and route implementation 2026-02-25 14:37:19 +08:00
3933f0120b refactor: add unified request audit logging for all web routes 2026-02-25 14:31:38 +08:00
e6a993fcf1 feat: support lateinit var args declaration for hostArgs injection and add coverage 2026-02-25 14:24:37 +08:00
c8f4695582 refactor: inject default @param values into hostArgs and cache compiled scripts for run execution 2026-02-25 14:17:46 +08:00
757af8d89b refactor: rename HOST_* env vars to SLHAF_HUB_* across CLI/TUI, security, docs, and compose 2026-02-25 13:59:27 +08:00
25 changed files with 1547 additions and 1058 deletions

View File

@@ -14,10 +14,10 @@ RUN gradle --no-daemon clean installDist
FROM ${RUNTIME_IMAGE}
WORKDIR /app
COPY --from=build /workspace/build/install/kotlin-scripts-host /app/kotlin-scripts-host
COPY --from=build /workspace/build/install/slhaf-hub /app/slhaf-hub
RUN mkdir -p /app/scripts
EXPOSE 8080
ENTRYPOINT ["/app/kotlin-scripts-host/bin/kotlin-scripts-host"]
ENTRYPOINT ["/app/slhaf-hub/bin/slhaf-hub"]
CMD ["--host=0.0.0.0", "--port=8080", "--scripts-dir=/app/scripts"]

View File

@@ -10,6 +10,7 @@ Language:
- Dynamic script loading from `scripts/*.hub.kts` without restarting host
- Root/Sub token authorization model
- Metadata in script comments (`@desc`, `@timeout`, `@param`)
- Compiled script cache (reuses compiled artifacts when script file is unchanged)
- Script CRUD + run + metadata APIs
- Subtoken management APIs
- Run concurrency limit (`--max-run-concurrency`)
@@ -37,15 +38,15 @@ cd slhaf-hub
docker build -t slhaf-hub:latest .
docker run --rm -p 8080:8080 \
-v "$(pwd)/scripts:/app/scripts" \
-e HOST_API_TOKEN=your-token \
-e SLHAF_HUB_TOKEN=your-token \
-e MAX_RUN_CONCURRENCY=8 \
slhaf-hub:latest
```
#### 3) Run with Docker Compose
```bash
# optional: export HOST_API_TOKEN=your-token
# optional: export HOST_PORT=8080
# optional: export SLHAF_HUB_TOKEN=your-token
# optional: export SLHAF_HUB_PORT=8080
# optional: export MAX_RUN_CONCURRENCY=8
docker compose up -d --build
```
@@ -79,7 +80,7 @@ Auth headers:
- `X-Host-Token: <token>`
Token source priority:
1. `HOST_API_TOKEN` env var
1. `SLHAF_HUB_TOKEN` env var
2. `scripts/.host-api-token`
3. Auto-generated token saved to `scripts/.host-api-token`

View File

@@ -10,6 +10,7 @@
-`scripts/*.hub.kts` 动态加载脚本,无需重启 host
- Root/Sub token 鉴权模型
- 脚本注释 metadata`@desc``@timeout``@param`
- 编译缓存(脚本文件未变化时复用已编译产物)
- 脚本 CRUD + run + meta API
- subtoken 管理 API
- 运行并发限制(`--max-run-concurrency`
@@ -37,15 +38,15 @@ cd slhaf-hub
docker build -t slhaf-hub:latest .
docker run --rm -p 8080:8080 \
-v "$(pwd)/scripts:/app/scripts" \
-e HOST_API_TOKEN=your-token \
-e SLHAF_HUB_TOKEN=your-token \
-e MAX_RUN_CONCURRENCY=8 \
slhaf-hub:latest
```
#### 3) Docker Compose 启动
```bash
# 可选export HOST_API_TOKEN=your-token
# 可选export HOST_PORT=8080
# 可选export SLHAF_HUB_TOKEN=your-token
# 可选export SLHAF_HUB_PORT=8080
# 可选export MAX_RUN_CONCURRENCY=8
docker compose up -d --build
```
@@ -79,7 +80,7 @@ CLI/TUI 环境变量:
- `X-Host-Token: <token>`
Token 来源优先级:
1. 环境变量 `HOST_API_TOKEN`
1. 环境变量 `SLHAF_HUB_TOKEN`
2. `scripts/.host-api-token`
3. 自动生成并写入 `scripts/.host-api-token`

View File

@@ -5,10 +5,6 @@ plugins {
val kotlinVersion = "2.2.20"
repositories {
mavenCentral()
}
dependencies {
implementation(kotlin("stdlib"))

View File

@@ -1,18 +1,12 @@
services:
slhaf-hub:
build:
context: .
dockerfile: Dockerfile
args:
BUILD_IMAGE: ${BUILD_IMAGE:-gradle:9.0.0-jdk17}
RUNTIME_IMAGE: ${RUNTIME_IMAGE:-eclipse-temurin:17-jre}
image: slhaf-hub:latest
image: ${SLHAF_HUB_IMAGE:-docker.io/slhafzjw/slhaf-hub:latest}
container_name: slhaf-hub
restart: unless-stopped
ports:
- "${HOST_PORT:-8080}:8080"
- "${SLHAF_HUB_PORT:-8080}:8080"
environment:
HOST_API_TOKEN: ${HOST_API_TOKEN:-}
SLHAF_HUB_TOKEN: ${SLHAF_HUB_TOKEN:-}
MAX_RUN_CONCURRENCY: ${MAX_RUN_CONCURRENCY:-}
volumes:
- ./scripts:/app/scripts

View File

@@ -1,10 +1,11 @@
// @desc: hello
// @timeout: 10s
// @param: name | default=world | desc=hello <name> | required=false
// @param: upper | default=false | desc=upper text | required=true
// @param: upper | default=false | desc=upper text | required=false
// @response: json
import java.time.LocalDateTime
val args: Array<String> = emptyArray()
lateinit var args: Array<String>
val kv =
args
.mapNotNull {
@@ -13,7 +14,7 @@ val kv =
}.toMap()
val name = kv["name"] ?: "world"
val upper = (kv["upper"]!!).toBoolean()
val upper = (kv["upper"] ?: "false").toBoolean()
val message = "Hello, $name @ ${LocalDateTime.now()}"
println(if (upper) message.uppercase() else message)

View File

@@ -1 +1,18 @@
rootProject.name = "slhaf-hub"
pluginManagement {
repositories {
maven("https://maven.aliyun.com/repository/gradle-plugin")
maven("https://maven.aliyun.com/repository/public")
gradlePluginPortal()
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
repositories {
maven("https://maven.aliyun.com/repository/public")
mavenCentral()
}
}

View File

@@ -1,387 +0,0 @@
package work.slhaf.hub
import kotlinx.coroutines.runBlocking
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.PrintStream
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
import kotlin.script.experimental.api.*
import kotlin.script.experimental.dependencies.*
import kotlin.script.experimental.dependencies.maven.MavenDependenciesResolver
import kotlin.script.experimental.host.toScriptSource
import kotlin.script.experimental.jvm.JvmDependency
import kotlin.script.experimental.jvm.jvm
import kotlin.script.experimental.jvm.updateClasspath
import kotlin.script.experimental.jvmhost.BasicJvmScriptingHost
private val scriptingHost = BasicJvmScriptingHost()
private val evalLock = Any()
private val metadataCache = ConcurrentHashMap<String, Pair<String, ScriptMetadata>>() // key -> stamp, metadata
private val resolver = CompoundDependenciesResolver(FileSystemDependenciesResolver(), MavenDependenciesResolver())
private val argsDeclarationRegex = Regex("""^\s*val\s+args\s*:\s*Array<String>\s*=\s*emptyArray\(\)\s*$""")
private const val DEFAULT_SCRIPT_TIMEOUT_MS = 10_000L
private val metadataParamNameRegex = Regex("[A-Za-z0-9._-]+")
private val evalExecutor = Executors.newCachedThreadPool { r ->
Thread(r, "script-eval-worker").apply { isDaemon = true }
}
fun explicitClasspathFromEnv(): List<File>? {
val value = System.getenv("HOST_SCRIPT_CLASSPATH") ?: return null
if (value.isBlank()) return null
return value.split(File.pathSeparator).filter { it.isNotBlank() }.map(::File)
}
private fun runtimeClasspathFromJavaProperty(): List<File> {
val raw = System.getProperty("java.class.path").orEmpty()
if (raw.isBlank()) return emptyList()
return raw
.split(File.pathSeparator)
.asSequence()
.filter { it.isNotBlank() }
.map(::File)
.filter { it.exists() }
.distinctBy { it.absolutePath }
.toList()
}
private fun configureMavenDepsOnAnnotations(
context: ScriptConfigurationRefinementContext
): ResultWithDiagnostics<ScriptCompilationConfiguration> {
val annotations = context.collectedData
?.get(ScriptCollectedData.collectedAnnotations)
?.takeIf { it.isNotEmpty() }
?: return context.compilationConfiguration.asSuccess()
return runBlocking {
resolver.resolveFromScriptSourceAnnotations(annotations)
}.onSuccess { files ->
context.compilationConfiguration.with {
dependencies.append(JvmDependency(files))
}.asSuccess()
}
}
private fun compilationConfiguration(explicitCp: List<File>?): ScriptCompilationConfiguration {
return ScriptCompilationConfiguration {
baseClass(SimpleScript::class)
defaultImports(DependsOn::class, Repository::class)
jvm {
val runtimeCp = runtimeClasspathFromJavaProperty()
updateClasspath((explicitCp ?: emptyList()) + runtimeCp)
}
refineConfiguration {
onAnnotations(DependsOn::class, Repository::class, handler = ::configureMavenDepsOnAnnotations)
}
}
}
private fun escapeKotlinString(value: String): String = buildString(value.length) {
value.forEach { ch ->
when (ch) {
'\\' -> append("\\\\")
'"' -> append("\\\"")
'\n' -> append("\\n")
'\r' -> append("\\r")
'\t' -> append("\\t")
else -> append(ch)
}
}
}
private fun argsInitializer(args: List<String>): String =
"val args: Array<String> = arrayOf(${args.joinToString(",") { "\"${escapeKotlinString(it)}\"" }})"
private fun scriptStamp(file: File): String = "${file.length()}-${file.lastModified()}"
private fun injectArgsDeclaration(scriptContent: String, args: List<String>): String {
val lines = scriptContent.lines()
val injected = argsInitializer(args)
var replaced = false
val result = lines.map { line ->
if (!replaced && argsDeclarationRegex.matches(line)) {
replaced = true
injected
} else {
line
}
}
return result.joinToString("\n")
}
private fun parseMetadataFromComments(scriptContent: String): ScriptMetadata {
var description: String? = null
var timeoutMs = DEFAULT_SCRIPT_TIMEOUT_MS
val params = mutableListOf<ScriptParamDefinition>()
scriptContent.lines().forEach { raw ->
val line = raw.trim()
if (!line.startsWith("//")) return@forEach
val comment = line.removePrefix("//").trim()
if (comment.startsWith("@desc:", ignoreCase = true)) {
description = comment.substringAfter(":").trim().takeIf { it.isNotBlank() }
return@forEach
}
if (comment.startsWith("@timeout:", ignoreCase = true)) {
val raw = comment.substringAfter(":").trim()
parseTimeoutMs(raw)?.let { timeoutMs = it }
return@forEach
}
if (comment.startsWith("@param:", ignoreCase = true)) {
val payload = comment.substringAfter(":").trim()
if (payload.isBlank()) return@forEach
val parts = payload.split("|").map { it.trim() }.filter { it.isNotBlank() }
if (parts.isEmpty()) return@forEach
val name = parts.first()
var required = false
var defaultValue: String? = null
var desc: String? = null
parts.drop(1).forEach { part ->
when {
part.equals("required", ignoreCase = true) -> required = true
part.startsWith("required=", ignoreCase = true) ->
required = part.substringAfter("=").trim().equals("true", ignoreCase = true)
part.startsWith("default=", ignoreCase = true) ->
defaultValue = part.substringAfter("=").trim().ifBlank { null }
part.startsWith("desc=", ignoreCase = true) ->
desc = part.substringAfter("=").trim().ifBlank { null }
}
}
params += ScriptParamDefinition(
name = name,
required = required,
defaultValue = defaultValue,
description = desc
)
}
}
return ScriptMetadata(description = description, params = params, timeoutMs = timeoutMs)
}
fun validateScriptMetadata(scriptContent: String): List<String> {
val errors = mutableListOf<String>()
val seenParams = mutableSetOf<String>()
scriptContent.lines().forEachIndexed { idx, raw ->
val lineNo = idx + 1
val line = raw.trim()
if (!line.startsWith("//")) return@forEachIndexed
val comment = line.removePrefix("//").trim()
if (comment.startsWith("@timeout:", ignoreCase = true)) {
val rawTimeout = comment.substringAfter(":").trim()
if (parseTimeoutMs(rawTimeout) == null) {
errors += "line $lineNo: invalid @timeout '$rawTimeout'. expected format: '@timeout: 10s' or '500ms' or '1m'."
}
return@forEachIndexed
}
if (comment.startsWith("@param:", ignoreCase = true)) {
val payload = comment.substringAfter(":").trim()
if (payload.isBlank()) {
errors += "line $lineNo: empty @param. expected format: '@param: name | required=true|false | default=value | desc=text'."
return@forEachIndexed
}
val parts = payload.split("|").map { it.trim() }.filter { it.isNotBlank() }
if (parts.isEmpty()) {
errors += "line $lineNo: invalid @param. expected format: '@param: name | required=true|false | default=value | desc=text'."
return@forEachIndexed
}
val name = parts.first()
if (!metadataParamNameRegex.matches(name)) {
errors += "line $lineNo: invalid param name '$name'. allowed pattern: [A-Za-z0-9._-]+."
}
if (!seenParams.add(name)) {
errors += "line $lineNo: duplicate @param name '$name'. param names must be unique."
}
var hasRequiredOption = false
parts.drop(1).forEach { part ->
when {
part.equals("required", ignoreCase = true) -> {
hasRequiredOption = true
}
part.startsWith("required=", ignoreCase = true) -> {
hasRequiredOption = true
val v = part.substringAfter("=").trim()
if (!v.equals("true", ignoreCase = true) && !v.equals("false", ignoreCase = true)) {
errors += "line $lineNo: invalid required value '$v'. expected true/false."
}
}
part.startsWith("default=", ignoreCase = true) -> Unit
part.startsWith("desc=", ignoreCase = true) -> Unit
else -> {
errors += "line $lineNo: unsupported @param option '$part'. supported: required=, default=, desc=."
}
}
}
if (!hasRequiredOption) {
errors += "line $lineNo: missing required option. expected '@param: name | required=true|false | default=value | desc=text'."
}
}
}
return errors
}
private fun parseTimeoutMs(raw: String): Long? {
if (raw.isBlank()) return null
val v = raw.trim().lowercase()
return when {
v.endsWith("ms") -> v.removeSuffix("ms").trim().toLongOrNull()
v.endsWith("s") -> v.removeSuffix("s").trim().toLongOrNull()?.times(1000)
v.endsWith("m") -> v.removeSuffix("m").trim().toLongOrNull()?.times(60_000)
else -> v.toLongOrNull()?.times(1000)
}?.takeIf { it > 0 }
}
private fun metadataForFile(scriptFile: File, scriptContent: String): ScriptMetadata {
val key = scriptFile.canonicalPath
val stamp = scriptStamp(scriptFile)
val cached = metadataCache[key]
if (cached != null && cached.first == stamp) return cached.second
val parsed = parseMetadataFromComments(scriptContent)
metadataCache[key] = stamp to parsed
return parsed
}
private fun evalSource(source: SourceCode): ResultWithDiagnostics<EvaluationResult> {
val explicitCp = explicitClasspathFromEnv()
return scriptingHost.eval(source, compilationConfiguration(explicitCp), null)
}
data class ScriptExecutionResult(
val ok: Boolean,
val output: String,
val metadata: ScriptMetadata,
val missingRequiredParams: List<String>,
val timedOut: Boolean = false,
)
fun cachedMetadata(scriptFile: File): ScriptMetadata? {
val key = scriptFile.canonicalPath
val cached = metadataCache[key] ?: return null
val currentStamp = scriptStamp(scriptFile)
return if (cached.first == currentStamp) cached.second else null
}
fun removeCachedMetadata(scriptFile: File) {
metadataCache.remove(scriptFile.canonicalPath)
}
fun loadMetadataFromComments(scriptFile: File): ScriptMetadata {
val content = scriptFile.readText()
return metadataForFile(scriptFile, content)
}
fun evalAndCapture(scriptFile: File, requestContext: ScriptRequestContext = ScriptRequestContext()): ScriptExecutionResult {
return evalAndCapture(scriptFile, requestContext, enforceRequiredParams = true)
}
fun evalAndCapture(
scriptFile: File,
requestContext: ScriptRequestContext = ScriptRequestContext(),
enforceRequiredParams: Boolean,
): ScriptExecutionResult {
synchronized(evalLock) {
val oldOut = System.out
val oldErr = System.err
val buffer = ByteArrayOutputStream()
val ps = PrintStream(buffer, true, Charsets.UTF_8.name())
return try {
System.setOut(ps)
System.setErr(ps)
val original = scriptFile.readText()
val metadata = metadataForFile(scriptFile, original)
val missingRequired = metadata.params
.filter { p ->
p.required && requestContext.args.none { token ->
token.substringBefore("=", missingDelimiterValue = "") == p.name
}
}
.map { it.name }
val injected = injectArgsDeclaration(original, requestContext.args)
val result = evalSource(injected.toScriptSource(scriptFile.name))
val returnValueError = (result as? ResultWithDiagnostics.Success)
?.value
?.returnValue as? ResultValue.Error
val hasErrorDiagnostics = result.reports.any {
it.severity == ScriptDiagnostic.Severity.ERROR || it.severity == ScriptDiagnostic.Severity.FATAL
}
val diagnostics = result.reports
.filter { it.severity > ScriptDiagnostic.Severity.DEBUG }
.joinToString("\n") {
val ex = it.exception?.let { e -> ": ${e::class.simpleName}: ${e.message}" } ?: ""
"[${it.severity}] ${it.message}$ex"
}
val missingMessage = if (!enforceRequiredParams || missingRequired.isEmpty()) "" else
"[ERROR] Missing required parameters: ${missingRequired.joinToString(", ")}"
val output = buffer.toString(Charsets.UTF_8.name()).trim()
val finalText = buildString {
if (output.isNotEmpty()) appendLine(output)
if (returnValueError != null) appendLine("[ERROR] ${returnValueError.error::class.simpleName}: ${returnValueError.error.message}")
if (diagnostics.isNotEmpty()) appendLine(diagnostics)
if (missingMessage.isNotEmpty()) appendLine(missingMessage)
}.trim()
ScriptExecutionResult(
ok = result is ResultWithDiagnostics.Success &&
!hasErrorDiagnostics &&
returnValueError == null &&
(!enforceRequiredParams || missingRequired.isEmpty()),
output = finalText,
metadata = metadata,
missingRequiredParams = missingRequired,
timedOut = false,
)
} finally {
ps.flush()
ps.close()
System.setOut(oldOut)
System.setErr(oldErr)
}
}
}
fun evalAndCaptureWithTimeout(
scriptFile: File,
requestContext: ScriptRequestContext = ScriptRequestContext(),
timeoutMs: Long,
enforceRequiredParams: Boolean = true,
): ScriptExecutionResult {
val boundedTimeout = timeoutMs.coerceAtLeast(1)
val future = evalExecutor.submit<ScriptExecutionResult> {
evalAndCapture(scriptFile, requestContext, enforceRequiredParams = enforceRequiredParams)
}
return try {
future.get(boundedTimeout, TimeUnit.MILLISECONDS)
} catch (_: TimeoutException) {
future.cancel(true)
val metadata = loadMetadataFromComments(scriptFile)
ScriptExecutionResult(
ok = false,
output = "[ERROR] Script execution timed out after ${boundedTimeout}ms",
metadata = metadata,
missingRequiredParams = emptyList(),
timedOut = true,
)
}
}

View File

@@ -1,334 +0,0 @@
package work.slhaf.hub
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.ApplicationCall
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.Routing
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.put
import io.ktor.server.routing.routing
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import java.io.File
private const val DEFAULT_PORT = 8080
private const val DEFAULT_SCRIPTS_DIR = "scripts"
private const val DEFAULT_HOST = "0.0.0.0"
private val DEFAULT_MAX_RUN_CONCURRENCY = Runtime.getRuntime().availableProcessors().coerceAtLeast(1)
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 suspend fun handleTypeForAuth(call: ApplicationCall, auth: AuthContext) {
call.respondText(tokenTypeJson(auth), contentType = ContentType.Application.Json)
}
private suspend fun handleScriptsForAuth(call: ApplicationCall, scriptsDir: File, auth: AuthContext) {
val allow = visibleScriptsFor(auth)
call.respondText(renderScriptList(scriptsDir, allow), ContentType.Text.Plain)
}
private suspend fun handleMetaForAuth(call: ApplicationCall, scriptsDir: File, auth: AuthContext) {
val name = call.parameters["script"]
?: return call.respondText("missing route name", status = HttpStatusCode.BadRequest)
if (!requireScriptAccess(call, auth, name)) return
val script = resolveScriptFile(scriptsDir, name)
?: return call.respondText("invalid script name", status = HttpStatusCode.BadRequest)
if (!script.exists()) {
return 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,
)
}
private suspend fun handleRunForAuth(
call: ApplicationCall,
scriptsDir: File,
auth: AuthContext,
runConcurrencyLimiter: Semaphore,
consumeBody: Boolean,
) {
val name = call.parameters["script"]
?: return call.respondText("missing route name", status = HttpStatusCode.BadRequest)
if (!requireScriptAccess(call, auth, name)) return
runConcurrencyLimiter.withPermit {
handleRunRequest(call, scriptsDir, consumeBody = consumeBody)
}
}
private fun Routing.registerHeaderAuthenticatedRoutes(
scriptsDir: File,
security: HostSecurity,
runConcurrencyLimiter: Semaphore,
) {
get("/type") {
val auth = requireAuth(call, security) ?: return@get
handleTypeForAuth(call, auth)
}
get("/scripts") {
val auth = requireAuth(call, security) ?: return@get
handleScriptsForAuth(call, scriptsDir, auth)
}
get("/meta/{script}") {
val auth = requireAuth(call, security) ?: return@get
handleMetaForAuth(call, scriptsDir, auth)
}
get("/run/{script}") {
val auth = requireAuth(call, security) ?: return@get
handleRunForAuth(call, scriptsDir, auth, runConcurrencyLimiter, consumeBody = false)
}
post("/run/{script}") {
val auth = requireAuth(call, security) ?: return@post
handleRunForAuth(call, scriptsDir, auth, runConcurrencyLimiter, consumeBody = true)
}
}
private fun Routing.registerSubTokenPathRoutes(
scriptsDir: File,
security: HostSecurity,
runConcurrencyLimiter: Semaphore,
) {
get("/u/{subAuth}/type") {
val auth = requireSubTokenPathAuth(call, security) ?: return@get
handleTypeForAuth(call, auth)
}
get("/u/{subAuth}/scripts") {
val auth = requireSubTokenPathAuth(call, security) ?: return@get
handleScriptsForAuth(call, scriptsDir, auth)
}
get("/u/{subAuth}/meta/{script}") {
val auth = requireSubTokenPathAuth(call, security) ?: return@get
handleMetaForAuth(call, scriptsDir, auth)
}
get("/u/{subAuth}/run/{script}") {
val auth = requireSubTokenPathAuth(call, security) ?: return@get
handleRunForAuth(call, scriptsDir, auth, runConcurrencyLimiter, consumeBody = false)
}
post("/u/{subAuth}/run/{script}") {
val auth = requireSubTokenPathAuth(call, security) ?: return@post
handleRunForAuth(call, scriptsDir, auth, runConcurrencyLimiter, consumeBody = true)
}
}
fun Application.webModule(scriptsDir: File, security: HostSecurity, runConcurrencyLimiter: Semaphore) {
routing {
get("/health") {
call.respondText("OK")
}
registerHeaderAuthenticatedRoutes(scriptsDir, security, runConcurrencyLimiter)
registerSubTokenPathRoutes(scriptsDir, security, runConcurrencyLimiter)
get("/scripts/{script}") {
val auth = requireAuth(call, security) ?: return@get
if (!requireRoot(call, auth)) return@get
handleGetScriptContent(call, scriptsDir)
}
post("/scripts/{script}") {
val auth = requireAuth(call, security) ?: return@post
if (!requireRoot(call, auth)) return@post
handleCreateScript(call, scriptsDir)
}
put("/scripts/{script}") {
val auth = requireAuth(call, security) ?: return@put
if (!requireRoot(call, auth)) return@put
handleUpdateScript(call, scriptsDir)
}
delete("/scripts/{script}") {
val auth = requireAuth(call, security) ?: return@delete
if (!requireRoot(call, auth)) return@delete
handleDeleteScript(call, scriptsDir)
}
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)
}
}
}
private fun usage() {
println(
"""
Usage:
./gradlew runWeb --args='[--host=0.0.0.0] [--port=8080] [--scripts-dir=./scripts] [--max-run-concurrency=N]'
Routes:
GET /health
GET /type
GET /u/{subtoken_name}@{subtoken}/type (subtoken path auth)
Authorization:
Authorization: Bearer <token>
or X-Host-Token: <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)
PUT /subtokens/{name} (root only, body: script names list)
DELETE /subtokens/{name} (root only)
""".trimIndent(),
)
}
private fun List<String>.optionValue(prefix: String): String? =
firstOrNull { it.startsWith(prefix) }?.substringAfter("=")
fun main(args: Array<String>) {
val cli = args.toList()
if ("--help" in cli || "-h" in cli) {
usage()
return
}
val port = cli.optionValue("--port=")?.toIntOrNull() ?: DEFAULT_PORT
val host = cli.optionValue("--host=")?.ifBlank { DEFAULT_HOST } ?: DEFAULT_HOST
val scriptsDir = File(cli.optionValue("--scripts-dir=") ?: DEFAULT_SCRIPTS_DIR).absoluteFile
val maxRunConcurrency = cli.optionValue("--max-run-concurrency=")?.toIntOrNull() ?: DEFAULT_MAX_RUN_CONCURRENCY
require(maxRunConcurrency > 0) { "--max-run-concurrency must be > 0" }
if (!scriptsDir.exists()) scriptsDir.mkdirs()
val auth = loadOrCreateApiToken(scriptsDir)
val security = createHostSecurity(scriptsDir, auth.token)
val runConcurrencyLimiter = Semaphore(maxRunConcurrency)
println("Starting script web host on http://$host:$port")
println("Scripts directory: ${scriptsDir.absolutePath}")
println("Run concurrency limit: $maxRunConcurrency")
println("Auth token source: ${auth.source}")
when {
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) {
webModule(scriptsDir, security, runConcurrencyLimiter)
}.start(wait = true)
}

View File

@@ -0,0 +1,314 @@
package work.slhaf.hub
import kotlinx.coroutines.runBlocking
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.PrintStream
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
import kotlin.script.experimental.api.*
import kotlin.script.experimental.dependencies.*
import kotlin.script.experimental.dependencies.maven.MavenDependenciesResolver
import kotlin.script.experimental.host.toScriptSource
import kotlin.script.experimental.jvm.JvmDependency
import kotlin.script.experimental.jvm.jvm
import kotlin.script.experimental.jvm.updateClasspath
import kotlin.script.experimental.jvmhost.BasicJvmScriptingHost
private val scriptingHost = BasicJvmScriptingHost()
private val evalLock = Any()
private val compiledScriptCache = ConcurrentHashMap<String, Pair<String, CompiledScript>>() // key -> stamp, compiled script
private val resolver = CompoundDependenciesResolver(FileSystemDependenciesResolver(), MavenDependenciesResolver())
private val argsDeclarationRegexes = listOf(
Regex("""^\s*val\s+args\s*:\s*Array<String>\s*=\s*emptyArray\(\)\s*$"""),
Regex("""^\s*lateinit\s+var\s+args\s*:\s*Array<String>\s*$"""),
)
private val evalExecutor = Executors.newCachedThreadPool { r ->
Thread(r, "script-eval-worker").apply { isDaemon = true }
}
fun explicitClasspathFromEnv(): List<File>? {
val value = System.getenv("HOST_SCRIPT_CLASSPATH") ?: return null
if (value.isBlank()) return null
return value.split(File.pathSeparator).filter { it.isNotBlank() }.map(::File)
}
private fun runtimeClasspathFromJavaProperty(): List<File> {
val raw = System.getProperty("java.class.path").orEmpty()
if (raw.isBlank()) return emptyList()
return raw
.split(File.pathSeparator)
.asSequence()
.filter { it.isNotBlank() }
.map(::File)
.filter { it.exists() }
.distinctBy { it.absolutePath }
.toList()
}
private fun configureMavenDepsOnAnnotations(
context: ScriptConfigurationRefinementContext
): ResultWithDiagnostics<ScriptCompilationConfiguration> {
val annotations = context.collectedData
?.get(ScriptCollectedData.collectedAnnotations)
?.takeIf { it.isNotEmpty() }
?: return context.compilationConfiguration.asSuccess()
return runBlocking {
resolver.resolveFromScriptSourceAnnotations(annotations)
}.onSuccess { files ->
context.compilationConfiguration.with {
dependencies.append(JvmDependency(files))
}.asSuccess()
}
}
private fun compilationConfiguration(explicitCp: List<File>?): ScriptCompilationConfiguration {
return ScriptCompilationConfiguration {
baseClass(SimpleScript::class)
defaultImports(DependsOn::class, Repository::class)
jvm {
val runtimeCp = runtimeClasspathFromJavaProperty()
updateClasspath((explicitCp ?: emptyList()) + runtimeCp)
}
refineConfiguration {
onAnnotations(DependsOn::class, Repository::class, handler = ::configureMavenDepsOnAnnotations)
}
}
}
private fun injectArgsBridgeDeclaration(scriptContent: String): String {
val lines = scriptContent.lines()
val injected = "val args: Array<String> = hostArgs"
var replaced = false
val result = lines.map { line ->
if (!replaced && argsDeclarationRegexes.any { it.matches(line) }) {
replaced = true
injected
} else {
line
}
}
return result.joinToString("\n")
}
private fun parseArgEntry(raw: String): Pair<String, String>? {
val idx = raw.indexOf('=')
if (idx <= 0) return null
return raw.substring(0, idx) to raw.substring(idx + 1)
}
private fun applyDefaultArgs(metadata: ScriptMetadata, requestArgs: List<String>): List<String> {
val existingKeys = requestArgs.mapNotNull { parseArgEntry(it)?.first }.toMutableSet()
val merged = requestArgs.toMutableList()
metadata.params.forEach { param ->
if (!existingKeys.contains(param.name) && param.defaultValue != null) {
merged += "${param.name}=${param.defaultValue}"
existingKeys += param.name
}
}
return merged
}
private fun compileSource(source: SourceCode): ResultWithDiagnostics<CompiledScript> {
val explicitCp = explicitClasspathFromEnv()
return runBlocking {
scriptingHost.compiler(source, compilationConfiguration(explicitCp))
}
}
private fun evalCompiled(compiledScript: CompiledScript, requestContext: ScriptRequestContext): ResultWithDiagnostics<EvaluationResult> {
val evaluationConfiguration = ScriptEvaluationConfiguration {
constructorArgs(requestContext.args.toTypedArray())
}
return runBlocking {
scriptingHost.evaluator(compiledScript, evaluationConfiguration)
}
}
private fun compiledScriptFor(scriptFile: File, preparedContent: String): ResultWithDiagnostics<CompiledScript> {
val key = scriptFile.canonicalPath
val stamp = scriptStamp(scriptFile)
val cached = compiledScriptCache[key]
if (cached != null && cached.first == stamp) {
return cached.second.asSuccess()
}
val compiled = compileSource(preparedContent.toScriptSource(scriptFile.name))
if (compiled is ResultWithDiagnostics.Success) {
compiledScriptCache[key] = stamp to compiled.value
}
return compiled
}
data class ScriptExecutionResult(
val ok: Boolean,
val output: String,
val metadata: ScriptMetadata,
val missingRequiredParams: List<String>,
val timedOut: Boolean = false,
)
fun removeCachedMetadata(scriptFile: File) {
clearMetadataCache(scriptFile)
compiledScriptCache.remove(scriptFile.canonicalPath)
}
fun validateCompilationAndCapture(scriptFile: File): ScriptExecutionResult {
synchronized(evalLock) {
val oldOut = System.out
val oldErr = System.err
val buffer = ByteArrayOutputStream()
val ps = PrintStream(buffer, true, Charsets.UTF_8.name())
return try {
System.setOut(ps)
System.setErr(ps)
val original = scriptFile.readText()
val metadata = metadataForFile(scriptFile, original)
val injected = injectArgsBridgeDeclaration(original)
val compilationResult = compiledScriptFor(scriptFile, injected)
val reports = compilationResult.reports
val hasErrorDiagnostics = reports.any {
it.severity == ScriptDiagnostic.Severity.ERROR || it.severity == ScriptDiagnostic.Severity.FATAL
}
val diagnostics = reports
.filter { it.severity > ScriptDiagnostic.Severity.DEBUG }
.joinToString("\n") {
val ex = it.exception?.let { e -> ": ${e::class.simpleName}: ${e.message}" } ?: ""
"[${it.severity}] ${it.message}$ex"
}
val output = buffer.toString(Charsets.UTF_8.name()).trim()
val finalText = buildString {
if (output.isNotEmpty()) appendLine(output)
if (diagnostics.isNotEmpty()) appendLine(diagnostics)
}.trim()
ScriptExecutionResult(
ok = compilationResult is ResultWithDiagnostics.Success && !hasErrorDiagnostics,
output = finalText,
metadata = metadata,
missingRequiredParams = emptyList(),
timedOut = false,
)
} finally {
ps.flush()
ps.close()
System.setOut(oldOut)
System.setErr(oldErr)
}
}
}
fun evalAndCapture(scriptFile: File, requestContext: ScriptRequestContext = ScriptRequestContext()): ScriptExecutionResult {
return evalAndCapture(scriptFile, requestContext, enforceRequiredParams = true)
}
fun evalAndCapture(
scriptFile: File,
requestContext: ScriptRequestContext = ScriptRequestContext(),
enforceRequiredParams: Boolean,
): ScriptExecutionResult {
synchronized(evalLock) {
val oldOut = System.out
val oldErr = System.err
val buffer = ByteArrayOutputStream()
val ps = PrintStream(buffer, true, Charsets.UTF_8.name())
return try {
System.setOut(ps)
System.setErr(ps)
val original = scriptFile.readText()
val metadata = metadataForFile(scriptFile, original)
val missingRequired = metadata.params
.filter { p ->
p.required && requestContext.args.none { token ->
token.substringBefore("=", missingDelimiterValue = "") == p.name
}
}
.map { it.name }
val effectiveArgs = applyDefaultArgs(metadata, requestContext.args)
val injected = injectArgsBridgeDeclaration(original)
val compilationResult = compiledScriptFor(scriptFile, injected)
val evaluationResult = if (compilationResult is ResultWithDiagnostics.Success) {
evalCompiled(compilationResult.value, requestContext.copy(args = effectiveArgs))
} else {
null
}
val reports = evaluationResult?.reports ?: compilationResult.reports
val returnValueError = (evaluationResult as? ResultWithDiagnostics.Success)
?.value
?.returnValue as? ResultValue.Error
val hasErrorDiagnostics = reports.any {
it.severity == ScriptDiagnostic.Severity.ERROR || it.severity == ScriptDiagnostic.Severity.FATAL
}
val diagnostics = reports
.filter { it.severity > ScriptDiagnostic.Severity.DEBUG }
.joinToString("\n") {
val ex = it.exception?.let { e -> ": ${e::class.simpleName}: ${e.message}" } ?: ""
"[${it.severity}] ${it.message}$ex"
}
val missingMessage = if (!enforceRequiredParams || missingRequired.isEmpty()) "" else
"[ERROR] Missing required parameters: ${missingRequired.joinToString(", ")}"
val output = buffer.toString(Charsets.UTF_8.name()).trim()
val finalText = buildString {
if (output.isNotEmpty()) appendLine(output)
if (returnValueError != null) appendLine("[ERROR] ${returnValueError.error::class.simpleName}: ${returnValueError.error.message}")
if (diagnostics.isNotEmpty()) appendLine(diagnostics)
if (missingMessage.isNotEmpty()) appendLine(missingMessage)
}.trim()
ScriptExecutionResult(
ok = compilationResult is ResultWithDiagnostics.Success &&
evaluationResult is ResultWithDiagnostics.Success &&
!hasErrorDiagnostics &&
returnValueError == null &&
(!enforceRequiredParams || missingRequired.isEmpty()),
output = finalText,
metadata = metadata,
missingRequiredParams = missingRequired,
timedOut = false,
)
} finally {
ps.flush()
ps.close()
System.setOut(oldOut)
System.setErr(oldErr)
}
}
}
fun evalAndCaptureWithTimeout(
scriptFile: File,
requestContext: ScriptRequestContext = ScriptRequestContext(),
timeoutMs: Long,
enforceRequiredParams: Boolean = true,
): ScriptExecutionResult {
val boundedTimeout = timeoutMs.coerceAtLeast(1)
val future = evalExecutor.submit<ScriptExecutionResult> {
evalAndCapture(scriptFile, requestContext, enforceRequiredParams = enforceRequiredParams)
}
return try {
future.get(boundedTimeout, TimeUnit.MILLISECONDS)
} catch (_: TimeoutException) {
future.cancel(true)
val metadata = loadMetadataFromComments(scriptFile)
ScriptExecutionResult(
ok = false,
output = "[ERROR] Script execution timed out after ${boundedTimeout}ms",
metadata = metadata,
missingRequiredParams = emptyList(),
timedOut = true,
)
}
}

View File

@@ -0,0 +1,194 @@
package work.slhaf.hub
import java.io.File
import java.util.concurrent.ConcurrentHashMap
private val metadataCache = ConcurrentHashMap<String, Pair<String, ScriptMetadata>>() // key -> stamp, metadata
private const val DEFAULT_SCRIPT_TIMEOUT_MS = 10_000L
private val metadataParamNameRegex = Regex("[A-Za-z0-9._-]+")
private val supportedResponseValues = setOf("text", "json", "html")
internal fun scriptStamp(file: File): String = "${file.length()}-${file.lastModified()}"
private fun parseMetadataFromComments(scriptContent: String): ScriptMetadata {
var description: String? = null
var timeoutMs = DEFAULT_SCRIPT_TIMEOUT_MS
var responseType = ScriptResponseType.TEXT
val params = mutableListOf<ScriptParamDefinition>()
scriptContent.lines().forEach { raw ->
val line = raw.trim()
if (!line.startsWith("//")) return@forEach
val comment = line.removePrefix("//").trim()
if (comment.startsWith("@desc:", ignoreCase = true)) {
description = comment.substringAfter(":").trim().takeIf { it.isNotBlank() }
return@forEach
}
if (comment.startsWith("@timeout:", ignoreCase = true)) {
val raw = comment.substringAfter(":").trim()
parseTimeoutMs(raw)?.let { timeoutMs = it }
return@forEach
}
if (comment.startsWith("@response:", ignoreCase = true)) {
val raw = comment.substringAfter(":").trim().lowercase()
responseType = when (raw) {
"json" -> ScriptResponseType.JSON
"html" -> ScriptResponseType.HTML
else -> ScriptResponseType.TEXT
}
return@forEach
}
if (comment.startsWith("@param:", ignoreCase = true)) {
val payload = comment.substringAfter(":").trim()
if (payload.isBlank()) return@forEach
val parts = payload.split("|").map { it.trim() }.filter { it.isNotBlank() }
if (parts.isEmpty()) return@forEach
val name = parts.first()
var required = false
var defaultValue: String? = null
var desc: String? = null
parts.drop(1).forEach { part ->
when {
part.equals("required", ignoreCase = true) -> required = true
part.startsWith("required=", ignoreCase = true) ->
required = part.substringAfter("=").trim().equals("true", ignoreCase = true)
part.startsWith("default=", ignoreCase = true) ->
defaultValue = part.substringAfter("=").trim().ifBlank { null }
part.startsWith("desc=", ignoreCase = true) ->
desc = part.substringAfter("=").trim().ifBlank { null }
}
}
params += ScriptParamDefinition(
name = name,
required = required,
defaultValue = defaultValue,
description = desc
)
}
}
return ScriptMetadata(
description = description,
params = params,
timeoutMs = timeoutMs,
responseType = responseType,
)
}
fun validateScriptMetadata(scriptContent: String): List<String> {
val errors = mutableListOf<String>()
val seenParams = mutableSetOf<String>()
scriptContent.lines().forEachIndexed { idx, raw ->
val lineNo = idx + 1
val line = raw.trim()
if (!line.startsWith("//")) return@forEachIndexed
val comment = line.removePrefix("//").trim()
if (comment.startsWith("@timeout:", ignoreCase = true)) {
val rawTimeout = comment.substringAfter(":").trim()
if (parseTimeoutMs(rawTimeout) == null) {
errors += "line $lineNo: invalid @timeout '$rawTimeout'. expected format: '@timeout: 10s' or '500ms' or '1m'."
}
return@forEachIndexed
}
if (comment.startsWith("@response:", ignoreCase = true)) {
val rawResponse = comment.substringAfter(":").trim().lowercase()
if (rawResponse !in supportedResponseValues) {
errors += "line $lineNo: invalid @response '$rawResponse'. expected one of: text, json, html."
}
return@forEachIndexed
}
if (comment.startsWith("@param:", ignoreCase = true)) {
val payload = comment.substringAfter(":").trim()
if (payload.isBlank()) {
errors += "line $lineNo: empty @param. expected format: '@param: name | required=true|false | default=value | desc=text'."
return@forEachIndexed
}
val parts = payload.split("|").map { it.trim() }.filter { it.isNotBlank() }
if (parts.isEmpty()) {
errors += "line $lineNo: invalid @param. expected format: '@param: name | required=true|false | default=value | desc=text'."
return@forEachIndexed
}
val name = parts.first()
if (!metadataParamNameRegex.matches(name)) {
errors += "line $lineNo: invalid param name '$name'. allowed pattern: [A-Za-z0-9._-]+."
}
if (!seenParams.add(name)) {
errors += "line $lineNo: duplicate @param name '$name'. param names must be unique."
}
var hasRequiredOption = false
parts.drop(1).forEach { part ->
when {
part.equals("required", ignoreCase = true) -> {
hasRequiredOption = true
}
part.startsWith("required=", ignoreCase = true) -> {
hasRequiredOption = true
val v = part.substringAfter("=").trim()
if (!v.equals("true", ignoreCase = true) && !v.equals("false", ignoreCase = true)) {
errors += "line $lineNo: invalid required value '$v'. expected true/false."
}
}
part.startsWith("default=", ignoreCase = true) -> Unit
part.startsWith("desc=", ignoreCase = true) -> Unit
else -> {
errors += "line $lineNo: unsupported @param option '$part'. supported: required=, default=, desc=."
}
}
}
if (!hasRequiredOption) {
errors += "line $lineNo: missing required option. expected '@param: name | required=true|false | default=value | desc=text'."
}
}
}
return errors
}
private fun parseTimeoutMs(raw: String): Long? {
if (raw.isBlank()) return null
val v = raw.trim().lowercase()
return when {
v.endsWith("ms") -> v.removeSuffix("ms").trim().toLongOrNull()
v.endsWith("s") -> v.removeSuffix("s").trim().toLongOrNull()?.times(1000)
v.endsWith("m") -> v.removeSuffix("m").trim().toLongOrNull()?.times(60_000)
else -> v.toLongOrNull()?.times(1000)
}?.takeIf { it > 0 }
}
internal fun metadataForFile(scriptFile: File, scriptContent: String): ScriptMetadata {
val key = scriptFile.canonicalPath
val stamp = scriptStamp(scriptFile)
val cached = metadataCache[key]
if (cached != null && cached.first == stamp) return cached.second
val parsed = parseMetadataFromComments(scriptContent)
metadataCache[key] = stamp to parsed
return parsed
}
fun cachedMetadata(scriptFile: File): ScriptMetadata? {
val key = scriptFile.canonicalPath
val cached = metadataCache[key] ?: return null
val currentStamp = scriptStamp(scriptFile)
return if (cached.first == currentStamp) cached.second else null
}
internal fun clearMetadataCache(scriptFile: File) {
metadataCache.remove(scriptFile.canonicalPath)
}
fun loadMetadataFromComments(scriptFile: File): ScriptMetadata {
val content = scriptFile.readText()
return metadataForFile(scriptFile, content)
}

View File

@@ -1,5 +1,11 @@
package work.slhaf.hub
enum class ScriptResponseType {
TEXT,
JSON,
HTML,
}
data class ScriptParamDefinition(
val name: String,
val required: Boolean = false,
@@ -11,6 +17,7 @@ data class ScriptMetadata(
val description: String? = null,
val params: List<ScriptParamDefinition> = emptyList(),
val timeoutMs: Long = 10_000L,
val responseType: ScriptResponseType = ScriptResponseType.TEXT,
)
data class ScriptRequestContext(

View File

@@ -3,4 +3,6 @@ package work.slhaf.hub
import kotlin.script.experimental.annotations.KotlinScript
@KotlinScript(fileExtension = "hub.kts")
abstract class SimpleScript
abstract class SimpleScript(
val hostArgs: Array<String> = emptyArray(),
)

View File

@@ -0,0 +1,83 @@
package work.slhaf.hub
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import kotlinx.coroutines.sync.Semaphore
import java.io.File
private const val DEFAULT_PORT = 8080
private const val DEFAULT_SCRIPTS_DIR = "scripts"
private const val DEFAULT_HOST = "0.0.0.0"
private val DEFAULT_MAX_RUN_CONCURRENCY = Runtime.getRuntime().availableProcessors().coerceAtLeast(1)
private fun usage() {
println(
"""
Usage:
./gradlew runWeb --args='[--host=0.0.0.0] [--port=8080] [--scripts-dir=./scripts] [--max-run-concurrency=N]'
Routes:
GET /health
GET /type
GET /u/{subtoken_name}@{subtoken}/type (subtoken path auth)
Authorization:
Authorization: Bearer <token>
or X-Host-Token: <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)
PUT /subtokens/{name} (root only, body: script names list)
DELETE /subtokens/{name} (root only)
""".trimIndent(),
)
}
private fun List<String>.optionValue(prefix: String): String? =
firstOrNull { it.startsWith(prefix) }?.substringAfter("=")
fun main(args: Array<String>) {
val cli = args.toList()
if ("--help" in cli || "-h" in cli) {
usage()
return
}
val port = cli.optionValue("--port=")?.toIntOrNull() ?: DEFAULT_PORT
val host = cli.optionValue("--host=")?.ifBlank { DEFAULT_HOST } ?: DEFAULT_HOST
val scriptsDir = File(cli.optionValue("--scripts-dir=") ?: DEFAULT_SCRIPTS_DIR).absoluteFile
val maxRunConcurrency = cli.optionValue("--max-run-concurrency=")?.toIntOrNull() ?: DEFAULT_MAX_RUN_CONCURRENCY
require(maxRunConcurrency > 0) { "--max-run-concurrency must be > 0" }
if (!scriptsDir.exists()) scriptsDir.mkdirs()
val auth = loadOrCreateApiToken(scriptsDir)
val security = createHostSecurity(scriptsDir, auth.token)
val runConcurrencyLimiter = Semaphore(maxRunConcurrency)
println("Starting script web host on http://$host:$port")
println("Scripts directory: ${scriptsDir.absolutePath}")
println("Run concurrency limit: $maxRunConcurrency")
println("Auth token source: ${auth.source}")
when {
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) {
webModule(scriptsDir, security, runConcurrencyLimiter)
}.start(wait = true)
}

View File

@@ -0,0 +1,398 @@
package work.slhaf.hub
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.call
import io.ktor.server.request.httpMethod
import io.ktor.server.request.path
import io.ktor.server.request.receiveText
import io.ktor.server.response.respondText
import io.ktor.server.routing.Routing
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.put
import io.ktor.server.routing.routing
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import org.slf4j.LoggerFactory
import java.io.File
private val requestLogger = LoggerFactory.getLogger("work.slhaf.hub.RequestAudit")
private val subTokenPathRegex = Regex("^/u/([^/]+)/")
private fun sanitizeRequestPath(path: String): String {
val match = subTokenPathRegex.find(path) ?: return path
val credential = match.groupValues[1]
val at = credential.indexOf('@')
if (at <= 0 || at == credential.lastIndex) return path
val nameOnly = credential.substring(0, at)
return path.replaceFirst("/u/$credential/", "/u/$nameOnly@***/")
}
private suspend inline fun withRequestAudit(
call: ApplicationCall,
endpoint: String,
authProvider: () -> AuthContext? = { null },
crossinline block: suspend () -> Unit,
) {
val startNs = System.nanoTime()
var thrown: Throwable? = null
try {
block()
} catch (t: Throwable) {
thrown = t
throw t
} finally {
val durationMs = (System.nanoTime() - startNs) / 1_000_000
val auth = authProvider()
val tokenType = auth?.type?.name?.lowercase() ?: "none"
val subToken = auth?.subTokenName ?: "-"
val script = call.parameters["script"] ?: "-"
val sanitizedPath = sanitizeRequestPath(call.request.path())
val status = call.response.status()?.value ?: if (thrown == null) 200 else 500
if (thrown == null) {
requestLogger.info(
"endpoint={} method={} path={} status={} durationMs={} tokenType={} subToken={} script={}",
endpoint,
call.request.httpMethod.value,
sanitizedPath,
status,
durationMs,
tokenType,
subToken,
script,
)
} else {
requestLogger.warn(
"endpoint={} method={} path={} status={} durationMs={} tokenType={} subToken={} script={} error={}",
endpoint,
call.request.httpMethod.value,
sanitizedPath,
status,
durationMs,
tokenType,
subToken,
script,
"${thrown::class.simpleName}: ${thrown.message}",
)
}
}
}
private suspend fun handleSubTokenCreate(call: 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: 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: 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: 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 suspend fun handleTypeForAuth(call: ApplicationCall, auth: AuthContext) {
call.respondText(tokenTypeJson(auth), contentType = ContentType.Application.Json)
}
private suspend fun handleScriptsForAuth(call: ApplicationCall, scriptsDir: File, auth: AuthContext) {
val allow = visibleScriptsFor(auth)
call.respondText(renderScriptList(scriptsDir, allow), ContentType.Text.Plain)
}
private suspend fun handleMetaForAuth(call: ApplicationCall, scriptsDir: File, auth: AuthContext) {
val name = call.parameters["script"]
?: return call.respondText("missing route name", status = HttpStatusCode.BadRequest)
if (!requireScriptAccess(call, auth, name)) return
val script = resolveScriptFile(scriptsDir, name)
?: return call.respondText("invalid script name", status = HttpStatusCode.BadRequest)
if (!script.exists()) {
return 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,
)
}
private suspend fun handleRunForAuth(
call: ApplicationCall,
scriptsDir: File,
auth: AuthContext,
runConcurrencyLimiter: Semaphore,
consumeBody: Boolean,
) {
val name = call.parameters["script"]
?: return call.respondText("missing route name", status = HttpStatusCode.BadRequest)
if (!requireScriptAccess(call, auth, name)) return
runConcurrencyLimiter.withPermit {
handleRunRequest(call, scriptsDir, consumeBody = consumeBody)
}
}
private fun Routing.registerHeaderAuthenticatedRoutes(
scriptsDir: File,
security: HostSecurity,
runConcurrencyLimiter: Semaphore,
) {
get("/type") {
var authForLog: AuthContext? = null
withRequestAudit(call, "type", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
handleTypeForAuth(call, auth)
}
}
get("/scripts") {
var authForLog: AuthContext? = null
withRequestAudit(call, "scripts.list", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
handleScriptsForAuth(call, scriptsDir, auth)
}
}
get("/meta/{script}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "meta.get", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
handleMetaForAuth(call, scriptsDir, auth)
}
}
get("/run/{script}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "run.get", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
handleRunForAuth(call, scriptsDir, auth, runConcurrencyLimiter, consumeBody = false)
}
}
post("/run/{script}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "run.post", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
handleRunForAuth(call, scriptsDir, auth, runConcurrencyLimiter, consumeBody = true)
}
}
}
private fun Routing.registerSubTokenPathRoutes(
scriptsDir: File,
security: HostSecurity,
runConcurrencyLimiter: Semaphore,
) {
get("/u/{subAuth}/type") {
var authForLog: AuthContext? = null
withRequestAudit(call, "u.type", { authForLog }) {
val auth = requireSubTokenPathAuth(call, security) ?: return@withRequestAudit
authForLog = auth
handleTypeForAuth(call, auth)
}
}
get("/u/{subAuth}/scripts") {
var authForLog: AuthContext? = null
withRequestAudit(call, "u.scripts.list", { authForLog }) {
val auth = requireSubTokenPathAuth(call, security) ?: return@withRequestAudit
authForLog = auth
handleScriptsForAuth(call, scriptsDir, auth)
}
}
get("/u/{subAuth}/meta/{script}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "u.meta.get", { authForLog }) {
val auth = requireSubTokenPathAuth(call, security) ?: return@withRequestAudit
authForLog = auth
handleMetaForAuth(call, scriptsDir, auth)
}
}
get("/u/{subAuth}/run/{script}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "u.run.get", { authForLog }) {
val auth = requireSubTokenPathAuth(call, security) ?: return@withRequestAudit
authForLog = auth
handleRunForAuth(call, scriptsDir, auth, runConcurrencyLimiter, consumeBody = false)
}
}
post("/u/{subAuth}/run/{script}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "u.run.post", { authForLog }) {
val auth = requireSubTokenPathAuth(call, security) ?: return@withRequestAudit
authForLog = auth
handleRunForAuth(call, scriptsDir, auth, runConcurrencyLimiter, consumeBody = true)
}
}
}
fun Application.webModule(scriptsDir: File, security: HostSecurity, runConcurrencyLimiter: Semaphore) {
routing {
get("/health") {
withRequestAudit(call, "health") {
call.respondText("OK")
}
}
registerHeaderAuthenticatedRoutes(scriptsDir, security, runConcurrencyLimiter)
registerSubTokenPathRoutes(scriptsDir, security, runConcurrencyLimiter)
get("/scripts/{script}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "scripts.get", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
if (!requireRoot(call, auth)) return@withRequestAudit
handleGetScriptContent(call, scriptsDir)
}
}
post("/scripts/{script}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "scripts.create", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
if (!requireRoot(call, auth)) return@withRequestAudit
handleCreateScript(call, scriptsDir)
}
}
put("/scripts/{script}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "scripts.update", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
if (!requireRoot(call, auth)) return@withRequestAudit
handleUpdateScript(call, scriptsDir)
}
}
delete("/scripts/{script}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "scripts.delete", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
if (!requireRoot(call, auth)) return@withRequestAudit
handleDeleteScript(call, scriptsDir)
}
}
get("/subtokens") {
var authForLog: AuthContext? = null
withRequestAudit(call, "subtokens.list", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
if (!requireRoot(call, auth)) return@withRequestAudit
call.respondText(subTokenListJson(security.subTokens.list()), contentType = ContentType.Application.Json)
}
}
get("/subtokens/{name}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "subtokens.get", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
if (!requireRoot(call, auth)) return@withRequestAudit
handleSubTokenGet(call, security)
}
}
post("/subtokens/{name}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "subtokens.create", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
if (!requireRoot(call, auth)) return@withRequestAudit
handleSubTokenCreate(call, security)
}
}
put("/subtokens/{name}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "subtokens.update", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
if (!requireRoot(call, auth)) return@withRequestAudit
handleSubTokenUpdate(call, security)
}
}
delete("/subtokens/{name}") {
var authForLog: AuthContext? = null
withRequestAudit(call, "subtokens.delete", { authForLog }) {
val auth = requireAuth(call, security) ?: return@withRequestAudit
authForLog = auth
if (!requireRoot(call, auth)) return@withRequestAudit
handleSubTokenDelete(call, security)
}
}
}
}

View File

@@ -59,6 +59,7 @@ private fun metadataValidationMessage(errors: List<String>): String =
appendLine("examples:")
appendLine("// @desc: Demo greeting API")
appendLine("// @timeout: 10s")
appendLine("// @response: text")
appendLine("// @param: name | required=false | default=world | desc=Name to greet")
}.trim()
@@ -92,12 +93,20 @@ private fun runFailureMessage(result: ScriptExecutionResult): String {
fun metadataJson(scriptName: String, metadata: ScriptMetadata, source: String): String {
val description = metadata.description?.let { "\"${it.jsonEscaped()}\"" } ?: "null"
val responseType = metadata.responseType.name.lowercase()
val params = metadata.params.joinToString(",") { param ->
val defaultValue = param.defaultValue?.let { "\"${it.jsonEscaped()}\"" } ?: "null"
val desc = param.description?.let { "\"${it.jsonEscaped()}\"" } ?: "null"
"""{"name":"${param.name.jsonEscaped()}","required":${param.required},"defaultValue":$defaultValue,"description":$desc}"""
}
return """{"script":"${scriptName.jsonEscaped()}","source":"${source.jsonEscaped()}","description":$description,"timeoutMs":${metadata.timeoutMs},"params":[$params]}"""
return """{"script":"${scriptName.jsonEscaped()}","source":"${source.jsonEscaped()}","description":$description,"timeoutMs":${metadata.timeoutMs},"responseType":"$responseType","params":[$params]}"""
}
private fun contentTypeFor(metadata: ScriptMetadata): ContentType =
when (metadata.responseType) {
ScriptResponseType.TEXT -> ContentType.Text.Plain
ScriptResponseType.JSON -> ContentType.Application.Json
ScriptResponseType.HTML -> ContentType.Text.Html
}
fun loadMetadata(script: File): Pair<ScriptMetadata, String> {
@@ -133,7 +142,7 @@ suspend fun handleCreateScript(call: ApplicationCall, scriptsDir: File) {
script.writeText(content)
removeCachedMetadata(script)
val result = evalAndCapture(script, ScriptRequestContext(), enforceRequiredParams = false)
val result = validateCompilationAndCapture(script)
if (!result.ok) {
script.delete()
removeCachedMetadata(script)
@@ -197,7 +206,7 @@ suspend fun handleUpdateScript(call: ApplicationCall, scriptsDir: File) {
script.writeText(newContent)
removeCachedMetadata(script)
val result = evalAndCapture(script, ScriptRequestContext(), enforceRequiredParams = false)
val result = validateCompilationAndCapture(script)
if (!result.ok) {
script.writeText(previousContent)
removeCachedMetadata(script)
@@ -255,7 +264,7 @@ suspend fun handleRunRequest(call: ApplicationCall, scriptsDir: File, consumeBod
runFailureMessage(result)
},
status = status,
contentType = ContentType.Text.Plain
contentType = if (result.ok) contentTypeFor(metadata) else ContentType.Text.Plain
)
}

View File

@@ -8,7 +8,7 @@ import io.ktor.server.response.respondText
import java.io.File
import java.security.SecureRandom
private const val ENV_API_TOKEN = "HOST_API_TOKEN"
private const val ENV_API_TOKEN = "SLHAF_HUB_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"

View File

@@ -1,295 +0,0 @@
package work.slhaf.hub
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.put
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.server.testing.testApplication
import kotlinx.coroutines.sync.Semaphore
import kotlin.io.path.createTempDirectory
import kotlin.io.path.writeText
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class WebHostApiTest {
private val tempDirs = mutableListOf<java.nio.file.Path>()
@AfterTest
fun cleanup() {
tempDirs.forEach { path ->
runCatching { path.toFile().deleteRecursively() }
}
tempDirs.clear()
}
@Test
fun healthAndUnauthorized() = withApp { _ ->
val health = client.get("/health")
assertEquals(HttpStatusCode.OK, health.status)
assertEquals("OK", health.bodyAsText())
val scripts = client.get("/scripts")
assertEquals(HttpStatusCode.Unauthorized, scripts.status)
assertTrue(scripts.bodyAsText().contains("unauthorized"))
}
@Test
fun scriptCrudMetaRunAndValidation() = withApp { scriptsDir ->
val create = client.post("/scripts/demo") {
bearerRoot()
setBody(
"""
// @desc: demo api
// @timeout: 10s
// @param: name | required=false | default=world | desc=Name to greet
val args: Array<String> = emptyArray()
val kv = args.mapNotNull {
val i = it.indexOf('=')
if (i <= 0) null else it.substring(0, i) to it.substring(i + 1)
}.toMap()
println("hi " + (kv["name"] ?: "world"))
""".trimIndent()
)
}
assertEquals(HttpStatusCode.Created, create.status)
val list = client.get("/scripts") { bearerRoot() }
assertEquals(HttpStatusCode.OK, list.status)
assertTrue(list.bodyAsText().contains("demo"))
val source = client.get("/scripts/demo") { bearerRoot() }
assertEquals(HttpStatusCode.OK, source.status)
assertTrue(source.bodyAsText().contains("@desc: demo api"))
val meta = client.get("/meta/demo") { bearerRoot() }
assertEquals(HttpStatusCode.OK, meta.status)
val metaText = meta.bodyAsText()
assertTrue(metaText.contains("\"script\":\"demo\""))
assertTrue(metaText.contains("\"timeoutMs\":10000"))
val run = client.get("/run/demo?name=Alice") { bearerRoot() }
assertEquals(HttpStatusCode.OK, run.status)
assertTrue(run.bodyAsText().contains("hi Alice"))
val invalidUpdate = client.put("/scripts/demo") {
bearerRoot()
setBody(
"""
// @desc: bad metadata
// @param: user name | required=maybe | xxx=1
val args: Array<String> = emptyArray()
println("bad")
""".trimIndent()
)
}
assertEquals(HttpStatusCode.BadRequest, invalidUpdate.status)
val invalidText = invalidUpdate.bodyAsText()
assertTrue(invalidText.contains("metadata validation failed"))
assertTrue(invalidText.contains("examples:"))
assertTrue(invalidText.contains("@param:"))
val remove = client.delete("/scripts/demo") { bearerRoot() }
assertEquals(HttpStatusCode.OK, remove.status)
assertFalse((scriptsDir.resolve("demo.hub.kts")).toFile().exists())
}
@Test
fun subTokenAccessControlAndFiltering() = withApp { scriptsDir ->
scriptsDir.resolve("allowed.hub.kts").writeText(
"""
// @desc: allowed script
val args: Array<String> = emptyArray()
println("allowed")
""".trimIndent()
)
scriptsDir.resolve("blocked.hub.kts").writeText(
"""
// @desc: blocked script
val args: Array<String> = emptyArray()
println("blocked")
""".trimIndent()
)
val createSub = client.post("/subtokens/demo-sub") {
bearerRoot()
setBody("allowed")
}
assertEquals(HttpStatusCode.Created, createSub.status)
val token = extractJsonField(createSub.bodyAsText(), "token")
assertNotNull(token)
val type = client.get("/type") { bearer(token) }
assertEquals(HttpStatusCode.OK, type.status)
val typeText = type.bodyAsText()
assertTrue(typeText.contains("\"tokenType\":\"sub\""))
assertTrue(typeText.contains("\"subTokenName\":\"demo-sub\""))
val scripts = client.get("/scripts") { bearer(token) }
assertEquals(HttpStatusCode.OK, scripts.status)
val scriptList = scripts.bodyAsText()
assertTrue(scriptList.contains("allowed"))
assertFalse(scriptList.contains("blocked"))
val metaAllowed = client.get("/meta/allowed") { bearer(token) }
assertEquals(HttpStatusCode.OK, metaAllowed.status)
val metaBlocked = client.get("/meta/blocked") { bearer(token) }
assertEquals(HttpStatusCode.Forbidden, metaBlocked.status)
val runAllowed = client.get("/run/allowed") { bearer(token) }
assertEquals(HttpStatusCode.OK, runAllowed.status)
val runBlocked = client.get("/run/blocked") { bearer(token) }
assertEquals(HttpStatusCode.Forbidden, runBlocked.status)
val createScript = client.post("/scripts/not-allowed") {
bearer(token)
setBody("val args: Array<String> = emptyArray()\nprintln(\"x\")")
}
assertEquals(HttpStatusCode.Forbidden, createScript.status)
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
fun runTimeoutReturnsRequestTimeout() = withApp { _ ->
val create = client.post("/scripts/slow") {
bearerRoot()
setBody(
"""
// @desc: slow script
// @timeout: 1ms
val args: Array<String> = emptyArray()
Thread.sleep(100)
println("done")
""".trimIndent()
)
}
assertEquals(HttpStatusCode.Created, create.status)
val run = client.get("/run/slow") { bearerRoot() }
assertEquals(HttpStatusCode.RequestTimeout, run.status)
assertTrue(run.bodyAsText().contains("timed out"))
}
@Test
fun metadataRequiresExplicitRequiredField() = withApp { _ ->
val create = client.post("/scripts/badmeta") {
bearerRoot()
setBody(
"""
// @desc: bad metadata
// @param: name | default=world | desc=missing required
val args: Array<String> = emptyArray()
println("ok")
""".trimIndent()
)
}
assertEquals(HttpStatusCode.BadRequest, create.status)
val body = create.bodyAsText()
assertTrue(body.contains("metadata validation failed"))
assertTrue(body.contains("missing required option"))
}
@Test
fun runErrorResponseIncludesParamsAndRequiredCheck() = withApp { _ ->
val create = client.post("/scripts/runner") {
bearerRoot()
setBody(
"""
// @desc: run test
// @param: must | required=true | default=world | desc=Must be provided explicitly
// @param: boom | required=false | default=false | desc=Trigger runtime failure
val args: Array<String> = emptyArray()
val kv = args.mapNotNull {
val i = it.indexOf('=')
if (i <= 0) null else it.substring(0, i) to it.substring(i + 1)
}.toMap()
if ((kv["boom"] ?: "false").equals("true", ignoreCase = true)) {
error("boom")
}
println("must=" + (kv["must"] ?: "none"))
""".trimIndent()
)
}
assertEquals(HttpStatusCode.Created, create.status)
val missingRequired = client.get("/run/runner") { bearerRoot() }
assertEquals(HttpStatusCode.BadRequest, missingRequired.status)
val missingBody = missingRequired.bodyAsText()
assertTrue(missingBody.contains("missing required params: must"))
assertTrue(missingBody.contains("params:"))
assertTrue(missingBody.contains("must (required=true"))
val runtimeError = client.get("/run/runner?must=ok&boom=true") { bearerRoot() }
assertEquals(HttpStatusCode.InternalServerError, runtimeError.status)
val runtimeErrorBody = runtimeError.bodyAsText()
assertTrue(runtimeErrorBody.contains("script execution failed"))
assertTrue(runtimeErrorBody.contains("params:"))
assertTrue(runtimeErrorBody.contains("boom (required=false"))
}
private fun withApp(testBlock: suspend io.ktor.server.testing.ApplicationTestBuilder.(java.nio.file.Path) -> Unit) {
val scriptsDir = createTempDirectory("webhost-api-test-")
tempDirs.add(scriptsDir)
testApplication {
val security = createHostSecurity(scriptsDir.toFile(), ROOT_TOKEN)
application {
webModule(scriptsDir.toFile(), security, Semaphore(4))
}
testBlock(scriptsDir)
}
}
private fun io.ktor.client.request.HttpRequestBuilder.bearer(token: String) {
headers.append(HttpHeaders.Authorization, "Bearer $token")
}
private fun io.ktor.client.request.HttpRequestBuilder.bearerRoot() {
bearer(ROOT_TOKEN)
}
private fun extractJsonField(json: String, field: String): String? {
val regex = Regex("\"" + Regex.escape(field) + "\":\"([^\"]*)\"")
return regex.find(json)?.groupValues?.getOrNull(1)
}
companion object {
private const val ROOT_TOKEN = "root-test-token"
}
}

View File

@@ -0,0 +1,152 @@
package work.slhaf.hub
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.put
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class WebAuthAndScriptApiTest : WebHostTestSupport() {
@Test
fun healthAndUnauthorized() = withApp { _ ->
val health = client.get("/health")
assertEquals(HttpStatusCode.OK, health.status)
assertEquals("OK", health.bodyAsText())
val scripts = client.get("/scripts")
assertEquals(HttpStatusCode.Unauthorized, scripts.status)
assertTrue(scripts.bodyAsText().contains("unauthorized"))
}
@Test
fun scriptCrudMetaRunAndValidation() = withApp { scriptsDir ->
val create = client.post("/scripts/demo") {
bearerRoot()
setBody(
"""
// @desc: demo api
// @timeout: 10s
// @param: name | required=false | default=world | desc=Name to greet
val args: Array<String> = emptyArray()
val kv = args.mapNotNull {
val i = it.indexOf('=')
if (i <= 0) null else it.substring(0, i) to it.substring(i + 1)
}.toMap()
println("hi " + (kv["name"] ?: "world"))
""".trimIndent()
)
}
assertEquals(HttpStatusCode.Created, create.status)
val list = client.get("/scripts") { bearerRoot() }
assertEquals(HttpStatusCode.OK, list.status)
assertTrue(list.bodyAsText().contains("demo"))
val source = client.get("/scripts/demo") { bearerRoot() }
assertEquals(HttpStatusCode.OK, source.status)
assertTrue(source.bodyAsText().contains("@desc: demo api"))
val meta = client.get("/meta/demo") { bearerRoot() }
assertEquals(HttpStatusCode.OK, meta.status)
val metaText = meta.bodyAsText()
assertTrue(metaText.contains("\"script\":\"demo\""))
assertTrue(metaText.contains("\"timeoutMs\":10000"))
assertTrue(metaText.contains("\"responseType\":\"text\""))
val run = client.get("/run/demo?name=Alice") { bearerRoot() }
assertEquals(HttpStatusCode.OK, run.status)
assertTrue(run.bodyAsText().contains("hi Alice"))
val invalidUpdate = client.put("/scripts/demo") {
bearerRoot()
setBody(
"""
// @desc: bad metadata
// @param: user name | required=maybe | xxx=1
val args: Array<String> = emptyArray()
println("bad")
""".trimIndent()
)
}
assertEquals(HttpStatusCode.BadRequest, invalidUpdate.status)
val invalidText = invalidUpdate.bodyAsText()
assertTrue(invalidText.contains("metadata validation failed"))
assertTrue(invalidText.contains("examples:"))
assertTrue(invalidText.contains("@param:"))
val remove = client.delete("/scripts/demo") { bearerRoot() }
assertEquals(HttpStatusCode.OK, remove.status)
assertFalse((scriptsDir.resolve("demo.hub.kts")).toFile().exists())
}
@Test
fun metadataRequiresExplicitRequiredField() = withApp { _ ->
val create = client.post("/scripts/badmeta") {
bearerRoot()
setBody(
"""
// @desc: bad metadata
// @param: name | default=world | desc=missing required
val args: Array<String> = emptyArray()
println("ok")
""".trimIndent()
)
}
assertEquals(HttpStatusCode.BadRequest, create.status)
val body = create.bodyAsText()
assertTrue(body.contains("metadata validation failed"))
assertTrue(body.contains("missing required option"))
}
@Test
fun metadataRejectsUnsupportedResponseType() = withApp { _ ->
val create = client.post("/scripts/bad-response") {
bearerRoot()
setBody(
"""
// @desc: bad response
// @response: xml
// @param: name | required=false | default=world | desc=name
val args: Array<String> = emptyArray()
println("ok")
""".trimIndent()
)
}
assertEquals(HttpStatusCode.BadRequest, create.status)
val body = create.bodyAsText()
assertTrue(body.contains("metadata validation failed"))
assertTrue(body.contains("invalid @response"))
assertTrue(body.contains("text, json, html"))
}
@Test
fun createWithRequiredParamsValidatesByCompilationOnly() = withApp { _ ->
val create = client.post("/scripts/required-param-script") {
bearerRoot()
setBody(
"""
// @desc: required param demo
// @param: owner | required=true | desc=Repository owner
lateinit var args: Array<String>
val owner = args.mapNotNull {
val i = it.indexOf('=')
if (i <= 0) null else it.substring(0, i) to it.substring(i + 1)
}.toMap()["owner"] ?: error("missing required param: owner")
println(owner)
""".trimIndent()
)
}
assertEquals(HttpStatusCode.Created, create.status)
val runMissing = client.get("/run/required-param-script") { bearerRoot() }
assertEquals(HttpStatusCode.BadRequest, runMissing.status)
assertTrue(runMissing.bodyAsText().contains("missing required params: owner"))
}
}

View File

@@ -0,0 +1,51 @@
package work.slhaf.hub
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.http.HttpHeaders
import io.ktor.server.testing.ApplicationTestBuilder
import io.ktor.server.testing.testApplication
import kotlinx.coroutines.sync.Semaphore
import kotlin.io.path.createTempDirectory
import kotlin.test.AfterTest
abstract class WebHostTestSupport {
private val tempDirs = mutableListOf<java.nio.file.Path>()
@AfterTest
fun cleanup() {
tempDirs.forEach { path ->
runCatching { path.toFile().deleteRecursively() }
}
tempDirs.clear()
}
protected fun withApp(testBlock: suspend ApplicationTestBuilder.(java.nio.file.Path) -> Unit) {
val scriptsDir = createTempDirectory("webhost-api-test-")
tempDirs.add(scriptsDir)
testApplication {
val security = createHostSecurity(scriptsDir.toFile(), ROOT_TOKEN)
application {
webModule(scriptsDir.toFile(), security, Semaphore(4))
}
testBlock(scriptsDir)
}
}
protected fun HttpRequestBuilder.bearer(token: String) {
headers.append(HttpHeaders.Authorization, "Bearer $token")
}
protected fun HttpRequestBuilder.bearerRoot() {
bearer(ROOT_TOKEN)
}
protected fun extractJsonField(json: String, field: String): String? {
val regex = Regex("\\\"" + Regex.escape(field) + "\\\":\\\"([^\\\"]*)\\\"")
return regex.find(json)?.groupValues?.getOrNull(1)
}
companion object {
private const val ROOT_TOKEN = "root-test-token"
}
}

View File

@@ -0,0 +1,163 @@
package work.slhaf.hub
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import io.ktor.http.HttpHeaders
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class WebRunApiTest : WebHostTestSupport() {
@Test
fun runTimeoutReturnsRequestTimeout() = withApp { _ ->
val create = client.post("/scripts/slow") {
bearerRoot()
setBody(
"""
// @desc: slow script
// @timeout: 1ms
val args: Array<String> = emptyArray()
Thread.sleep(100)
println("done")
""".trimIndent()
)
}
assertEquals(HttpStatusCode.Created, create.status)
val run = client.get("/run/slow") { bearerRoot() }
assertEquals(HttpStatusCode.RequestTimeout, run.status)
assertTrue(run.bodyAsText().contains("timed out"))
}
@Test
fun runErrorResponseIncludesParamsAndRequiredCheck() = withApp { _ ->
val create = client.post("/scripts/runner") {
bearerRoot()
setBody(
"""
// @desc: run test
// @param: must | required=true | default=world | desc=Must be provided explicitly
// @param: boom | required=false | default=false | desc=Trigger runtime failure
val args: Array<String> = emptyArray()
val kv = args.mapNotNull {
val i = it.indexOf('=')
if (i <= 0) null else it.substring(0, i) to it.substring(i + 1)
}.toMap()
if ((kv["boom"] ?: "false").equals("true", ignoreCase = true)) {
error("boom")
}
println("must=" + (kv["must"] ?: "none"))
""".trimIndent()
)
}
assertEquals(HttpStatusCode.Created, create.status)
val missingRequired = client.get("/run/runner") { bearerRoot() }
assertEquals(HttpStatusCode.BadRequest, missingRequired.status)
val missingBody = missingRequired.bodyAsText()
assertTrue(missingBody.contains("missing required params: must"))
assertTrue(missingBody.contains("params:"))
assertTrue(missingBody.contains("must (required=true"))
val runtimeError = client.get("/run/runner?must=ok&boom=true") { bearerRoot() }
assertEquals(HttpStatusCode.InternalServerError, runtimeError.status)
val runtimeErrorBody = runtimeError.bodyAsText()
assertTrue(runtimeErrorBody.contains("script execution failed"))
assertTrue(runtimeErrorBody.contains("params:"))
assertTrue(runtimeErrorBody.contains("boom (required=false"))
}
@Test
fun defaultParamValueIsInjectedIntoHostArgs() = withApp { _ ->
val create = client.post("/scripts/defaults") {
bearerRoot()
setBody(
"""
// @desc: default args test
// @param: name | required=false | default=world | desc=Name fallback
val args: Array<String> = emptyArray()
val kv = args.mapNotNull {
val i = it.indexOf('=')
if (i <= 0) null else it.substring(0, i) to it.substring(i + 1)
}.toMap()
println("name=" + (kv["name"] ?: "missing"))
""".trimIndent()
)
}
assertEquals(HttpStatusCode.Created, create.status)
val runWithoutArg = client.get("/run/defaults") { bearerRoot() }
assertEquals(HttpStatusCode.OK, runWithoutArg.status)
assertTrue(runWithoutArg.bodyAsText().contains("name=world"))
val runWithArg = client.get("/run/defaults?name=alice") { bearerRoot() }
assertEquals(HttpStatusCode.OK, runWithArg.status)
assertTrue(runWithArg.bodyAsText().contains("name=alice"))
}
@Test
fun lateinitArgsDeclarationIsSupported() = withApp { _ ->
val create = client.post("/scripts/lateinit-args") {
bearerRoot()
setBody(
"""
// @desc: lateinit args
// @param: name | required=false | default=world | desc=Name fallback
lateinit var args: Array<String>
val kv = args.mapNotNull {
val i = it.indexOf('=')
if (i <= 0) null else it.substring(0, i) to it.substring(i + 1)
}.toMap()
println("name=" + (kv["name"] ?: "missing"))
""".trimIndent()
)
}
assertEquals(HttpStatusCode.Created, create.status)
val run = client.get("/run/lateinit-args") { bearerRoot() }
assertEquals(HttpStatusCode.OK, run.status)
assertTrue(run.bodyAsText().contains("name=world"))
}
@Test
fun runSuccessContentTypeCanBeJsonOrHtml() = withApp { _ ->
val createJson = client.post("/scripts/json-out") {
bearerRoot()
setBody(
"""
// @desc: json output
// @response: json
val args: Array<String> = emptyArray()
println("{\"ok\":true}")
""".trimIndent()
)
}
assertEquals(HttpStatusCode.Created, createJson.status)
val runJson = client.get("/run/json-out") { bearerRoot() }
assertEquals(HttpStatusCode.OK, runJson.status)
assertTrue((runJson.headers[HttpHeaders.ContentType] ?: "").startsWith("application/json"))
assertTrue(runJson.bodyAsText().contains("\"ok\":true"))
val createHtml = client.post("/scripts/html-out") {
bearerRoot()
setBody(
"""
// @desc: html output
// @response: html
val args: Array<String> = emptyArray()
println("<h1>ok</h1>")
""".trimIndent()
)
}
assertEquals(HttpStatusCode.Created, createHtml.status)
val runHtml = client.get("/run/html-out") { bearerRoot() }
assertEquals(HttpStatusCode.OK, runHtml.status)
assertTrue((runHtml.headers[HttpHeaders.ContentType] ?: "").startsWith("text/html"))
assertTrue(runHtml.bodyAsText().contains("<h1>ok</h1>"))
}
}

View File

@@ -0,0 +1,98 @@
package work.slhaf.hub
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import kotlin.io.path.writeText
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class WebSubTokenApiTest : WebHostTestSupport() {
@Test
fun subTokenAccessControlAndFiltering() = withApp { scriptsDir ->
scriptsDir.resolve("allowed.hub.kts").writeText(
"""
// @desc: allowed script
val args: Array<String> = emptyArray()
println("allowed")
""".trimIndent()
)
scriptsDir.resolve("blocked.hub.kts").writeText(
"""
// @desc: blocked script
val args: Array<String> = emptyArray()
println("blocked")
""".trimIndent()
)
val createSub = client.post("/subtokens/demo-sub") {
bearerRoot()
setBody("allowed")
}
assertEquals(HttpStatusCode.Created, createSub.status)
val token = extractJsonField(createSub.bodyAsText(), "token")
assertNotNull(token)
val type = client.get("/type") { bearer(token) }
assertEquals(HttpStatusCode.OK, type.status)
val typeText = type.bodyAsText()
assertTrue(typeText.contains("\"tokenType\":\"sub\""))
assertTrue(typeText.contains("\"subTokenName\":\"demo-sub\""))
val scripts = client.get("/scripts") { bearer(token) }
assertEquals(HttpStatusCode.OK, scripts.status)
val scriptList = scripts.bodyAsText()
assertTrue(scriptList.contains("allowed"))
assertFalse(scriptList.contains("blocked"))
val metaAllowed = client.get("/meta/allowed") { bearer(token) }
assertEquals(HttpStatusCode.OK, metaAllowed.status)
val metaBlocked = client.get("/meta/blocked") { bearer(token) }
assertEquals(HttpStatusCode.Forbidden, metaBlocked.status)
val runAllowed = client.get("/run/allowed") { bearer(token) }
assertEquals(HttpStatusCode.OK, runAllowed.status)
val runBlocked = client.get("/run/blocked") { bearer(token) }
assertEquals(HttpStatusCode.Forbidden, runBlocked.status)
val createScript = client.post("/scripts/not-allowed") {
bearer(token)
setBody("val args: Array<String> = emptyArray()\nprintln(\"x\")")
}
assertEquals(HttpStatusCode.Forbidden, createScript.status)
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)
}
}

View File

@@ -9,8 +9,8 @@ import java.net.http.HttpResponse
import java.nio.charset.StandardCharsets
import kotlin.system.exitProcess
val ENV_API_BASE_URL = "HOST_API_BASE_URL"
val ENV_API_TOKEN = "HOST_API_TOKEN"
val ENV_API_BASE_URL = "SLHAF_HUB_BASE_URL"
val ENV_API_TOKEN = "SLHAF_HUB_TOKEN"
data class GlobalOptions(
val baseUrl: String,
@@ -30,12 +30,13 @@ Usage:
kotlin slhaf-hub-cli.kts [global options] <command> [command options]
Global options:
--base-url=<url> Default: HOST_API_BASE_URL or http://127.0.0.1:8080
--base-url=<url> Default: SLHAF_HUB_BASE_URL or http://127.0.0.1:8080
--token=<token> Authorization token
--token-file=<path> Load token from file (fallback: HOST_API_TOKEN env)
--token-file=<path> Load token from file (fallback: SLHAF_HUB_TOKEN env)
Commands:
health
template <script>
type
list
show <script>
@@ -52,6 +53,7 @@ Commands:
sub-delete <name>
Examples:
kotlin slhaf-hub-cli.kts template hello
kotlin slhaf-hub-cli.kts --token-file=./scripts/.host-api-token type
kotlin slhaf-hub-cli.kts --token-file=./scripts/.host-api-token sub-list
kotlin slhaf-hub-cli.kts --token-file=./scripts/.host-api-token sub-create demo --scripts=hello,time
@@ -159,6 +161,23 @@ fun parseScriptsArg(args: List<String>): Set<String> {
return items
}
fun initialScriptTemplate(name: String): String =
"""
// @desc: $name
// @timeout: 10s
// @response: text
// @param: sample | required=false | default=value | desc=example parameter
lateinit var args: Array<String>
val kv = args.mapNotNull {
val i = it.indexOf('=')
if (i <= 0) null else it.substring(0, i) to it.substring(i + 1)
}.toMap()
println("script=$name")
println("sample=" + (kv["sample"] ?: "value"))
""".trimIndent()
fun request(
client: HttpClient,
baseUrl: String,
@@ -198,6 +217,10 @@ fun main(args: Array<String>) {
val (status, body) =
when (input.command) {
"health" -> request(client, base, null, "GET", "/health")
"template" -> {
val script = requireScriptName(input.commandArgs)
200 to initialScriptTemplate(script)
}
"type" -> request(client, base, token, "GET", "/type")
"list" -> request(client, base, token, "GET", "/scripts")
"show" -> {

View File

@@ -54,8 +54,8 @@ private val YELLOW = "\u001b[33m"
private val RED = "\u001b[31m"
private val BG_BLUE = "\u001b[44m"
private val FG_BLACK = "\u001b[30m"
val ENV_API_BASE_URL = "HOST_API_BASE_URL"
val ENV_API_TOKEN = "HOST_API_TOKEN"
val ENV_API_BASE_URL = "SLHAF_HUB_BASE_URL"
val ENV_API_TOKEN = "SLHAF_HUB_TOKEN"
private fun ok(text: String) = "$GREEN$text$RESET"
private fun warn(text: String) = "$YELLOW$text$RESET"
@@ -81,8 +81,8 @@ Keys:
q Quit
Env fallback:
HOST_API_BASE_URL
HOST_API_TOKEN
SLHAF_HUB_BASE_URL
SLHAF_HUB_TOKEN
""".trimIndent()
fun parseOptions(args: List<String>): Options {
@@ -113,7 +113,7 @@ fun readToken(options: Options): String {
return file.readText().trim()
}
return System.getenv(ENV_API_TOKEN)?.trim()
?: error("Missing token. Use --token or --token-file or HOST_API_TOKEN")
?: error("Missing token. Use --token or --token-file or SLHAF_HUB_TOKEN")
}
fun encode(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8)
@@ -476,9 +476,10 @@ fun initialScriptTemplate(name: String): String =
"""
// @desc: $name
// @timeout: 10s
// @response: text
// @param: sample | required=false | default=value | desc=example parameter
val args: Array<String> = emptyArray()
lateinit var args: Array<String>
val kv = args.mapNotNull {
val i = it.indexOf('=')
if (i <= 0) null else it.substring(0, i) to it.substring(i + 1)