diff --git a/src/components/sidebar/index.js b/src/components/sidebar/index.js index 7920facfb..fc8b5015c 100644 --- a/src/components/sidebar/index.js +++ b/src/components/sidebar/index.js @@ -216,7 +216,7 @@ function create($container, $toggler) { return; } - defaultAvatar.classList.add("loading"); + defaultAvatar.classList.add("avatar-loading"); const img = User avatar; const avatarFile = await getUserAvatar(user); diff --git a/src/components/sidebar/style.scss b/src/components/sidebar/style.scss index 6b3e76763..55bacb5ec 100644 --- a/src/components/sidebar/style.scss +++ b/src/components/sidebar/style.scss @@ -132,6 +132,17 @@ body.no-animation { &.active { opacity: 1; } + + &.avatar-loading { + opacity: 0.45; + background-color: rgba(255, 255, 255, 0.08); + animation: sidebar-avatar-loading 1.2s ease-in-out infinite; + + &:hover { + opacity: 0.45; + background-color: rgba(255, 255, 255, 0.08); + } + } } } @@ -340,6 +351,17 @@ body.no-animation { } } +@keyframes sidebar-avatar-loading { + 0%, + 100% { + transform: scale(1); + } + + 50% { + transform: scale(0.94); + } +} + .user-menu { position: absolute; bottom: 55px; diff --git a/src/handlers/intent.js b/src/handlers/intent.js index 5e7335b61..a929b46cf 100644 --- a/src/handlers/intent.js +++ b/src/handlers/intent.js @@ -28,6 +28,10 @@ export default async function HandleIntent(intent = {}) { const path = url.replace("acode://", ""); const [module, action, value] = path.split("/"); + if (module === "auth" && action === "callback") { + return; + } + let defaultPrevented = false; const event = new IntentEvent(module, action, value); for (const handler of handlers) { diff --git a/src/lib/auth.js b/src/lib/auth.js index 0e1594778..cf4fa33c9 100644 --- a/src/lib/auth.js +++ b/src/lib/auth.js @@ -1,7 +1,4 @@ -import toast from "components/toast"; -import { addIntentHandler } from "handlers/intent"; import config from "./config"; -import customTab from "./customTab"; /** * @typedef {object} User @@ -29,6 +26,7 @@ let loggedInUser = null; let cacheTimeout = null; const CACHE_USER_KEY = "cached-logged-in-user"; +const LOGIN_RESUME_TIMEOUT_MS = 60_000; const loginEvents = { listeners: new Set(), @@ -50,7 +48,6 @@ class AuthService { #loginTimeout = null; constructor() { - addIntentHandler(this.onIntentReceiver.bind(this)); loginEvents.addListener(() => { clearTimeout(this.#loginTimeout); for (const callback of this.#loginCallbacks) { @@ -66,29 +63,10 @@ class AuthService { } this.#loginCallbacks.clear(); - }, 1000); + }, LOGIN_RESUME_TIMEOUT_MS); }); } - async onIntentReceiver(event) { - try { - if (event?.module === "user" && event?.action === "login") { - if (event?.value) { - this.#exec("saveToken", [event.value]); - toast("Logged in successfully"); - - setTimeout(() => { - loginEvents.emit(); - }, 500); - } - } - return null; - } catch (error) { - console.error("Failed to parse intent token.", error); - return null; - } - } - /** * Helper to wrap cordova.exec in a Promise */ @@ -159,12 +137,22 @@ class AuthService { async login() { return new Promise((resolve, reject) => { - customTab(`${config.BASE_URL}/login?redirect=app`).catch((err) => { - console.error("Custom tab error", err); - reject("Failed to open browser"); - }); - - this.#loginCallbacks.add({ resolve, reject }); + const callback = { resolve, reject }; + this.#loginCallbacks.add(callback); + this.#exec("login", [ + { + baseUrl: config.BASE_URL, + appVersionCode: window.BuildInfo?.versionCode || 0, + }, + ]) + .then(() => { + loginEvents.emit(); + }) + .catch((err) => { + console.error("Native login error", err); + this.#loginCallbacks.delete(callback); + reject("Failed to login"); + }); }); } } diff --git a/src/lib/checkPluginsUpdate.js b/src/lib/checkPluginsUpdate.js index 58d89dc14..4c8a6a6b1 100644 --- a/src/lib/checkPluginsUpdate.js +++ b/src/lib/checkPluginsUpdate.js @@ -1,5 +1,6 @@ import fsOperation from "fileSystem"; import Url from "utils/Url"; +import { isVersionGreater } from "utils/version"; import config from "./config"; export default async function checkPluginsUpdate() { @@ -20,7 +21,26 @@ export default async function checkPluginsUpdate() { if (res.ok) { const json = await res.json(); - if (json.update) { + if (!json.update) return; + + if (json.version) { + if (isVersionGreater(json.version, plugin.version)) { + updates.push(plugin.id); + } + return; + } + + const remotePlugin = await fsOperation( + config.API_BASE, + `plugin/${plugin.id}`, + ) + .readFile("json") + .catch(() => null); + + if ( + !remotePlugin?.version || + isVersionGreater(remotePlugin.version, plugin.version) + ) { updates.push(plugin.id); } } diff --git a/src/lib/installPlugin.js b/src/lib/installPlugin.js index b7de3508d..9dd31ab78 100644 --- a/src/lib/installPlugin.js +++ b/src/lib/installPlugin.js @@ -6,6 +6,7 @@ import purchaseListener from "handlers/purchase"; import JSZip from "jszip"; import helpers from "utils/helpers"; import Url from "utils/Url"; +import { isVersionGreater } from "utils/version"; import config from "./config"; import InstallState from "./installState"; import { loadPluginWithTimeout } from "./loadPlugins"; @@ -406,7 +407,8 @@ async function resolveDepsManifest(deps) { throw new Error(`Unknown plugin dependency: ${dependency}`); const version = await getInstalledPluginVersion(remoteDependency.id); - if (remoteDependency?.version === version) continue; + if (version && !isVersionGreater(remoteDependency?.version, version)) + continue; if (remoteDependency.dependencies) { const manifests = await resolveDepsManifest( diff --git a/src/pages/plugin/plugin.js b/src/pages/plugin/plugin.js index 7106a4e5e..4788e0b1c 100644 --- a/src/pages/plugin/plugin.js +++ b/src/pages/plugin/plugin.js @@ -21,6 +21,7 @@ import markdownItTaskLists from "markdown-it-task-lists"; import { highlightCodeBlock, initHighlighting } from "utils/codeHighlight"; import helpers from "utils/helpers"; import Url from "utils/Url"; +import { isVersionGreater } from "utils/version"; import view, { cleanups } from "./plugin.view.js"; let $lastPluginPage; @@ -160,7 +161,10 @@ export default async function PluginInclude( if (cancelled || !remotePlugin) return; - if (installed && remotePlugin?.version !== plugin.version) { + if ( + installed && + isVersionGreater(remotePlugin?.version, plugin.version) + ) { currentVersion = plugin.version; update = true; } diff --git a/src/plugins/auth/plugin.xml b/src/plugins/auth/plugin.xml index 521b422a3..f8ed0e675 100644 --- a/src/plugins/auth/plugin.xml +++ b/src/plugins/auth/plugin.xml @@ -12,10 +12,11 @@ + - \ No newline at end of file + diff --git a/src/plugins/auth/src/android/Authenticator.java b/src/plugins/auth/src/android/Authenticator.java index f2df9768f..54f754415 100644 --- a/src/plugins/auth/src/android/Authenticator.java +++ b/src/plugins/auth/src/android/Authenticator.java @@ -1,12 +1,25 @@ package com.foxdebug.acode.rk.auth; +import android.content.Intent; +import android.net.Uri; import android.util.Log; import android.webkit.CookieManager; import android.webkit.WebView; +import androidx.browser.customtabs.CustomTabsIntent; import com.foxdebug.acode.rk.auth.EncryptedPreferenceManager; +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; import org.apache.cordova.*; import org.json.JSONArray; import org.json.JSONException; +import org.json.JSONObject; public class Authenticator extends CordovaPlugin { private static final String TAG = "AcodeAuth"; @@ -14,6 +27,11 @@ public class Authenticator extends CordovaPlugin { private static final String KEY_TOKEN = "auth_token"; private static final String PRO_PURCHASED = "pro_purchased"; private static final String KEY_MIGRATED_V2 = "migrated_host_to_domain_cookies"; + private static final String KEY_PENDING_STATE = "pending_login_state"; + private static final String KEY_PENDING_VERIFIER = "pending_login_verifier"; + private static final String KEY_PENDING_BASE_URL = "pending_login_base_url"; + private static final int AUTH_CONNECT_TIMEOUT_MS = 15_000; + private static final int AUTH_READ_TIMEOUT_MS = 30_000; private static final String[] API_ORIGINS = { "https://acode.app" }; @@ -22,6 +40,8 @@ public class Authenticator extends CordovaPlugin { "https://dev.acode.app" }; private EncryptedPreferenceManager prefManager; + private final Object loginCallbackLock = new Object(); + private volatile CallbackContext loginCallback; @Override protected void pluginInitialize() { @@ -41,6 +61,8 @@ protected void pluginInitialize() { if (!token.isEmpty()) { setTokenCookie(token); } + + handleAuthCallback(cordova.getActivity().getIntent()); } @Override @@ -60,12 +82,209 @@ public boolean execute(String action, JSONArray args, CallbackContext callbackCo cordova.getActivity().runOnUiThread(() -> setTokenCookie(token)); callbackContext.success(); return true; + case "login": + JSONObject options = args.optJSONObject(0); + startLogin(options != null ? options : new JSONObject(), callbackContext); + return true; default: Log.w(TAG, "Attempted to call unknown action: " + action); return false; } } + @Override + public void onNewIntent(Intent intent) { + if (!handleAuthCallback(intent)) { + super.onNewIntent(intent); + } + } + + private void startLogin(JSONObject options, CallbackContext callbackContext) { + String baseUrl = options.optString("baseUrl", "https://acode.app"); + int appVersionCode = options.optInt("appVersionCode", 0); + String state = randomHex(24); + String verifier = randomHex(32); + String challenge = sha256Hex(verifier); + + prefManager.setString(KEY_PENDING_STATE, state); + prefManager.setString(KEY_PENDING_VERIFIER, verifier); + prefManager.setString(KEY_PENDING_BASE_URL, baseUrl); + setLoginCallback(callbackContext); + + Uri loginUri = Uri.parse(baseUrl) + .buildUpon() + .appendEncodedPath("login") + .appendQueryParameter("redirect", "app") + .appendQueryParameter("authFlow", "app-code") + .appendQueryParameter("state", state) + .appendQueryParameter("challenge", challenge) + .appendQueryParameter("appVersionCode", String.valueOf(appVersionCode)) + .build(); + + cordova.getActivity().runOnUiThread(() -> { + try { + CustomTabsIntent customTabsIntent = new CustomTabsIntent.Builder().build(); + customTabsIntent.intent.putExtra(CustomTabsIntent.EXTRA_TITLE_VISIBILITY_STATE, CustomTabsIntent.SHOW_PAGE_TITLE); + customTabsIntent.launchUrl(cordova.getActivity(), loginUri); + + PluginResult result = new PluginResult(PluginResult.Status.NO_RESULT); + result.setKeepCallback(true); + callbackContext.sendPluginResult(result); + } catch (Exception error) { + Intent fallback = new Intent(Intent.ACTION_VIEW, loginUri); + try { + cordova.getActivity().startActivity(fallback); + PluginResult result = new PluginResult(PluginResult.Status.NO_RESULT); + result.setKeepCallback(true); + callbackContext.sendPluginResult(result); + } catch (Exception fallbackError) { + failLogin(fallbackError.getMessage()); + } + } + }); + } + + private boolean handleAuthCallback(Intent intent) { + Uri data = intent != null ? intent.getData() : null; + if (data == null || !"acode".equals(data.getScheme()) || !"auth".equals(data.getHost()) || !"/callback".equals(data.getPath())) { + return false; + } + + String code = data.getQueryParameter("code"); + String state = data.getQueryParameter("state"); + String expectedState = prefManager.getString(KEY_PENDING_STATE, ""); + String verifier = prefManager.getString(KEY_PENDING_VERIFIER, ""); + String baseUrl = prefManager.getString(KEY_PENDING_BASE_URL, "https://acode.app"); + + if (code == null || state == null || expectedState.isEmpty() || verifier.isEmpty() || !expectedState.equals(state)) { + failLogin("Invalid login callback"); + return true; + } + + cordova.getThreadPool().execute(() -> { + try { + String token = exchangeCode(baseUrl, code, state, verifier); + prefManager.setString(KEY_TOKEN, token); + prefManager.remove(KEY_PENDING_STATE); + prefManager.remove(KEY_PENDING_VERIFIER); + prefManager.remove(KEY_PENDING_BASE_URL); + cordova.getActivity().runOnUiThread(() -> setTokenCookie(token)); + CallbackContext callback = takeLoginCallback(); + if (callback != null) { + callback.success(); + } + } catch (Exception error) { + Log.e(TAG, "Failed to exchange app auth code", error); + failLogin("Unable to complete login"); + } + }); + + return true; + } + + private String exchangeCode(String baseUrl, String code, String state, String verifier) throws Exception { + URL url = new URL(baseUrl + "/api/user/app-token/exchange"); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + try { + connection.setConnectTimeout(AUTH_CONNECT_TIMEOUT_MS); + connection.setReadTimeout(AUTH_READ_TIMEOUT_MS); + connection.setRequestMethod("POST"); + connection.setRequestProperty("Content-Type", "application/json"); + connection.setDoOutput(true); + + JSONObject body = new JSONObject(); + body.put("code", code); + body.put("state", state); + body.put("verifier", verifier); + + try (OutputStream os = connection.getOutputStream()) { + os.write(body.toString().getBytes(StandardCharsets.UTF_8)); + } + + int status = connection.getResponseCode(); + InputStream stream = status >= 200 && status < 300 ? connection.getInputStream() : connection.getErrorStream(); + String response = readStream(stream); + + if (status < 200 || status >= 300) { + throw new IllegalStateException(response); + } + + JSONObject json = new JSONObject(response); + String token = json.optString("token", ""); + if (token.isEmpty()) { + throw new IllegalStateException("Missing token"); + } + + return token; + } finally { + connection.disconnect(); + } + } + + private String readStream(InputStream stream) throws Exception { + if (stream == null) return ""; + StringBuilder builder = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + builder.append(line); + } + } + return builder.toString(); + } + + private void failLogin(String message) { + prefManager.remove(KEY_PENDING_STATE); + prefManager.remove(KEY_PENDING_VERIFIER); + prefManager.remove(KEY_PENDING_BASE_URL); + CallbackContext callback = takeLoginCallback(); + if (callback != null) { + callback.error(message); + } + } + + private void setLoginCallback(CallbackContext callbackContext) { + CallbackContext previousCallback = null; + synchronized (loginCallbackLock) { + previousCallback = loginCallback; + loginCallback = callbackContext; + } + if (previousCallback != null) { + previousCallback.error("Login cancelled"); + } + } + + private CallbackContext takeLoginCallback() { + synchronized (loginCallbackLock) { + CallbackContext callback = loginCallback; + loginCallback = null; + return callback; + } + } + + private String randomHex(int byteCount) { + byte[] bytes = new byte[byteCount]; + new SecureRandom().nextBytes(bytes); + StringBuilder builder = new StringBuilder(byteCount * 2); + for (byte b : bytes) { + builder.append(String.format("%02x", b)); + } + return builder.toString(); + } + + private String sha256Hex(String value) { + try { + byte[] hash = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder builder = new StringBuilder(hash.length * 2); + for (byte b : hash) { + builder.append(String.format("%02x", b)); + } + return builder.toString(); + } catch (Exception error) { + throw new IllegalStateException("Unable to create auth challenge", error); + } + } + private void setTokenCookie(String token) { CookieManager cm = CookieManager.getInstance(); for (String origin : API_ORIGINS) { diff --git a/src/plugins/system/android/com/foxdebug/system/System.java b/src/plugins/system/android/com/foxdebug/system/System.java index 672a19e4d..917b7be82 100644 --- a/src/plugins/system/android/com/foxdebug/system/System.java +++ b/src/plugins/system/android/com/foxdebug/system/System.java @@ -1885,6 +1885,12 @@ private void setDeprecatedSystemUiVisibility(View decorView, int visibility) { private void getCordovaIntent(CallbackContext callback) { Intent intent = activity.getIntent(); + if (isReservedAuthIntent(intent)) { + callback.sendPluginResult( + new PluginResult(PluginResult.Status.OK, new JSONObject()) + ); + return; + } callback.sendPluginResult( new PluginResult(PluginResult.Status.OK, getIntentJson(intent)) ); @@ -1899,6 +1905,9 @@ private void setIntentHandler(CallbackContext callback) { @Override public void onNewIntent(Intent intent) { + if (isReservedAuthIntent(intent)) { + return; + } if (intentHandler != null) { PluginResult result = new PluginResult( PluginResult.Status.OK, @@ -1909,6 +1918,16 @@ public void onNewIntent(Intent intent) { } } + private boolean isReservedAuthIntent(Intent intent) { + Uri data = intent != null ? intent.getData() : null; + if (data == null || !"acode".equals(data.getScheme())) { + return false; + } + String host = data.getHost(); + String path = data.getPath(); + return "auth".equals(host) && "/callback".equals(path); + } + private JSONObject getIntentJson(Intent intent) { JSONObject json = new JSONObject(); try { diff --git a/src/test/sanity.tests.js b/src/test/sanity.tests.js index 61971c8d9..22802918c 100644 --- a/src/test/sanity.tests.js +++ b/src/test/sanity.tests.js @@ -1,4 +1,5 @@ import { getLanguageModeRecommendationSearchKeyword } from "../lib/languageModeRecommendations"; +import { isVersionGreater } from "../utils/version"; import { TestRunner } from "./tester"; export async function runSanityTests(writeOutput) { @@ -82,6 +83,28 @@ export async function runSanityTests(writeOutput) { ); }); + runner.test( + "Plugin version comparison only accepts newer versions", + (test) => { + test.assert( + isVersionGreater("1.1.2", "1.1.1"), + "Patch updates should be newer", + ); + test.assert( + isVersionGreater("1.2.0", "1.1.9"), + "Minor updates should be newer", + ); + test.assert( + !isVersionGreater("1.1.1", "1.1.1"), + "Equal versions should not be updates", + ); + test.assert( + !isVersionGreater("1.0.0", "1.1.1"), + "Lower remote versions should not be updates", + ); + }, + ); + // Run all tests return await runner.run(writeOutput); } diff --git a/src/utils/version.js b/src/utils/version.js new file mode 100644 index 000000000..1a89aca82 --- /dev/null +++ b/src/utils/version.js @@ -0,0 +1,35 @@ +export function parseVersion(version) { + const parts = String(version || "") + .trim() + .replace(/^v/i, "") + .split("."); + + if (parts.length !== 3) return null; + + const numbers = parts.map((part) => { + if (!/^\d+$/.test(part)) return Number.NaN; + return Number(part); + }); + + if (numbers.some((part) => !Number.isSafeInteger(part))) return null; + + return numbers; +} + +export function compareVersions(versionA, versionB) { + const a = parseVersion(versionA); + const b = parseVersion(versionB); + + if (!a || !b) return 0; + + for (let i = 0; i < a.length; i++) { + if (a[i] > b[i]) return 1; + if (a[i] < b[i]) return -1; + } + + return 0; +} + +export function isVersionGreater(newVersion, currentVersion) { + return compareVersions(newVersion, currentVersion) > 0; +}