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 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" } } }