From ee62623950123ac8aab2570545b97351989b7dd7 Mon Sep 17 00:00:00 2001 From: Maccesch Date: Wed, 31 Jan 2024 16:54:25 +0000 Subject: [PATCH] finished use_cookie --- CHANGELOG.md | 2 + Cargo.toml | 13 +- examples/ssr/Cargo.toml | 19 +- examples/ssr/src/app.rs | 26 +- examples/ssr/src/fileserv.rs | 6 +- examples/ssr/src/main.rs | 6 +- examples/use_cookie/Cargo.toml | 2 + examples/use_cookie/src/main.rs | 24 +- src/storage/use_local_storage.rs | 2 +- src/storage/use_session_storage.rs | 2 +- src/storage/use_storage.rs | 21 +- src/use_broadcast_channel.rs | 4 + src/use_cookie.rs | 736 +++++++++++++++++++++++++++-- 13 files changed, 776 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 378b5b1..a5f632a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The trait `Codec` has been renamed to `StringCodec` and has been moved to `util::StringCodec`. - The struct `StringCodec` has been renamed to `FromToStringCodec` and has been moved to `util::FromToStringCodec`. - The structs `JsonCodec` and `ProstCodec` have been moved to `util` as well. +- The function `use_storage` now requires type parameters for the stored type and the codec like all the other + `...storage...` functions. ### Fixes 🍕 diff --git a/Cargo.toml b/Cargo.toml index 2ca4ec2..96570e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ js-sys = "0.3" lazy_static = "1" leptos = "0.6" leptos_axum = { version = "0.6", optional = true } +leptos_actix = { version = "0.6", optional = true } num = { version = "0.4", optional = true } paste = "1" prost = { version = "0.12", optional = true } @@ -44,7 +45,7 @@ features = [ "AddEventListenerOptions", "BinaryType", "BroadcastChannel", - "Coordinates", + "Coordinates", "CloseEvent", "CssStyleDeclaration", "CustomEvent", @@ -62,7 +63,7 @@ features = [ "FileList", "Geolocation", "HtmlDocument", - "HtmlElement", + "HtmlElement", "HtmlLinkElement", "HtmlStyleElement", "IntersectionObserver", @@ -72,7 +73,7 @@ features = [ "MediaQueryList", "MediaStream", "MediaStreamTrack", - "MessageEvent", + "MessageEvent", "MouseEvent", "MutationObserver", "MutationObserverInit", @@ -120,8 +121,12 @@ features = [ "WritableStreamDefaultWriter", ] +[dev-dependencies] +rand = "0.8" +getrandom = { version = "0.2", features = ["js"] } + [features] -actix = ["dep:actix-web", "dep:http0_2"] +actix = ["dep:actix-web", "dep:leptos_actix", "dep:http0_2"] axum = ["dep:leptos_axum", "dep:http1"] docs = [] math = ["num"] diff --git a/examples/ssr/Cargo.toml b/examples/ssr/Cargo.toml index 28b1909..6b657b8 100644 --- a/examples/ssr/Cargo.toml +++ b/examples/ssr/Cargo.toml @@ -7,24 +7,24 @@ edition = "2021" crate-type = ["cdylib", "rlib"] [dependencies] -axum = { version = "0.6.4", optional = true } +axum = { version = "0.7", optional = true } console_error_panic_hook = "0.1" console_log = "1" cfg-if = "1" leptos = { version = "0.6", features = ["nightly"] } -leptos_axum = { version = "0.5", optional = true } -leptos_meta = { version = "0.5", features = ["nightly"] } -leptos_router = { version = "0.5", features = ["nightly"] } +leptos_axum = { version = "0.6", optional = true } +leptos_meta = { version = "0.6", features = ["nightly"] } +leptos_router = { version = "0.6", features = ["nightly"] } leptos-use = { path = "../.." } log = "0.4" simple_logger = "4" -tokio = { version = "1.25.0", optional = true } -tower = { version = "0.4.13", optional = true } -tower-http = { version = "0.4.3", features = ["fs"], optional = true } +tokio = { version = "1", features = ["full"], optional = true } +tower = { version = "0.4", optional = true } +tower-http = { version = "0.5", features = ["fs"], optional = true } wasm-bindgen = "0.2.88" thiserror = "1.0.38" tracing = { version = "0.1.37", optional = true } -http = "0.2.8" +http = "1" [features] hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"] @@ -38,7 +38,8 @@ ssr = [ "leptos_meta/ssr", "leptos_router/ssr", "dep:tracing", - "leptos-use/ssr" + "leptos-use/ssr", + "leptos-use/axum", ] [package.metadata.cargo-all-features] diff --git a/examples/ssr/src/app.rs b/examples/ssr/src/app.rs index 491f896..0cc7768 100644 --- a/examples/ssr/src/app.rs +++ b/examples/ssr/src/app.rs @@ -3,11 +3,12 @@ use leptos::ev::{keypress, KeyboardEvent}; use leptos::*; use leptos_meta::*; use leptos_router::*; -use leptos_use::storage::{use_local_storage, StringCodec}; +use leptos_use::storage::use_local_storage; +use leptos_use::utils::FromToStringCodec; use leptos_use::{ - use_color_mode, use_debounce_fn, use_event_listener, use_interval, use_intl_number_format, - use_preferred_dark, use_timestamp, use_window, ColorMode, UseColorModeReturn, - UseIntervalReturn, UseIntlNumberFormatOptions, + use_color_mode, use_cookie_with_options, use_debounce_fn, use_event_listener, use_interval, + use_intl_number_format, use_preferred_dark, use_timestamp, use_window, ColorMode, + UseColorModeReturn, UseCookieOptions, UseIntervalReturn, UseIntlNumberFormatOptions, }; #[component] @@ -15,6 +16,11 @@ pub fn App() -> impl IntoView { // Provides context that manages stylesheets, titles, meta tags, etc. provide_meta_context(); + #[cfg(feature = "ssr")] + { + expect_context::(); + } + view! { @@ -38,7 +44,7 @@ pub fn App() -> impl IntoView { #[component] fn HomePage() -> impl IntoView { // Creates a reactive value to update the button - let (count, set_count, _) = use_local_storage::("count-state"); + let (count, set_count, _) = use_local_storage::("count-state"); let on_click = move |_| set_count.update(|count| *count += 1); let nf = use_intl_number_format( @@ -70,6 +76,13 @@ fn HomePage() -> impl IntoView { let is_dark_preferred = use_preferred_dark(); + let (session_cookie, _) = use_cookie_with_options::( + "session", + UseCookieOptions::::default() + .max_age(3600) + .default_value(Some("Bogus session string".to_owned())), + ); + view! {

Leptos-Use SSR Example

@@ -83,6 +96,7 @@ fn HomePage() -> impl IntoView {

{timestamp}

Dark preferred: {is_dark_preferred}

+

Session cookie: {session_cookie}

} } @@ -90,7 +104,7 @@ fn HomePage() -> impl IntoView { pub fn LocalStorageTest() -> impl IntoView { let UseIntervalReturn { counter, .. } = use_interval(1000); logging::log!("test log"); - let (state, set_state, ..) = use_local_storage::("test-state"); + let (state, set_state, ..) = use_local_storage::("test-state"); view! {

{counter}

diff --git a/examples/ssr/src/fileserv.rs b/examples/ssr/src/fileserv.rs index acc1635..6cb34a3 100644 --- a/examples/ssr/src/fileserv.rs +++ b/examples/ssr/src/fileserv.rs @@ -2,7 +2,7 @@ use cfg_if::cfg_if; cfg_if! { if #[cfg(feature = "ssr")] { use axum::{ - body::{boxed, Body, BoxBody}, + body::Body, extract::State, response::IntoResponse, http::{Request, Response, StatusCode, Uri}, @@ -25,12 +25,12 @@ cfg_if! { if #[cfg(feature = "ssr")] { } } - async fn get_static_file(uri: Uri, root: &str) -> Result, (StatusCode, String)> { + async fn get_static_file(uri: Uri, root: &str) -> Result, (StatusCode, String)> { let req = Request::builder().uri(uri.clone()).body(Body::empty()).unwrap(); // `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot` // This path is relative to the cargo root match ServeDir::new(root).oneshot(req).await { - Ok(res) => Ok(res.map(boxed)), + Ok(res) => Ok(res.into_response()), Err(err) => Err(( StatusCode::INTERNAL_SERVER_ERROR, format!("Something went wrong: {err}"), diff --git a/examples/ssr/src/main.rs b/examples/ssr/src/main.rs index da34a9f..4d92392 100644 --- a/examples/ssr/src/main.rs +++ b/examples/ssr/src/main.rs @@ -27,11 +27,9 @@ async fn main() { .fallback(file_and_error_handler) .with_state(leptos_options); - // run our app with hyper - // `axum::Server` is a re-export of `hyper::Server` + let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); log!("listening on http://{}", &addr); - axum::Server::bind(&addr) - .serve(app.into_make_service()) + axum::serve(listener, app.into_make_service()) .await .unwrap(); } diff --git a/examples/use_cookie/Cargo.toml b/examples/use_cookie/Cargo.toml index 90f4bb2..280824f 100644 --- a/examples/use_cookie/Cargo.toml +++ b/examples/use_cookie/Cargo.toml @@ -9,6 +9,8 @@ console_error_panic_hook = "0.1" console_log = "1" log = "0.4" leptos-use = { path = "../..", features = ["docs"] } +rand = "0.8" +getrandom = { version = "0.2", features = ["js"] } web-sys = "0.3" [dev-dependencies] diff --git a/examples/use_cookie/src/main.rs b/examples/use_cookie/src/main.rs index c6674f6..094509d 100644 --- a/examples/use_cookie/src/main.rs +++ b/examples/use_cookie/src/main.rs @@ -1,15 +1,27 @@ use leptos::*; use leptos_use::docs::demo_or_body; use leptos_use::use_cookie; +use leptos_use::utils::FromToStringCodec; +use rand::prelude::*; #[component] fn Demo() -> impl IntoView { - if let Some(cookie) = use_cookie("auth") { - view! {
"'auth' cookie set to " "`" {cookie.value().to_string()} "`"
} - .into_view() - } else { - view! {
"No 'auth' cookie set"
} - .into_view() + let (counter, set_counter) = use_cookie::("counter"); + + let reset = move || set_counter(Some(random())); + + if counter().is_none() { + reset(); + } + + let increase = move || { + set_counter(counter().map(|c| c + 1)); + }; + + view! { +

Counter: {move || counter().map(|c| c.to_string()).unwrap_or("—".to_string())}

+ + } } diff --git a/src/storage/use_local_storage.rs b/src/storage/use_local_storage.rs index f1b546d..2e9d375 100644 --- a/src/storage/use_local_storage.rs +++ b/src/storage/use_local_storage.rs @@ -31,7 +31,7 @@ pub fn use_local_storage_with_options( ) -> (Signal, WriteSignal, impl Fn() + Clone) where T: Clone + PartialEq, - C: StringCodec, + C: StringCodec + Default, { use_storage_with_options(StorageType::Local, key, options) } diff --git a/src/storage/use_session_storage.rs b/src/storage/use_session_storage.rs index 6d2a215..55c963b 100644 --- a/src/storage/use_session_storage.rs +++ b/src/storage/use_session_storage.rs @@ -31,7 +31,7 @@ pub fn use_session_storage_with_options( ) -> (Signal, WriteSignal, impl Fn() + Clone) where T: Clone + PartialEq, - C: StringCodec, + C: StringCodec + Default, { use_storage_with_options(StorageType::Session, key, options) } diff --git a/src/storage/use_storage.rs b/src/storage/use_storage.rs index 77e95d1..bb1c54d 100644 --- a/src/storage/use_storage.rs +++ b/src/storage/use_storage.rs @@ -1,4 +1,3 @@ -use crate::utils::FromToStringCodec; use crate::{ core::{MaybeRwSignal, StorageType}, utils::{FilterOptions, StringCodec}, @@ -29,7 +28,7 @@ const INTERNAL_STORAGE_EVENT: &str = "leptos-use-storage"; /// /// ``` /// # use leptos::*; -/// # use leptos_use::storage::{StorageType, use_local_storage, use_session_storage, use_storage_with_options, UseStorageOptions}; +/// # use leptos_use::storage::{StorageType, use_local_storage, use_session_storage, use_storage}; /// # use serde::{Deserialize, Serialize}; /// # use leptos_use::utils::{FromToStringCodec, JsonCodec, ProstCodec}; /// # @@ -46,7 +45,7 @@ const INTERNAL_STORAGE_EVENT: &str = "leptos-use-storage"; /// let (count, set_count, _) = use_session_storage::("my-count-kept-in-js"); /// /// // Bind string with SessionStorage stored in ProtoBuf format: -/// let (id, set_id, _) = use_storage_with::( +/// let (id, set_id, _) = use_storage::( /// StorageType::Session, /// "my-id", /// ); @@ -76,15 +75,15 @@ const INTERNAL_STORAGE_EVENT: &str = "leptos-use-storage"; /// /// All you need to do is to implement the [`StringCodec`] trait together with `Default` and `Clone`. #[inline(always)] -pub fn use_storage( +pub fn use_storage( storage_type: StorageType, key: impl AsRef, -) -> (Signal, WriteSignal, impl Fn() + Clone) { - use_storage_with_options::( - storage_type, - key, - UseStorageOptions::default(), - ) +) -> (Signal, WriteSignal, impl Fn() + Clone) +where + T: Default + Clone + PartialEq, + C: StringCodec + Default, +{ + use_storage_with_options::(storage_type, key, UseStorageOptions::default()) } /// Version of [`use_storage`] that accepts [`UseStorageOptions`]. @@ -95,7 +94,7 @@ pub fn use_storage_with_options( ) -> (Signal, WriteSignal, impl Fn() + Clone) where T: Clone + PartialEq, - C: StringCodec, + C: StringCodec + Default, { let UseStorageOptions { codec, diff --git a/src/use_broadcast_channel.rs b/src/use_broadcast_channel.rs index 010815b..eed589b 100644 --- a/src/use_broadcast_channel.rs +++ b/src/use_broadcast_channel.rs @@ -65,6 +65,10 @@ use wasm_bindgen::JsValue; /// # view! { } /// # } /// ``` +/// +/// ## Create Your Own Custom Codec +/// +/// All you need to do is to implement the [`StringCodec`] trait together with `Default` and `Clone`. pub fn use_broadcast_channel( name: &str, ) -> UseBroadcastChannelReturn diff --git a/src/use_cookie.rs b/src/use_cookie.rs index 1932f9b..758031b 100644 --- a/src/use_cookie.rs +++ b/src/use_cookie.rs @@ -1,7 +1,14 @@ -use cookie::Cookie; -use default_struct_builder::DefaultBuilder; +#![allow(clippy::too_many_arguments)] -/// Get a cookie by name, for both SSR and CSR +use crate::core::now; +use crate::utils::StringCodec; +use cookie::time::{Duration, OffsetDateTime}; +use cookie::{Cookie, CookieJar, SameSite}; +use default_struct_builder::DefaultBuilder; +use leptos::*; +use std::rc::Rc; + +/// SSR-friendly and reactive cookie access. /// /// ## Demo /// @@ -9,34 +16,69 @@ use default_struct_builder::DefaultBuilder; /// /// ## Usage /// -/// This provides you with the cookie that has been set. For more details on how to use the cookie provided, refer: https://docs.rs/cookie/0.18/cookie/struct.Cookie.html +/// The example below creates a cookie called `counter`. If the cookie doesn't exist, it is initially set to a random value. +/// Whenever we update the `counter` variable, the cookie will be updated accordingly. /// /// ``` /// # use leptos::*; /// # use leptos_use::use_cookie; +/// # use leptos_use::utils::FromToStringCodec; +/// # use rand::prelude::*; +/// /// # /// # #[component] /// # fn Demo() -> impl IntoView { -/// if let Some(cookie) = use_cookie("auth") { -/// view! { -///
-/// format!("'auth' cookie set to `{}`", cookie.value()) -///
-/// }.into_view() -/// } else { -/// view! { -///
-/// "No 'auth' cookie set" -///
-/// }.into_view() +/// let (counter, set_counter) = use_cookie::("counter"); +/// +/// let reset = move || set_counter.set(Some(random())); +/// +/// if counter.get().is_none() { +/// reset(); /// } +/// +/// let increase = move || { +/// set_counter.set(counter.get().map(|c| c + 1)); +/// }; +/// +/// view! { +///

Counter: {move || counter.get().map(|c| c.to_string()).unwrap_or("—".to_string())}

+/// +/// +/// } +/// # } +/// ``` +/// +/// See [`StringCodec`] for details on how to handle versioning — dealing with data that can outlast your code. +/// +/// ## Cookie attributes +/// +/// As part of the options when you use `use_cookie_with_options` you can specify cookie attributes. +/// +/// ``` +/// # use cookie::SameSite; +/// # use leptos::*; +/// # use leptos_use::{use_cookie_with_options, UseCookieOptions}; +/// # use leptos_use::utils::FromToStringCodec; +/// # +/// # #[component] +/// # fn Demo() -> impl IntoView { +/// let (cookie, set_cookie) = use_cookie_with_options::( +/// "user_info", +/// UseCookieOptions::default() +/// .max_age(3600_000) // one hour +/// .same_site(SameSite::Lax) +/// ); +/// # +/// # view! {} /// # } /// ``` /// /// ## Server-Side Rendering /// /// This works equally well on the server or the client. -/// On the server this function gets the cookie from the HTTP request header. +/// On the server this function reads the cookie from the HTTP request header and writes it back into +/// the HTTP response header according to options (if provided). +/// The returned `WriteSignal` will not affect the cookie headers on the server. /// /// > If you're using `axum` you have to enable the `"axum"` feature in your Cargo.toml. /// > In case it's `actix-web` enable the feature `"actix"`. @@ -44,62 +86,379 @@ use default_struct_builder::DefaultBuilder; /// ### Bring your own header /// /// In case you're neither using Axum nor Actix, or the default implementation is not to your liking, -/// you can provide your own way of reading the cookie header value. +/// you can provide your own way of reading and writing the cookie header value. /// /// ``` +/// # use cookie::Cookie; /// # use leptos::*; +/// # use serde::{Deserialize, Serialize}; /// # use leptos_use::{use_cookie_with_options, UseCookieOptions}; +/// # use leptos_use::utils::JsonCodec; +/// # +/// # #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +/// # pub struct Auth { +/// # pub username: String, +/// # pub token: String, +/// # } /// # /// # #[component] /// # fn Demo() -> impl IntoView { -/// use_cookie_with_options("auth", UseCookieOptions::default().ssr_cookies_header_getter(|| { -/// #[cfg(feature = "ssr")] -/// { -/// "Somehow get the value of the cookie header as a string".to_owned() -/// } -/// })); +/// use_cookie_with_options::( +/// "auth", +/// UseCookieOptions::default() +/// .ssr_cookies_header_getter(|| { +/// #[cfg(feature = "ssr")] +/// { +/// "Somehow get the value of the cookie header as a string".to_owned() +/// } +/// }) +/// .ssr_set_cookie(|cookie: &Cookie| { +/// #[cfg(feature = "ssr")] +/// { +/// // somehow insert the Set-Cookie header for this cookie +/// } +/// }), +/// ); /// # view! {} /// # } /// ``` -pub fn use_cookie(cookie_name: &str) -> Option> { - use_cookie_with_options(cookie_name, UseCookieOptions::default()) +/// +/// ## Create Your Own Custom Codec +/// +/// All you need to do is to implement the [`StringCodec`] trait together with `Default` and `Clone`. +pub fn use_cookie(cookie_name: &str) -> (Signal>, WriteSignal>) +where + C: StringCodec + Default + Clone, + T: Clone, +{ + use_cookie_with_options::(cookie_name, UseCookieOptions::default()) } /// Version of [`use_cookie`] that takes [`UseCookieOptions`]. -pub fn use_cookie_with_options( +pub fn use_cookie_with_options( cookie_name: &str, - options: UseCookieOptions, -) -> Option> { + options: UseCookieOptions, +) -> (Signal>, WriteSignal>) +where + C: StringCodec + Default + Clone, + T: Clone, +{ let UseCookieOptions { + max_age, + expires, + http_only, + secure, + domain, + path, + same_site, ssr_cookies_header_getter, + ssr_set_cookie, + default_value, + readonly, + on_error, } = options; - let cookies = read_cookies_string(ssr_cookies_header_getter); + let delay = if let Some(max_age) = max_age { + Some(max_age * 1000) + } else { + expires.map(|expires| expires * 1000 - now() as i64) + }; - Cookie::split_parse_encoded(cookies) - .filter_map(|cookie| cookie.ok()) - .find(|cookie| cookie.name() == cookie_name) - .map(|cookie| cookie.into_owned()) + let has_expired = if let Some(delay) = delay { + delay <= 0 + } else { + false + }; + + let (cookie, set_cookie) = create_signal(None::); + + let jar = store_value(CookieJar::new()); + let codec = C::default(); + + if !has_expired { + let ssr_cookies_header_getter = Rc::clone(&ssr_cookies_header_getter); + + jar.update_value(|jar| { + *jar = load_and_parse_cookie_jar(ssr_cookies_header_getter); + + set_cookie.set( + jar.get(cookie_name) + .and_then(|c| { + codec + .decode(c.value().to_string()) + .map_err(|err| on_error(err)) + .ok() + }) + .or(default_value), + ); + }); + + handle_expiration(delay, set_cookie); + } else { + logging::debug_warn!( + "not setting cookie '{}' because it has already expired", + cookie_name + ); + } + + #[cfg(not(feature = "ssr"))] + { + use crate::{ + use_broadcast_channel, watch_pausable, UseBroadcastChannelReturn, WatchPausableReturn, + }; + + let UseBroadcastChannelReturn { message, post, .. } = + use_broadcast_channel::, OptionStringCodec>(&format!( + "leptos-use:cookies:{cookie_name}" + )); + + let on_cookie_change = { + let cookie_name = cookie_name.to_owned(); + let ssr_cookies_header_getter = Rc::clone(&ssr_cookies_header_getter); + let codec = codec.clone(); + let on_error = Rc::clone(&on_error); + let domain = domain.clone(); + let path = path.clone(); + + move || { + if readonly { + return; + } + + let value = cookie.with_untracked(|cookie| { + cookie + .as_ref() + .and_then(|cookie| codec.encode(cookie).map_err(|err| on_error(err)).ok()) + }); + + if value + == jar.with_value(|jar| jar.get(&cookie_name).map(|c| c.value().to_owned())) + { + return; + } + + jar.update_value(|jar| { + write_client_cookie( + &cookie_name, + &value, + jar, + max_age, + expires, + &domain, + &path, + same_site, + secure, + http_only, + Rc::clone(&ssr_cookies_header_getter), + ); + }); + + post(&value); + } + }; + + let WatchPausableReturn { + pause, + resume, + stop, + .. + } = watch_pausable(move || cookie.get(), { + let on_cookie_change = on_cookie_change.clone(); + + move |_, _, _| { + on_cookie_change(); + } + }); + + // listen to cookie changes from the broadcast channel + create_effect({ + let ssr_cookies_header_getter = Rc::clone(&ssr_cookies_header_getter); + let cookie_name = cookie_name.to_owned(); + + move |_| { + if let Some(message) = message.get() { + pause(); + + if let Some(message) = message { + match codec.decode(message.clone()) { + Ok(value) => { + let ssr_cookies_header_getter = + Rc::clone(&ssr_cookies_header_getter); + + jar.update_value(|jar| { + update_client_cookie_jar( + &cookie_name, + &Some(message), + jar, + max_age, + expires, + &domain, + &path, + same_site, + secure, + http_only, + ssr_cookies_header_getter, + ); + }); + + set_cookie.set(Some(value)); + } + Err(err) => { + on_error(err); + } + } + } else { + let cookie_name = cookie_name.clone(); + + jar.update_value(|jar| { + jar.force_remove(cookie_name); + }); + + set_cookie.set(None); + } + + resume(); + } + } + }); + + on_cleanup(move || { + stop(); + on_cookie_change(); + }); + + let _ = ssr_set_cookie; + } + + #[cfg(feature = "ssr")] + { + if !readonly { + let value = cookie + .with_untracked(|cookie| { + cookie + .as_ref() + .map(|cookie| codec.encode(&cookie).map_err(|err| on_error(err)).ok()) + }) + .flatten(); + jar.update_value(|jar| { + write_server_cookie( + cookie_name, + value, + jar, + max_age, + expires, + domain, + path, + same_site, + secure, + http_only, + ssr_set_cookie, + ) + }); + } + } + + (cookie.into(), set_cookie) } /// Options for [`use_cookie_with_options`]. #[derive(DefaultBuilder)] -pub struct UseCookieOptions { +pub struct UseCookieOptions { + /// [`Max-Age` of the cookie](https://tools.ietf.org/html/rfc6265#section-5.2.2) in seconds. The returned signal will turn to `None` after the max age is reached. + /// Default: `None` + /// + /// > The [cookie storage model specification](https://tools.ietf.org/html/rfc6265#section-5.3) states + /// > that if both `expires` and `max_age` is set, then `max_age` takes precedence, + /// > but not all clients may obey this, so if both are set, they should point to the same date and time! + /// + /// > If neither of `expires` and `max_age` is set, the cookie will be session-only and removed when the user closes their browser. + #[builder(into)] + max_age: Option, + + /// [Expiration date-time of the cookie](https://tools.ietf.org/html/rfc6265#section-5.2.1) as UNIX timestamp in seconds. + /// The signal will turn to `None` after the expiration date-time is reached. + /// Default: `None` + /// + /// > The [cookie storage model specification](https://tools.ietf.org/html/rfc6265#section-5.3) states + /// > that if both `expires` and `max_age` is set, then `max_age` takes precedence, + /// > but not all clients may obey this, so if both are set, they should point to the same date and time! + /// + /// > If neither of `expires` and `max_age` is set, the cookie will be session-only and removed when the user closes their browser. + #[builder(into)] + expires: Option, + + /// Specifies the [`HttpOnly` cookie attribute](https://tools.ietf.org/html/rfc6265#section-5.2.6). + /// When `true`, the `HttpOnly` attribute is set; otherwise it is not. + /// By default, the `HttpOnly` attribute is not set. + /// + /// > Be careful when setting this to `true`, as compliant clients will not allow client-side JavaScript to see the cookie in `document.cookie`. + http_only: bool, + + /// Specifies the value for the [`Secure` cookie attribute](https://tools.ietf.org/html/rfc6265#section-5.2.5). + /// When `true`, the `Secure` attribute is set; otherwise it is not. + /// By default, the `Secure` attribute is not set. + /// + /// > Be careful when setting this to `true`, as compliant clients will not send the cookie back to the + /// > server in the future if the browser does not have an HTTPS connection. This can lead to hydration errors. + secure: bool, + + /// Specifies the value for the [`Domain` cookie attribute](https://tools.ietf.org/html/rfc6265#section-5.2.3). + /// By default, no domain is set, and most clients will consider applying the cookie only to the current domain. + #[builder(into)] + domain: Option, + + /// Specifies the value for the [`Path` cookie attribute](https://tools.ietf.org/html/rfc6265#section-5.2.4). + /// By default, the path is considered the ["default path"](https://tools.ietf.org/html/rfc6265#section-5.1.4). + #[builder(into)] + path: Option, + + /// Specifies the value for the [`SameSite` cookie attribute](https://tools.ietf.org/html/draft-ietf-httpbis-rfc6265bis-03#section-4.1.2.7). + /// + /// - `'Some(SameSite::Lax)'` will set the `SameSite` attribute to `Lax` for lax same-site enforcement. + /// - `'Some(SameSite::None)'` will set the `SameSite` attribute to `None` for an explicit cross-site cookie. + /// - `'Some(SameSite::Strict)'` will set the `SameSite` attribute to `Strict` for strict same-site enforcement. + /// - `None` will not set the `SameSite` attribute (default). + /// + /// More information about the different enforcement levels can be found in [the specification](https://tools.ietf.org/html/draft-ietf-httpbis-rfc6265bis-03#section-4.1.2.7). + #[builder(into)] + same_site: Option, + + /// The default cookie value in case the cookie is not set. + /// Defaults to `None`. + default_value: Option, + + /// If `true` the returned `WriteSignal` will not affect the actual cookie. + /// Default: `false` + readonly: bool, + /// Getter function to return the string value of the cookie header. /// When you use one of the features "axum" or "actix" there's a valid default implementation provided. - ssr_cookies_header_getter: Box String>, + ssr_cookies_header_getter: Rc String>, + + /// Function to add a set cookie header to the response on the server. + /// When you use one of the features "axum" or "actix" there's a valid default implementation provided. + ssr_set_cookie: Rc, + + /// Callback for encoding/decoding errors. Defaults to logging the error to the console. + on_error: Rc, } -impl Default for UseCookieOptions { +impl Default for UseCookieOptions { #[allow(dead_code)] fn default() -> Self { Self { - ssr_cookies_header_getter: Box::new(move || { + max_age: None, + expires: None, + http_only: false, + default_value: None, + readonly: false, + secure: false, + domain: None, + path: None, + same_site: None, + ssr_cookies_header_getter: Rc::new(move || { #[cfg(feature = "ssr")] { - #[cfg(any(feature = "axum", feature = "actix"))] - use leptos::expect_context; - #[cfg(all(feature = "actix", feature = "axum"))] compile_error!("You cannot enable only one of features \"actix\" and \"axum\" at the same time"); @@ -142,11 +501,52 @@ impl Default for UseCookieOptions { #[cfg(not(feature = "ssr"))] "".to_owned() }), + ssr_set_cookie: Rc::new(|cookie: &Cookie| { + #[cfg(feature = "ssr")] + { + #[cfg(feature = "actix")] + use leptos_actix::ResponseOptions; + #[cfg(feature = "axum")] + use leptos_axum::ResponseOptions; + + #[cfg(feature = "actix")] + const SET_COOKIE: http0_2::HeaderName = http0_2::header::SET_COOKIE; + #[cfg(feature = "axum")] + const SET_COOKIE: http1::HeaderName = http1::header::SET_COOKIE; + + #[cfg(feature = "actix")] + type HeaderValue = http0_2::HeaderValue; + #[cfg(feature = "axum")] + type HeaderValue = http1::HeaderValue; + + #[cfg(all(not(feature = "axum"), not(feature = "actix")))] + { + let _ = cookie; + leptos::logging::warn!("If you're using use_cookie without the feature `axum` or `actix` enabled, you should provide the option `ssr_set_cookie`"); + } + + #[cfg(any(feature = "axum", feature = "actix"))] + { + let response_options = expect_context::(); + + if let Ok(header_value) = + HeaderValue::from_str(&cookie.encoded().to_string()) + { + response_options.insert_header(SET_COOKIE, header_value); + } + } + } + + let _ = cookie; + }), + on_error: Rc::new(|_| { + logging::error!("cookie (de-/)serialization error"); + }), } } } -fn read_cookies_string(ssr_cookies_header_getter: Box String>) -> String { +fn read_cookies_string(ssr_cookies_header_getter: Rc String>) -> String { let cookies; #[cfg(feature = "ssr")] @@ -167,3 +567,255 @@ fn read_cookies_string(ssr_cookies_header_getter: Box String>) -> St cookies } + +fn handle_expiration(delay: Option, set_cookie: WriteSignal>) { + if let Some(delay) = delay { + #[cfg(not(feature = "ssr"))] + { + use leptos::leptos_dom::helpers::TimeoutHandle; + use std::cell::Cell; + use std::cell::RefCell; + + // The maximum value allowed on a timeout delay. + // Reference: https://developer.mozilla.org/en-US/docs/Web/API/setTimeout#maximum_delay_value + const MAX_TIMEOUT_DELAY: i64 = 2_147_483_647; + + let timeout = Rc::new(Cell::new(None::)); + let elapsed = Rc::new(Cell::new(0)); + + on_cleanup({ + let timeout = Rc::clone(&timeout); + move || { + if let Some(timeout) = timeout.take() { + timeout.clear(); + } + } + }); + + let create_expiration_timeout = Rc::new(RefCell::new(None::>)); + + create_expiration_timeout.replace(Some(Box::new({ + let timeout = Rc::clone(&timeout); + let elapsed = Rc::clone(&elapsed); + let create_expiration_timeout = Rc::clone(&create_expiration_timeout); + + move || { + if let Some(timeout) = timeout.take() { + timeout.clear(); + } + + let time_remaining = delay - elapsed.get(); + let timeout_length = time_remaining.min(MAX_TIMEOUT_DELAY); + + let elapsed = Rc::clone(&elapsed); + let create_expiration_timeout = Rc::clone(&create_expiration_timeout); + + timeout.set( + set_timeout_with_handle( + move || { + elapsed.set(elapsed.get() + timeout_length); + if elapsed.get() < delay { + if let Some(create_expiration_timeout) = + &*create_expiration_timeout.borrow() + { + create_expiration_timeout(); + } + return; + } + + set_cookie.set(None); + }, + std::time::Duration::from_millis(timeout_length as u64), + ) + .ok(), + ); + } + }))); + + if let Some(create_expiration_timeout) = &*create_expiration_timeout.borrow() { + create_expiration_timeout(); + }; + } + + #[cfg(feature = "ssr")] + { + let _ = set_cookie; + let _ = delay; + } + } +} + +#[cfg(not(feature = "ssr"))] +fn write_client_cookie( + name: &str, + value: &Option, + jar: &mut CookieJar, + max_age: Option, + expires: Option, + domain: &Option, + path: &Option, + same_site: Option, + secure: bool, + http_only: bool, + ssr_cookies_header_getter: Rc String>, +) { + use wasm_bindgen::JsCast; + + update_client_cookie_jar( + name, + value, + jar, + max_age, + expires, + domain, + path, + same_site, + secure, + http_only, + ssr_cookies_header_getter, + ); + + let document = document(); + let document: &web_sys::HtmlDocument = document.unchecked_ref(); + + document.set_cookie(&cookie_jar_to_string(jar)).ok(); +} + +#[cfg(not(feature = "ssr"))] +fn update_client_cookie_jar( + name: &str, + value: &Option, + jar: &mut CookieJar, + max_age: Option, + expires: Option, + domain: &Option, + path: &Option, + same_site: Option, + secure: bool, + http_only: bool, + ssr_cookies_header_getter: Rc String>, +) { + *jar = load_and_parse_cookie_jar(ssr_cookies_header_getter); + + if let Some(value) = value { + let cookie = build_cookie_from_options( + name, max_age, expires, http_only, secure, path, same_site, domain, value, + ); + + jar.add_original(cookie); + } else { + jar.force_remove(name); + } +} + +#[cfg(not(feature = "ssr"))] +fn cookie_jar_to_string(jar: &CookieJar) -> String { + jar.iter() + .map(|c| c.encoded().to_string()) + .collect::>() + .join("; ") +} + +fn build_cookie_from_options( + name: &str, + max_age: Option, + expires: Option, + http_only: bool, + secure: bool, + path: &Option, + same_site: Option, + domain: &Option, + value: &str, +) -> Cookie<'static> { + let mut cookie = Cookie::build((name, value)); + if let Some(max_age) = max_age { + cookie = cookie.max_age(Duration::milliseconds(max_age)); + } + if let Some(expires) = expires { + match OffsetDateTime::from_unix_timestamp(expires) { + Ok(expires) => { + cookie = cookie.expires(expires); + } + Err(err) => { + logging::debug_warn!("failed to set cookie expiration: {:?}", err); + } + } + } + if http_only { + cookie = cookie.http_only(true); + } + if secure { + cookie = cookie.secure(true); + } + if let Some(domain) = domain { + cookie = cookie.domain(domain); + } + if let Some(path) = path { + cookie = cookie.path(path); + } + if let Some(same_site) = same_site { + cookie = cookie.same_site(same_site); + } + + let cookie: Cookie = cookie.into(); + cookie.into_owned() +} + +#[cfg(feature = "ssr")] +fn write_server_cookie( + name: &str, + value: Option, + jar: &mut CookieJar, + max_age: Option, + expires: Option, + domain: Option, + path: Option, + same_site: Option, + secure: bool, + http_only: bool, + ssr_set_cookie: Rc, +) { + if let Some(value) = value { + let cookie: Cookie = build_cookie_from_options( + name, max_age, expires, http_only, secure, &path, same_site, &domain, &value, + ) + .into(); + + jar.add(cookie.into_owned()); + } else { + jar.remove(name.to_owned()); + } + + for cookie in jar.delta() { + ssr_set_cookie(cookie); + } +} + +fn load_and_parse_cookie_jar(ssr_cookies_header_getter: Rc String>) -> CookieJar { + let mut jar = CookieJar::new(); + let cookies = read_cookies_string(ssr_cookies_header_getter); + + for cookie in Cookie::split_parse_encoded(cookies).flatten() { + jar.add_original(cookie); + } + + jar +} + +#[derive(Default, Copy, Clone)] +struct OptionStringCodec; + +impl StringCodec> for OptionStringCodec { + type Error = (); + + fn encode(&self, val: &Option) -> Result { + match val { + Some(val) => Ok(format!("~<|Some|>~{val}")), + None => Ok("~<|None|>~".to_owned()), + } + } + + fn decode(&self, str: String) -> Result, Self::Error> { + Ok(str.strip_prefix("~<|Some|>~").map(|v| v.to_owned())) + } +}