chore: initialize slhaf hub project
This commit is contained in:
134
src/main/kotlin/work/slhaf/hub/CliHost.kt
Normal file
134
src/main/kotlin/work/slhaf/hub/CliHost.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
244
src/main/kotlin/work/slhaf/hub/ScriptEngine.kt
Normal file
244
src/main/kotlin/work/slhaf/hub/ScriptEngine.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
18
src/main/kotlin/work/slhaf/hub/ScriptRuntime.kt
Normal file
18
src/main/kotlin/work/slhaf/hub/ScriptRuntime.kt
Normal 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
|
||||
)
|
||||
6
src/main/kotlin/work/slhaf/hub/SimpleScriptDef.kt
Normal file
6
src/main/kotlin/work/slhaf/hub/SimpleScriptDef.kt
Normal file
@@ -0,0 +1,6 @@
|
||||
package work.slhaf.hub
|
||||
|
||||
import kotlin.script.experimental.annotations.KotlinScript
|
||||
|
||||
@KotlinScript(fileExtension = "hub.kts")
|
||||
abstract class SimpleScript
|
||||
125
src/main/kotlin/work/slhaf/hub/WebHost.kt
Normal file
125
src/main/kotlin/work/slhaf/hub/WebHost.kt
Normal 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)
|
||||
}
|
||||
206
src/main/kotlin/work/slhaf/hub/WebScriptService.kt
Normal file
206
src/main/kotlin/work/slhaf/hub/WebScriptService.kt
Normal 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)
|
||||
}
|
||||
63
src/main/kotlin/work/slhaf/hub/WebSecurity.kt
Normal file
63
src/main/kotlin/work/slhaf/hub/WebSecurity.kt
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user