diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt index 93a5685e79..6894a30734 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.geode +import com.vitorpamplona.geode.server.Nip11HttpRoute import com.vitorpamplona.geode.server.Nip86HttpRoute import com.vitorpamplona.geode.server.WebSocketSessionPump import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -28,9 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86Server import com.vitorpamplona.quartz.nip98HttpAuth.Nip98AuthVerifier -import io.ktor.http.ContentType import io.ktor.http.HttpHeaders -import io.ktor.http.HttpStatusCode import io.ktor.server.application.install import io.ktor.server.application.serverConfig import io.ktor.server.cio.CIO @@ -38,7 +37,6 @@ import io.ktor.server.cio.CIOApplicationEngine import io.ktor.server.engine.connector import io.ktor.server.engine.embeddedServer import io.ktor.server.request.header -import io.ktor.server.response.respondText import io.ktor.server.routing.get import io.ktor.server.routing.post import io.ktor.server.routing.routing @@ -142,6 +140,7 @@ class KtorRelay( publicUrl ?: ("http://" + (call.request.header(HttpHeaders.Host) ?: "$host:$resolvedPort") + path) }, ) + private val nip11Route = Nip11HttpRoute(liveJson = { relay.info.json }) private var engine: CIOApplicationEngine? = null private var resolvedPort: Int = -1 @@ -203,20 +202,7 @@ class KtorRelay( // serves NIP-11 for plain HTTP GETs and only upgrades // to a WebSocket when the request is a WS upgrade. get(path) { - val accept = call.request.header(HttpHeaders.Accept).orEmpty() - if (accept.contains("application/nostr+json")) { - call.response.headers.append("Access-Control-Allow-Origin", "*") - call.respondText( - relay.info.json, - ContentType.parse("application/nostr+json"), - ) - } else { - call.respondText( - "Use a Nostr client (NIP-01 WebSocket) or send Accept: application/nostr+json (NIP-11).", - ContentType.Text.Plain, - HttpStatusCode.UpgradeRequired, - ) - } + nip11Route.handle(call) } // NIP-86: POST application/nostr+json+rpc with a NIP-98 // signed Authorization header → JSON-RPC dispatch. diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/server/Nip11HttpRoute.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/server/Nip11HttpRoute.kt new file mode 100644 index 0000000000..8afdcca219 --- /dev/null +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/Nip11HttpRoute.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.geode.server + +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.request.header +import io.ktor.server.response.respondText + +/** + * NIP-11 GET handler. Branches on the `Accept` header so a single path + * can serve both flavors of GET we expect: + * + * - `Accept: application/nostr+json` → respond with the live relay + * info JSON via [liveJson]. CORS-open so browser-based clients + * (which can't set custom headers cross-origin without preflight) + * can still load it. + * - anything else → respond 426 Upgrade Required with a short hint + * pointing at the two valid ways to talk to this endpoint + * (WebSocket upgrade or NIP-11 Accept). + * + * [liveJson] is a callback rather than a snapshot string because the + * NIP-86 `changerelay*` admin RPCs mutate the in-memory doc while the + * relay is running; we must read the latest JSON on every request. + */ +internal class Nip11HttpRoute( + private val liveJson: () -> String, +) { + suspend fun handle(call: ApplicationCall) { + val accept = call.request.header(HttpHeaders.Accept).orEmpty() + if (accept.contains(NIP11_CONTENT_TYPE)) { + call.response.headers.append("Access-Control-Allow-Origin", "*") + call.respondText(liveJson(), ContentType.parse(NIP11_CONTENT_TYPE)) + } else { + call.respondText( + "Use a Nostr client (NIP-01 WebSocket) or send Accept: $NIP11_CONTENT_TYPE (NIP-11).", + ContentType.Text.Plain, + HttpStatusCode.UpgradeRequired, + ) + } + } + + companion object { + const val NIP11_CONTENT_TYPE = "application/nostr+json" + } +}