diff --git a/lib/main.dart b/lib/main.dart index e5751788..a9c4b65e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -197,19 +197,22 @@ class Spotube extends HookConsumerWidget { "http://${server.server.address.host}:${server.port}", serverSecret: ref.read(serverRandomSecretProvider), pluginScript: """ +console.log("Local Timezone", Timezone.getLocalTimezone()); +console.log("Available Timezones", Timezone.getAvailableTimezones()); class AuthEndpoint { } class CoreEndpoint { async checkUpdate() { console.log(globalThis); - // const webview = await WebView.create("https://spotube.krtirtho.dev"); - // webview.onUrlChange((url) => { - // console.log("url_request: ", url); - // if (url.includes("/about")) { - // webview.close(); - // } - // }); - // await webview.open(); + const webview = await WebView.create("https://spotube.krtirtho.dev"); + webview.onUrlChange(async (url) => { + console.log("url_request: ", url); + if (url.includes("/about")) { + console.log(await webview.cookies()) + webview.close(); + } + }); + await webview.open(); // const res = await SpotubeForm.show("Hello", [ // { // objectType: "input", @@ -222,9 +225,8 @@ class CoreEndpoint { // } // ]) // console.log("Form Result: ", res); - - console.log("LocalStorage Value: ", localStorage.getItem("test_key")); - localStorage.setItem("test_key", "test_value"); + // console.log("LocalStorage Value: ", localStorage.getItem("test_key")); + // localStorage.setItem("test_key", "test_value"); } } class Plugin { diff --git a/lib/provider/server/routes/plugin_apis/webview.dart b/lib/provider/server/routes/plugin_apis/webview.dart index 8349ace0..40340a64 100644 --- a/lib/provider/server/routes/plugin_apis/webview.dart +++ b/lib/provider/server/routes/plugin_apis/webview.dart @@ -1,16 +1,38 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shelf/shelf.dart'; import 'package:shelf_router/shelf_router.dart'; +import 'package:spotube/provider/server/server.dart'; import 'package:spotube/src/plugin_api/webview/webview.dart'; import 'package:async/async.dart'; +import 'package:encrypt/encrypt.dart' as encrypt; class ServerWebviewRoutes { + final Ref ref; + ServerWebviewRoutes({required this.ref}); + final Map _webviews = {}; + String _encryptCookies(dynamic cookies, String secret) { + final keyBytes = base64.decode(secret); + final key = encrypt.Key(keyBytes); + final ivBytes = List.generate(16, (_) => Random.secure().nextInt(256)); + final iv = encrypt.IV(Uint8List.fromList(ivBytes)); + + final encrypter = encrypt.Encrypter( + encrypt.AES(key, mode: encrypt.AESMode.cbc, padding: 'PKCS7'), + ); + + final encrypted = encrypter.encrypt(jsonEncode(cookies), iv: iv); + final combined = iv.bytes + encrypted.bytes; + return base64.encode(combined); + } + Future postCreateWebview(Request request) async { final payload = jsonDecode(await request.readAsString()); final uri = Uri.parse(payload['url'] as String); @@ -105,6 +127,8 @@ class ServerWebviewRoutes { } Future postGetWebviewCookies(Request request) async { + final secret = ref.read(serverRandomSecretProvider); + final body = jsonDecode(await request.readAsString()); final uid = body['uid'] as String; final url = body['url'] as String; @@ -114,8 +138,9 @@ class ServerWebviewRoutes { return Response.notFound('Webview with uid $uid not found'); } final cookies = await webview.getCookies(url); + final encryptedCookies = _encryptCookies(cookies, secret); return Response.ok( - jsonEncode(cookies), + jsonEncode({'data': encryptedCookies}), encoding: utf8, headers: { 'Content-Type': 'application/json', @@ -131,4 +156,5 @@ class ServerWebviewRoutes { } } -final serverWebviewRoutesProvider = Provider((ref) => ServerWebviewRoutes()); +final serverWebviewRoutesProvider = + Provider((ref) => ServerWebviewRoutes(ref: ref)); diff --git a/lib/provider/server/server.dart b/lib/provider/server/server.dart index de9f947f..ca397a81 100644 --- a/lib/provider/server/server.dart +++ b/lib/provider/server/server.dart @@ -15,7 +15,7 @@ final serverRandomSecretProvider = Provider( (ref) { final random = Random.secure(); final values = List.generate(16, (i) => random.nextInt(256)); - return base64Url.encode(values); + return base64.encode(values); }, ); final serverProvider = FutureProvider( diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 71a8f46f..97f8b1b8 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -364,6 +364,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf 0.12.1", +] + [[package]] name = "cipher" version = "0.5.0-rc.2" @@ -1812,7 +1822,7 @@ dependencies = [ "hex-simd", "llrt_build", "memchr", - "phf", + "phf 0.13.1", ] [[package]] @@ -2456,6 +2466,15 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", +] + [[package]] name = "phf" version = "0.13.1" @@ -2463,7 +2482,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ "phf_macros", - "phf_shared", + "phf_shared 0.13.1", ] [[package]] @@ -2473,7 +2492,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", ] [[package]] @@ -2483,12 +2502,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.13.1", "proc-macro2", "quote", "syn", ] +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + [[package]] name = "phf_shared" version = "0.13.1" @@ -2922,11 +2950,15 @@ name = "rust_lib_spotube" version = "0.1.0" dependencies = [ "anyhow", + "base64 0.22.1", + "chrono-tz", "confy", "eventsource-client", "flutter_rust_bridge", "heck", + "iana-time-zone", "llrt_modules", + "openssl", "reqwest", "rquickjs", "serde", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index d66ee23f..04791119 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -22,6 +22,10 @@ llrt_modules = { git = "https://github.com/awslabs/llrt.git", rev = "7d749dd18cf eventsource-client = "0.15.1" reqwest = { version = "0.12", features = ["json"] } confy = "2.0.0" +chrono-tz = "0.10" +iana-time-zone = "0.1" +base64 = "0.22.1" +openssl = "0.10.75" [patch."https://github.com/DelSkayn/rquickjs"] rquickjs = "0.10.0" diff --git a/rust/src/api/plugin/plugin.rs b/rust/src/api/plugin/plugin.rs index 9874b121..29055a38 100644 --- a/rust/src/api/plugin/plugin.rs +++ b/rust/src/api/plugin/plugin.rs @@ -12,7 +12,7 @@ use crate::api::plugin::senders::{ }; use crate::frb_generated::StreamSink; use crate::internal::apis; -use crate::internal::apis::{form, get_platform_directories, webview}; +use crate::internal::apis::{form, get_platform_directories, timezone, webview}; use anyhow::anyhow; use flutter_rust_bridge::{frb, Rust2DartSendError}; use llrt_modules::module_builder::ModuleBuilder; @@ -55,7 +55,8 @@ async fn create_context( .with_global(timers::init) .with_global(util::init) .with_global(form::init) - .with_global(webview::init); + .with_global(webview::init) + .with_global(timezone::init); let (module_resolver, module_loader, global_attachment) = module_builder.build(); runtime diff --git a/rust/src/internal/apis/mod.rs b/rust/src/internal/apis/mod.rs index 46a0b1d8..dc3ef54e 100644 --- a/rust/src/internal/apis/mod.rs +++ b/rust/src/internal/apis/mod.rs @@ -4,10 +4,12 @@ use serde::{Deserialize, Serialize}; pub mod form; pub mod local_storage; pub mod webview; +pub mod timezone; pub fn init(ctx: &Ctx, endpoint_url: String, secret: String) -> rquickjs::Result<()> { ctx.globals().set("__serverUrl", endpoint_url)?; ctx.globals().set("__serverSecret", secret)?; + Ok(()) } diff --git a/rust/src/internal/apis/timezone.rs b/rust/src/internal/apis/timezone.rs new file mode 100644 index 00000000..e6f195d0 --- /dev/null +++ b/rust/src/internal/apis/timezone.rs @@ -0,0 +1,27 @@ +use rquickjs::prelude::Func; +use rquickjs::{Class, Ctx, Object}; + +pub fn get_local_timezone() -> rquickjs::Result { + let timezone = iana_time_zone::get_timezone() + .map_err(|e| rquickjs::Error::new_from_js_message("Timezone", "Error", &e.to_string()))?; + Ok(timezone) +} + +pub fn get_available_timezones() -> rquickjs::Result> { + let timezones: Vec = chrono_tz::TZ_VARIANTS + .iter() + .map(|tz| tz.name().to_string()) + .collect(); + Ok(timezones) +} + +pub fn init(ctx: &Ctx) -> rquickjs::Result<()> { + let globals = ctx.globals(); + let timezone_obj = Object::new(ctx.clone())?; + timezone_obj.set("getLocalTimezone", Func::new(get_local_timezone))?; + timezone_obj.set("getAvailableTimezones", Func::new(get_available_timezones))?; + + globals.set("Timezone", timezone_obj)?; + + Ok(()) +} diff --git a/rust/src/internal/apis/webview.rs b/rust/src/internal/apis/webview.rs index ab3da917..5725b7cf 100644 --- a/rust/src/internal/apis/webview.rs +++ b/rust/src/internal/apis/webview.rs @@ -1,6 +1,8 @@ +use base64::{engine::general_purpose::STANDARD, Engine as _}; use eventsource_client::{Client as EventSourceClient, ClientBuilder}; use flutter_rust_bridge::for_generated::futures::StreamExt; -use rquickjs::{class::Trace, Class, Ctx, Function, JsLifetime}; +use openssl::symm::{decrypt, Cipher}; +use rquickjs::{class::Trace, CatchResultExt, Class, Ctx, Function, JsLifetime, Value}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -78,7 +80,7 @@ impl<'js> WebView<'js> { Class::instance(ctx, webview) } - pub async fn open(&self) -> rquickjs::Result<()> { + pub async fn open(&self, ctx: Ctx<'js>) -> rquickjs::Result<()> { let client = reqwest::Client::new(); let endpoint = format!("{}/plugin-api/webview/open", self.endpoint_url); @@ -95,12 +97,13 @@ impl<'js> WebView<'js> { rquickjs::Error::new_from_js_message("reqwest", "Error", &e.to_string()) })?; - self.url_change_task().await; + self.url_change_task(ctx).await; Ok(()) } - pub async fn cookies(&self, ctx: Ctx<'js>) -> rquickjs::Result> { + pub async fn cookies(&self, ctx: Ctx<'js>) -> rquickjs::Result> { + let secret: String = ctx.globals().get("__serverSecret").unwrap_or_default(); let client = reqwest::Client::new(); let endpoint = format!("{}/plugin-api/webview/cookies", self.endpoint_url); @@ -122,7 +125,36 @@ impl<'js> WebView<'js> { rquickjs::Error::new_from_js_message("reqwest", "Error", &e.to_string()) })?; - let value = ctx.json_parse(data.to_string())?; + let enc = data.get("data").and_then(|v| v.as_str()).ok_or_else(|| { + rquickjs::Error::new_from_js_message("cookies", "Error", "missing encrypted data") + })?; + + let combined = STANDARD.decode(enc.trim()).map_err(|e| { + rquickjs::Error::new_from_js_message("cookies", "Error", &format!("b64 decode: {}", e)) + })?; + + if combined.len() < 16 { + return Err(rquickjs::Error::new_from_js_message( + "cookies", + "Error", + "invalid payload (too short)", + )); + } + + let (iv, cipher_bytes) = combined.split_at(16); + let key = STANDARD.decode(secret.trim()).map_err(|e| { + rquickjs::Error::new_from_js_message("cookies", "Error", &format!("key decode: {}", e)) + })?; + + let plain = decrypt(Cipher::aes_128_cbc(), &key, Some(iv), cipher_bytes).map_err(|e| { + rquickjs::Error::new_from_js_message("cookies", "Error", &format!("decrypt: {}", e)) + })?; + + let cookies_json: serde_json::Value = serde_json::from_slice(&plain).map_err(|e| { + rquickjs::Error::new_from_js_message("cookies", "Error", &format!("json decode: {}", e)) + })?; + + let value = ctx.json_parse(cookies_json.to_string())?; Ok(value) } @@ -152,7 +184,7 @@ impl<'js> WebView<'js> { Ok(()) } - async fn url_change_task(&self) { + async fn url_change_task(&self, ctx: Ctx<'js>) { let endpoint = format!( "{}/plugin-api/webview/{}/on-url-request", self.endpoint_url, self.uid @@ -181,8 +213,17 @@ impl<'js> WebView<'js> { { let url = data.get("url").cloned().unwrap_or_default(); for callback in self.callbacks.iter() { - if let Err(e) = callback.call::<_, ()>((url.clone(),)) { - eprintln!("Error in onUrlChange callback: {}", e); + match callback.call::<_, Value>((url.clone(),)) { + Ok(res) => { + if let Some(promise) = res.into_promise() { + if let Err(e) = promise.into_future::<()>().await.catch(&ctx) { + eprintln!("Error in onUrlChange promise: {}", e); + } + } + } + Err(e) => { + eprintln!("Error in onUrlChange callback: {}", e); + } } } } else { @@ -203,8 +244,6 @@ impl<'js> WebView<'js> { } } - - pub fn init(ctx: &Ctx) -> rquickjs::Result<()> { Class::::define(&ctx.globals())?;