Compare commits
4 Commits
3933f0120b
...
d79ff57b89
| Author | SHA1 | Date | |
|---|---|---|---|
| d79ff57b89 | |||
| ab6d1204e6 | |||
| 56d0b40dd3 | |||
| 62bd88efc9 |
@@ -19,7 +19,6 @@ 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 compiledScriptCache = ConcurrentHashMap<String, Pair<String, CompiledScript>>() // key -> stamp, compiled script
|
||||
|
||||
private val resolver = CompoundDependenciesResolver(FileSystemDependenciesResolver(), MavenDependenciesResolver())
|
||||
@@ -27,8 +26,6 @@ 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 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 }
|
||||
}
|
||||
@@ -85,8 +82,6 @@ private fun compilationConfiguration(explicitCp: List<File>?): ScriptCompilation
|
||||
}
|
||||
}
|
||||
|
||||
private fun scriptStamp(file: File): String = "${file.length()}-${file.lastModified()}"
|
||||
|
||||
private fun injectArgsBridgeDeclaration(scriptContent: String): String {
|
||||
val lines = scriptContent.lines()
|
||||
val injected = "val args: Array<String> = hostArgs"
|
||||
@@ -120,151 +115,6 @@ private fun applyDefaultArgs(metadata: ScriptMetadata, requestArgs: List<String>
|
||||
return merged
|
||||
}
|
||||
|
||||
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 compileSource(source: SourceCode): ResultWithDiagnostics<CompiledScript> {
|
||||
val explicitCp = explicitClasspathFromEnv()
|
||||
return runBlocking {
|
||||
@@ -304,23 +154,11 @@ data class ScriptExecutionResult(
|
||||
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)
|
||||
clearMetadataCache(scriptFile)
|
||||
compiledScriptCache.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)
|
||||
}
|
||||
|
||||
171
src/main/kotlin/work/slhaf/hub/ScriptMetadataEngine.kt
Normal file
171
src/main/kotlin/work/slhaf/hub/ScriptMetadataEngine.kt
Normal file
@@ -0,0 +1,171 @@
|
||||
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._-]+")
|
||||
|
||||
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
|
||||
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 }
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -1,394 +1,14 @@
|
||||
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.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 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 val requestLogger = LoggerFactory.getLogger("work.slhaf.hub.RequestAudit")
|
||||
|
||||
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 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,
|
||||
call.request.path(),
|
||||
status,
|
||||
durationMs,
|
||||
tokenType,
|
||||
subToken,
|
||||
script,
|
||||
)
|
||||
} else {
|
||||
requestLogger.warn(
|
||||
"endpoint={} method={} path={} status={} durationMs={} tokenType={} subToken={} script={} error={}",
|
||||
endpoint,
|
||||
call.request.httpMethod.value,
|
||||
call.request.path(),
|
||||
status,
|
||||
durationMs,
|
||||
tokenType,
|
||||
subToken,
|
||||
script,
|
||||
"${thrown::class.simpleName}: ${thrown.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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") {
|
||||
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 }) {
|
||||
authForLog = requireAuth(call, security) ?: return@withRequestAudit
|
||||
handleRunForAuth(call, scriptsDir, authForLog, runConcurrencyLimiter, consumeBody = false)
|
||||
}
|
||||
}
|
||||
|
||||
post("/run/{script}") {
|
||||
var authForLog: AuthContext? = null
|
||||
withRequestAudit(call, "run.post", { authForLog }) {
|
||||
authForLog = requireAuth(call, security) ?: return@withRequestAudit
|
||||
handleRunForAuth(call, scriptsDir, authForLog, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun usage() {
|
||||
println(
|
||||
|
||||
398
src/main/kotlin/work/slhaf/hub/WebRoutes.kt
Normal file
398
src/main/kotlin/work/slhaf/hub/WebRoutes.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
106
src/test/kotlin/work/slhaf/hub/WebAuthAndScriptApiTest.kt
Normal file
106
src/test/kotlin/work/slhaf/hub/WebAuthAndScriptApiTest.kt
Normal file
@@ -0,0 +1,106 @@
|
||||
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"))
|
||||
|
||||
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"))
|
||||
}
|
||||
}
|
||||
@@ -1,347 +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"))
|
||||
}
|
||||
|
||||
@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"))
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
51
src/test/kotlin/work/slhaf/hub/WebHostTestSupport.kt
Normal file
51
src/test/kotlin/work/slhaf/hub/WebHostTestSupport.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
123
src/test/kotlin/work/slhaf/hub/WebRunApiTest.kt
Normal file
123
src/test/kotlin/work/slhaf/hub/WebRunApiTest.kt
Normal file
@@ -0,0 +1,123 @@
|
||||
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.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"))
|
||||
}
|
||||
}
|
||||
98
src/test/kotlin/work/slhaf/hub/WebSubTokenApiTest.kt
Normal file
98
src/test/kotlin/work/slhaf/hub/WebSubTokenApiTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user