diff --git a/.claude/rules/frontend.md b/.claude/rules/frontend.md index a524590..48048de 100644 --- a/.claude/rules/frontend.md +++ b/.claude/rules/frontend.md @@ -48,5 +48,5 @@ paths: - Custom styles go to a CSS file in `assets/css/` and must be linked with `` in `src/components/header.rs` (order matters: after the vendor files to override them). `style/main.scss` is not linked anywhere and has no effect. Never edit `assets/vendor/` (the one app-owned file there is `assets/vendor/css/control.css`). - The admin portal has a light and a dark theme (`src/components/theme.rs`, switch in the navbar: auto / light / dark, stored in the `theme` cookie). Dark colours are overrides in `assets/css/dark.css`, which `AdminPortal` links with a `media` attribute — the public page and the login page never load it. When you add or restyle something in the admin portal, check it in both themes and add the dark override there; don't hard-code light colours (`bg-light`, `bg-white`, inline `color`) in admin views. - Charts are drawn on the server, so the chart server fns take a `dark` flag from `Theme::is_dark()`. -- The public page banner style template is `assets/banner.css`. On start it is copied to the data directory, and the appearance upload generates `banner-.css` from it by replacing the `/*bg-img*/` marker — keep that marker intact. +- Styles of the public page are in `assets/css/public.css`, scoped by its own classes (`.public-page` wraps the public route only, `.public-banner*`), so they don't leak into the admin portal. The customer's appearance settings reach it as CSS variables in a `style` attribute (`Appearance::banner_style()` in `src/backend/data.rs`) — never generate CSS files. Every value put into a `style` attribute must be validated first. - Stylesheets and meta tags are declared in `src/components/header.rs`. diff --git a/CLAUDE.md b/CLAUDE.md index 285d924..2e06256 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,7 @@ Unit tests are `#[cfg(test)] mod tests` at the bottom of the file they test (`#[ - First start creates the `admin` user with the password from `REZERVATOR_ADMIN_PASSWORD` or `[admin] initial_password` in the config (no default; the server refuses to start when no admin exists and no password is given). It also seeds a placeholder company, empty mail templates and an appearance row (`create_admin`, `check_company`, `check_messages`, `check_appearance` in `src/main.rs`). - With `transport = "File"` e-mails are written as `.eml` into `Maildir/` instead of being sent. - `assets/` is copied to `target/site` on build and served as static files. -- Files created at runtime (uploaded banner, generated banner CSS) go to the data directory: `data/` in the working directory by default (gitignored), `[storage] data_dir` in the config, `/app/data` volume in Docker. It is served under `/data` and survives rebuilds. On start the server copies the `banner.css` template there from the site root and regenerates a missing banner style. Server code gets the path with `get_data_dir()`; never write into `target/site`. +- Files created at runtime (the uploaded banner) go to the data directory: `data/` in the working directory by default (gitignored), `[storage] data_dir` in the config, `/app/data` volume in Docker. It is served under `/data` and survives rebuilds. Server code gets the path with `get_data_dir()`; never write into `target/site`. ## Layout diff --git a/Cargo.lock b/Cargo.lock index b8c51bb..0455320 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3357,7 +3357,6 @@ dependencies = [ "lettre", "log", "pwhash", - "rand 0.8.5", "regex", "rust_decimal", "serde", diff --git a/Cargo.toml b/Cargo.toml index b436b56..9a6910d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,8 +42,7 @@ leptos-captcha = "0.4.0" charts-rs = { version = "0.3.5", optional = true} #image = { version = "0.24.8", optional = true } base64 = "0.22.0" -rand = "0.8.5" -# rand needs the "js" backend when compiled for the browser +# The captcha's proof of work needs the "js" backend when compiled for the browser getrandom = { version = "0.2", features = ["js"] } [features] diff --git a/Dockerfile b/Dockerfile index 8fc074c..f6df107 100644 --- a/Dockerfile +++ b/Dockerfile @@ -52,7 +52,7 @@ EXPOSE 3000 RUN mkdir -p /app/config VOLUME /app/config -# Uploaded banner and generated styles. Keep this volume when the container is replaced. +# Uploaded banner. Keep this volume when the container is replaced. RUN mkdir -p /app/data VOLUME /app/data diff --git a/README.md b/README.md index 8677d65..1b7e3ec 100644 --- a/README.md +++ b/README.md @@ -44,9 +44,9 @@ Older versions created `admin` with the password `admin`. The server logs a warn ## Data directory -Files created while the application runs (the uploaded banner and the styles generated for it) are stored in the data directory and served under `/data`. By default it is `data` in the working directory; change it with `data_dir` in the `[storage]` section of the config. Keep this directory when you upgrade the application and include it in backups. Never put the config file into it. +Files created while the application runs (the uploaded banner) are stored in the data directory and served under `/data`. By default it is `data` in the working directory; change it with `data_dir` in the `[storage]` section of the config. Keep this directory when you upgrade the application and include it in backups. Never put the config file into it. -When upgrading from a version that kept these files in `target/site/data`, move the banner image to the data directory. The style for it is generated again on start. +When upgrading from a version that kept these files in `target/site/data`, move the banner image to the data directory. ## Running in Docker diff --git a/assets/banner.css b/assets/banner.css deleted file mode 100644 index ea38dcf..0000000 --- a/assets/banner.css +++ /dev/null @@ -1,28 +0,0 @@ -div.header_banner { - height:100px; - clip-path: inset(0 0 0 0); - /*bg-img*/ - background-size:cover; - padding:30px; - color: white; -} - -div.header_banner_text { - padding: 10px; -} - -h1.header_banner { - font-size: x-large; - color: white; - text-shadow: -3px -3px 0 #000, 3px -3px 0 #000, -3px 3px 0 #000, 3px 3px 0 #000; -} - -@media (min-width: 1200px) { - div.header_banner { - height: 200px; - } - - h1.header_banner { - font-size: xxx-large; - } -} \ No newline at end of file diff --git a/assets/css/public.css b/assets/css/public.css new file mode 100644 index 0000000..cce8df9 --- /dev/null +++ b/assets/css/public.css @@ -0,0 +1,35 @@ +/* +* Public booking page. +* +* Settings from the appearance table come as CSS variables in the style attribute +* (Appearance::banner_style in src/backend/data.rs), so nothing here is generated. +******************************************************************************/ + +.public-banner { + height: 100px; + clip-path: inset(0 0 0 0); + background-image: var(--rz-banner-image, none); + background-size: cover; + padding: 30px; + color: white; +} + +.public-banner-title { + font-size: x-large; + color: white; + text-shadow: -3px -3px 0 #000, 3px -3px 0 #000, -3px 3px 0 #000, 3px 3px 0 #000; +} + +.public-banner-text { + padding: 10px; +} + +@media (min-width: 1200px) { + .public-banner { + height: 200px; + } + + .public-banner-title { + font-size: xxx-large; + } +} diff --git a/config_sample.toml b/config_sample.toml index 2445e11..e48ecd9 100644 --- a/config_sample.toml +++ b/config_sample.toml @@ -33,7 +33,7 @@ path = "Maildir" #[admin] #initial_password = "" -# Directory for uploaded files (banner) and generated styles, served under /data. +# Directory for uploaded files (banner), served under /data. # Default is "data" in the working directory. Must survive application upgrades. #[storage] #data_dir = "data" diff --git a/deploy/README.md b/deploy/README.md index 0e4cedf..77a275f 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -166,7 +166,7 @@ If a customer does not become healthy, the script puts back the database and dat ./rezervator.sh restore alpha 20260101T020000Z ``` -A backup is two files in `backups//`: `-db.sql.gz` (database dump) and `-data.tar.gz` (uploaded banner and generated styles). `restore` replaces both the database and the data volume, and keeps the image the customer was running. +A backup is two files in `backups//`: `-db.sql.gz` (database dump) and `-data.tar.gz` (uploaded banner). `restore` replaces both the database and the data volume, and keeps the image the customer was running. The script does not schedule backups, does not delete old ones and does not copy them off the host. Run `backup --all` from cron, and ship `backups/`, `customers/` and `.env` somewhere else — `customers/` and `.env` hold the passwords and session keys and cannot be regenerated. diff --git a/migrations/06_appearance_style.sql b/migrations/06_appearance_style.sql new file mode 100644 index 0000000..7657258 --- /dev/null +++ b/migrations/06_appearance_style.sql @@ -0,0 +1,4 @@ +-- Banner style is no longer a generated file, the public page gets its settings as CSS variables +ALTER TABLE appearance DROP COLUMN css_name; +ALTER TABLE appearance DROP COLUMN style_wide; +ALTER TABLE appearance DROP COLUMN style_narrow; diff --git a/src/app.rs b/src/app.rs index cd3bcc7..15f0d7c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -96,7 +96,8 @@ pub fn App() -> impl IntoView {
- }/> + // The class scopes the customer's appearance to the public page (assets/css/public.css) + }/> }/> diff --git a/src/backend/appearance.rs b/src/backend/appearance.rs index b6c6727..bf291ca 100644 --- a/src/backend/appearance.rs +++ b/src/backend/appearance.rs @@ -21,14 +21,8 @@ cfg_if! { if #[cfg(feature = "ssr")] { use crate::backend::get_pool; use actix_web::web::Data; use crate::backend::AppData; - use regex::Regex; - use rand::Rng; - use rand::distributions::Alphanumeric; use log::{info, warn}; - /// Style of the public page banner. The template is shipped with the application, - /// the copy in the data directory is the base for generated styles. - const BANNER_CSS: &str = "banner.css"; const BANNER_EXTENSIONS: [&str; 5] = ["jpg", "jpeg", "png", "gif", "webp"]; /// Turns the file name sent by the browser into a safe name inside the data directory. @@ -49,19 +43,29 @@ cfg_if! { if #[cfg(feature = "ssr")] { Some(format!("{}.{}", stem, extension.to_lowercase())) } - /// Creates the data directory and puts the banner style template into it, if it is not there yet - pub fn ensure_data_dir(data_dir: &Path, site_root: &Path) -> Result<(), AppError> { + /// Creates the data directory and removes the banner styles that older versions generated into it + pub fn ensure_data_dir(data_dir: &Path) -> Result<(), AppError> { fs::create_dir_all(data_dir)?; - if !data_dir.join(BANNER_CSS).exists() { - info!("Copying {} to data directory {}", BANNER_CSS, data_dir.display()); - fs::copy(site_root.join(BANNER_CSS), data_dir.join(BANNER_CSS))?; + for entry in fs::read_dir(data_dir)? { + let name = entry?.file_name().to_string_lossy().to_string(); + + if is_old_banner_style(&name) { + info!("Removing unused banner style {}", name); + // Not needed any more, so a failure is not a problem + let _ = fs::remove_file(data_dir.join(&name)); + } } Ok(()) } - pub async fn check_appearance(pool: &PgPool, data_dir: &Path) -> Result<(), AppError> { + /// `banner.css` template and `banner-.css` styles generated from it + fn is_old_banner_style(name: &str) -> bool { + name == "banner.css" || (name.starts_with("banner-") && name.ends_with(".css")) + } + + pub async fn check_appearance(pool: &PgPool) -> Result<(), AppError> { let count: (i64,) = query_as("SELECT COUNT(id) FROM appearance") .fetch_one(pool) .await?; @@ -72,26 +76,6 @@ cfg_if! { if #[cfg(feature = "ssr")] { .await?; } - let app = query_as::<_, Appearance>("SELECT * FROM appearance").fetch_one(pool).await?; - if let None = app.css_name { - query("UPDATE appearance SET css_name = $1").bind(BANNER_CSS).execute(pool).await?; - } - - // The generated style can be missing, e.g. when the data directory was not kept during an upgrade - let css_name = app.css_name.unwrap_or(BANNER_CSS.to_string()); - if !data_dir.join(&css_name).exists() { - match app.banner { - Some(banner) if data_dir.join(&banner).exists() => { - warn!("Banner style {} is missing, generating it again", css_name); - modify_style(&banner, pool, data_dir).await?; - } - _ => { - warn!("Banner style {} is missing, using default style", css_name); - query("UPDATE appearance SET css_name = $1").bind(BANNER_CSS).execute(pool).await?; - } - } - } - Ok(()) } @@ -104,43 +88,6 @@ cfg_if! { if #[cfg(feature = "ssr")] { Ok(()) } - async fn modify_style(file_name: &str, pool: &PgPool, data_dir: &Path) -> Result<(), AppError> { - let mut css_str = fs::read_to_string(data_dir.join(BANNER_CSS))?; - - if css_str.contains("/*bg-img*/") { - css_str = css_str.replace("/*bg-img*/", &format!("background-image: url('{}');", file_name)); - } else { - let re = Regex::new(r#"background-image: url\('[aA-zZ._0-9\-]+'\)"#).unwrap(); - css_str = re.replace(&css_str, &format!("background-image: url('{}')", file_name)).to_string(); - } - - let old_css: (Option,) = query_as("SELECT css_name FROM appearance") - .fetch_one(pool) - .await?; - - if let Some(old_css) = old_css.0 { - if old_css != BANNER_CSS { - // The old style may be already gone - let _ = fs::remove_file(data_dir.join(old_css)); - } - } - - let s: String = rand::thread_rng() - .sample_iter(&Alphanumeric) - .take(5) - .map(char::from) - .collect(); - let css_name = format!("banner-{}.css", s); - let mut css_file = File::create(data_dir.join(&css_name))?; - css_file.write_all(css_str.as_bytes())?; - query("UPDATE appearance SET css_name = $1") - .bind(css_name) - .execute(pool) - .await?; - - Ok(()) - } - async fn save_banner(field: &mut actix_multipart::Field, file_name: &str, app_data: &AppData) -> Result<(), AppError> { let data_dir = app_data.data_dir(); let old_banner: (Option,) = query_as("SELECT banner FROM appearance") @@ -154,7 +101,6 @@ cfg_if! { if #[cfg(feature = "ssr")] { } set_banner_name(file_name, app_data.db_pool()).await?; - modify_style(file_name, app_data.db_pool(), data_dir).await?; if let Some(old_banner) = old_banner.0 { if old_banner != file_name { @@ -296,31 +242,35 @@ mod tests { } #[test] - fn ensure_data_dir_copies_template_once() { + fn ensure_data_dir_creates_directory() { let base = std::env::temp_dir().join(format!("rezervator-test-data-{}", std::process::id())); - let site_root = base.join("site"); let data_dir = base.join("data/nested"); - fs::create_dir_all(&site_root).unwrap(); - fs::write(site_root.join(BANNER_CSS), "template").unwrap(); - ensure_data_dir(&data_dir, &site_root).unwrap(); - assert_eq!(fs::read_to_string(data_dir.join(BANNER_CSS)).unwrap(), "template"); + ensure_data_dir(&data_dir).unwrap(); + assert!(data_dir.is_dir()); - // An existing file is not overwritten - fs::write(data_dir.join(BANNER_CSS), "customized").unwrap(); - ensure_data_dir(&data_dir, &site_root).unwrap(); - assert_eq!(fs::read_to_string(data_dir.join(BANNER_CSS)).unwrap(), "customized"); + // An existing directory is fine + ensure_data_dir(&data_dir).unwrap(); fs::remove_dir_all(base).unwrap(); } #[test] - fn ensure_data_dir_fails_without_template() { - let base = std::env::temp_dir().join(format!("rezervator-test-notemplate-{}", std::process::id())); - fs::create_dir_all(base.join("site")).unwrap(); + fn ensure_data_dir_removes_only_old_banner_styles() { + let data_dir = std::env::temp_dir().join(format!("rezervator-test-styles-{}", std::process::id())); + fs::create_dir_all(&data_dir).unwrap(); + for name in ["banner.css", "banner-DnyIx.css", "banner.jpg", "banner-1.png", "other.css"] { + fs::write(data_dir.join(name), "x").unwrap(); + } - assert!(ensure_data_dir(&base.join("data"), &base.join("site")).is_err()); + ensure_data_dir(&data_dir).unwrap(); - fs::remove_dir_all(base).unwrap(); + assert!(!data_dir.join("banner.css").exists()); + assert!(!data_dir.join("banner-DnyIx.css").exists()); + assert!(data_dir.join("banner.jpg").exists()); + assert!(data_dir.join("banner-1.png").exists()); + assert!(data_dir.join("other.css").exists()); + + fs::remove_dir_all(data_dir).unwrap(); } } diff --git a/src/backend/data.rs b/src/backend/data.rs index fa0d287..d38deeb 100644 --- a/src/backend/data.rs +++ b/src/backend/data.rs @@ -741,14 +741,29 @@ pub struct Appearance { id: i32, pub banner: Option, pub text: Option, - pub title: Option, - pub css_name: Option + pub title: Option } impl Appearance { pub fn id(&self) -> i32 { self.id } + + /// CSS variables for the banner of the public page, used as its `style` attribute. + /// The styles themselves are in `assets/css/public.css`. + pub fn banner_style(&self) -> String { + match &self.banner { + Some(banner) if is_css_safe_file_name(banner) => format!("--rz-banner-image: url('/data/{}')", banner), + _ => String::new() + } + } +} + +/// The value is put into a `style` attribute, so it must not be able to end the `url()` or the declaration +fn is_css_safe_file_name(name: &str) -> bool { + !name.is_empty() + && !name.starts_with('.') + && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') } pub fn validate_closing_time(time: &ClosingTime) -> Result<(), ValidationError> { @@ -1272,6 +1287,25 @@ mod tests { assert_eq!(SlotType::Day.to_string(), "Day"); } + #[test] + fn banner_style_sets_image_variable() { + let appearance = Appearance { banner: Some("my-banner_1.jpg".to_string()), ..Default::default() }; + assert_eq!(appearance.banner_style(), "--rz-banner-image: url('/data/my-banner_1.jpg')"); + } + + #[test] + fn banner_style_is_empty_without_banner() { + assert_eq!(Appearance::default().banner_style(), ""); + } + + #[test] + fn banner_style_ignores_unsafe_file_names() { + for name in ["", "a'); color: red; x: url('.jpg", "a b.jpg", "../config.toml", "a/b.jpg", "a\\b.jpg", "a\"b.jpg", "a;b.jpg", "a)b.jpg", ".hidden"] { + let appearance = Appearance { banner: Some(name.to_string()), ..Default::default() }; + assert_eq!(appearance.banner_style(), "", "name: {:?}", name); + } + } + #[test] fn enum_defaults() { assert_eq!(ReservationState::default(), ReservationState::New); diff --git a/src/components/header.rs b/src/components/header.rs index 3fc76aa..4ce73b5 100644 --- a/src/components/header.rs +++ b/src/components/header.rs @@ -1,14 +1,12 @@ use leptos::prelude::*; use leptos_meta::*; use crate::app::DialogHelper; -use crate::backend::appearance::get_appearance; use crate::components::user_menu::MenuOpener; #[component] pub fn Header() -> impl IntoView { let drawer = use_context::().expect("No drawer opener"); let dlg_helper = use_context::().expect("No dialog helper"); - let appearance = Resource::new_blocking(||(), |_| get_appearance()); view! { impl IntoView { - - {move || { - appearance.get().map(|a| { - let css_name = match a { - Ok(a) => a.css_name.unwrap_or_default(), - Err(_) => "banner.css".to_string() - }; - view! {} - }) - }} - + // diff --git a/src/config.rs b/src/config.rs index b1f6aa9..769c345 100644 --- a/src/config.rs +++ b/src/config.rs @@ -133,7 +133,7 @@ impl Logging { } } -/// Where files created at runtime (uploaded banner, generated CSS) are kept +/// Where files created at runtime (uploaded banner) are kept #[cfg(feature = "ssr")] #[derive(Deserialize, Default)] pub struct Storage { diff --git a/src/main.rs b/src/main.rs index 40e608d..10ae44a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -63,9 +63,9 @@ async fn main() -> std::io::Result<()> { let conf = get_configuration(None).unwrap(); - // Files created at runtime (banner, generated styles) live outside the site root, so they survive upgrades + // Files created at runtime (uploaded banner) live outside the site root, so they survive upgrades let data_dir = srv_conf.storage().data_dir(); - if let Err(e) = ensure_data_dir(&data_dir, Path::new(&*conf.leptos_options.site_root)) { + if let Err(e) = ensure_data_dir(&data_dir) { error!("Cannot prepare data directory {}: {}", data_dir.display(), e); return Err(std::io::Error::other(e)); } @@ -102,7 +102,7 @@ async fn main() -> std::io::Result<()> { if let Err(e) = check_messages(&pool).await { error!("Error while checking messages: {:?}", e); } - if let Err(e) = check_appearance(&pool, &data_dir).await { + if let Err(e) = check_appearance(&pool).await { error!("Error while checking messages: {:?}", e); } diff --git a/src/pages/appearance_settings.rs b/src/pages/appearance_settings.rs index d9a6128..3565642 100644 --- a/src/pages/appearance_settings.rs +++ b/src/pages/appearance_settings.rs @@ -91,11 +91,12 @@ pub fn appearance() -> impl IntoView { app_edit.set(app.clone()); let has_banner = app.banner.is_some(); let title = app.title.clone().unwrap_or_default(); + let style = app.banner_style(); view! {
-
-

{title.clone()}

+
+

{title.clone()}

diff --git a/src/pages/public.rs b/src/pages/public.rs index 1a66298..e0e059f 100644 --- a/src/pages/public.rs +++ b/src/pages/public.rs @@ -145,15 +145,16 @@ pub fn Public() -> impl IntoView { Ok(a) => { let has_banner = a.banner.is_some(); let title = a.title.clone().unwrap_or_default(); + let style = a.banner_style(); view! {
-
-

{title.clone()}

+
+

{title.clone()}

-
+