feat: add timezone and encrypt cookies endpoint

This commit is contained in:
Kingkor Roy Tirtho 2025-12-07 19:17:59 +06:00
parent 949519aa61
commit 4129a61d85
9 changed files with 163 additions and 30 deletions

View File

@ -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 {

View File

@ -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<String, Webview> _webviews = {};
String _encryptCookies(dynamic cookies, String secret) {
final keyBytes = base64.decode(secret);
final key = encrypt.Key(keyBytes);
final ivBytes = List<int>.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<Response> 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<Response> 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));

View File

@ -15,7 +15,7 @@ final serverRandomSecretProvider = Provider<String>(
(ref) {
final random = Random.secure();
final values = List<int>.generate(16, (i) => random.nextInt(256));
return base64Url.encode(values);
return base64.encode(values);
},
);
final serverProvider = FutureProvider(

40
rust/Cargo.lock generated
View File

@ -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",

View File

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

View File

@ -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

View File

@ -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(())
}

View File

@ -0,0 +1,27 @@
use rquickjs::prelude::Func;
use rquickjs::{Class, Ctx, Object};
pub fn get_local_timezone() -> rquickjs::Result<String> {
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<Vec<String>> {
let timezones: Vec<String> = 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(())
}

View File

@ -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<rquickjs::Value<'js>> {
pub async fn cookies(&self, ctx: Ctx<'js>) -> rquickjs::Result<Value<'js>> {
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::<WebView>::define(&ctx.globals())?;