1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
|
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}"
}
}
}
|