aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/UiAssets.kt
blob: 2300668422bb12a8b1c4a77a55230383bf7d5553 (plain) (blame)
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
package org.meshbay.client.shell

import android.content.Context
import android.webkit.WebResourceResponse
import androidx.webkit.WebViewAssetLoader
import java.io.File

/**
 * Serves the packaged interface, and nothing else.
 *
 * The page's origin is `https://appassets.androidplatform.net` — a secure
 * context, without which `crypto.subtle` does not exist — and every file comes
 * out of the APK's `assets/ui/`, copied from the hub's static directory at build
 * time (§8.3). The hub never becomes the document origin: that is the whole
 * reason the application exists (T3).
 *
 * The stock asset handler sets no headers, so this one exists for three: the
 * policy, sent as a header because a <meta> policy drops `frame-ancestors`;
 * `nosniff`; and `no-store`, since every file is already local.
 */
class UiAssets(private val context: Context) : WebViewAssetLoader.PathHandler {

    override fun handle(path: String): WebResourceResponse? {
        // Asset paths are not a filesystem, but a `..` that reached
        // AssetManager would still be a path the page chose; refuse it.
        if (path.split('/').any { it == ".." || it == "." } || path.startsWith("/")) return notFound()
        val asset = "ui/" + path.ifEmpty { "index.html" }
        val stream = try { context.assets.open(asset) } catch (e: java.io.IOException) { return notFound() }
        val headers = mapOf(
            "Content-Security-Policy" to CSP,
            "X-Content-Type-Options" to "nosniff",
            "Cache-Control" to "no-store",
        )
        val type = contentType(asset)
        val charset = if (type.startsWith("text/") || type == "application/json") "utf-8" else null
        return WebResourceResponse(type, charset, 200, "OK", headers, stream)
    }

    private fun notFound() =
        WebResourceResponse("text/plain", "utf-8", 404, "Not Found", emptyMap(), "".byteInputStream())

    companion object {
        const val HOST = "appassets.androidplatform.net"
        const val ORIGIN = "https://$HOST"
        const val PREFIX = "/ui/"
        const val START = "$ORIGIN${PREFIX}index.html"

        // reCAPTCHA gates sign-up here as it does in a browser and on the
        // desktop; these two hosts and no others.
        private const val RECAPTCHA_SRC = "https://www.google.com https://www.gstatic.com"

        /**
         * meshbay-client/src/main.js's CSP, directive for directive
         * (test_android_shell.py holds them together). `'wasm-unsafe-eval'` is
         * the Argon2 that opens bundles: without it nobody reaches their keys.
         */
        val CSP = listOf(
            "default-src 'none'",
            "script-src 'self' 'wasm-unsafe-eval' $RECAPTCHA_SRC",
            "style-src 'self' 'unsafe-inline'",
            "img-src 'self' data: blob: $RECAPTCHA_SRC",
            "media-src 'self' blob:",
            "font-src 'self'",
            "connect-src 'self' $RECAPTCHA_SRC",
            "worker-src 'self'",
            "object-src blob:",
            "frame-src blob: $RECAPTCHA_SRC",
            "frame-ancestors 'none'",
            "base-uri 'none'",
            "form-action 'none'",
        ).joinToString("; ")

        fun isRecaptcha(host: String?) = host == "www.google.com" || host == "www.gstatic.com"

        fun contentType(name: String): String = when (File(name).extension.lowercase()) {
            "html" -> "text/html"
            "js", "mjs" -> "text/javascript"
            "css" -> "text/css"
            "json" -> "application/json"
            "wasm" -> "application/wasm"
            "svg" -> "image/svg+xml"
            "png" -> "image/png"
            "jpg", "jpeg" -> "image/jpeg"
            "ico" -> "image/x-icon"
            "webp" -> "image/webp"
            "woff2" -> "font/woff2"
            "woff" -> "font/woff"
            "txt" -> "text/plain"
            "xml" -> "application/xml"
            else -> "application/octet-stream"
        }
    }
}