From 9ebbfb2e3ccef8e0f277a0c02fe6b399b41feeb6 Mon Sep 17 00:00:00 2001 From: Amr Bashir Date: Mon, 17 Mar 2025 12:26:42 +0200 Subject: [PATCH] feat(http): persist cookies on disk (#1978) * enhance(http): persist cookies on disk closes tauri-apps/tauri#11518 * clippy * inline reqwest_cookie_store to fix clippy * clippy * Update .changes/persist-cookies.md * Update plugins/http/src/reqwest_cookie_store.rs * update example * fallback to empty store if failed to load * fix example * persist cookies immediately * clone * lint * .cookies filename * prevent race condition --------- Co-authored-by: Lucas Nogueira --- .changes/persist-cookies.md | 7 ++ Cargo.lock | 3 + examples/api/src-tauri/Cargo.toml | 2 + examples/api/src-tauri/src/lib.rs | 21 +++- plugins/http/Cargo.toml | 4 +- plugins/http/src/lib.rs | 51 ++++++++- plugins/http/src/reqwest_cookie_store.rs | 133 +++++++++++++++++++++++ 7 files changed, 217 insertions(+), 4 deletions(-) create mode 100644 .changes/persist-cookies.md create mode 100644 plugins/http/src/reqwest_cookie_store.rs diff --git a/.changes/persist-cookies.md b/.changes/persist-cookies.md new file mode 100644 index 00000000..7160fb7b --- /dev/null +++ b/.changes/persist-cookies.md @@ -0,0 +1,7 @@ +--- +"http": "patch" +"http-js": "patch" +--- + +Persist cookies to disk and load it on next app start. + diff --git a/Cargo.lock b/Cargo.lock index 605d15fc..51dab43b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -233,6 +233,7 @@ dependencies = [ "tauri-plugin-store", "tauri-plugin-updater", "tauri-plugin-window-state", + "time", "tiny_http", ] @@ -6707,6 +6708,8 @@ dependencies = [ name = "tauri-plugin-http" version = "2.4.2" dependencies = [ + "bytes", + "cookie_store", "data-url", "http", "regex", diff --git a/examples/api/src-tauri/Cargo.toml b/examples/api/src-tauri/Cargo.toml index eeee7794..acd0f87e 100644 --- a/examples/api/src-tauri/Cargo.toml +++ b/examples/api/src-tauri/Cargo.toml @@ -18,6 +18,7 @@ tauri-build = { workspace = true, features = ["codegen", "isolation"] } serde_json = { workspace = true } serde = { workspace = true } tiny_http = "0.12" +time = "0.3" log = { workspace = true } tauri-plugin-log = { path = "../../../plugins/log", version = "2.3.1" } tauri-plugin-fs = { path = "../../../plugins/fs", version = "2.2.0", features = [ @@ -27,6 +28,7 @@ tauri-plugin-clipboard-manager = { path = "../../../plugins/clipboard-manager", tauri-plugin-dialog = { path = "../../../plugins/dialog", version = "2.2.0" } tauri-plugin-http = { path = "../../../plugins/http", features = [ "multipart", + "cookies", ], version = "2.4.2" } tauri-plugin-notification = { path = "../../../plugins/notification", version = "2.2.2", features = [ "windows7-compat", diff --git a/examples/api/src-tauri/src/lib.rs b/examples/api/src-tauri/src/lib.rs index dc133799..a19992b0 100644 --- a/examples/api/src-tauri/src/lib.rs +++ b/examples/api/src-tauri/src/lib.rs @@ -102,9 +102,28 @@ pub fn run() { if let Ok(mut request) = server.recv() { let mut body = Vec::new(); let _ = request.as_reader().read_to_end(&mut body); + let mut headers = request.headers().to_vec(); + + if !headers.iter().any(|header| header.field == tiny_http::HeaderField::from_bytes(b"Cookie").unwrap()) { + let expires = time::OffsetDateTime::now_utc() + time::Duration::days(1); + // RFC 1123 format + let format = time::macros::format_description!( + "[weekday repr:short], [day] [month repr:short] [year] [hour]:[minute]:[second] GMT" + ); + let expires_str = expires.format(format).unwrap(); + headers.push( + tiny_http::Header::from_bytes( + &b"Set-Cookie"[..], + format!("session-token=test-value; Secure; Path=/; Expires={expires_str}") + .as_bytes(), + ) + .unwrap(), + ); + } + let response = tiny_http::Response::new( tiny_http::StatusCode(200), - request.headers().to_vec(), + headers, std::io::Cursor::new(body), request.body_length(), None, diff --git a/plugins/http/Cargo.toml b/plugins/http/Cargo.toml index b9676e34..d3a78a23 100644 --- a/plugins/http/Cargo.toml +++ b/plugins/http/Cargo.toml @@ -41,6 +41,8 @@ http = "1" reqwest = { version = "0.12", default-features = false } url = { workspace = true } data-url = "0.3" +cookie_store = { version = "0.21.1", optional = true, features = ["serde"] } +bytes = { version = "1.9", optional = true } tracing = { workspace = true, optional = true } [features] @@ -62,7 +64,7 @@ rustls-tls-manual-roots = ["reqwest/rustls-tls-manual-roots"] rustls-tls-webpki-roots = ["reqwest/rustls-tls-webpki-roots"] rustls-tls-native-roots = ["reqwest/rustls-tls-native-roots"] blocking = ["reqwest/blocking"] -cookies = ["reqwest/cookies"] +cookies = ["reqwest/cookies", "dep:cookie_store", "dep:bytes"] gzip = ["reqwest/gzip"] brotli = ["reqwest/brotli"] deflate = ["reqwest/deflate"] diff --git a/plugins/http/src/lib.rs b/plugins/http/src/lib.rs index 65829e09..5acc2b47 100644 --- a/plugins/http/src/lib.rs +++ b/plugins/http/src/lib.rs @@ -14,25 +14,72 @@ pub use error::{Error, Result}; mod commands; mod error; +#[cfg(feature = "cookies")] +mod reqwest_cookie_store; mod scope; +#[cfg(feature = "cookies")] +const COOKIES_FILENAME: &str = ".cookies"; + pub(crate) struct Http { #[cfg(feature = "cookies")] - cookies_jar: std::sync::Arc, + cookies_jar: std::sync::Arc, } pub fn init() -> TauriPlugin { Builder::::new("http") .setup(|app, _| { + #[cfg(feature = "cookies")] + let cookies_jar = { + use crate::reqwest_cookie_store::*; + use std::fs::File; + use std::io::BufReader; + + let cache_dir = app.path().app_cache_dir()?; + std::fs::create_dir_all(&cache_dir)?; + + let path = cache_dir.join(COOKIES_FILENAME); + let file = File::options() + .create(true) + .append(true) + .read(true) + .open(&path)?; + + let reader = BufReader::new(file); + CookieStoreMutex::load(path.clone(), reader).unwrap_or_else(|_e| { + #[cfg(feature = "tracing")] + tracing::warn!( + "failed to load cookie store: {_e}, falling back to empty store" + ); + CookieStoreMutex::new(path, Default::default()) + }) + }; + let state = Http { #[cfg(feature = "cookies")] - cookies_jar: std::sync::Arc::new(reqwest::cookie::Jar::default()), + cookies_jar: std::sync::Arc::new(cookies_jar), }; app.manage(state); Ok(()) }) + .on_event(|app, event| { + #[cfg(feature = "cookies")] + if let tauri::RunEvent::Exit = event { + let state = app.state::(); + + match state.cookies_jar.request_save() { + Ok(rx) => { + let _ = rx.recv(); + } + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::error!("failed to save cookie jar: {_e}"); + } + } + } + }) .invoke_handler(tauri::generate_handler![ commands::fetch, commands::fetch_cancel, diff --git a/plugins/http/src/reqwest_cookie_store.rs b/plugins/http/src/reqwest_cookie_store.rs new file mode 100644 index 00000000..6a7c0186 --- /dev/null +++ b/plugins/http/src/reqwest_cookie_store.rs @@ -0,0 +1,133 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +// taken from https://github.com/pfernie/reqwest_cookie_store/blob/2ec4afabcd55e24d3afe3f0626ee6dc97bed938d/src/lib.rs + +use std::{ + path::PathBuf, + sync::{mpsc::Receiver, Mutex}, +}; + +use cookie_store::{CookieStore, RawCookie, RawCookieParseError}; +use reqwest::header::HeaderValue; + +fn set_cookies( + cookie_store: &mut CookieStore, + cookie_headers: &mut dyn Iterator, + url: &url::Url, +) { + let cookies = cookie_headers.filter_map(|val| { + std::str::from_utf8(val.as_bytes()) + .map_err(RawCookieParseError::from) + .and_then(RawCookie::parse) + .map(|c| c.into_owned()) + .ok() + }); + cookie_store.store_response_cookies(cookies, url); +} + +fn cookies(cookie_store: &CookieStore, url: &url::Url) -> Option { + let s = cookie_store + .get_request_values(url) + .map(|(name, value)| format!("{}={}", name, value)) + .collect::>() + .join("; "); + + if s.is_empty() { + return None; + } + + HeaderValue::from_maybe_shared(bytes::Bytes::from(s)).ok() +} + +/// A [`cookie_store::CookieStore`] wrapped internally by a [`std::sync::Mutex`], suitable for use in +/// async/concurrent contexts. +#[derive(Debug)] +pub struct CookieStoreMutex { + pub path: PathBuf, + store: Mutex, + save_task: Mutex>, +} + +impl CookieStoreMutex { + /// Create a new [`CookieStoreMutex`] from an existing [`cookie_store::CookieStore`]. + pub fn new(path: PathBuf, cookie_store: CookieStore) -> CookieStoreMutex { + CookieStoreMutex { + path, + store: Mutex::new(cookie_store), + save_task: Default::default(), + } + } + + pub fn load( + path: PathBuf, + reader: R, + ) -> cookie_store::Result { + cookie_store::serde::load(reader, |c| serde_json::from_str(c)) + .map(|store| CookieStoreMutex::new(path, store)) + } + + fn cookies_to_str(&self) -> Result { + let mut cookies = Vec::new(); + for cookie in self + .store + .lock() + .expect("poisoned cookie jar mutex") + .iter_unexpired() + { + if cookie.is_persistent() { + cookies.push(cookie.clone()); + } + } + serde_json::to_string(&cookies) + } + + pub fn request_save(&self) -> cookie_store::Result> { + let cookie_str = self.cookies_to_str()?; + let path = self.path.clone(); + let (tx, rx) = std::sync::mpsc::channel(); + let task = tauri::async_runtime::spawn(async move { + match tokio::fs::write(&path, &cookie_str).await { + Ok(()) => { + let _ = tx.send(()); + } + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::error!("failed to save cookie jar: {_e}"); + } + } + }); + self.save_task + .lock() + .unwrap() + .replace(CancellableTask(task)); + Ok(rx) + } +} + +impl reqwest::cookie::CookieStore for CookieStoreMutex { + fn set_cookies(&self, cookie_headers: &mut dyn Iterator, url: &url::Url) { + set_cookies(&mut self.store.lock().unwrap(), cookie_headers, url); + + // try to persist cookies immediately asynchronously + if let Err(_e) = self.request_save() { + #[cfg(feature = "tracing")] + tracing::error!("failed to save cookie jar: {_e}"); + } + } + + fn cookies(&self, url: &url::Url) -> Option { + let store = self.store.lock().unwrap(); + cookies(&store, url) + } +} + +#[derive(Debug)] +struct CancellableTask(tauri::async_runtime::JoinHandle<()>); + +impl Drop for CancellableTask { + fn drop(&mut self) { + self.0.abort(); + } +}