fieldAudioProxy.kt
src/jvmMain/kotlin/afield/app/server/fieldAudioProxy.kt · 124 lines
package and imports — 45 lines, from line 1
package afield.app.server
import afield.app.diagnostics.logging.lprintln
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.server.request.httpMethod
import io.ktor.server.request.receiveStream
import io.ktor.server.request.uri
import io.ktor.server.response.respond
import io.ktor.server.response.respondBytes
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.put
import io.ktor.server.routing.route
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.time.Duration
private val fieldAudioProxyClient: HttpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.version(HttpClient.Version.HTTP_1_1)
.followRedirects(HttpClient.Redirect.NEVER)
.build()
private val fieldAudioServiceBase: String =
System.getenv("FIELD_AUDIO_SERVICE_URL")?.trim()?.trimEnd('/')?.takeIf { it.isNotEmpty() }
?: "http://127.0.0.1:8765"
private val representationContentPath =
Regex("^/v1/representations/[A-Za-z0-9:_-]+/content$")
private val representationWaveformPath =
Regex("^/v1/representations/[A-Za-z0-9:_-]+/waveform$")
private val analysisPreviewPath =
Regex("^/v1/analysis-results/[A-Za-z0-9:_-]+/preview$")
private val candidateJudgementPath =
Regex("^/v1/candidate-sets/[A-Za-z0-9:_-]+/candidates/[0-9]+$")
private val jobCancellationPath =
Regex("^/v1/jobs/[A-Za-z0-9:_-]+/cancel$")
internal fun isAllowedFieldAudioProxyRequest(method: HttpMethod, path: String): Boolean
internal fun isAllowedFieldAudioProxyRequest(method: HttpMethod, path: String): Boolean = when (method) {
HttpMethod.Get -> path == "/monitor" || path == "/v1/jobs" ||
representationContentPath.matches(path) || representationWaveformPath.matches(path) ||
analysisPreviewPath.matches(path)
HttpMethod.Post -> jobCancellationPath.matches(path)
HttpMethod.Put -> candidateJudgementPath.matches(path)
else -> false
}
internal fun fieldAudioProxyTimeout(path: String): Duration
internal fun fieldAudioProxyTimeout(path: String): Duration =
if (representationWaveformPath.matches(path)) Duration.ofSeconds(20) else Duration.ofMinutes(2)
fun Route.installFieldAudioProxyRoutes()
A same-origin bridge for browser-facing Field Audio UI, limited to an allowlist of paths per method; anything else answers 404.
The model service remains loopback-only. Browsers fetch candidate audio and persist candidate judgements through Field's own HTTPS origin, which also makes remote Field sessions independent of the browser machine's localhost.
body — 57 lines, from line 67
fun Route.installFieldAudioProxyRoutes() {
route("/field-audio") {
get("{...}") { call.proxyFieldAudioRequest() }
post("{...}") { call.proxyFieldAudioRequest() }
put("{...}") { call.proxyFieldAudioRequest() }
}
}
private suspend fun io.ktor.server.application.ApplicationCall.proxyFieldAudioRequest() {
val requestUri = runCatching { URI(request.uri) }.getOrElse {
respond(HttpStatusCode.BadRequest, "Invalid Field Audio browser route")
return
}
val upstreamPath = requestUri.rawPath.removePrefix("/field-audio")
val method = request.httpMethod
if (!isAllowedFieldAudioProxyRequest(method, upstreamPath)) {
respond(HttpStatusCode.NotFound, "Field Audio browser route not found")
return
}
val query = requestUri.rawQuery?.let { "?$it" }.orEmpty()
val target = URI.create("$fieldAudioServiceBase$upstreamPath$query")
val builder = HttpRequest.newBuilder(target).timeout(fieldAudioProxyTimeout(upstreamPath))
listOf(HttpHeaders.Accept, HttpHeaders.ContentType, HttpHeaders.Range, HttpHeaders.IfNoneMatch,
HttpHeaders.IfModifiedSince).forEach { header ->
request.headers[header]?.let { builder.header(header, it) }
}
val body = if (method == HttpMethod.Put || method == HttpMethod.Post) {
HttpRequest.BodyPublishers.ofByteArray(receiveStream().use { it.readBytes() })
} else {
HttpRequest.BodyPublishers.noBody()
}
builder.method(method.value, body)
val upstream = try {
withContext(Dispatchers.IO) {
fieldAudioProxyClient.send(builder.build(), HttpResponse.BodyHandlers.ofByteArray())
}
} catch (error: Exception) {
lprintln("warning,field-audio", "browser proxy failed for $upstreamPath: ${error.message}")
respond(HttpStatusCode.BadGateway, "Field Audio service unavailable")
return
}
listOf(HttpHeaders.AcceptRanges, HttpHeaders.ContentRange, HttpHeaders.ETag,
HttpHeaders.LastModified, HttpHeaders.CacheControl).forEach { header ->
upstream.headers().firstValue(header).ifPresent { response.headers.append(header, it) }
}
val contentType = upstream.headers().firstValue(HttpHeaders.ContentType).orElse(null)
?.let { runCatching { ContentType.parse(it) }.getOrNull() }
?: ContentType.Application.OctetStream
respondBytes(
bytes = upstream.body(),
contentType = contentType,
status = HttpStatusCode.fromValue(upstream.statusCode()),
)
}