chore: initialize slhaf hub project

This commit is contained in:
2026-02-24 17:57:03 +08:00
commit 50cdd8e126
17 changed files with 2226 additions and 0 deletions

View File

@@ -0,0 +1,134 @@
package work.slhaf.hub
import java.io.File
import java.nio.file.FileSystems
import java.nio.file.Path
import java.nio.file.StandardWatchEventKinds
private fun usage() {
println(
"""
Usage:
./gradlew runCli --args='<script.hub.kts> [--arg=key=value ...] [--body=text] [--watch] [--debounce-ms=300]'
Examples:
./gradlew runCli --args='scripts/hello.hub.kts'
./gradlew runCli --args='scripts/hello.hub.kts --arg=name=Codex'
./gradlew runCli --args='scripts/hello.hub.kts --watch --debounce-ms=200'
""".trimIndent()
)
}
private fun parseDebounce(cliArgs: List<String>): Long {
val token = cliArgs.firstOrNull { it.startsWith("--debounce-ms=") } ?: return 300L
return token.substringAfter("=").toLongOrNull()?.coerceAtLeast(50L) ?: 300L
}
private fun parseScriptArgs(cliArgs: List<String>): List<String> =
cliArgs.asSequence()
.filter { it.startsWith("--arg=") }
.map {
val token = it.substringAfter("--arg=")
token
}
.toList()
private fun parseBody(cliArgs: List<String>): String? =
cliArgs.firstOrNull { it.startsWith("--body=") }?.substringAfter("=")
private fun runOnce(scriptFile: File, requestContext: ScriptRequestContext) {
println("\\n=== Evaluating ${scriptFile.absolutePath} @ ${java.time.LocalTime.now()} ===")
val result = evalAndCapture(scriptFile, requestContext)
if (result.output.isNotBlank()) {
println(result.output)
}
if (result.metadata.description != null) {
println("[META] description: ${result.metadata.description}")
}
if (result.metadata.params.isNotEmpty()) {
println(
"[META] params: " + result.metadata.params.joinToString(", ") { p ->
"${p.name}(required=${p.required}, default=${p.defaultValue ?: "null"})"
}
)
}
if (result.ok) {
println("[OK] Script evaluation finished")
} else {
println("[FAIL] Script evaluation failed")
}
}
fun main(args: Array<String>) {
val rawArgs = args.toList()
if (rawArgs.isEmpty() || rawArgs.contains("--help") || rawArgs.contains("-h")) {
usage()
kotlin.system.exitProcess(if (rawArgs.isEmpty()) 1 else 0)
}
val scriptPath = rawArgs.firstOrNull { !it.startsWith("--") }
if (scriptPath == null) {
usage()
kotlin.system.exitProcess(1)
}
val scriptFile = File(scriptPath).absoluteFile
if (!scriptFile.exists()) {
println("Script file not found: ${scriptFile.absolutePath}")
kotlin.system.exitProcess(2)
}
val watch = rawArgs.contains("--watch")
val debounceMs = parseDebounce(rawArgs)
val requestContext = ScriptRequestContext(
args = parseScriptArgs(rawArgs),
body = parseBody(rawArgs)
)
runOnce(scriptFile, requestContext)
if (!watch) return
val watcher = FileSystems.getDefault().newWatchService()
val dir: Path = scriptFile.parentFile.toPath()
dir.register(
watcher,
StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_MODIFY,
StandardWatchEventKinds.ENTRY_DELETE
)
println("[WATCH] Watching ${scriptFile.absolutePath}, debounce=${debounceMs}ms")
println("[WATCH] Press Ctrl+C to stop")
var lastStamp = scriptFile.takeIf { it.exists() }?.let { "${it.length()}-${it.lastModified()}" } ?: "MISSING"
while (true) {
val key = watcher.take()
var shouldReload = false
for (event in key.pollEvents()) {
val changed = event.context()?.toString() ?: continue
if (changed == scriptFile.name) {
shouldReload = true
}
}
key.reset()
if (!shouldReload) continue
Thread.sleep(debounceMs)
val currentStamp = scriptFile.takeIf { it.exists() }?.let { "${it.length()}-${it.lastModified()}" } ?: "MISSING"
if (currentStamp == lastStamp) continue
lastStamp = currentStamp
if (!scriptFile.exists()) {
println("[WATCH] Script deleted: ${scriptFile.absolutePath}")
continue
}
runOnce(scriptFile, requestContext)
}
}

View File

@@ -0,0 +1,244 @@
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 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*$""")
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
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("@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)
}
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>
)
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 evalAndCapture(scriptFile: File, requestContext: ScriptRequestContext = ScriptRequestContext()): 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
} && p.defaultValue == null
}
.map { it.name }
val injected = injectArgsDeclaration(original, requestContext.args)
val result = evalSource(injected.toScriptSource(scriptFile.name))
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 (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 (diagnostics.isNotEmpty()) appendLine(diagnostics)
if (missingMessage.isNotEmpty()) appendLine(missingMessage)
}.trim()
ScriptExecutionResult(
ok = result is ResultWithDiagnostics.Success && missingRequired.isEmpty(),
output = finalText,
metadata = metadata,
missingRequiredParams = missingRequired
)
} finally {
ps.flush()
ps.close()
System.setOut(oldOut)
System.setErr(oldErr)
}
}
}

View File

@@ -0,0 +1,18 @@
package work.slhaf.hub
data class ScriptParamDefinition(
val name: String,
val required: Boolean = false,
val defaultValue: String? = null,
val description: String? = null
)
data class ScriptMetadata(
val description: String? = null,
val params: List<ScriptParamDefinition> = emptyList()
)
data class ScriptRequestContext(
val args: List<String> = emptyList(),
val body: String? = null
)

View File

@@ -0,0 +1,6 @@
package work.slhaf.hub
import kotlin.script.experimental.annotations.KotlinScript
@KotlinScript(fileExtension = "hub.kts")
abstract class SimpleScript

View File

@@ -0,0 +1,125 @@
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.call
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import io.ktor.server.response.respondText
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 java.io.File
private const val DEFAULT_PORT = 8080
private const val DEFAULT_SCRIPTS_DIR = "scripts"
private const val HOST = "127.0.0.1"
private fun Application.module(scriptsDir: File, apiToken: String) {
routing {
get("/health") {
call.respondText("OK")
}
get("/scripts") {
if (!requireAuth(call, apiToken)) return@get
call.respondText(renderScriptList(scriptsDir), ContentType.Text.Plain)
}
get("/scripts/{script}") {
if (!requireAuth(call, apiToken)) return@get
handleGetScriptContent(call, scriptsDir)
}
post("/scripts/{script}") {
if (!requireAuth(call, apiToken)) return@post
handleCreateScript(call, scriptsDir)
}
put("/scripts/{script}") {
if (!requireAuth(call, apiToken)) return@put
handleUpdateScript(call, scriptsDir)
}
delete("/scripts/{script}") {
if (!requireAuth(call, apiToken)) return@delete
handleDeleteScript(call, scriptsDir)
}
get("/meta/{script}") {
if (!requireAuth(call, apiToken)) return@get
val name = call.parameters["script"]
?: return@get call.respondText("missing route name", status = HttpStatusCode.BadRequest)
val script = resolveScriptFile(scriptsDir, name)
?: return@get call.respondText("invalid script name", status = HttpStatusCode.BadRequest)
if (!script.exists()) {
return@get call.respondText("script not found: ${script.name}", status = HttpStatusCode.NotFound)
}
val (metadata, source) = loadMetadata(script)
call.respondText(
metadataJson(name, metadata, source),
contentType = ContentType.Application.Json
)
}
get("/run/{script}") {
if (!requireAuth(call, apiToken)) return@get
handleRunRequest(call, scriptsDir, consumeBody = false)
}
post("/run/{script}") {
if (!requireAuth(call, apiToken)) return@post
handleRunRequest(call, scriptsDir, consumeBody = true)
}
}
}
private fun usage() {
println(
"""
Usage:
./gradlew runWeb --args='[--port=8080] [--scripts-dir=./scripts]'
Routes:
GET /health
Authorization:
Authorization: Bearer <token>
or X-Host-Token: <token>
GET /scripts
GET /scripts/{script}
POST /scripts/{script}
PUT /scripts/{script}
DELETE /scripts/{script}
GET /meta/{script}
GET /run/{script}?k=v
POST /run/{script}?k=v
""".trimIndent()
)
}
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 scriptsDir = File(cli.optionValue("--scripts-dir=") ?: DEFAULT_SCRIPTS_DIR).absoluteFile
if (!scriptsDir.exists()) scriptsDir.mkdirs()
val auth = loadOrCreateApiToken(scriptsDir)
println("Starting script web host on http://$HOST:$port")
println("Scripts directory: ${scriptsDir.absolutePath}")
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) {
module(scriptsDir, auth.token)
}.start(wait = true)
}

View File

@@ -0,0 +1,206 @@
package work.slhaf.hub
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
import io.ktor.server.request.receiveText
import io.ktor.server.response.respondText
import java.io.File
private const val SCRIPT_EXTENSION = ".hub.kts"
private val ROUTE_NAME_REGEX = Regex("[A-Za-z0-9._-]+")
fun resolveScriptFile(baseDir: File, routeName: String): File? {
if (!routeName.matches(ROUTE_NAME_REGEX)) return null
val canonicalBase = baseDir.canonicalFile
val candidate = File(baseDir, "$routeName$SCRIPT_EXTENSION").canonicalFile
val insideBase = candidate.path.startsWith(canonicalBase.path + File.separator)
return if (insideBase || candidate == canonicalBase) candidate else null
}
private fun listScriptNames(scriptsDir: File): List<String> =
scriptsDir.listFiles()
?.asSequence()
?.filter { it.isFile && it.name.endsWith(SCRIPT_EXTENSION) }
?.map { it.name.removeSuffix(SCRIPT_EXTENSION) }
?.sorted()
?.toList()
?: emptyList()
fun renderScriptList(scriptsDir: File): String =
listScriptNames(scriptsDir).joinToString("\n") { name ->
val file = resolveScriptFile(scriptsDir, name)
val description = file?.let(::cachedMetadata)?.description
if (description.isNullOrBlank()) name else "$name\t$description"
}
private fun String.jsonEscaped(): String = buildString(length) {
for (ch in this@jsonEscaped) {
when (ch) {
'\\' -> append("\\\\")
'"' -> append("\\\"")
'\n' -> append("\\n")
'\r' -> append("\\r")
'\t' -> append("\\t")
else -> append(ch)
}
}
}
fun metadataJson(scriptName: String, metadata: ScriptMetadata, source: String): String {
val description = metadata.description?.let { "\"${it.jsonEscaped()}\"" } ?: "null"
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,"params":[$params]}"""
}
fun loadMetadata(script: File): Pair<ScriptMetadata, String> {
val cached = cachedMetadata(script)
if (cached != null) return cached to "cache"
val executed = evalAndCapture(script, ScriptRequestContext(args = emptyList())).metadata
return executed to "parsed"
}
suspend fun handleCreateScript(call: ApplicationCall, scriptsDir: File) {
val name = call.parameters["script"]
?: return call.respondText("missing route name", status = HttpStatusCode.BadRequest)
val script = resolveScriptFile(scriptsDir, name)
?: return call.respondText("invalid script name", status = HttpStatusCode.BadRequest)
if (script.exists()) {
return call.respondText("script already exists: ${script.name}", status = HttpStatusCode.Conflict)
}
val content = call.receiveText()
if (content.isBlank()) {
return call.respondText("script content is empty", status = HttpStatusCode.BadRequest)
}
script.parentFile?.mkdirs()
script.writeText(content)
removeCachedMetadata(script)
val result = evalAndCapture(script, ScriptRequestContext())
if (!result.ok) {
script.delete()
removeCachedMetadata(script)
return call.respondText(
"script validation failed:\n${result.output.ifBlank { "unknown error" }}",
status = HttpStatusCode.BadRequest,
contentType = ContentType.Text.Plain
)
}
call.respondText(
"created ${script.name}\n${result.output}".trim(),
status = HttpStatusCode.Created,
contentType = ContentType.Text.Plain
)
}
suspend fun handleDeleteScript(call: ApplicationCall, scriptsDir: File) {
val name = call.parameters["script"]
?: return call.respondText("missing route name", status = HttpStatusCode.BadRequest)
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 deleted = script.delete()
removeCachedMetadata(script)
if (!deleted) {
return call.respondText(
"failed to delete script: ${script.name}",
status = HttpStatusCode.InternalServerError
)
}
call.respondText("deleted ${script.name}", status = HttpStatusCode.OK)
}
suspend fun handleUpdateScript(call: ApplicationCall, scriptsDir: File) {
val name = call.parameters["script"]
?: return call.respondText("missing route name", status = HttpStatusCode.BadRequest)
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 newContent = call.receiveText()
if (newContent.isBlank()) {
return call.respondText("script content is empty", status = HttpStatusCode.BadRequest)
}
val previousContent = script.readText()
script.writeText(newContent)
removeCachedMetadata(script)
val result = evalAndCapture(script, ScriptRequestContext())
if (!result.ok) {
script.writeText(previousContent)
removeCachedMetadata(script)
return call.respondText(
"script validation failed, rolled back:\n${result.output.ifBlank { "unknown error" }}",
status = HttpStatusCode.BadRequest,
contentType = ContentType.Text.Plain
)
}
call.respondText(
"updated ${script.name}\n${result.output}".trim(),
status = HttpStatusCode.OK,
contentType = ContentType.Text.Plain
)
}
suspend fun handleRunRequest(call: ApplicationCall, scriptsDir: File, consumeBody: Boolean) {
val name = call.parameters["script"]
?: return call.respondText("missing route name", status = HttpStatusCode.BadRequest)
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 requestArgs = call.request.queryParameters.entries()
.mapNotNull { entry ->
val value = entry.value.lastOrNull() ?: return@mapNotNull null
"${entry.key}=$value"
}
.toList()
val requestBody = if (consumeBody) call.receiveText() else null
val result = evalAndCapture(script, ScriptRequestContext(args = requestArgs, body = requestBody))
val status = when {
result.ok -> HttpStatusCode.OK
result.missingRequiredParams.isNotEmpty() -> HttpStatusCode.BadRequest
else -> HttpStatusCode.InternalServerError
}
call.respondText(
result.output.ifBlank { if (result.ok) "OK" else "FAILED" },
status = status,
contentType = ContentType.Text.Plain
)
}
suspend fun handleGetScriptContent(call: ApplicationCall, scriptsDir: File) {
val name = call.parameters["script"]
?: return call.respondText("missing route name", status = HttpStatusCode.BadRequest)
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)
}
call.respondText(script.readText(), contentType = ContentType.Text.Plain)
}

View File

@@ -0,0 +1,63 @@
package work.slhaf.hub
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
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 TOKEN_FILE_NAME = ".host-api-token"
private const val ALT_TOKEN_HEADER = "X-Host-Token"
data class ApiTokenConfig(
val token: String,
val source: String,
val tokenFile: File?
)
private fun randomTokenHex(bytes: Int = 32): String {
val random = ByteArray(bytes)
SecureRandom().nextBytes(random)
return random.joinToString("") { "%02x".format(it) }
}
fun loadOrCreateApiToken(scriptsDir: File): ApiTokenConfig {
val envToken = System.getenv(ENV_API_TOKEN)?.trim()
if (!envToken.isNullOrBlank()) {
return ApiTokenConfig(envToken, "env:$ENV_API_TOKEN", null)
}
val tokenFile = File(scriptsDir, TOKEN_FILE_NAME)
if (tokenFile.exists()) {
val saved = tokenFile.readText().trim()
if (saved.isNotBlank()) return ApiTokenConfig(saved, "file:${tokenFile.absolutePath}", tokenFile)
}
val token = randomTokenHex()
tokenFile.writeText(token)
tokenFile.setReadable(false, false)
tokenFile.setReadable(true, true)
tokenFile.setWritable(false, false)
tokenFile.setWritable(true, true)
return ApiTokenConfig(token, "generated:file:${tokenFile.absolutePath}", tokenFile)
}
private fun extractProvidedToken(call: ApplicationCall): String? {
val auth = call.request.headers[HttpHeaders.Authorization]
if (!auth.isNullOrBlank() && auth.startsWith("Bearer ", ignoreCase = true)) {
return auth.substringAfter("Bearer ").trim()
}
return call.request.headers[ALT_TOKEN_HEADER]?.trim()
}
suspend fun requireAuth(call: ApplicationCall, expectedToken: String): Boolean {
val provided = extractProvidedToken(call)
if (provided == expectedToken) return true
call.response.headers.append(HttpHeaders.WWWAuthenticate, "Bearer realm=\"script-host\"")
call.respondText("unauthorized", status = HttpStatusCode.Unauthorized, contentType = ContentType.Text.Plain)
return false
}