diff --git a/.claude/rules/backend.md b/.claude/rules/backend.md index 30246de..9bc754a 100644 --- a/.claude/rules/backend.md +++ b/.claude/rules/backend.md @@ -29,7 +29,8 @@ paths: - Every server fn that reads or changes non-public data starts with `perm_check!(is_logged_in)` or `perm_check!(is_admin)`; use `user_check!(login)` when a non-admin may only touch their own record. The macros return early with 403 and `ApiResponse::Error`, so they only work in functions returning `Result, _>`. - 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 (like `appearance::upload`) must check the session themselves. +- 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. - 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 4158c2e..70e4c0b 100644 --- a/.claude/rules/frontend.md +++ b/.claude/rules/frontend.md @@ -48,6 +48,6 @@ 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()`. -- 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 (`Colors::style()` in `src/backend/color.rs`, `Appearance::banner_style()` in `src/backend/data.rs`) — never generate CSS files. Every value put into a `style` attribute must be validated first. +- 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 (`Colors::style()` in `src/backend/color.rs`, `BannerOptions::style()` in `src/backend/banner.rs`, `Appearance::banner_style()` in `src/backend/data.rs`) — never generate CSS files. Every value put into a `style` attribute must be validated first. - The preview in the appearance settings is a `.public-page.public-preview` element inside the admin portal, styled by the same `public.css`. A rule there must set every property that `dark.css` sets for the same component, otherwise the preview changes with the admin theme. When the public form gets a new element, add it to the preview (`Preview` in `src/pages/appearance_settings.rs`) and check it with a dark palette. - Stylesheets and meta tags are declared in `src/components/header.rs`. diff --git a/CLAUDE.md b/CLAUDE.md index 521008a..07e4bc0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,14 +33,15 @@ 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 (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`. +- Files created at runtime (the uploaded banner and logo) 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 -- `src/main.rs` — Actix setup: config, pool, migrations, seeding, session + auth middleware, `/api/*` server fns, `/upload`, `/health` (`src/backend/health.rs`). +- `src/main.rs` — Actix setup: config, pool, migrations, seeding, session + auth middleware, `/api/*` server fns, `/health` (`src/backend/health.rs`). - `src/app.rs` — ``, router, shared contexts (`MenuHelper`, `MenuOpener`, `DialogHelper`). - `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/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. - `src/error.rs` — `AppError`; `src/config.rs` — `config.toml` structs (ssr only); `src/validator.rs` — client-side form validation helper. diff --git a/Cargo.lock b/Cargo.lock index 0455320..4bcd76d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -90,44 +90,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "actix-multipart" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5118a26dee7e34e894f7e85aa0ee5080ae4c18bf03c0e30d49a80e418f00a53" -dependencies = [ - "actix-multipart-derive", - "actix-utils", - "actix-web", - "derive_more 0.99.18", - "futures-core", - "futures-util", - "httparse", - "local-waker", - "log", - "memchr", - "mime", - "rand 0.8.5", - "serde", - "serde_json", - "serde_plain", - "tempfile", - "tokio", -] - -[[package]] -name = "actix-multipart-derive" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11eb847f49a700678ea2fa73daeb3208061afa2b9d1a8527c03390f4c4a1c6b" -dependencies = [ - "darling", - "parse-size", - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "actix-router" version = "0.5.4" @@ -2654,6 +2616,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http 1.5.0", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "native-tls" version = "0.2.13" @@ -2860,12 +2839,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "parse-size" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "487f2ccd1e17ce8c1bfab3a65c89525af41cfad4c8659021a1e9a2aacd73b89b" - [[package]] name = "paste" version = "1.0.15" @@ -3334,7 +3307,6 @@ name = "rezervator" version = "1.2.0" dependencies = [ "actix-files", - "actix-multipart", "actix-session", "actix-web", "base64 0.22.1", @@ -3356,6 +3328,7 @@ dependencies = [ "leptos_router", "lettre", "log", + "multer", "pwhash", "regex", "rust_decimal", @@ -3654,15 +3627,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_plain" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" -dependencies = [ - "serde", -] - [[package]] name = "serde_qs" version = "0.15.0" @@ -3721,6 +3685,7 @@ dependencies = [ "http 1.5.0", "inventory", "js-sys", + "multer", "or_poisoned", "pin-project-lite", "rustc_version", diff --git a/Cargo.toml b/Cargo.toml index 9a6910d..9f3b74b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,18 +10,19 @@ crate-type = ["cdylib", "rlib"] actix-files = { version = "0.6.2", optional = true } actix-web = { version = "4.5.1", optional = true, features = ["macros"] } actix-session = { version = "0.10.0", optional = true, features = ["cookie-session"] } -actix-multipart = { version = "0.7.2", optional = true } 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 } cfg-if = "1" -leptos = { version = "0.8.22" } +leptos = { version = "0.8.22", features = ["multipart"] } leptos_meta = { version = "0.8" } leptos_actix = { version = "0.8", optional = true } -server_fn = { version = "0.8" } +server_fn = { version = "0.8", features = ["multipart"] } 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"] } +web-sys = { version = "0.3.69", features = ["Navigator", "Blob", "DomRect", "Element", "File", "FileList", "FormData", "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"] } @@ -53,7 +54,7 @@ ssr = [ "dep:actix-web", "dep:leptos_actix", "dep:actix-session", - "dep:actix-multipart", + "dep:multer", "dep:sqlx", "dep:lettre", "dep:charts-rs", diff --git a/Dockerfile b/Dockerfile index f6df107..026373d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -52,7 +52,7 @@ EXPOSE 3000 RUN mkdir -p /app/config VOLUME /app/config -# Uploaded banner. Keep this volume when the container is replaced. +# Uploaded banner and logo. 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 1b7e3ec..5e93b45 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Older versions created `admin` with the password `admin`. The server logs a warn ## Data directory -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. +Files created while the application runs (the uploaded banner and logo) 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. diff --git a/assets/css/public.css b/assets/css/public.css index c6d775d..041a356 100644 --- a/assets/css/public.css +++ b/assets/css/public.css @@ -161,22 +161,41 @@ color: var(--rz-body); } -/* Banner */ +/* Banner: the image (or a strip in the primary colour) with an optional logo and the title. + The fallbacks are for the form embedded in the admin portal, outside of .public-page. */ .public-banner { - height: 100px; - clip-path: inset(0 0 0 0); - background-image: var(--rz-banner-image, none); + --rz-banner-current-height: var(--rz-banner-height, 100px); + + display: flex; + align-items: center; + justify-content: var(--rz-banner-justify, flex-start); + gap: 1rem; + height: var(--rz-banner-current-height); + padding: 0 30px; + overflow: hidden; + background-color: var(--rz-primary, #696cff); + background-image: + linear-gradient(rgba(0, 0, 0, var(--rz-banner-dim, 0)), rgba(0, 0, 0, var(--rz-banner-dim, 0))), + var(--rz-banner-image, none); background-size: cover; - padding: 30px; - color: white; + background-position: var(--rz-banner-position, center); +} + +.public-banner-logo { + flex: none; + height: calc(var(--rz-banner-current-height) * 0.6); + max-width: 40%; + object-fit: contain; } /* Two classes, to win over the heading colour above */ .public-banner .public-banner-title { + min-width: 0; + margin: 0; font-size: x-large; - color: white; - text-shadow: -3px -3px 0 #000, 3px -3px 0 #000, -3px 3px 0 #000, 3px 3px 0 #000; + color: var(--rz-banner-title, #ffffff); + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.35); } .public-banner-text { @@ -185,7 +204,7 @@ @media (min-width: 1200px) { .public-banner { - height: 200px; + --rz-banner-current-height: var(--rz-banner-height-wide, 200px); } .public-banner .public-banner-title { @@ -193,11 +212,26 @@ } } -/* The preview is narrow whatever the width of the window */ +/* The preview is narrow whatever the width of the window. Clicking into its banner sets + the part of the image that stays visible, the mark shows it. */ .public-preview .public-banner { - height: 100px; + --rz-banner-current-height: var(--rz-banner-height, 100px); + + position: relative; + cursor: crosshair; } .public-preview .public-banner .public-banner-title { font-size: x-large; } + +.public-banner-focus { + position: absolute; + width: 14px; + height: 14px; + border: 2px solid #ffffff; + border-radius: 50%; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6); + transform: translate(-50%, -50%); + pointer-events: none; +} diff --git a/config_sample.toml b/config_sample.toml index e48ecd9..a05241f 100644 --- a/config_sample.toml +++ b/config_sample.toml @@ -33,7 +33,7 @@ path = "Maildir" #[admin] #initial_password = "" -# Directory for uploaded files (banner), served under /data. +# Directory for uploaded files (banner, logo), 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 77a275f..638fdbf 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -111,7 +111,7 @@ The script manages databases with `sudo -u postgres psql` and `pg_dump`. The cus Every customer container listens on `127.0.0.1` on its own port (`BASE_PORT` and up), not reachable from outside. `add` writes `customers//nginx.conf`, a plain HTTP server block that proxies the domain to that port; the commands to install it and to add a certificate with certbot are at the top of the file. Adapt it to how TLS is done on your server. -nginx's default limit for uploads is 1 MB; the generated block raises it to 20 MB for banner images. +nginx's default limit for uploads is 1 MB; the generated block raises it to 20 MB. The application itself accepts banner and logo images up to 5 MB. ### Moving an existing installation into a container @@ -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). `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 and logo). `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/08_banner_options.sql b/migrations/08_banner_options.sql new file mode 100644 index 0000000..983693f --- /dev/null +++ b/migrations/08_banner_options.sql @@ -0,0 +1,8 @@ +-- Look of the public page banner and an optional logo +ALTER TABLE appearance ADD banner_height VARCHAR(10) NOT NULL DEFAULT 'medium'; +ALTER TABLE appearance ADD banner_focus_x SMALLINT NOT NULL DEFAULT 50; +ALTER TABLE appearance ADD banner_focus_y SMALLINT NOT NULL DEFAULT 50; +ALTER TABLE appearance ADD banner_dim SMALLINT NOT NULL DEFAULT 30; +ALTER TABLE appearance ADD title_color VARCHAR(7) NOT NULL DEFAULT '#ffffff'; +ALTER TABLE appearance ADD title_align VARCHAR(10) NOT NULL DEFAULT 'left'; +ALTER TABLE appearance ADD logo VARCHAR; diff --git a/src/backend/appearance.rs b/src/backend/appearance.rs index 8128144..563fa65 100644 --- a/src/backend/appearance.rs +++ b/src/backend/appearance.rs @@ -1,6 +1,8 @@ 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::components::data_form::ForValidation; @@ -9,40 +11,12 @@ cfg_if! { if #[cfg(feature = "ssr")] { use std::fs; use std::path::Path; - use actix_web::{post, Responder}; - use actix_multipart::Multipart; - use actix_session::Session; - use actix_web::web::Redirect; - use std::fs::File; - use std::io::Write; - use futures_util::{StreamExt, TryStreamExt}; - use crate::backend::data::User; use crate::error::AppError; use sqlx::{query, query_as, PgPool}; use crate::backend::get_pool; - use actix_web::web::Data; - use crate::backend::AppData; - use log::{info, warn}; - - 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. - /// Returns `None` when the file is not an image that can be used as a banner. - pub fn banner_file_name(name: &str) -> Option { - // Only the last path segment, whatever separator the client used - let name = name.rsplit(['/', '\\']).next().unwrap_or_default(); - let (stem, extension) = name.rsplit_once('.')?; - - if stem.is_empty() || !BANNER_EXTENSIONS.contains(&extension.to_lowercase().as_str()) { - return None - } - - let stem = stem.chars() - .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }) - .collect::(); - - Some(format!("{}.{}", stem, extension.to_lowercase())) - } + use crate::backend::banner::{image_extension, is_image_content, is_stored_image_name, ImageKind, MAX_IMAGE_SIZE}; + use log::info; + use uuid::Uuid; /// 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> { @@ -80,75 +54,45 @@ cfg_if! { if #[cfg(feature = "ssr")] { Ok(()) } - async fn set_banner_name(file_name: &str, pool: &PgPool) -> Result<(), AppError> { - query("UPDATE appearance SET banner = $1") + /// The multipart codec of server_fn panics when the request has no usable boundary. + /// Such a request to the upload must be refused before it gets there (see `src/main.rs`). + pub fn is_broken_upload(path: &str, content_type: Option<&str>) -> bool { + path.starts_with("/api/upload_image") + && !content_type.is_some_and(|content_type| multer::parse_boundary(content_type).is_ok()) + } + + /// Name under which an uploaded image is stored. It is always a new one, so a replaced image + /// is never served from the browser cache, and nothing of the name sent by the browser gets + /// into the file system except the extension. + fn new_image_name(kind: ImageKind, extension: &str) -> String { + let id = Uuid::new_v4().simple().to_string(); + + format!("{}-{}.{}", kind.name(), &id[..8], extension) + } + + /// Stores the name of the image (or none) and removes the file of the previous one + async fn replace_image(kind: ImageKind, file_name: Option<&str>, pool: &PgPool, data_dir: &Path) -> Result<(), AppError> { + // The column name comes from the enum, never from the request + let column = kind.name(); + let old: (Option,) = query_as(&format!("SELECT {} FROM appearance", column)) + .fetch_one(pool) + .await?; + + query(&format!("UPDATE appearance SET {} = $1", column)) .bind(file_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") - .fetch_one(app_data.db_pool()) - .await?; - - let mut file = File::create(data_dir.join(file_name))?; - while let Some(chunk) = field.next().await { - let chunk = chunk.map_err(|e| AppError::FileIOError(e.to_string()))?; - file.write_all(&chunk)?; - } - - set_banner_name(file_name, app_data.db_pool()).await?; - - if let Some(old_banner) = old_banner.0 { - if old_banner != file_name { - let _ = fs::remove_file(data_dir.join(old_banner)); + if let Some(old) = old.0 { + if is_stored_image_name(&old) && Some(old.as_str()) != file_name { + // The file may be already gone + let _ = fs::remove_file(data_dir.join(old)); } } Ok(()) } - #[post("/upload")] - pub async fn upload(mut data: Multipart, session: Session, app_data: Data) -> impl Responder { - let user = session.get::("user").unwrap_or(None); - - if user.is_none() { - return Redirect::to("/login").see_other(); - } - - if let Some(u) = user { - if !u.admin { - return Redirect::to("/admin/appearance").see_other(); - } - } - - if let Ok(Some(mut field)) = data.try_next().await { - let sent_name = field.content_disposition() - .and_then(|cd| cd.get_filename()) - .unwrap_or_default() - .to_string(); - - match banner_file_name(&sent_name) { - Some(file_name) => { - if let Err(e) = save_banner(&mut field, &file_name, &app_data).await { - warn!("Banner {} not saved: {}", file_name, e); - } - } - None => { - if !sent_name.is_empty() { - warn!("Uploaded file {:?} is not a supported image", sent_name); - } - } - } - } - - Redirect::to("/admin/appearance").see_other() - } - }} #[server] @@ -179,58 +123,125 @@ pub async fn update_appearance(appearance: Appearance) -> Result Ok(ApiResponse::Data(())) } -#[server] -pub async fn update_colors(colors: Colors) -> Result, ServerFnError> { - use crate::perm_check; - - perm_check!(is_admin); - - // The colours end up in a style attribute of the public page - if colors.validate().is_err() { - return Ok(ApiResponse::Error("Invalid colour".to_string())) - } - - let pool = get_pool().await?; - let colors = colors.sanitized(); - query("UPDATE appearance SET color_primary = $1, color_background = $2, color_card = $3, color_text = $4") - .bind(colors.primary) - .bind(colors.background) - .bind(colors.card) - .bind(colors.text) - .execute(&pool) - .await?; - - Ok(ApiResponse::Data(())) -} - impl ForValidation for UpdateAppearance { fn entity(&self) -> &dyn Validate { &self.appearance } } +/// Saves the look of the public page: its colours and the options of the banner #[server] -pub async fn delete_banner() -> Result, ServerFnError> { - use std::fs; +pub async fn update_style(colors: Colors, banner: BannerOptions) -> Result, ServerFnError> { + use crate::perm_check; + + perm_check!(is_admin); + + // The values end up in style attributes of the public page + if colors.validate().is_err() { + return Ok(ApiResponse::Error("Invalid colour".to_string())) + } + if banner.validate().is_err() { + return Ok(ApiResponse::Error("Invalid banner settings".to_string())) + } + + let pool = get_pool().await?; + let colors = colors.sanitized(); + let banner = banner.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") + .bind(colors.primary) + .bind(colors.background) + .bind(colors.card) + .bind(colors.text) + .bind(banner.height) + .bind(banner.focus_x) + .bind(banner.focus_y) + .bind(banner.dim) + .bind(banner.title_color) + .bind(banner.title_align) + .execute(&pool) + .await?; + + Ok(ApiResponse::Data(())) +} + +/// Upload of the banner image or the logo. The form has the fields `kind` (`banner` or `logo`, +/// it must come first) and `file`. +#[server(input = MultipartFormData)] +pub async fn upload_image(data: MultipartData) -> Result, ServerFnError> { use crate::perm_check; use crate::backend::get_data_dir; perm_check!(is_admin); - let appearance = get_appearance().await?; - let pool = get_pool().await?; - let data_dir = get_data_dir().await?; - query("UPDATE appearance SET banner = $1 WHERE id = $2") - .bind(None::) - .bind(appearance.id()) - .execute(&pool) - .await?; + let Some(mut data) = data.into_inner() else { + return Ok(ApiResponse::Error("No file was sent".to_string())) + }; + let mut kind = None; - if let Some(f) = appearance.banner { - // The file may be already gone - let _ = fs::remove_file(data_dir.join(f)); + while let Some(mut field) = data.next_field().await.map_err(|e| ServerFnError::new(e.to_string()))? { + match field.name() { + Some("kind") => { + let name = field.text().await.map_err(|e| ServerFnError::new(e.to_string()))?; + kind = ImageKind::from_name(&name); + } + Some("file") => { + let Some(kind) = kind else { + return Ok(ApiResponse::Error("Invalid upload".to_string())) + }; + let Some(extension) = image_extension(field.file_name().unwrap_or_default()) else { + return Ok(ApiResponse::Error("Unsupported image type, use JPG, PNG, GIF or WebP".to_string())) + }; + + // The limit is small enough to keep the image in memory until it is known to be acceptable + let mut content: Vec = vec![]; + while let Some(chunk) = field.chunk().await.map_err(|e| ServerFnError::new(e.to_string()))? { + if content.len() + chunk.len() > MAX_IMAGE_SIZE { + return Ok(ApiResponse::Error("Image is too large, 5 MB at most".to_string())) + } + content.extend_from_slice(&chunk); + } + + if !is_image_content(&extension, &content) { + return Ok(ApiResponse::Error("Unsupported image type, use JPG, PNG, GIF or WebP".to_string())) + } + + let pool = get_pool().await?; + let data_dir = get_data_dir().await?; + let file_name = new_image_name(kind, &extension); + fs::write(data_dir.join(&file_name), &content).map_err(AppError::from)?; + replace_image(kind, Some(&file_name), &pool, &data_dir).await?; + + return Ok(ApiResponse::Data(())) + } + _ => {} + } } + Ok(ApiResponse::Error("No file was sent".to_string())) +} + +#[server] +pub async fn delete_banner() -> Result, ServerFnError> { + use crate::perm_check; + use crate::backend::get_data_dir; + + perm_check!(is_admin); + + replace_image(ImageKind::Banner, None, &get_pool().await?, &get_data_dir().await?).await?; + + Ok(ApiResponse::Data(())) +} + +#[server] +pub async fn delete_logo() -> Result, ServerFnError> { + use crate::perm_check; + use crate::backend::get_data_dir; + + perm_check!(is_admin); + + replace_image(ImageKind::Logo, None, &get_pool().await?, &get_data_dir().await?).await?; + Ok(ApiResponse::Data(())) } @@ -239,31 +250,31 @@ mod tests { use super::*; #[test] - fn banner_file_name_keeps_simple_names() { - assert_eq!(banner_file_name("banner1.jpg"), Some("banner1.jpg".to_string())); - assert_eq!(banner_file_name("my-banner_2.PNG"), Some("my-banner_2.png".to_string())); + fn new_image_names_are_safe_and_unique() { + let first = new_image_name(ImageKind::Banner, "jpg"); + let second = new_image_name(ImageKind::Banner, "jpg"); + + assert!(first.starts_with("banner-") && first.ends_with(".jpg"), "name: {}", first); + assert_eq!(first.len(), "banner-12345678.jpg".len()); + assert!(is_stored_image_name(&first)); + assert_ne!(first, second); + assert!(new_image_name(ImageKind::Logo, "png").starts_with("logo-")); + // A new banner must not look like a style generated by old versions, those are removed on start + assert!(!is_old_banner_style(&first)); } #[test] - fn banner_file_name_strips_directories() { - assert_eq!(banner_file_name("../../etc/banner.jpg"), Some("banner.jpg".to_string())); - assert_eq!(banner_file_name("/etc/banner.jpg"), Some("banner.jpg".to_string())); - assert_eq!(banner_file_name("C:\\Users\\joe\\banner.jpg"), Some("banner.jpg".to_string())); - assert_eq!(banner_file_name("..\\..\\banner.jpg"), Some("banner.jpg".to_string())); - } + fn broken_uploads_are_recognized() { + let upload = "/api/upload_image8164345504715272186"; - #[test] - fn banner_file_name_replaces_unsafe_characters() { - assert_eq!(banner_file_name("můj banner (1).jpg"), Some("m_j_banner__1_.jpg".to_string())); - assert_eq!(banner_file_name("a'b\"c.gif"), Some("a_b_c.gif".to_string())); - assert_eq!(banner_file_name("archive.tar.webp"), Some("archive_tar.webp".to_string())); - } - - #[test] - fn banner_file_name_rejects_other_files() { - for name in ["", "banner", "banner.css", "config.toml", "index.html", "script.js", "image.svg", ".jpg", "..", "dir/"] { - assert_eq!(banner_file_name(name), None, "name: {:?}", name); - } + assert!(!is_broken_upload(upload, Some("multipart/form-data; boundary=----WebKitFormBoundaryabc"))); + assert!(is_broken_upload(upload, Some("multipart/form-data"))); + assert!(is_broken_upload(upload, Some("application/x-www-form-urlencoded"))); + assert!(is_broken_upload(upload, Some(""))); + assert!(is_broken_upload(upload, None)); + // Other server functions are not multipart + assert!(!is_broken_upload("/api/update_style123", Some("application/x-www-form-urlencoded"))); + assert!(!is_broken_upload("/", None)); } #[test] diff --git a/src/backend/banner.rs b/src/backend/banner.rs new file mode 100644 index 0000000..e9941cb --- /dev/null +++ b/src/backend/banner.rs @@ -0,0 +1,318 @@ +use serde::{Deserialize, Serialize}; +use validator::{Validate, ValidationError}; +use crate::backend::color::is_color; + +/// Largest image accepted as a banner or a logo +pub const MAX_IMAGE_SIZE: usize = 5 * 1024 * 1024; +/// The image can be darkened by this many percent at most, more would hide it +pub const MAX_DIM: i16 = 80; + +const IMAGE_EXTENSIONS: [&str; 5] = ["jpg", "jpeg", "png", "gif", "webp"]; +/// Value for the `accept` attribute of the file inputs +pub const IMAGE_ACCEPT: &str = ".jpg,.jpeg,.png,.gif,.webp"; + +const DEFAULT_HEIGHT: &str = "medium"; +const DEFAULT_ALIGN: &str = "left"; +const DEFAULT_TITLE_COLOR: &str = "#ffffff"; +const DEFAULT_DIM: i16 = 30; + +/// Banner heights: name, height in pixels on a narrow and on a wide screen +const HEIGHTS: [(&str, u16, u16); 3] = [ + ("low", 70, 120), + (DEFAULT_HEIGHT, 100, 200), + ("high", 150, 300), +]; + +/// Title alignments: name and the matching `justify-content` value +const ALIGNS: [(&str, &str); 2] = [ + (DEFAULT_ALIGN, "flex-start"), + ("center", "center"), +]; + +/// The two images a customer can upload +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ImageKind { + Banner, + Logo, +} + +impl ImageKind { + /// Value of the form field that says what is being uploaded + pub fn name(&self) -> &'static str { + match self { + ImageKind::Banner => "banner", + ImageKind::Logo => "logo", + } + } + + pub fn from_name(name: &str) -> Option { + match name { + "banner" => Some(ImageKind::Banner), + "logo" => Some(ImageKind::Logo), + _ => None, + } + } +} + +/// How the banner of the public page looks, set by the customer in the appearance settings +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Validate)] +#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] +pub struct BannerOptions { + /// `low`, `medium` or `high` + #[cfg_attr(feature = "ssr", sqlx(rename = "banner_height"))] + #[validate(custom(function = "validate_height", message = "Invalid banner height"))] + pub height: String, + /// Point of the image that stays visible when it is cropped, in percent from the left and from the top + #[cfg_attr(feature = "ssr", sqlx(rename = "banner_focus_x"))] + #[validate(range(min = 0, max = 100, message = "Invalid banner position"))] + pub focus_x: i16, + #[cfg_attr(feature = "ssr", sqlx(rename = "banner_focus_y"))] + #[validate(range(min = 0, max = 100, message = "Invalid banner position"))] + pub focus_y: i16, + /// Darkening of the image in percent, so the title stays readable + #[cfg_attr(feature = "ssr", sqlx(rename = "banner_dim"))] + #[validate(range(min = 0, max = 80, message = "Invalid banner darkening"))] + pub dim: i16, + #[validate(custom(function = "validate_title_color", message = "Invalid colour"))] + pub title_color: String, + /// `left` or `center` + #[validate(custom(function = "validate_align", message = "Invalid title alignment"))] + pub title_align: String, +} + +impl Default for BannerOptions { + fn default() -> Self { + BannerOptions { + height: DEFAULT_HEIGHT.to_string(), + focus_x: 50, + focus_y: 50, + dim: DEFAULT_DIM, + title_color: DEFAULT_TITLE_COLOR.to_string(), + title_align: DEFAULT_ALIGN.to_string(), + } + } +} + +impl BannerOptions { + /// Replaces values that are not valid with the default ones, so the result is safe to put into a style + pub fn sanitized(&self) -> Self { + let defaults = BannerOptions::default(); + + BannerOptions { + height: if is_height(&self.height) { self.height.clone() } else { defaults.height }, + focus_x: self.focus_x.clamp(0, 100), + focus_y: self.focus_y.clamp(0, 100), + dim: self.dim.clamp(0, MAX_DIM), + title_color: if is_color(&self.title_color) { self.title_color.to_lowercase() } else { defaults.title_color }, + title_align: if is_align(&self.title_align) { self.title_align.clone() } else { defaults.title_align }, + } + } + + /// CSS variables for the banner, without the image. An image is darkened, the plain strip + /// shown when there is no image is not. + pub fn style(&self, has_image: bool) -> String { + let options = self.sanitized(); + let (_, narrow, wide) = HEIGHTS.iter().find(|h| h.0 == options.height).unwrap_or(&HEIGHTS[1]); + let (_, justify) = ALIGNS.iter().find(|a| a.0 == options.title_align).unwrap_or(&ALIGNS[0]); + let dim = if has_image { options.dim } else { 0 }; + + format!("--rz-banner-height: {}px; --rz-banner-height-wide: {}px; --rz-banner-position: {}% {}%; \ + --rz-banner-dim: {}.{:02}; --rz-banner-title: {}; --rz-banner-justify: {}", + narrow, wide, options.focus_x, options.focus_y, dim / 100, dim % 100, options.title_color, justify) + } +} + +fn is_height(height: &str) -> bool { + HEIGHTS.iter().any(|h| h.0 == height) +} + +fn is_align(align: &str) -> bool { + ALIGNS.iter().any(|a| a.0 == align) +} + +fn validate_height(height: &str) -> Result<(), ValidationError> { + if is_height(height) { Ok(()) } else { Err(ValidationError::new("invalid height")) } +} + +fn validate_align(align: &str) -> Result<(), ValidationError> { + if is_align(align) { Ok(()) } else { Err(ValidationError::new("invalid align")) } +} + +fn validate_title_color(color: &str) -> Result<(), ValidationError> { + if is_color(color) { Ok(()) } else { Err(ValidationError::new("invalid color")) } +} + +/// Lower-case extension of an image that can be used as a banner or a logo, taken from the name +/// of the uploaded file. Nothing else of that name is used. +pub fn image_extension(file_name: &str) -> Option { + let (stem, extension) = file_name.rsplit_once('.')?; + let extension = extension.to_lowercase(); + + if stem.is_empty() || !IMAGE_EXTENSIONS.contains(&extension.as_str()) { + return None + } + + Some(extension) +} + +/// Checks the first bytes of a file against the format its extension claims +pub fn is_image_content(extension: &str, head: &[u8]) -> bool { + match extension { + "jpg" | "jpeg" => head.starts_with(&[0xff, 0xd8, 0xff]), + "png" => head.starts_with(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]), + "gif" => head.starts_with(b"GIF87a") || head.starts_with(b"GIF89a"), + "webp" => head.len() >= 12 && head.starts_with(b"RIFF") && &head[8..12] == b"WEBP", + _ => false, + } +} + +/// Files are stored under names made by the server (`banner-1a2b3c4d.jpg`); only such a name +/// may get into a URL or a style. +pub fn is_stored_image_name(name: &str) -> bool { + !name.is_empty() + && !name.starts_with('.') + && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_options_are_valid() { + let options = BannerOptions::default(); + assert!(options.validate().is_ok()); + assert_eq!(options, options.sanitized()); + } + + #[test] + fn validation_rejects_other_values() { + let invalid: [fn(&mut BannerOptions); 8] = [ + |o| o.height = "huge".to_string(), + |o| o.height = "100px; color: red".to_string(), + |o| o.focus_x = -1, + |o| o.focus_y = 101, + |o| o.dim = 81, + |o| o.dim = -5, + |o| o.title_color = "white".to_string(), + |o| o.title_align = "right".to_string(), + ]; + + for (index, change) in invalid.iter().enumerate() { + let mut options = BannerOptions::default(); + change(&mut options); + assert!(options.validate().is_err(), "case {}", index); + } + } + + #[test] + fn validation_accepts_all_listed_values() { + for height in ["low", "medium", "high"] { + for align in ["left", "center"] { + let options = BannerOptions { height: height.to_string(), title_align: align.to_string(), + focus_x: 0, focus_y: 100, dim: 80, title_color: "#000000".to_string() }; + assert!(options.validate().is_ok(), "{} {}", height, align); + } + } + } + + #[test] + fn sanitized_replaces_invalid_values() { + let options = BannerOptions { + height: "x".to_string(), + focus_x: -20, + focus_y: 300, + dim: 100, + title_color: "red; x: y".to_string(), + title_align: "justify".to_string(), + }.sanitized(); + + assert_eq!(options, BannerOptions { focus_x: 0, focus_y: 100, dim: 80, ..Default::default() }); + } + + #[test] + fn style_of_default_options() { + assert_eq!(BannerOptions::default().style(true), + "--rz-banner-height: 100px; --rz-banner-height-wide: 200px; --rz-banner-position: 50% 50%; \ + --rz-banner-dim: 0.30; --rz-banner-title: #ffffff; --rz-banner-justify: flex-start"); + } + + #[test] + fn style_of_changed_options() { + let options = BannerOptions { height: "high".to_string(), focus_x: 10, focus_y: 85, dim: 5, + title_color: "#102030".to_string(), title_align: "center".to_string() }; + assert_eq!(options.style(true), + "--rz-banner-height: 150px; --rz-banner-height-wide: 300px; --rz-banner-position: 10% 85%; \ + --rz-banner-dim: 0.05; --rz-banner-title: #102030; --rz-banner-justify: center"); + } + + #[test] + fn style_without_image_is_not_darkened() { + assert!(BannerOptions::default().style(false).contains("--rz-banner-dim: 0.00;")); + } + + #[test] + fn style_never_contains_invalid_values() { + let options = BannerOptions { height: "1px; x: url(y)".to_string(), title_color: "".to_string(), + title_align: "\"".to_string(), ..Default::default() }; + assert_eq!(options.style(true), BannerOptions::default().style(true)); + } + + #[test] + fn image_extension_of_supported_files() { + assert_eq!(image_extension("banner1.jpg"), Some("jpg".to_string())); + assert_eq!(image_extension("My Photo (1).JPEG"), Some("jpeg".to_string())); + assert_eq!(image_extension("../../x.PNG"), Some("png".to_string())); + assert_eq!(image_extension("a.tar.webp"), Some("webp".to_string())); + assert_eq!(image_extension("a.gif"), Some("gif".to_string())); + } + + #[test] + fn image_extension_rejects_other_files() { + for name in ["", "banner", "banner.css", "config.toml", "index.html", "script.js", "image.svg", ".jpg", "..", "dir/", "a.jpg.exe"] { + assert_eq!(image_extension(name), None, "name: {:?}", name); + } + } + + #[test] + fn image_content_matches_extension() { + let jpeg = [0xff, 0xd8, 0xff, 0xe0, 0, 0]; + let png = [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0]; + let webp = b"RIFF\x10\x00\x00\x00WEBPVP8 "; + + assert!(is_image_content("jpg", &jpeg)); + assert!(is_image_content("jpeg", &jpeg)); + assert!(is_image_content("png", &png)); + assert!(is_image_content("gif", b"GIF89a...")); + assert!(is_image_content("gif", b"GIF87a...")); + assert!(is_image_content("webp", webp)); + + assert!(!is_image_content("png", &jpeg)); + assert!(!is_image_content("jpg", &png)); + assert!(!is_image_content("jpg", b"