From d610479c528356bf5622fab4a044b711e5cbb007 Mon Sep 17 00:00:00 2001 From: Josef Rokos Date: Tue, 13 Oct 2026 14:51:50 +0200 Subject: [PATCH] Added simple editor for the text under the banner, stored HTML is sanitized. Fixes #17 Co-Authored-By: Claude Opus 5.5 --- .claude/rules/backend.md | 1 + .claude/rules/frontend.md | 1 + CLAUDE.md | 1 + Cargo.lock | 173 +++++++++++++++++++- Cargo.toml | 5 +- assets/vendor/css/control.css | 10 ++ src/backend/appearance.rs | 59 ++++--- src/backend/data.rs | 1 - src/backend/mod.rs | 1 + src/backend/rich_text.rs | 169 ++++++++++++++++++++ src/locales/catalogues.rs | 13 ++ src/pages/appearance_settings.rs | 260 +++++++++++++++++++------------ 12 files changed, 558 insertions(+), 136 deletions(-) create mode 100644 src/backend/rich_text.rs diff --git a/.claude/rules/backend.md b/.claude/rules/backend.md index 9bc754a..9f459f9 100644 --- a/.claude/rules/backend.md +++ b/.claude/rules/backend.md @@ -31,6 +31,7 @@ paths: - The auth middleware protects only `/admin*` page requests. `/api/*` is not covered by it — a server fn without `perm_check!` is public. Only the public booking form endpoints should be. - Plain Actix handlers must check the session themselves. - Files are uploaded through a multipart server function (`upload_image` in `src/backend/appearance.rs`): `#[server(input = MultipartFormData)]`, called in the browser through `Action::new_local` with a `web_sys::FormData`. The server stores a file under a name it makes itself, checks size and content, and never trusts the name sent by the browser. The multipart codec panics on a request without a boundary; `is_broken_upload` in `src/main.rs` refuses those first — extend it when adding another multipart function. +- HTML that comes from a user and is rendered with `inner_html` (the text under the banner) must go through `sanitize_text` in `src/backend/rich_text.rs` before it is stored. Extend its allow-list together with the editor toolbar, never the other way round. - The logged-in `User` is stored in the cookie session under `"user"`; read it with `logged_in_user()`. ## Database access diff --git a/.claude/rules/frontend.md b/.claude/rules/frontend.md index a94e89c..626a270 100644 --- a/.claude/rules/frontend.md +++ b/.claude/rules/frontend.md @@ -39,6 +39,7 @@ paths: - Every user-visible string goes through `trl("English text")`; never hard-code Czech (or any text) in a view. The English text is the key and the fallback. - When you add or change a key, add the Czech translation to the `cs` map in `src/locales/catalogues.rs`. This includes validation `message`s and `ApiResponse::Error` strings from the backend. +- `trl()` reads the language from context, so in an event handler it silently returns the English key. Translate in the component body or in the view; keep a key in a signal and translate it when rendering (see `ImageUpload` in `src/pages/appearance_settings.rs`). - `trl()` returns a closure — use it directly as `{trl("...")}` or attribute value; call it (`trl("...")()`) when a `String` is needed. - Dates shown to users go through `loc_date()`, weekday names through `show_day()`. diff --git a/CLAUDE.md b/CLAUDE.md index e20df4a..9e93c0e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,7 @@ Unit tests are `#[cfg(test)] mod tests` at the bottom of the file they test (`#[ - `src/backend/` — server functions per domain, plus `data.rs` (all DTOs/entities shared by server and client) and `auth_middleware.rs` (redirects unauthenticated `/admin*` page requests to `/login`). - `src/pages/` — page and dialog components; `src/components/` — reusable pieces (`DataForm`, `QuestionDialog`, `ModalDialog`, `ServerErr`, `ValidationErr`, `AdminPortal`, `Theme` for the light/dark theme of the admin portal). - `src/backend/banner.rs` — options of the public page banner (`BannerOptions`) and the checks of uploaded images; shared by server and browser. +- `src/backend/rich_text.rs` — the text under the banner: `sanitize_text` reduces its HTML to a few allowed tags before it is stored (it is shown to every visitor), `normalize_link` prepares link addresses. - `src/backend/layout.rs` — layout options of the public booking form (`LayoutOptions`), which become classes on the page wrapper. - `src/backend/color.rs` — colours of the public page set by the customer (`Colors`), contrast checks and the ready-made palettes; shared by server and browser. - `src/locales/` — `trl()` and the translation catalogue. diff --git a/Cargo.lock b/Cargo.lock index 4bcd76d..da96674 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -333,6 +333,18 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "ammonia" +version = "4.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061e83b03c2681c18a6787d956e355c74e0b98ba7ba3d69b0822ade1e6f1d716" +dependencies = [ + "cssparser", + "html5ever", + "maplit", + "url", +] + [[package]] name = "android-tzdata" version = "0.1.1" @@ -1067,6 +1079,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "cssparser" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11119743ad110e8c1bdccd930d7f5c30c99e5fc76a7b63ec9807e84eef0c5f59" +dependencies = [ + "dtoa-short", + "itoa", + "smallvec", +] + [[package]] name = "ctr" version = "0.9.2" @@ -1266,6 +1289,21 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "669a445ee724c5c69b1b06fe0b63e70a1c84bc9bb7d9696cd4f4e3ec45050408" +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + [[package]] name = "either" version = "1.13.0" @@ -1817,6 +1855,17 @@ dependencies = [ "utf8-width", ] +[[package]] +name = "html5ever" +version = "0.40.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456a1a377e608e555d22ddab27ac0114bc7a7b4199078108e34c2aeae6c9b130" +dependencies = [ + "log", + "markup5ever", + "memchr", +] + [[package]] name = "http" version = "0.2.12" @@ -2546,6 +2595,23 @@ dependencies = [ "quote", ] +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "markup5ever" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab3dc68ac4a0f5719e560136778c1ee716e296030d75dbd4484e37e39e3a842" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + [[package]] name = "md-5" version = "0.9.1" @@ -2569,9 +2635,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.4" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "mime" @@ -2650,6 +2716,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "next_tuple" version = "0.1.0" @@ -2866,6 +2938,45 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b585a510fb76fdebead6897982ef2a03a21d8e6cbcca904999742a4afc6ffe" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.8" @@ -2952,6 +3063,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "prettyplease" version = "0.3.0" @@ -3309,6 +3426,7 @@ dependencies = [ "actix-files", "actix-session", "actix-web", + "ammonia", "base64 0.22.1", "cfg-if", "charts-rs", @@ -3820,6 +3938,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + [[package]] name = "slab" version = "0.4.9" @@ -4159,6 +4283,30 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "string_cache" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffa8a5dbe8b3f0bbe29d4c3225daafaeead63afdc1b65fc4c01a1384166038e6" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928dcdf75e47626b3617a976ec205d9f057584c371c1f23b782129268d0e6edc" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -4287,6 +4435,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -4909,6 +5066,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web_atoms" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7572660c8890448ba236b7376f27e389c6a7e1c70195622faced601f855c0ada" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + [[package]] name = "webpki-roots" version = "0.25.4" diff --git a/Cargo.toml b/Cargo.toml index 9f3b74b..c4cc72b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,8 @@ actix-session = { version = "0.10.0", optional = true, features = ["cookie-sessi console_error_panic_hook = "0.1.7" # Used by the multipart codec of server_fn; needed directly to check a request before it gets there multer = { version = "3", optional = true } +# Sanitizes the HTML of the text shown on the public page +ammonia = { version = "4", optional = true } cfg-if = "1" leptos = { version = "0.8.22", features = ["multipart"] } leptos_meta = { version = "0.8" } @@ -22,7 +24,7 @@ leptos_router = { version = "0.8" } serde = { version = "1", features = ["derive"] } serde_json = "1.0.117" wasm-bindgen = "=0.2.129" -web-sys = { version = "0.3.69", features = ["Navigator", "Blob", "DomRect", "Element", "File", "FileList", "FormData", "HtmlInputElement", "MouseEvent", "Url"] } +web-sys = { version = "0.3.69", features = ["Navigator", "Blob", "ClipboardEvent", "DataTransfer", "DomRect", "Element", "File", "FileList", "FormData", "HtmlDocument", "HtmlInputElement", "MouseEvent", "Url"] } lazy_static = "1.4.0" chrono = { version = "0.4.38", features = ["serde"]} sqlx = { version = "0.8.0", optional = true, features = ["runtime-tokio-rustls", "postgres", "chrono", "rust_decimal", "uuid"] } @@ -55,6 +57,7 @@ ssr = [ "dep:leptos_actix", "dep:actix-session", "dep:multer", + "dep:ammonia", "dep:sqlx", "dep:lettre", "dep:charts-rs", diff --git a/assets/vendor/css/control.css b/assets/vendor/css/control.css index cbcd7f5..c853791 100644 --- a/assets/vendor/css/control.css +++ b/assets/vendor/css/control.css @@ -2,3 +2,13 @@ a.disabled { pointer-events: none; color: #ccc; } + +/* Editor of the text under the banner in the appearance settings */ +.text-editor { + min-height: 8rem; + height: auto; +} + +.text-editor > :last-child { + margin-bottom: 0; +} diff --git a/src/backend/appearance.rs b/src/backend/appearance.rs index 4481629..d340008 100644 --- a/src/backend/appearance.rs +++ b/src/backend/appearance.rs @@ -1,12 +1,10 @@ use cfg_if::cfg_if; use leptos::prelude::*; use server_fn::codec::{MultipartData, MultipartFormData}; -use validator::Validate; use crate::backend::banner::BannerOptions; use crate::backend::color::Colors; use crate::backend::data::{ApiResponse, Appearance}; use crate::backend::layout::LayoutOptions; -use crate::components::data_form::ForValidation; cfg_if! { if #[cfg(feature = "ssr")] { @@ -106,38 +104,30 @@ pub async fn get_appearance() -> Result { Ok(appearance) } +/// Saves everything that is edited on the appearance page except the images: title and text of +/// the public page, its colours, the options of the banner and the layout of the form. +/// Returns the text as it was stored, that is after sanitization. #[server] -pub async fn update_appearance(appearance: Appearance) -> Result, ServerFnError> { +pub async fn save_appearance( + title: String, + text: String, + colors: Colors, + banner: BannerOptions, + layout: LayoutOptions) -> Result, ServerFnError> { use crate::perm_check; + use crate::backend::rich_text::{sanitize_text, MAX_TEXT_LENGTH, MAX_TITLE_LENGTH}; + use validator::Validate; perm_check!(is_admin); - let pool = get_pool().await?; - let id = appearance.id(); - query("UPDATE appearance SET title = $1, text = $2 WHERE id = $3") - .bind(appearance.title) - .bind(appearance.text) - .bind(id) - .execute(&pool) - .await?; - - Ok(ApiResponse::Data(())) -} - -impl ForValidation for UpdateAppearance { - fn entity(&self) -> &dyn Validate { - &self.appearance + let title = title.trim(); + if title.chars().count() > MAX_TITLE_LENGTH { + return Ok(ApiResponse::Error("Title is too long".to_string())) } -} - -/// Saves the look of the public page: its colours, the options of the banner and the layout of the form -#[server] -pub async fn update_style(colors: Colors, banner: BannerOptions, layout: LayoutOptions) -> Result, ServerFnError> { - use crate::perm_check; - - perm_check!(is_admin); - - // The values end up in style attributes of the public page + if text.len() > MAX_TEXT_LENGTH { + return Ok(ApiResponse::Error("Text is too long".to_string())) + } + // The values end up in style and class attributes of the public page if colors.validate().is_err() { return Ok(ApiResponse::Error("Invalid colour".to_string())) } @@ -149,12 +139,17 @@ pub async fn update_style(colors: Colors, banner: BannerOptions, layout: LayoutO } let pool = get_pool().await?; + // The text is shown to every visitor as HTML + let text = sanitize_text(&text); let colors = colors.sanitized(); let banner = banner.sanitized(); let layout = layout.sanitized(); - query("UPDATE appearance SET color_primary = $1, color_background = $2, color_card = $3, color_text = $4, \ - banner_height = $5, banner_focus_x = $6, banner_focus_y = $7, banner_dim = $8, title_color = $9, title_align = $10, \ - layout_width = $11, layout_arrangement = $12, layout_slots = $13, layout_corners = $14") + query("UPDATE appearance SET title = $1, text = $2, \ + color_primary = $3, color_background = $4, color_card = $5, color_text = $6, \ + banner_height = $7, banner_focus_x = $8, banner_focus_y = $9, banner_dim = $10, title_color = $11, title_align = $12, \ + layout_width = $13, layout_arrangement = $14, layout_slots = $15, layout_corners = $16") + .bind(title) + .bind(&text) .bind(colors.primary) .bind(colors.background) .bind(colors.card) @@ -172,7 +167,7 @@ pub async fn update_style(colors: Colors, banner: BannerOptions, layout: LayoutO .execute(&pool) .await?; - Ok(ApiResponse::Data(())) + Ok(ApiResponse::Data(text.unwrap_or_default())) } /// Upload of the banner image or the logo. The form has the fields `kind` (`banner` or `logo`, diff --git a/src/backend/data.rs b/src/backend/data.rs index 391f48e..8229686 100644 --- a/src/backend/data.rs +++ b/src/backend/data.rs @@ -745,7 +745,6 @@ pub struct Appearance { pub banner: Option, pub text: Option, pub title: Option, - // Forms that edit the title or the text don't send the colours #[serde(default)] #[cfg_attr(feature = "ssr", sqlx(flatten))] pub colors: Colors, diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 6d73b77..713805b 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -12,6 +12,7 @@ pub mod appearance; pub mod color; pub mod banner; pub mod layout; +pub mod rich_text; #[cfg(feature = "ssr")] pub mod health; diff --git a/src/backend/rich_text.rs b/src/backend/rich_text.rs new file mode 100644 index 0000000..fd93ee3 --- /dev/null +++ b/src/backend/rich_text.rs @@ -0,0 +1,169 @@ +//! Title and formatted text shown at the top of the public page. +//! +//! The text is written in a simple editor in the appearance settings and stored as HTML. +//! It is shown to every visitor, so the server reduces it to a few harmless tags before storing it. + +/// Longest title of the public page, in characters +pub const MAX_TITLE_LENGTH: usize = 200; +/// Longest text under the banner, in bytes of its HTML +pub const MAX_TEXT_LENGTH: usize = 20_000; + +/// Schemes a link in the text may have +const LINK_SCHEMES: [&str; 4] = ["http", "https", "mailto", "tel"]; + +/// Turns what a user typed as a link address into a URL the sanitizer accepts: an address +/// without a scheme becomes `https://` (or `mailto:` for an e-mail address). +/// Returns `None` when it can't be a link. +pub fn normalize_link(input: &str) -> Option { + let link = input.trim(); + + if link.is_empty() || link.chars().any(|c| c.is_whitespace() || c.is_control() || matches!(c, '"' | '<' | '>')) { + return None + } + + let lower = link.to_lowercase(); + if LINK_SCHEMES.iter().any(|scheme| { + lower.strip_prefix(scheme).and_then(|rest| rest.strip_prefix(':')).is_some_and(|rest| !rest.is_empty()) + }) { + return Some(link.to_string()) + } + + // Any other scheme (javascript:, data:, ...); a colon after a host name is a port + if let Some((before, after)) = lower.split_once(':') { + let is_port = after.split('/').next().is_some_and(|port| !port.is_empty() && port.chars().all(|c| c.is_ascii_digit())); + if !before.contains('/') && !is_port { + return None + } + } + + if link.contains('@') && !link.contains('/') { + Some(format!("mailto:{}", link)) + } else { + Some(format!("https://{}", link)) + } +} + +/// Reduces HTML to what the editor can produce: paragraphs, line breaks, bold, italic, +/// headings, lists and links. Everything else is removed, scripts and styles with their content. +/// Returns `None` when no text is left. +#[cfg(feature = "ssr")] +pub fn sanitize_text(html: &str) -> Option { + use std::collections::{HashMap, HashSet}; + use ammonia::{Builder, UrlRelative}; + + let clean = Builder::default() + // `div` is what browsers put around lines typed into an editable element + .tags(HashSet::from(["p", "div", "br", "strong", "b", "em", "i", "h2", "h3", "h4", "ul", "ol", "li", "a"])) + .tag_attributes(HashMap::from([("a", HashSet::from(["href"]))])) + .generic_attributes(HashSet::new()) + .url_schemes(HashSet::from(LINK_SCHEMES)) + .url_relative(UrlRelative::Deny) + .link_rel(Some("noopener noreferrer")) + .clean(html) + .to_string(); + + // Markup without any text, like the "


" an emptied editor leaves behind + let text = regex::Regex::new(r"<[^>]*>").unwrap().replace_all(&clean, ""); + if text.replace(" ", "").trim().is_empty() { + return None + } + + Some(clean) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_link_keeps_allowed_schemes() { + for link in ["https://example.com/a?b=c#d", "http://example.com", "mailto:info@example.com", "tel:+420123456789", + "HTTPS://Example.com"] { + assert_eq!(normalize_link(link), Some(link.to_string())); + } + } + + #[test] + fn normalize_link_adds_scheme() { + assert_eq!(normalize_link("example.com"), Some("https://example.com".to_string())); + assert_eq!(normalize_link(" www.example.com/path "), Some("https://www.example.com/path".to_string())); + assert_eq!(normalize_link("example.com:8080/path"), Some("https://example.com:8080/path".to_string())); + assert_eq!(normalize_link("example.com/a:b"), Some("https://example.com/a:b".to_string())); + assert_eq!(normalize_link("info@example.com"), Some("mailto:info@example.com".to_string())); + } + + #[test] + fn normalize_link_rejects_other_input() { + for link in ["", " ", "javascript:alert(1)", "JavaScript:alert(1)", "data:text/html,x", "vbscript:x", "file:///etc/passwd", + "two words", "a\"b", "", "vbscript:x", + "/relative", "relative.html", "ftp://example.com"] { + let cleaned = clean(&format!("x", href)); + assert!(!cleaned.contains("href"), "href: {:?} -> {:?}", href, cleaned); + } + } + + #[test] + fn removes_scripts_styles_and_handlers() { + assert_eq!(clean("

a

"), "

a

"); + assert_eq!(clean("

a

"), "

a

"); + assert_eq!(clean("

a

"), "

a

"); + assert_eq!(clean("a"), + "a"); + assert_eq!(clean("text"), "text"); + assert_eq!(clean("text"), "text"); + assert_eq!(clean("

a

"), "

a

"); + } + + #[test] + fn removes_other_tags_but_keeps_their_text() { + assert_eq!(clean("

Title

cell
bigu"), + "Titlecellbigu"); + // Formatting pasted from a word processor + assert_eq!(clean("

Text

"), + "

Text

"); + } + + #[test] + fn repairs_broken_markup() { + assert_eq!(clean("

unclosed bold"), "

unclosed bold

"); + assert_eq!(clean("plain text"), "plain text"); + assert_eq!(clean("a < b & c"), "a < b & c"); + } + + #[test] + fn empty_content_is_none() { + for html in ["", " ", "


", "

", "

 

", "", "

"] { + assert_eq!(sanitize_text(html), None, "html: {:?}", html); + } + } + } +} diff --git a/src/locales/catalogues.rs b/src/locales/catalogues.rs index aa7413a..c12b9ff 100644 --- a/src/locales/catalogues.rs +++ b/src/locales/catalogues.rs @@ -165,6 +165,19 @@ lazy_static! { ("Save", "Uložit"), ("Reset to default", "Obnovit výchozí"), ("Settings saved", "Nastavení je uloženo"), + ("Text under the banner", "Text pod bannerem"), + ("Bold", "Tučně"), + ("Italic", "Kurzíva"), + ("Heading", "Nadpis"), + ("Paragraph", "Odstavec"), + ("Bullet list", "Seznam s odrážkami"), + ("Numbered list", "Číslovaný seznam"), + ("Link", "Odkaz"), + ("Remove link", "Odebrat odkaz"), + ("Link address", "Adresa odkazu"), + ("Title is too long", "Titulek je příliš dlouhý"), + ("Text is too long", "Text je příliš dlouhý"), + ("Resets colours, banner options and layout", "Vrátí barvy, nastavení banneru a rozložení"), ("Layout", "Rozložení"), ("Content width", "Šířka obsahu"), ("Full width", "Celá šířka"), diff --git a/src/pages/appearance_settings.rs b/src/pages/appearance_settings.rs index 41330ec..90e74bd 100644 --- a/src/pages/appearance_settings.rs +++ b/src/pages/appearance_settings.rs @@ -1,19 +1,23 @@ use leptos::prelude::*; -use leptos::ev::MouseEvent; +use leptos::ev::{ClipboardEvent, MouseEvent}; use leptos::wasm_bindgen::JsCast; -use web_sys::{Element, File, FormData, HtmlInputElement, Url}; -use crate::backend::appearance::{DeleteBanner, DeleteLogo, get_appearance, UpdateAppearance, UpdateStyle, upload_image}; +use web_sys::{Element, File, FormData, HtmlDocument, HtmlInputElement, Url}; +use crate::backend::appearance::{DeleteBanner, DeleteLogo, get_appearance, SaveAppearance, upload_image}; use crate::backend::banner::{image_extension, BannerOptions, ImageKind, IMAGE_ACCEPT, MAX_DIM, MAX_IMAGE_SIZE}; use crate::backend::color::{Colors, PALETTES}; use crate::backend::data::{banner_style, ApiResponse, Appearance}; use crate::backend::layout::LayoutOptions; -use crate::components::data_form::{DataForm, QuestionDialog}; +use crate::backend::rich_text::{normalize_link, MAX_TITLE_LENGTH}; +use crate::components::data_form::QuestionDialog; use crate::components::modal_box::DialogOpener; use crate::locales::trl; -/// Look of the public page as it is being edited. Nothing is stored until it is saved. +/// The public page as it is being edited. Nothing is stored until it is saved. #[derive(Clone, Copy)] struct Look { + title: RwSignal, + /// HTML of the text under the banner, as it is in the editor + text: RwSignal, colors: RwSignal, banner: RwSignal, layout: RwSignal, @@ -22,49 +26,86 @@ struct Look { new_logo: RwSignal>, } +/// Button of the editor toolbar. It must not take the focus, the selection in the text would be lost. #[component] -fn edit_title(opener: DialogOpener, appearance: ReadSignal) -> impl IntoView { - let update = ServerAction::::new(); +fn tool_button(icon: &'static str, title: &'static str, #[prop(into)] on_press: Callback<()>) -> impl IntoView { view! { - - - -
-
- - -
-
-
+ } } +/// Simple editor of the text under the banner: bold, italic, heading, lists and links. +/// The browser does the editing (`contenteditable` and `execCommand`); whatever comes out of it, +/// the server reduces to the allowed tags when the text is saved (`sanitize_text`). +/// The content is put into the element by its owner, through the node reference. #[component] -fn edit_text(opener: DialogOpener, appearance: ReadSignal) -> impl IntoView { - let update = ServerAction::::new(); +fn text_editor(text: RwSignal, editor: NodeRef) -> impl IntoView { + // Translations are not available in event handlers + let link_prompt = trl("Link address")(); + + let sync = move || { + if let Some(element) = editor.get_untracked() { + text.set(element.inner_html()); + } + }; + let exec = move |command: &str, value: &str| { + if let Ok(document) = document().dyn_into::() { + let _ = document.exec_command_with_show_ui_and_value(command, false, value); + } + sync(); + }; + let add_link = move || { + if let Ok(Some(input)) = window().prompt_with_message_and_default(&link_prompt, "https://") { + if let Some(link) = normalize_link(&input) { + exec("createLink", &link); + } + } + }; + view! { - - - -
-
- -