Added simple editor for the text under the banner, stored HTML is sanitized. Fixes #17

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 14:51:50 +02:00
co-authored by Claude Opus 5.5
parent 0f48160fb4
commit d610479c52
12 changed files with 558 additions and 136 deletions
+1
View File
@@ -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
+1
View File
@@ -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()`.
+1
View File
@@ -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.
Generated
+171 -2
View File
@@ -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"
+4 -1
View File
@@ -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",
+10
View File
@@ -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;
}
+26 -31
View File
@@ -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<Appearance, ServerFnError> {
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<ApiResponse<()>, ServerFnError> {
pub async fn save_appearance(
title: String,
text: String,
colors: Colors,
banner: BannerOptions,
layout: LayoutOptions) -> Result<ApiResponse<String>, 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(()))
let title = title.trim();
if title.chars().count() > MAX_TITLE_LENGTH {
return Ok(ApiResponse::Error("Title is too long".to_string()))
}
impl ForValidation for UpdateAppearance {
fn entity(&self) -> &dyn Validate {
&self.appearance
if text.len() > MAX_TEXT_LENGTH {
return Ok(ApiResponse::Error("Text 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<ApiResponse<()>, ServerFnError> {
use crate::perm_check;
perm_check!(is_admin);
// The values end up in style attributes of the public page
// 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`,
-1
View File
@@ -745,7 +745,6 @@ pub struct Appearance {
pub banner: Option<String>,
pub text: Option<String>,
pub title: Option<String>,
// Forms that edit the title or the text don't send the colours
#[serde(default)]
#[cfg_attr(feature = "ssr", sqlx(flatten))]
pub colors: Colors,
+1
View File
@@ -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;
+169
View File
@@ -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<String> {
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<String> {
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 "<p><br></p>" an emptied editor leaves behind
let text = regex::Regex::new(r"<[^>]*>").unwrap().replace_all(&clean, "");
if text.replace("&nbsp;", "").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", "<script>", "https:", "ftp://example.com"] {
assert_eq!(normalize_link(link), None, "link: {:?}", link);
}
}
#[cfg(feature = "ssr")]
mod sanitize {
use super::super::sanitize_text;
fn clean(html: &str) -> String {
sanitize_text(html).unwrap_or_default()
}
#[test]
fn keeps_what_the_editor_produces() {
let html = "<h3>Opening hours</h3><p>Open <strong>daily</strong>, <em>also</em> on <b>holidays</b> <i>!</i><br>8–20</p>\
<ul><li>one</li><li>two</li></ul><ol><li>first</li></ol><div>line</div><h2>a</h2><h4>b</h4>";
assert_eq!(clean(html), html);
}
#[test]
fn links_keep_only_safe_addresses() {
assert_eq!(clean("<a href=\"https://example.com\">web</a>"),
"<a href=\"https://example.com\" rel=\"noopener noreferrer\">web</a>");
assert_eq!(clean("<a href=\"mailto:info@example.com\">mail</a>"),
"<a href=\"mailto:info@example.com\" rel=\"noopener noreferrer\">mail</a>");
assert_eq!(clean("<a href=\"tel:+420123456789\">call</a>"),
"<a href=\"tel:+420123456789\" rel=\"noopener noreferrer\">call</a>");
for href in ["javascript:alert(1)", "JaVaScRiPt:alert(1)", "data:text/html,<script>x</script>", "vbscript:x",
"/relative", "relative.html", "ftp://example.com"] {
let cleaned = clean(&format!("<a href=\"{}\">x</a>", href));
assert!(!cleaned.contains("href"), "href: {:?} -> {:?}", href, cleaned);
}
}
#[test]
fn removes_scripts_styles_and_handlers() {
assert_eq!(clean("<p>a</p><script>alert(1)</script>"), "<p>a</p>");
assert_eq!(clean("<style>p { color: red }</style><p>a</p>"), "<p>a</p>");
assert_eq!(clean("<p onclick=\"alert(1)\" style=\"color: red\" class=\"x\" id=\"y\">a</p>"), "<p>a</p>");
assert_eq!(clean("<a href=\"https://example.com\" onclick=\"x()\" target=\"_blank\" style=\"x\">a</a>"),
"<a href=\"https://example.com\" rel=\"noopener noreferrer\">a</a>");
assert_eq!(clean("<img src=\"x\" onerror=\"alert(1)\">text"), "text");
assert_eq!(clean("<iframe src=\"https://example.com\"></iframe>text"), "text");
assert_eq!(clean("<svg onload=\"alert(1)\"></svg><p>a</p>"), "<p>a</p>");
}
#[test]
fn removes_other_tags_but_keeps_their_text() {
assert_eq!(clean("<h1>Title</h1><table><tr><td>cell</td></tr></table><span style=\"font-size: 40px\">big</span><u>u</u>"),
"Titlecellbigu");
// Formatting pasted from a word processor
assert_eq!(clean("<p class=\"MsoNormal\" style=\"margin: 0\"><span lang=\"CS\"><font color=\"red\">Text</font></span></p>"),
"<p>Text</p>");
}
#[test]
fn repairs_broken_markup() {
assert_eq!(clean("<p>unclosed <strong>bold"), "<p>unclosed <strong>bold</strong></p>");
assert_eq!(clean("plain text"), "plain text");
assert_eq!(clean("a < b & c"), "a &lt; b &amp; c");
}
#[test]
fn empty_content_is_none() {
for html in ["", " ", "<p><br></p>", "<div><br></div>", "<p>&nbsp;</p>", "<script>alert(1)</script>", "<p> </p><ul><li></li></ul>"] {
assert_eq!(sanitize_text(html), None, "html: {:?}", html);
}
}
}
}
+13
View File
@@ -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"),
+157 -97
View File
@@ -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<String>,
/// HTML of the text under the banner, as it is in the editor
text: RwSignal<String>,
colors: RwSignal<Colors>,
banner: RwSignal<BannerOptions>,
layout: RwSignal<LayoutOptions>,
@@ -22,49 +26,86 @@ struct Look {
new_logo: RwSignal<Option<String>>,
}
/// 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<Appearance>) -> impl IntoView {
let update = ServerAction::<UpdateAppearance>::new();
fn tool_button(icon: &'static str, title: &'static str, #[prop(into)] on_press: Callback<()>) -> impl IntoView {
view! {
<DataForm opener=opener action=update title="Update title">
<input type="hidden" prop:value={move || appearance.get().text.unwrap_or_default()} name="appearance[text]" />
<input type="hidden" prop:value={move || appearance.get().id()} name="appearance[id]"/>
<div class="row">
<div class="col mb-3">
<label for="title" class="form-label">{trl("Title")}</label>
<input
type="text"
id="title"
class="form-control"
placeholder={trl("Enter title")}
prop:value={move || appearance.get().title.unwrap_or_default()}
name="appearance[title]"
/>
</div>
</div>
</DataForm>
<button
type="button"
class="btn btn-sm btn-outline-secondary"
title=trl(title)
aria-label=trl(title)
on:mousedown=move |ev| ev.prevent_default()
on:click=move |_| on_press.run(())>
<i class=format!("bx {}", icon)></i>
</button>
}
}
/// 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<Appearance>) -> impl IntoView {
let update = ServerAction::<UpdateAppearance>::new();
fn text_editor(text: RwSignal<String>, editor: NodeRef<leptos::html::Div>) -> 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::<HtmlDocument>() {
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! {
<DataForm opener=opener action=update title="Update text">
<input type="hidden" prop:value={move || appearance.get().title.unwrap_or_default()} name="appearance[title]" />
<input type="hidden" prop:value={move || appearance.get().id()} name="appearance[id]"/>
<div class="row">
<div class="col mb-3">
<label for="text" class="form-label">{trl("Text")}</label>
<textarea
id="title"
class="form-control"
prop:value={move || appearance.get().text.unwrap_or_default()}
name="appearance[text]"
/>
<div class="btn-toolbar mb-2" role="toolbar">
<div class="btn-group me-2" role="group">
<ToolButton icon="bx-bold" title="Bold" on_press=move |_| exec("bold", "")/>
<ToolButton icon="bx-italic" title="Italic" on_press=move |_| exec("italic", "")/>
</div>
<div class="btn-group me-2" role="group">
<ToolButton icon="bx-heading" title="Heading" on_press=move |_| exec("formatBlock", "h3")/>
<ToolButton icon="bx-paragraph" title="Paragraph" on_press=move |_| exec("formatBlock", "p")/>
</div>
<div class="btn-group me-2" role="group">
<ToolButton icon="bx-list-ul" title="Bullet list" on_press=move |_| exec("insertUnorderedList", "")/>
<ToolButton icon="bx-list-ol" title="Numbered list" on_press=move |_| exec("insertOrderedList", "")/>
</div>
<div class="btn-group" role="group">
<ToolButton icon="bx-link" title="Link" on_press=move |_| add_link()/>
<ToolButton icon="bx-unlink" title="Remove link" on_press=move |_| exec("unlink", "")/>
</div>
</div>
</DataForm>
<div
id="text_editor"
class="form-control text-editor"
contenteditable="true"
role="textbox"
aria-multiline="true"
aria-labelledby="text_editor_label"
node_ref=editor
on:input=move |_| sync()
// Only the text of what is pasted, not the formatting it had elsewhere
on:paste=move |ev: ClipboardEvent| {
if let Some(pasted) = ev.clipboard_data().and_then(|data| data.get_data("text/plain").ok()) {
ev.prevent_default();
exec("insertText", &pasted);
}
}
></div>
}
}
@@ -152,6 +193,7 @@ fn image_upload(
uploads: RwSignal<u32>,
delete: DialogOpener) -> impl IntoView {
let file = RwSignal::new_local(None::<File>);
// A translation key; translations are not available in event handlers
let error = RwSignal::new(None::<String>);
let input_ref = NodeRef::<leptos::html::Input>::new();
let upload = Action::new_local(|data: &FormData| upload_image(data.clone().into()));
@@ -174,7 +216,7 @@ fn image_upload(
clear();
uploads.update(|count| *count += 1);
}
Some(Ok(ApiResponse::Error(e))) => error.set(Some(trl(&e)())),
Some(Ok(ApiResponse::Error(e))) => error.set(Some(e)),
Some(Err(e)) => error.set(Some(format!("Server error: {}", e))),
None => {}
}
@@ -203,9 +245,9 @@ fn image_upload(
// The server checks the same again
if let Some(chosen) = chosen {
if image_extension(&chosen.name()).is_none() {
error.set(Some(trl("Unsupported image type, use JPG, PNG, GIF or WebP")()));
error.set(Some("Unsupported image type, use JPG, PNG, GIF or WebP".to_string()));
} else if chosen.size() > MAX_IMAGE_SIZE as f64 {
error.set(Some(trl("Image is too large, 5 MB at most")()));
error.set(Some("Image is too large, 5 MB at most".to_string()));
} else {
selected.set(Url::create_object_url_with_blob(&chosen).ok().filter(|url| is_object_url(url)));
file.set(Some(chosen));
@@ -214,7 +256,7 @@ fn image_upload(
}
/>
{move || error.get().map(|e| view! {
<div class="text-danger mt-1">{e}</div>
<div class="text-danger mt-1">{trl(&e)}</div>
})}
<div class="mt-2">
{move || if file.get().is_some() {
@@ -255,7 +297,7 @@ fn image_upload(
fn preview(look: Look, appearance: ReadSignal<Appearance>) -> impl IntoView {
let image = move || look.new_banner.get().or_else(|| appearance.get().banner_url());
let logo = move || look.new_logo.get().or_else(|| appearance.get().logo_url());
let title = move || appearance.get().title.unwrap_or_default();
let title = move || look.title.get();
let has_banner = move || image().is_some() || logo().is_some() || !title().trim().is_empty();
// Clicking into the banner chooses the part of the image that stays visible
@@ -294,7 +336,7 @@ fn preview(look: Look, appearance: ReadSignal<Appearance>) -> impl IntoView {
</Show>
</div>
</Show>
<div class="public-banner-text" inner_html=move || appearance.get().text.unwrap_or_default()></div>
<div class="public-banner-text" inner_html=move || look.text.get()></div>
<div class="card-body public-form">
<div class="row public-form-columns">
<div class="col-sm">
@@ -440,33 +482,49 @@ fn layout_settings(layout: RwSignal<LayoutOptions>) -> impl IntoView {
}
}
/// Everything that is saved together as one look
type SavedLook = (Colors, BannerOptions, LayoutOptions);
/// Everything that is saved together: title, text, colours, banner options and layout
type Saved = (String, String, Colors, BannerOptions, LayoutOptions);
/// Saves colours, banner options and layout together, they make one look
#[component]
fn save_bar(look: Look, saved: RwSignal<SavedLook>) -> impl IntoView {
let save = ServerAction::<UpdateStyle>::new();
let sent = StoredValue::new(SavedLook::default());
let current = move || (look.colors.get(), look.banner.get(), look.layout.get());
fn save_bar(look: Look, saved: RwSignal<Saved>, editor: NodeRef<leptos::html::Div>) -> impl IntoView {
let save = ServerAction::<SaveAppearance>::new();
let sent = StoredValue::new(Saved::default());
let current = move || (look.title.get(), look.text.get(), look.colors.get(), look.banner.get(), look.layout.get());
let dirty = move || current() != saved.get();
Effect::new(move |_| {
if let Some(Ok(ApiResponse::Data(_))) = save.value().get() {
saved.set(sent.get_value());
if let Some(Ok(ApiResponse::Data(stored_text))) = save.value().get() {
let (title, text, colors, banner, layout) = sent.get_value();
let title = title.trim().to_string();
// The server may have removed something from the text; show what is really stored
if stored_text != text {
if let Some(element) = editor.get_untracked() {
element.set_inner_html(&stored_text);
}
}
saved.set((title.clone(), stored_text.clone(), colors, banner, layout));
// Only if nothing was changed while the request was running
if look.title.get_untracked() == sent.get_value().0 {
look.title.set(title);
}
if look.text.get_untracked() == text {
look.text.set(stored_text);
}
}
});
view! {
<div>
<button type="button" class="btn btn-primary me-2" on:click=move |_| {
let (colors, banner, layout) = current();
sent.set_value((colors.clone(), banner.clone(), layout.clone()));
save.dispatch(UpdateStyle { colors, banner, layout });
let (title, text, colors, banner, layout) = current();
sent.set_value((title.clone(), text.clone(), colors.clone(), banner.clone(), layout.clone()));
save.dispatch(SaveAppearance { title, text, colors, banner, layout });
}>
{trl("Save")}
</button>
<button type="button" class="btn btn-outline-secondary" on:click=move |_| {
<button type="button" class="btn btn-outline-secondary" title=trl("Resets colours, banner options and layout")
on:click=move |_| {
look.colors.set(Colors::default());
look.banner.set(BannerOptions::default());
look.layout.set(LayoutOptions::default());
@@ -502,77 +560,79 @@ fn save_bar(look: Look, saved: RwSignal<SavedLook>) -> impl IntoView {
#[component]
pub fn appearance() -> impl IntoView {
let title_edit = DialogOpener::new();
let text_edit = DialogOpener::new();
let del_banner = DialogOpener::new();
let del_logo = DialogOpener::new();
let uploads = RwSignal::new(0u32);
let appearance = Resource::new(
move || (title_edit.visible() || text_edit.visible() || del_banner.visible() || del_logo.visible(), uploads.get()),
move || (del_banner.visible() || del_logo.visible(), uploads.get()),
|_| get_appearance());
let app_edit = RwSignal::new(Appearance::default());
let look = Look {
title: RwSignal::new(String::new()),
text: RwSignal::new(String::new()),
colors: RwSignal::new(Colors::default()),
banner: RwSignal::new(BannerOptions::default()),
layout: RwSignal::new(LayoutOptions::default()),
new_banner: RwSignal::new(None),
new_logo: RwSignal::new(None),
};
let saved = RwSignal::new(SavedLook::default());
let saved = RwSignal::new(Saved::default());
let app_preview = RwSignal::new(Appearance::default());
let look_loaded = StoredValue::new(false);
let loaded = StoredValue::new(false);
let editor = NodeRef::<leptos::html::Div>::new();
// Runs in the browser only, so the server and the browser render the same markup first.
// The data are loaded again after every dialog and upload; that must not throw away a look being edited.
// The data are loaded again after every upload and deleted image; that must not throw away what is being edited.
Effect::new(move |_| {
if let Some(Ok(app)) = appearance.get() {
if !look_loaded.get_value() {
if let (Some(element), Some(Ok(app))) = (editor.get(), appearance.get()) {
if !loaded.get_value() {
let title = app.title.clone().unwrap_or_default();
let text = app.text.clone().unwrap_or_default();
element.set_inner_html(&text);
look.title.set(title.clone());
look.text.set(text.clone());
look.colors.set(app.colors.clone());
look.banner.set(app.banner_options.clone());
look.layout.set(app.layout.clone());
saved.set((app.colors.clone(), app.banner_options.clone(), app.layout.clone()));
look_loaded.set_value(true);
saved.set((title, text, app.colors.clone(), app.banner_options.clone(), app.layout.clone()));
loaded.set_value(true);
}
app_preview.set(app);
}
});
view! {
<EditTitle opener=title_edit appearance=app_edit.read_only()/>
<EditText opener=text_edit appearance=app_edit.read_only()/>
<DeleteBanner opener=del_banner/>
<DeleteLogo opener=del_logo/>
<h1>{trl("Appearance settings")}</h1>
<Transition fallback=move || view! {<p>{trl("Loading...")}</p> }>
{move || {
appearance.get().map(|a| match a {
Ok(_) => view! {<div></div>}.into_any(),
Err(e) => view! {
<div class="alert alert-danger">{trl("Error loading data")}": "{e.to_string()}</div>
}.into_any()
})
}}
</Transition>
<div class="row mb-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">{trl("Title and text")}</h5>
<Transition fallback=move || view! {<p>{trl("Loading...")}</p> }>
{move || {
appearance.get().map(|a| match a {
Ok(app) => {
app_edit.set(app.clone());
view! {
<div>
<div class="fs-4 mb-2">{app.title.clone().unwrap_or_default()}</div>
<button class="btn btn-info" on:click=move |_| title_edit.show()>
<i class="bx bx-edit-alt me-1"></i> {trl("Edit title")}</button>
<br/><br/>
<div inner_html={app.text.unwrap_or("<< TEXT >>".to_string())}>
<div class="mb-3">
<label for="title" class="form-label">{trl("Title")}</label>
<input
type="text"
id="title"
class="form-control"
maxlength=MAX_TITLE_LENGTH.to_string()
placeholder={trl("Enter title")}
prop:value=move || look.title.get()
on:input=move |ev| look.title.set(event_target_value(&ev))
/>
</div>
<a class="dropdown-item" href="javascript:void(0);" on:click=move |_| text_edit.show()>
<i class="bx bx-edit-alt me-1"></i> {trl("Edit text")}</a>
</div>
}.into_any()
}
Err(e) => {view! {<div><p>{trl("Error loading data")}</p>
<p>{e.to_string()}</p></div>
}.into_any()}
})
}}
</Transition>
<div class="form-label" id="text_editor_label">{trl("Text under the banner")}</div>
<TextEditor text=look.text editor=editor/>
</div>
</div>
</div>
@@ -607,7 +667,7 @@ pub fn appearance() -> impl IntoView {
</div>
<div class="card mb-3">
<div class="card-body">
<SaveBar look=look saved=saved/>
<SaveBar look=look saved=saved editor=editor/>
</div>
</div>
</div>