package org.meshbay.client.hub import android.content.SharedPreferences import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONObject import org.meshbay.client.bridge.Refused import java.io.IOException import java.net.ConnectException import java.net.SocketTimeoutException import java.net.UnknownHostException import java.util.concurrent.TimeUnit import javax.net.ssl.SSLException /** * Every call to the hub leaves from here, never from the page. * * Not a preference: the page's origin is `https://appassets.androidplatform.net`, * which the hub's absent CORS refuses — and that posture is worth keeping, its * API is reachable from no web origin at all. So the page asks and this goes, * to the hub it is signed in to and nowhere else (main.js `hub:fetch`). */ class HubClient(private val prefs: SharedPreferences) { private val http = OkHttpClient.Builder() // The hub's longest call is signaling, which gives up at fifteen // seconds; past this, no answer is still coming (HUB_FETCH_TIMEOUT_MS). .callTimeout(FETCH_TIMEOUT_S, TimeUnit.SECONDS) .followRedirects(false) .build() val base: String get() = prefs.getString(KEY_BASE, "") ?: "" /** Check that the address answers as a hub before writing it down. */ fun setBase(raw: String): String { val url = raw.trim().trimEnd('/') // An empty address is not "no hub": main.js probes it like any other // and it fails, so the first-run screen cannot be passed with nothing. if (url.isEmpty()) throw Refused("Enter the address of a hub.") if (!url.startsWith("https://") && !LOOPBACK_HTTP.containsMatchIn(url)) { // http only to this device's loopback; anywhere else it would put // the session token on the wire in clear. throw Refused("The hub address must be https") } val probe = url.toHttpUrlOrNull()?.newBuilder()?.encodedPath("/v1/hub/version")?.build() ?: throw Refused("$url is not an address") val answer = try { http.newBuilder().callTimeout(PROBE_TIMEOUT_S, TimeUnit.SECONDS).build() .newCall(Request.Builder().url(probe).build()).execute().use { r -> if (!r.isSuccessful) throw IOException("answered ${r.code}") JSONObject(r.body.string()) } } catch (e: Exception) { throw Refused(describeUnreachable(url, e)) } if (!answer.has("hub")) throw Refused(describeUnreachable(url, IOException("did not answer as a hub"))) prefs.edit().putString(KEY_BASE, url).apply() return url } /** `{status, ok, headers, body}`, the shape main.js returns and platform.apiFetch reads. */ fun fetch(url: String, init: JSONObject?): JSONObject { val target = url.toHttpUrlOrNull() ?: throw Refused("not an address") val hub = base.toHttpUrlOrNull() // The page may only reach the hub it is signed in to: a path it // controls must not become a request to somewhere else. if (hub == null || !sameOrigin(target, hub)) throw Refused("Refused: not this hub") val method = (init?.optString("method").takeUnless { it.isNullOrEmpty() } ?: "GET").uppercase() val builder = Request.Builder().url(target) var contentType: String? = null init?.optJSONObject("headers")?.let { h -> for (name in h.keys()) { val value = h.get(name).toString() if (name.equals("content-type", ignoreCase = true)) contentType = value builder.header(name, value) } } val text = init?.opt("body")?.takeUnless { it == JSONObject.NULL }?.toString() val body = when { method == "GET" || method == "HEAD" -> null else -> (text ?: "").toRequestBody(contentType?.toMediaTypeOrNull()) } builder.method(method, body) return try { http.newCall(builder.build()).execute().use { r -> val headers = JSONObject() for (name in r.headers.names()) headers.put(name.lowercase(), r.headers.values(name).joinToString(", ")) JSONObject().put("status", r.code).put("ok", r.isSuccessful) .put("headers", headers).put("body", r.body.string()) } } catch (e: IOException) { // OkHttp's call timeout is an InterruptedIOException("timeout"), a // read timeout a SocketTimeoutException; both mean the same thing. if (e is SocketTimeoutException || e.message?.contains("timeout", ignoreCase = true) == true) { throw Refused("${originOf(hub)} accepted the connection but did not answer within ${FETCH_TIMEOUT_S}s.") } throw Refused(describeUnreachable(originOf(hub), e)) } } companion object { private const val KEY_BASE = "hubBase" const val FETCH_TIMEOUT_S = 30L private const val PROBE_TIMEOUT_S = 10L private val LOOPBACK_HTTP = Regex("^http://(localhost|127\\.)") fun sameOrigin(a: HttpUrl, b: HttpUrl) = a.scheme == b.scheme && a.host == b.host && a.port == b.port private fun originOf(u: HttpUrl): String { val defaultPort = (u.scheme == "https" && u.port == 443) || (u.scheme == "http" && u.port == 80) return "${u.scheme}://${u.host}" + if (defaultPort) "" else ":${u.port}" } /** Why the hub could not be reached, in words somebody can act on (main.js). */ fun describeUnreachable(url: String, e: Throwable): String = when { url.startsWith("https:") && e is SSLException -> "$url does not speak https. If this hub is on your own machine, it is probably http — try http:// instead." e is ConnectException -> "Nothing is listening at $url. Is the hub running?" e is UnknownHostException -> "$url could not be found. Check the address." e is SocketTimeoutException || e.message?.contains("timeout", true) == true -> "$url did not answer in time." else -> "Could not reach $url: ${e.message ?: e.javaClass.simpleName}" } } }