@protobase/mobile 0.44.5 → 0.45.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,47 @@
1
+ buildscript {
2
+ repositories {
3
+ google()
4
+ mavenCentral()
5
+ maven {
6
+ url = "https://plugins.gradle.org/m2/"
7
+ }
8
+ }
9
+ dependencies {
10
+ classpath 'com.android.tools.build:gradle:8.13.0'
11
+ }
12
+ }
13
+
14
+ apply plugin: 'com.android.library'
15
+
16
+ android {
17
+ namespace = "net.protobase.mobile"
18
+ compileSdk = project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 36
19
+ defaultConfig {
20
+ minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 24
21
+ targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 36
22
+ versionCode 1
23
+ versionName "1.0"
24
+ }
25
+ buildTypes {
26
+ release {
27
+ minifyEnabled false
28
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
29
+ }
30
+ }
31
+ lintOptions {
32
+ abortOnError = false
33
+ }
34
+ compileOptions {
35
+ sourceCompatibility JavaVersion.VERSION_21
36
+ targetCompatibility JavaVersion.VERSION_21
37
+ }
38
+ }
39
+
40
+ repositories {
41
+ google()
42
+ mavenCentral()
43
+ }
44
+
45
+ dependencies {
46
+ implementation project(':capacitor-android')
47
+ }
@@ -0,0 +1,2 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ </manifest>
@@ -0,0 +1,65 @@
1
+ package net.protobase.mobile;
2
+
3
+ import com.getcapacitor.JSObject;
4
+ import java.util.Arrays;
5
+ import java.util.HashSet;
6
+ import java.util.Iterator;
7
+ import java.util.Locale;
8
+ import java.util.Set;
9
+ import org.json.JSONObject;
10
+
11
+ /**
12
+ * The request headers ProtobaseAuth sends of those page script gives: the string-valued ones, without the Fetch spec's
13
+ * forbidden request header names, which a page's Request never carries. So page script cannot set the cookie, the
14
+ * origin, or the host a shared ingress routes by, nor override the method.
15
+ */
16
+ final class AuthHeaders {
17
+
18
+ private static final Set<String> FORBIDDEN = new HashSet<>(
19
+ Arrays.asList(
20
+ "accept-charset",
21
+ "accept-encoding",
22
+ "access-control-request-headers",
23
+ "access-control-request-method",
24
+ "connection",
25
+ "content-length",
26
+ "cookie",
27
+ "cookie2",
28
+ "date",
29
+ "dnt",
30
+ "expect",
31
+ "host",
32
+ "keep-alive",
33
+ "origin",
34
+ "referer",
35
+ "set-cookie",
36
+ "te",
37
+ "trailer",
38
+ "transfer-encoding",
39
+ "upgrade",
40
+ "via",
41
+ "x-http-method",
42
+ "x-http-method-override",
43
+ "x-method-override"
44
+ )
45
+ );
46
+
47
+ private AuthHeaders() {}
48
+
49
+ static JSObject allowed(JSONObject given) {
50
+ JSObject headers = new JSObject();
51
+ if (given == null) return headers;
52
+ Iterator<String> names = given.keys();
53
+ while (names.hasNext()) {
54
+ String name = names.next();
55
+ Object value = given.opt(name);
56
+ if (value instanceof String && !forbidden(name)) headers.put(name, (String) value);
57
+ }
58
+ return headers;
59
+ }
60
+
61
+ private static boolean forbidden(String name) {
62
+ String lower = name.toLowerCase(Locale.ROOT);
63
+ return FORBIDDEN.contains(lower) || lower.startsWith("proxy-") || lower.startsWith("sec-");
64
+ }
65
+ }
@@ -0,0 +1,71 @@
1
+ package net.protobase.mobile;
2
+
3
+ import java.net.MalformedURLException;
4
+ import java.net.URI;
5
+ import java.net.URISyntaxException;
6
+ import java.net.URL;
7
+ import java.util.Arrays;
8
+ import java.util.Locale;
9
+ import java.util.regex.Pattern;
10
+
11
+ /**
12
+ * Whether ProtobaseAuth may call a URL: one below the auth base path of the API origin, as the shell's
13
+ * nativeAuthFetch checks it. Parsed by URI and by URL, which HttpRequestHandler opens it with, and both must agree;
14
+ * dot segments and an encoded dot, slash or backslash are refused, since the server's URL parser decodes `%2e%2e`
15
+ * into `..`.
16
+ */
17
+ final class AuthUrl {
18
+
19
+ /** Where Better Auth's endpoints are served, as packages/schema/src/auth-base-path.ts names it. */
20
+ static final String AUTH_BASE_PATH = "/api/auth";
21
+
22
+ private static final Pattern ENCODED_DOT_OR_SEPARATOR = Pattern.compile("%(2e|2f|5c)", Pattern.CASE_INSENSITIVE);
23
+
24
+ private AuthUrl() {}
25
+
26
+ /** The parts both parsers agree on. */
27
+ private static final class Parts {
28
+
29
+ final String scheme;
30
+ final String host;
31
+ final int port;
32
+ final String path;
33
+
34
+ Parts(String scheme, String host, int port, String path) {
35
+ this.scheme = scheme;
36
+ this.host = host;
37
+ this.port = port;
38
+ this.path = path;
39
+ }
40
+ }
41
+
42
+ static boolean allowed(String url, String apiOrigin) {
43
+ Parts origin = parse(apiOrigin);
44
+ Parts target = parse(url);
45
+ if (origin == null || target == null || !origin.path.isEmpty()) return false;
46
+ if (!target.scheme.equals(origin.scheme) || !target.host.equals(origin.host) || target.port != origin.port) return false;
47
+ String path = target.path;
48
+ if (!path.startsWith(AUTH_BASE_PATH + "/") || path.contains("\\") || ENCODED_DOT_OR_SEPARATOR.matcher(path).find()) return false;
49
+ return Arrays.stream(path.split("/")).noneMatch((segment) -> segment.equals(".") || segment.equals(".."));
50
+ }
51
+
52
+ // `null` when the parsers disagree, or the URL is not plain http or https.
53
+ private static Parts parse(String text) {
54
+ URI uri;
55
+ URL url;
56
+ try {
57
+ uri = new URI(text);
58
+ url = new URL(text);
59
+ } catch (URISyntaxException | MalformedURLException e) {
60
+ return null;
61
+ }
62
+ String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT);
63
+ if (!scheme.equals("http") && !scheme.equals("https")) return null;
64
+ if (uri.getHost() == null || uri.getRawUserInfo() != null || url.getUserInfo() != null || uri.getRawPath() == null) return null;
65
+ String host = uri.getHost().toLowerCase(Locale.ROOT);
66
+ int port = uri.getPort() == -1 ? (scheme.equals("https") ? 443 : 80) : uri.getPort();
67
+ int urlPort = url.getPort() == -1 ? url.getDefaultPort() : url.getPort();
68
+ boolean agree = url.getProtocol().equalsIgnoreCase(scheme) && url.getHost().equalsIgnoreCase(host) && urlPort == port && url.getPath().equals(uri.getRawPath());
69
+ return agree ? new Parts(scheme, host, port, uri.getRawPath()) : null;
70
+ }
71
+ }
@@ -0,0 +1,73 @@
1
+ package net.protobase.mobile;
2
+
3
+ import android.webkit.WebResourceRequest;
4
+ import android.webkit.WebResourceResponse;
5
+ import android.webkit.WebView;
6
+ import com.getcapacitor.Bridge;
7
+ import com.getcapacitor.BridgeWebViewClient;
8
+ import java.io.ByteArrayInputStream;
9
+ import java.io.File;
10
+ import java.io.IOException;
11
+ import java.util.Arrays;
12
+ import java.util.Collections;
13
+ import java.util.Objects;
14
+
15
+ /**
16
+ * Keeps page script from reading the app's files through its local server, which opens any file a request's path
17
+ * names (AndroidProtocolHandler.java:66-87): the WebView's cookie database, with the session token in plain text,
18
+ * lives below the app's data folder. Refused, with an empty 403:
19
+ * - the file and content routes, `/_capacitor_file_` and `/_capacitor_content_`, which Protobase never uses;
20
+ * - a `..` segment in the decoded path the server opens;
21
+ * - while the server serves files from a folder (the live updater's bundle), a path whose file is not inside the
22
+ * live updater's bundles folder, wherever that folder came from.
23
+ * The decoded path is the one the server reads (WebViewLocalServer.java:236-239,686-689). The server's folder is read
24
+ * again after it answered, so a switch between the check and the read is refused too.
25
+ */
26
+ final class GuardedWebViewClient extends BridgeWebViewClient {
27
+
28
+ private final Bridge bridge;
29
+ private final File bundles;
30
+
31
+ GuardedWebViewClient(Bridge bridge, File bundles) {
32
+ super(bridge);
33
+ this.bridge = bridge;
34
+ this.bundles = bundles;
35
+ }
36
+
37
+ @Override
38
+ public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
39
+ String decoded = request.getUrl().getPath();
40
+ String path = decoded == null ? "" : decoded;
41
+ String basePath = bridge.getServerBasePath();
42
+ if (refused(path, basePath)) return forbidden();
43
+ WebResourceResponse response = super.shouldInterceptRequest(view, request);
44
+ // A stream left open here is the garbage collector's to close: it only happens to a request racing a switch.
45
+ if (!Objects.equals(basePath, bridge.getServerBasePath())) return forbidden();
46
+ return response;
47
+ }
48
+
49
+ private boolean refused(String path, String basePath) {
50
+ if (path.startsWith(Bridge.CAPACITOR_FILE_START) || path.startsWith(Bridge.CAPACITOR_CONTENT_START)) return true;
51
+ if (Arrays.asList(path.split("/")).contains("..")) return true;
52
+ if (basePath == null || !new File(basePath).isAbsolute()) return false;
53
+ return !inside(new File(basePath + path));
54
+ }
55
+
56
+ // Compares whole path components of the canonical paths, so a sibling such as `<bundles>_x` is not inside. One
57
+ // that cannot be made canonical is refused.
58
+ private boolean inside(File file) {
59
+ String canonical;
60
+ String root;
61
+ try {
62
+ canonical = file.getCanonicalPath();
63
+ root = bundles.getCanonicalPath();
64
+ } catch (IOException e) {
65
+ return false;
66
+ }
67
+ return canonical.equals(root) || canonical.startsWith(root + File.separator);
68
+ }
69
+
70
+ private static WebResourceResponse forbidden() {
71
+ return new WebResourceResponse("text/plain", "utf-8", 403, "Forbidden", Collections.emptyMap(), new ByteArrayInputStream(new byte[0]));
72
+ }
73
+ }
@@ -0,0 +1,22 @@
1
+ package net.protobase.mobile;
2
+
3
+ import com.getcapacitor.Plugin;
4
+ import com.getcapacitor.annotation.CapacitorPlugin;
5
+ import java.io.File;
6
+
7
+ /**
8
+ * Puts GuardedWebViewClient in front of the app's local server. It has no methods: it only acts when it loads, which
9
+ * is before the first page, since Capacitor 8.5.3 loads plugins before it installs the bridge's WebViewClient on the
10
+ * WebView (Bridge.java:231-233,281).
11
+ */
12
+ @CapacitorPlugin(name = "ProtobaseLocalServerGuard")
13
+ public class LocalServerGuard extends Plugin {
14
+
15
+ // The folder the live updater keeps its bundles in, a fixed name below the app's files (LiveUpdate.java:114).
16
+ private static final String LIVE_UPDATE_BUNDLES = "_capacitor_live_update_bundles";
17
+
18
+ @Override
19
+ public void load() {
20
+ getBridge().setWebViewClient(new GuardedWebViewClient(getBridge(), new File(getContext().getFilesDir(), LIVE_UPDATE_BUNDLES)));
21
+ }
22
+ }
@@ -0,0 +1,105 @@
1
+ package net.protobase.mobile;
2
+
3
+ import com.getcapacitor.JSObject;
4
+ import com.getcapacitor.Plugin;
5
+ import com.getcapacitor.PluginCall;
6
+ import com.getcapacitor.PluginMethod;
7
+ import com.getcapacitor.annotation.CapacitorPlugin;
8
+ import com.getcapacitor.plugin.util.HttpRequestHandler;
9
+ import java.util.ArrayList;
10
+ import java.util.Iterator;
11
+ import java.util.List;
12
+ import java.util.Locale;
13
+ import java.util.concurrent.ExecutorService;
14
+ import java.util.concurrent.Executors;
15
+ import org.json.JSONObject;
16
+
17
+ /**
18
+ * The shell's sign-in requests, through native HTTP and the device's cookie jar, to `<apiOrigin>/api/auth/` only. It
19
+ * names the Capacitor origin of the app, which the server trusts for sign-in, and hands page script no Set-Cookie: the
20
+ * session token stays in the jar. `apiOrigin` and `origin` come from capacitor.config.json, which the CLI writes and
21
+ * ships outside the app's web part, so neither page script nor a live update can change them.
22
+ */
23
+ @CapacitorPlugin(name = "ProtobaseAuth")
24
+ public class ProtobaseAuth extends Plugin {
25
+
26
+ private final ExecutorService executor = Executors.newCachedThreadPool();
27
+
28
+ @Override
29
+ protected void handleOnDestroy() {
30
+ super.handleOnDestroy();
31
+ executor.shutdownNow();
32
+ }
33
+
34
+ @PluginMethod
35
+ public void request(PluginCall call) {
36
+ String apiOrigin = getConfig().getString("apiOrigin");
37
+ String origin = getConfig().getString("origin");
38
+ if (apiOrigin == null || origin == null) {
39
+ call.reject("ProtobaseAuth has no apiOrigin or origin in its config");
40
+ return;
41
+ }
42
+ String url = call.getString("url");
43
+ if (url == null || !AuthUrl.allowed(url, apiOrigin)) {
44
+ call.reject("ProtobaseAuth calls " + apiOrigin + AuthUrl.AUTH_BASE_PATH + "/ only");
45
+ return;
46
+ }
47
+ String method = call.getString("method", "GET").toUpperCase(Locale.ROOT);
48
+ // All Better Auth serves.
49
+ if (!method.equals("GET") && !method.equals("POST")) {
50
+ call.reject("ProtobaseAuth sends GET or POST only, not " + method);
51
+ return;
52
+ }
53
+ Object data = call.getData().opt("data");
54
+ if (data != null && !(data instanceof String)) {
55
+ call.reject("ProtobaseAuth sends a text body only");
56
+ return;
57
+ }
58
+ JSObject headers = AuthHeaders.allowed(call.getObject("headers"));
59
+ headers.put("Origin", origin);
60
+ replaceOptions(call.getData(), url, method, headers, (String) data);
61
+ if (executor.isShutdown()) {
62
+ call.reject("ProtobaseAuth was shut down");
63
+ return;
64
+ }
65
+ executor.submit(() -> send(call, method));
66
+ }
67
+
68
+ private void send(PluginCall call, String method) {
69
+ // Every failure answers the call, as CapacitorHttp's does: a task's exception would otherwise leave it unanswered.
70
+ try {
71
+ JSObject response = HttpRequestHandler.request(call, method, getBridge());
72
+ removeCookieHeaders(response.optJSONObject("headers"));
73
+ call.resolve(response);
74
+ } catch (Exception e) {
75
+ call.reject(e.getLocalizedMessage(), e.getClass().getSimpleName(), e);
76
+ }
77
+ }
78
+
79
+ // HttpRequestHandler reads its options from the call: only the checked ones are left, so page script cannot add
80
+ // params, a file body (`dataType`), timeouts or redirects.
81
+ private static void replaceOptions(JSObject options, String url, String method, JSObject headers, String data) {
82
+ for (String key : keysOf(options)) options.remove(key);
83
+ options.put("url", url);
84
+ options.put("method", method);
85
+ options.put("headers", headers);
86
+ if (data != null) options.put("data", data);
87
+ options.put("responseType", "text");
88
+ options.put("disableRedirects", true);
89
+ }
90
+
91
+ private static void removeCookieHeaders(JSONObject headers) {
92
+ if (headers == null) return;
93
+ for (String name : keysOf(headers)) {
94
+ if (name.equalsIgnoreCase("set-cookie") || name.equalsIgnoreCase("set-cookie2")) headers.remove(name);
95
+ }
96
+ }
97
+
98
+ // Collected before any is removed, which the iterator does not allow.
99
+ private static List<String> keysOf(JSONObject object) {
100
+ List<String> keys = new ArrayList<>();
101
+ Iterator<String> names = object.keys();
102
+ while (names.hasNext()) keys.add(names.next());
103
+ return keys;
104
+ }
105
+ }
@@ -0,0 +1,13 @@
1
+ package net.protobase.mobile;
2
+
3
+ import com.getcapacitor.PluginCall;
4
+
5
+ /** How the stand-ins answer page script that calls a stock plugin they replace. */
6
+ final class Refusal {
7
+
8
+ private Refusal() {}
9
+
10
+ static void reject(PluginCall call, String plugin) {
11
+ call.reject(plugin + " is not available to page script in a Protobase app");
12
+ }
13
+ }
@@ -0,0 +1,45 @@
1
+ package net.protobase.mobile;
2
+
3
+ import com.getcapacitor.Plugin;
4
+ import com.getcapacitor.PluginCall;
5
+ import com.getcapacitor.PluginMethod;
6
+ import com.getcapacitor.annotation.CapacitorPlugin;
7
+
8
+ /**
9
+ * Takes the place of Capacitor's CapacitorCookies, which lets page script write and delete any cookie in the device's
10
+ * jar: plant another session or clear the user's. Replaced by name as RefusedHttp is. The stock plugin's load() has
11
+ * already installed the cookie handler native HTTP keeps the session with (CapacitorCookies.java:21-28).
12
+ */
13
+ @CapacitorPlugin(name = "CapacitorCookies")
14
+ public class RefusedCookies extends Plugin {
15
+
16
+ @Override
17
+ public void load() {
18
+ getBridge().getWebView().removeJavascriptInterface("CapacitorCookiesAndroidInterface");
19
+ }
20
+
21
+ @PluginMethod
22
+ public void getCookies(PluginCall call) {
23
+ Refusal.reject(call, "CapacitorCookies");
24
+ }
25
+
26
+ @PluginMethod
27
+ public void setCookie(PluginCall call) {
28
+ Refusal.reject(call, "CapacitorCookies");
29
+ }
30
+
31
+ @PluginMethod
32
+ public void deleteCookie(PluginCall call) {
33
+ Refusal.reject(call, "CapacitorCookies");
34
+ }
35
+
36
+ @PluginMethod
37
+ public void clearCookies(PluginCall call) {
38
+ Refusal.reject(call, "CapacitorCookies");
39
+ }
40
+
41
+ @PluginMethod
42
+ public void clearAllCookies(PluginCall call) {
43
+ Refusal.reject(call, "CapacitorCookies");
44
+ }
45
+ }
@@ -0,0 +1,52 @@
1
+ package net.protobase.mobile;
2
+
3
+ import com.getcapacitor.Plugin;
4
+ import com.getcapacitor.PluginCall;
5
+ import com.getcapacitor.PluginMethod;
6
+ import com.getcapacitor.annotation.CapacitorPlugin;
7
+
8
+ /**
9
+ * Takes the place of Capacitor's CapacitorHttp, whose answers hand page script every response header, the session's
10
+ * Set-Cookie included, for any request it makes with the device's cookie jar. Capacitor 8.5.3 registers its core
11
+ * plugins first and an app's plugins after them under the same map, keyed by name only (Bridge.java:660-668,711), so
12
+ * this one replaces it. Native code keeps HttpRequestHandler, which ProtobaseAuth uses.
13
+ */
14
+ @CapacitorPlugin(name = "CapacitorHttp")
15
+ public class RefusedHttp extends Plugin {
16
+
17
+ @Override
18
+ public void load() {
19
+ // Added by the stock plugin's load(), which already ran; plugins load before the first page.
20
+ getBridge().getWebView().removeJavascriptInterface("CapacitorHttpAndroidInterface");
21
+ }
22
+
23
+ @PluginMethod
24
+ public void request(PluginCall call) {
25
+ Refusal.reject(call, "CapacitorHttp");
26
+ }
27
+
28
+ @PluginMethod
29
+ public void get(PluginCall call) {
30
+ Refusal.reject(call, "CapacitorHttp");
31
+ }
32
+
33
+ @PluginMethod
34
+ public void post(PluginCall call) {
35
+ Refusal.reject(call, "CapacitorHttp");
36
+ }
37
+
38
+ @PluginMethod
39
+ public void put(PluginCall call) {
40
+ Refusal.reject(call, "CapacitorHttp");
41
+ }
42
+
43
+ @PluginMethod
44
+ public void patch(PluginCall call) {
45
+ Refusal.reject(call, "CapacitorHttp");
46
+ }
47
+
48
+ @PluginMethod
49
+ public void delete(PluginCall call) {
50
+ Refusal.reject(call, "CapacitorHttp");
51
+ }
52
+ }
@@ -0,0 +1,35 @@
1
+ package net.protobase.mobile;
2
+
3
+ import com.getcapacitor.Plugin;
4
+ import com.getcapacitor.PluginCall;
5
+ import com.getcapacitor.PluginMethod;
6
+ import com.getcapacitor.annotation.CapacitorPlugin;
7
+
8
+ /**
9
+ * Takes the place of Capacitor's WebView plugin, which lets page script point the app's local server at any folder,
10
+ * and keep it there across launches (WebView.java:17-48). Replaced by name as RefusedHttp is. The live updater sets
11
+ * the server's folder on the bridge itself, not through this plugin.
12
+ */
13
+ @CapacitorPlugin(name = "WebView")
14
+ public class RefusedWebView extends Plugin {
15
+
16
+ @PluginMethod
17
+ public void setServerAssetPath(PluginCall call) {
18
+ Refusal.reject(call, "WebView");
19
+ }
20
+
21
+ @PluginMethod
22
+ public void setServerBasePath(PluginCall call) {
23
+ Refusal.reject(call, "WebView");
24
+ }
25
+
26
+ @PluginMethod
27
+ public void getServerBasePath(PluginCall call) {
28
+ Refusal.reject(call, "WebView");
29
+ }
30
+
31
+ @PluginMethod
32
+ public void persistServerBasePath(PluginCall call) {
33
+ Refusal.reject(call, "WebView");
34
+ }
35
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@protobase/mobile",
3
- "version": "0.44.5",
4
- "description": "The Capacitor that protobase mobile builds a Protobase app's iOS and Android projects with, at fixed versions; installed by the CLI on first use.",
3
+ "version": "0.45.1",
4
+ "description": "The Capacitor that protobase mobile builds a Protobase app's iOS and Android projects with, at fixed versions, and the native plugin that keeps page script away from the session; installed by the CLI on first use.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -14,8 +14,16 @@
14
14
  "node": ">=22.12.0"
15
15
  },
16
16
  "files": [
17
- "LICENSE"
17
+ "LICENSE",
18
+ "android/src/main/",
19
+ "android/build.gradle"
18
20
  ],
21
+ "nativeContract": 1,
22
+ "capacitor": {
23
+ "android": {
24
+ "src": "android"
25
+ }
26
+ },
19
27
  "publishConfig": {
20
28
  "access": "public"
21
29
  },