mirror of
https://github.com/tauri-apps/plugins-workspace.git
synced 2026-08-09 18:36:03 +02:00
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 <lucas@tauri.app>
This commit is contained in:
co-authored by
Lucas Nogueira
parent
4bbcdbd556
commit
9ebbfb2e3c
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"http": "patch"
|
||||||
|
"http-js": "patch"
|
||||||
|
---
|
||||||
|
|
||||||
|
Persist cookies to disk and load it on next app start.
|
||||||
|
|
||||||
Generated
+3
@@ -233,6 +233,7 @@ dependencies = [
|
|||||||
"tauri-plugin-store",
|
"tauri-plugin-store",
|
||||||
"tauri-plugin-updater",
|
"tauri-plugin-updater",
|
||||||
"tauri-plugin-window-state",
|
"tauri-plugin-window-state",
|
||||||
|
"time",
|
||||||
"tiny_http",
|
"tiny_http",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -6707,6 +6708,8 @@ dependencies = [
|
|||||||
name = "tauri-plugin-http"
|
name = "tauri-plugin-http"
|
||||||
version = "2.4.2"
|
version = "2.4.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"cookie_store",
|
||||||
"data-url",
|
"data-url",
|
||||||
"http",
|
"http",
|
||||||
"regex",
|
"regex",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ tauri-build = { workspace = true, features = ["codegen", "isolation"] }
|
|||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
tiny_http = "0.12"
|
tiny_http = "0.12"
|
||||||
|
time = "0.3"
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
tauri-plugin-log = { path = "../../../plugins/log", version = "2.3.1" }
|
tauri-plugin-log = { path = "../../../plugins/log", version = "2.3.1" }
|
||||||
tauri-plugin-fs = { path = "../../../plugins/fs", version = "2.2.0", features = [
|
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-dialog = { path = "../../../plugins/dialog", version = "2.2.0" }
|
||||||
tauri-plugin-http = { path = "../../../plugins/http", features = [
|
tauri-plugin-http = { path = "../../../plugins/http", features = [
|
||||||
"multipart",
|
"multipart",
|
||||||
|
"cookies",
|
||||||
], version = "2.4.2" }
|
], version = "2.4.2" }
|
||||||
tauri-plugin-notification = { path = "../../../plugins/notification", version = "2.2.2", features = [
|
tauri-plugin-notification = { path = "../../../plugins/notification", version = "2.2.2", features = [
|
||||||
"windows7-compat",
|
"windows7-compat",
|
||||||
|
|||||||
@@ -102,9 +102,28 @@ pub fn run() {
|
|||||||
if let Ok(mut request) = server.recv() {
|
if let Ok(mut request) = server.recv() {
|
||||||
let mut body = Vec::new();
|
let mut body = Vec::new();
|
||||||
let _ = request.as_reader().read_to_end(&mut body);
|
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(
|
let response = tiny_http::Response::new(
|
||||||
tiny_http::StatusCode(200),
|
tiny_http::StatusCode(200),
|
||||||
request.headers().to_vec(),
|
headers,
|
||||||
std::io::Cursor::new(body),
|
std::io::Cursor::new(body),
|
||||||
request.body_length(),
|
request.body_length(),
|
||||||
None,
|
None,
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ http = "1"
|
|||||||
reqwest = { version = "0.12", default-features = false }
|
reqwest = { version = "0.12", default-features = false }
|
||||||
url = { workspace = true }
|
url = { workspace = true }
|
||||||
data-url = "0.3"
|
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 }
|
tracing = { workspace = true, optional = true }
|
||||||
|
|
||||||
[features]
|
[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-webpki-roots = ["reqwest/rustls-tls-webpki-roots"]
|
||||||
rustls-tls-native-roots = ["reqwest/rustls-tls-native-roots"]
|
rustls-tls-native-roots = ["reqwest/rustls-tls-native-roots"]
|
||||||
blocking = ["reqwest/blocking"]
|
blocking = ["reqwest/blocking"]
|
||||||
cookies = ["reqwest/cookies"]
|
cookies = ["reqwest/cookies", "dep:cookie_store", "dep:bytes"]
|
||||||
gzip = ["reqwest/gzip"]
|
gzip = ["reqwest/gzip"]
|
||||||
brotli = ["reqwest/brotli"]
|
brotli = ["reqwest/brotli"]
|
||||||
deflate = ["reqwest/deflate"]
|
deflate = ["reqwest/deflate"]
|
||||||
|
|||||||
+49
-2
@@ -14,25 +14,72 @@ pub use error::{Error, Result};
|
|||||||
|
|
||||||
mod commands;
|
mod commands;
|
||||||
mod error;
|
mod error;
|
||||||
|
#[cfg(feature = "cookies")]
|
||||||
|
mod reqwest_cookie_store;
|
||||||
mod scope;
|
mod scope;
|
||||||
|
|
||||||
|
#[cfg(feature = "cookies")]
|
||||||
|
const COOKIES_FILENAME: &str = ".cookies";
|
||||||
|
|
||||||
pub(crate) struct Http {
|
pub(crate) struct Http {
|
||||||
#[cfg(feature = "cookies")]
|
#[cfg(feature = "cookies")]
|
||||||
cookies_jar: std::sync::Arc<reqwest::cookie::Jar>,
|
cookies_jar: std::sync::Arc<crate::reqwest_cookie_store::CookieStoreMutex>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||||
Builder::<R>::new("http")
|
Builder::<R>::new("http")
|
||||||
.setup(|app, _| {
|
.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 {
|
let state = Http {
|
||||||
#[cfg(feature = "cookies")]
|
#[cfg(feature = "cookies")]
|
||||||
cookies_jar: std::sync::Arc::new(reqwest::cookie::Jar::default()),
|
cookies_jar: std::sync::Arc::new(cookies_jar),
|
||||||
};
|
};
|
||||||
|
|
||||||
app.manage(state);
|
app.manage(state);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
|
.on_event(|app, event| {
|
||||||
|
#[cfg(feature = "cookies")]
|
||||||
|
if let tauri::RunEvent::Exit = event {
|
||||||
|
let state = app.state::<Http>();
|
||||||
|
|
||||||
|
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![
|
.invoke_handler(tauri::generate_handler![
|
||||||
commands::fetch,
|
commands::fetch,
|
||||||
commands::fetch_cancel,
|
commands::fetch_cancel,
|
||||||
|
|||||||
@@ -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<Item = &HeaderValue>,
|
||||||
|
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<HeaderValue> {
|
||||||
|
let s = cookie_store
|
||||||
|
.get_request_values(url)
|
||||||
|
.map(|(name, value)| format!("{}={}", name, value))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.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<CookieStore>,
|
||||||
|
save_task: Mutex<Option<CancellableTask>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<R: std::io::BufRead>(
|
||||||
|
path: PathBuf,
|
||||||
|
reader: R,
|
||||||
|
) -> cookie_store::Result<CookieStoreMutex> {
|
||||||
|
cookie_store::serde::load(reader, |c| serde_json::from_str(c))
|
||||||
|
.map(|store| CookieStoreMutex::new(path, store))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cookies_to_str(&self) -> Result<String, serde_json::Error> {
|
||||||
|
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<Receiver<()>> {
|
||||||
|
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<Item = &HeaderValue>, 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<HeaderValue> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user