Added banner options, logo and image upload with validation to appearance settings. Fixes #15
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<ApiResponse<_>, _>`.
|
||||
- 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
|
||||
|
||||
@@ -48,6 +48,6 @@ paths:
|
||||
- Custom styles go to a CSS file in `assets/css/` and must be linked with `<Link rel="stylesheet">` 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`.
|
||||
|
||||
@@ -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` — `<App/>`, 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.
|
||||
|
||||
Generated
+19
-54
@@ -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",
|
||||
|
||||
+6
-5
@@ -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",
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+45
-11
@@ -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;
|
||||
}
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+2
-2
@@ -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/<name>/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/<name>/`: `<timestamp>-db.sql.gz` (database dump) and `<timestamp>-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/<name>/`: `<timestamp>-db.sql.gz` (database dump) and `<timestamp>-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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
+159
-148
@@ -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<String> {
|
||||
// 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::<String>();
|
||||
|
||||
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<String>,) = 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<String>,) = 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<AppData>) -> impl Responder {
|
||||
let user = session.get::<User>("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<ApiResponse<()>
|
||||
Ok(ApiResponse::Data(()))
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn update_colors(colors: Colors) -> Result<ApiResponse<()>, 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<ApiResponse<()>, ServerFnError> {
|
||||
use std::fs;
|
||||
pub async fn update_style(colors: Colors, banner: BannerOptions) -> Result<ApiResponse<()>, 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<ApiResponse<()>, 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::<String>)
|
||||
.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<u8> = 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<ApiResponse<()>, 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<ApiResponse<()>, 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]
|
||||
|
||||
@@ -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<Self> {
|
||||
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<String> {
|
||||
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: "</style>".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"<html><script>"));
|
||||
assert!(!is_image_content("gif", b"GIF"));
|
||||
assert!(!is_image_content("webp", b"RIFF\x10\x00\x00\x00WAVE"));
|
||||
assert!(!is_image_content("webp", b"RIFF"));
|
||||
assert!(!is_image_content("svg", b"<svg"));
|
||||
assert!(!is_image_content("jpg", &[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_image_names() {
|
||||
assert!(is_stored_image_name("banner-1a2b3c4d.jpg"));
|
||||
assert!(is_stored_image_name("banner1.jpg"));
|
||||
|
||||
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"] {
|
||||
assert!(!is_stored_image_name(name), "name: {:?}", name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_kind_names() {
|
||||
for kind in [ImageKind::Banner, ImageKind::Logo] {
|
||||
assert_eq!(ImageKind::from_name(kind.name()), Some(kind));
|
||||
}
|
||||
assert_eq!(ImageKind::from_name("config"), None);
|
||||
}
|
||||
}
|
||||
+61
-15
@@ -4,6 +4,7 @@ use std::fmt::{Display, Formatter};
|
||||
use std::str::FromStr;
|
||||
use chrono::{Local, NaiveDate, NaiveTime, Weekday};
|
||||
use lazy_static::lazy_static;
|
||||
use crate::backend::banner::{is_stored_image_name, BannerOptions};
|
||||
use crate::backend::color::Colors;
|
||||
use regex::Regex;
|
||||
use rust_decimal::Decimal;
|
||||
@@ -746,7 +747,11 @@ pub struct Appearance {
|
||||
// Forms that edit the title or the text don't send the colours
|
||||
#[serde(default)]
|
||||
#[cfg_attr(feature = "ssr", sqlx(flatten))]
|
||||
pub colors: Colors
|
||||
pub colors: Colors,
|
||||
pub logo: Option<String>,
|
||||
#[serde(default)]
|
||||
#[cfg_attr(feature = "ssr", sqlx(flatten))]
|
||||
pub banner_options: BannerOptions
|
||||
}
|
||||
|
||||
impl Appearance {
|
||||
@@ -754,21 +759,46 @@ impl Appearance {
|
||||
self.id
|
||||
}
|
||||
|
||||
/// URL of the banner image, if there is one
|
||||
pub fn banner_url(&self) -> Option<String> {
|
||||
stored_image_url(&self.banner)
|
||||
}
|
||||
|
||||
/// URL of the logo, if there is one
|
||||
pub fn logo_url(&self) -> Option<String> {
|
||||
stored_image_url(&self.logo)
|
||||
}
|
||||
|
||||
/// The public page has a header when there is anything to show in it
|
||||
pub fn has_banner(&self) -> bool {
|
||||
self.banner_url().is_some()
|
||||
|| self.logo_url().is_some()
|
||||
|| !self.title.clone().unwrap_or_default().trim().is_empty()
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
banner_style(self.banner_url(), &self.banner_options)
|
||||
}
|
||||
}
|
||||
|
||||
/// Style of a banner with the given image; the preview in the appearance settings uses it
|
||||
/// with an image that is not uploaded yet
|
||||
pub fn banner_style(image_url: Option<String>, options: &BannerOptions) -> String {
|
||||
let options = options.style(image_url.is_some());
|
||||
|
||||
match image_url {
|
||||
Some(url) => format!("--rz-banner-image: url('{}'); {}", url, options),
|
||||
None => options
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 == '.')
|
||||
fn stored_image_url(name: &Option<String>) -> Option<String> {
|
||||
name.as_ref()
|
||||
.filter(|name| is_stored_image_name(name))
|
||||
.map(|name| format!("/data/{}", name))
|
||||
}
|
||||
|
||||
pub fn validate_closing_time(time: &ClosingTime) -> Result<(), ValidationError> {
|
||||
@@ -1295,22 +1325,38 @@ mod tests {
|
||||
#[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')");
|
||||
assert_eq!(appearance.banner_style(),
|
||||
format!("--rz-banner-image: url('/data/my-banner_1.jpg'); {}", BannerOptions::default().style(true)));
|
||||
assert!(appearance.has_banner());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn banner_style_is_empty_without_banner() {
|
||||
assert_eq!(Appearance::default().banner_style(), "");
|
||||
fn banner_style_without_image_has_only_options() {
|
||||
assert_eq!(Appearance::default().banner_style(), BannerOptions::default().style(false));
|
||||
}
|
||||
|
||||
#[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);
|
||||
for name in ["", "a'); color: red; x: url('.jpg", "a b.jpg", "../config.toml", "a/b.jpg", ".hidden"] {
|
||||
let appearance = Appearance { banner: Some(name.to_string()), logo: Some(name.to_string()), ..Default::default() };
|
||||
assert_eq!(appearance.banner_style(), BannerOptions::default().style(false), "name: {:?}", name);
|
||||
assert_eq!(appearance.banner_url(), None, "name: {:?}", name);
|
||||
assert_eq!(appearance.logo_url(), None, "name: {:?}", name);
|
||||
assert!(!appearance.has_banner(), "name: {:?}", name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn banner_is_shown_for_title_logo_or_image() {
|
||||
assert!(!Appearance::default().has_banner());
|
||||
assert!(!Appearance { title: Some(" ".to_string()), ..Default::default() }.has_banner());
|
||||
assert!(Appearance { title: Some("Tennis".to_string()), ..Default::default() }.has_banner());
|
||||
|
||||
let with_logo = Appearance { logo: Some("logo-1a2b3c4d.png".to_string()), ..Default::default() };
|
||||
assert!(with_logo.has_banner());
|
||||
assert_eq!(with_logo.logo_url(), Some("/data/logo-1a2b3c4d.png".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enum_defaults() {
|
||||
assert_eq!(ReservationState::default(), ReservationState::New);
|
||||
|
||||
@@ -10,6 +10,7 @@ pub mod customer;
|
||||
pub mod mail;
|
||||
pub mod appearance;
|
||||
pub mod color;
|
||||
pub mod banner;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod health;
|
||||
|
||||
|
||||
@@ -164,7 +164,30 @@ lazy_static! {
|
||||
("Text colour", "Barva textu"),
|
||||
("Save", "Uložit"),
|
||||
("Reset to default", "Obnovit výchozí"),
|
||||
("Colours saved", "Barvy jsou uloženy"),
|
||||
("Settings saved", "Nastavení je uloženo"),
|
||||
("Title", "Titulek"),
|
||||
("Enter title", "Zadejte titulek"),
|
||||
("Title and text", "Titulek a text"),
|
||||
("Banner", "Banner"),
|
||||
("Banner image", "Obrázek banneru"),
|
||||
("Logo", "Logo"),
|
||||
("Height", "Výška"),
|
||||
("Low", "Nízký"),
|
||||
("Medium", "Střední"),
|
||||
("High", "Vysoký"),
|
||||
("Darkening", "Ztmavení"),
|
||||
("Title colour", "Barva titulku"),
|
||||
("Title alignment", "Zarovnání titulku"),
|
||||
("Left", "Vlevo"),
|
||||
("Centre", "Na střed"),
|
||||
("Click into the banner in the preview to choose the part of the image that stays visible.", "Kliknutím do banneru v náhledu určíte část obrázku, která zůstane vidět."),
|
||||
("Unsupported image type, use JPG, PNG, GIF or WebP", "Nepodporovaný typ obrázku. Použijte JPG, PNG, GIF nebo WebP."),
|
||||
("Image is too large, 5 MB at most", "Obrázek je příliš velký, nejvýše 5 MB."),
|
||||
("No file was sent", "Nebyl odeslán žádný soubor"),
|
||||
("Invalid upload", "Neplatné nahrání souboru"),
|
||||
("Invalid banner settings", "Neplatné nastavení banneru"),
|
||||
("Delete logo", "Smazat logo"),
|
||||
("Are you sure you want to delete logo?", "Opravdu chcete smazat logo?"),
|
||||
("Unsaved changes", "Neuložené změny"),
|
||||
("Invalid colour", "Neplatná barva"),
|
||||
("Text is hard to read on the card background", "Text je na pozadí karet špatně čitelný"),
|
||||
|
||||
+13
-2
@@ -4,7 +4,7 @@
|
||||
use actix_web::middleware::Logger;
|
||||
use leptos_captcha::spow::pow::Pow;
|
||||
use log::error;
|
||||
use rezervator::backend::appearance::{check_appearance, ensure_data_dir};
|
||||
use rezervator::backend::appearance::{check_appearance, ensure_data_dir, is_broken_upload};
|
||||
use rezervator::backend::company::check_company;
|
||||
use rezervator::backend::mail::{check_messages, Mailer};
|
||||
use rezervator::backend::user::create_admin;
|
||||
@@ -115,6 +115,18 @@ async fn main() -> std::io::Result<()> {
|
||||
App::new()
|
||||
.app_data(Data::new(AppData::new(pool.clone(), mailer.clone(), data_dir.clone())))
|
||||
// The health check is called every few seconds, keep it out of the access log
|
||||
.wrap_fn(|req, srv| {
|
||||
use actix_web::dev::Service;
|
||||
use futures_util::future::{ready, Either};
|
||||
|
||||
// Without this the multipart codec of server_fn would panic on such a request
|
||||
let content_type = req.headers().get(http::header::CONTENT_TYPE).and_then(|value| value.to_str().ok());
|
||||
if is_broken_upload(req.path(), content_type) {
|
||||
return Either::Left(ready(Err(error::ErrorBadRequest("Invalid upload"))))
|
||||
}
|
||||
|
||||
Either::Right(srv.call(req))
|
||||
})
|
||||
.wrap(Logger::default().exclude("/health"))
|
||||
.wrap(Authentication)
|
||||
.wrap(SessionMiddleware::new(
|
||||
@@ -141,7 +153,6 @@ async fn main() -> std::io::Result<()> {
|
||||
}
|
||||
})
|
||||
.app_data(Data::new(leptos_options.to_owned()))
|
||||
.service(appearance::upload)
|
||||
.service(health)
|
||||
// Runtime files first, the site root would shadow them
|
||||
.service(Files::new("/data", data_dir.clone()))
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
use leptos::prelude::*;
|
||||
use crate::backend::appearance::{DeleteBanner, get_appearance, UpdateAppearance, UpdateColors};
|
||||
use leptos::ev::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 crate::backend::banner::{image_extension, BannerOptions, ImageKind, IMAGE_ACCEPT, MAX_DIM, MAX_IMAGE_SIZE};
|
||||
use crate::backend::color::{Colors, PALETTES};
|
||||
use crate::backend::data::{ApiResponse, Appearance};
|
||||
use crate::backend::data::{banner_style, ApiResponse, Appearance};
|
||||
use crate::components::data_form::{DataForm, 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.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Look {
|
||||
colors: RwSignal<Colors>,
|
||||
banner: RwSignal<BannerOptions>,
|
||||
/// Images selected in the file inputs and not uploaded yet, as object URLs for the preview
|
||||
new_banner: RwSignal<Option<String>>,
|
||||
new_logo: RwSignal<Option<String>>,
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn edit_title(opener: DialogOpener, appearance: ReadSignal<Appearance>) -> impl IntoView {
|
||||
let update = ServerAction::<UpdateAppearance>::new();
|
||||
@@ -62,13 +76,22 @@ fn delete_banner(opener: DialogOpener) -> impl IntoView {
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn delete_logo(opener: DialogOpener) -> impl IntoView {
|
||||
let delete = ServerAction::<DeleteLogo>::new();
|
||||
view! {
|
||||
<QuestionDialog opener=opener action=delete title="Delete logo">
|
||||
<div>{trl("Are you sure you want to delete logo?")}</div>
|
||||
</QuestionDialog>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn color_input(
|
||||
id: &'static str,
|
||||
label: &'static str,
|
||||
colors: RwSignal<Colors>,
|
||||
get: fn(&Colors) -> String,
|
||||
set: fn(&mut Colors, String)) -> impl IntoView {
|
||||
#[prop(into)] value: Signal<String>,
|
||||
#[prop(into)] on_change: Callback<String>) -> impl IntoView {
|
||||
view! {
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<label for=id class="form-label mb-0">{trl(label)}</label>
|
||||
@@ -76,27 +99,198 @@ fn color_input(
|
||||
type="color"
|
||||
id=id
|
||||
class="form-control form-control-color"
|
||||
prop:value=move || get(&colors.get())
|
||||
on:input=move |ev| colors.update(|c| set(c, event_target_value(&ev)))
|
||||
prop:value=move || value.get()
|
||||
on:input=move |ev| on_change.run(event_target_value(&ev))
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample of the public page in the colours being edited. It uses the same classes and the same
|
||||
/// One of a few values, as a row of buttons. Options are pairs of a value and its label.
|
||||
#[component]
|
||||
fn choice(
|
||||
label: &'static str,
|
||||
options: Vec<(&'static str, &'static str)>,
|
||||
#[prop(into)] value: Signal<String>,
|
||||
#[prop(into)] on_change: Callback<String>) -> impl IntoView {
|
||||
view! {
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<div class="form-label mb-0">{trl(label)}</div>
|
||||
<div class="btn-group" role="group" aria-label=trl(label)>
|
||||
{options.into_iter().map(|(option, option_label)| view! {
|
||||
<button
|
||||
type="button"
|
||||
class=move || if value.get() == option { "btn btn-sm btn-primary" } else { "btn btn-sm btn-outline-secondary" }
|
||||
on:click=move |_| on_change.run(option.to_string())>
|
||||
{trl(option_label)}
|
||||
</button>
|
||||
}).collect_view()}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Object URLs are made by the browser; still, only a plain one is let into a style
|
||||
fn is_object_url(url: &str) -> bool {
|
||||
url.starts_with("blob:")
|
||||
&& url.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, ':' | '/' | '.' | '-' | '_' | '[' | ']'))
|
||||
}
|
||||
|
||||
/// Selecting, uploading and deleting of the banner image or the logo. A selected image is shown
|
||||
/// in the preview first and is uploaded on request.
|
||||
#[component]
|
||||
fn image_upload(
|
||||
kind: ImageKind,
|
||||
label: &'static str,
|
||||
/// The stored image exists
|
||||
#[prop(into)] exists: Signal<bool>,
|
||||
/// Object URL of the selected image, for the preview
|
||||
selected: RwSignal<Option<String>>,
|
||||
/// Counter of finished uploads, the page reloads its data when it changes
|
||||
uploads: RwSignal<u32>,
|
||||
delete: DialogOpener) -> impl IntoView {
|
||||
let file = RwSignal::new_local(None::<File>);
|
||||
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()));
|
||||
let id = format!("{}_file", kind.name());
|
||||
|
||||
let clear = move || {
|
||||
if let Some(url) = selected.get_untracked() {
|
||||
let _ = Url::revoke_object_url(&url);
|
||||
}
|
||||
selected.set(None);
|
||||
file.set(None);
|
||||
if let Some(input) = input_ref.get_untracked() {
|
||||
input.set_value("");
|
||||
}
|
||||
};
|
||||
|
||||
Effect::new(move |_| {
|
||||
match upload.value().get() {
|
||||
Some(Ok(ApiResponse::Data(_))) => {
|
||||
clear();
|
||||
uploads.update(|count| *count += 1);
|
||||
}
|
||||
Some(Ok(ApiResponse::Error(e))) => error.set(Some(trl(&e)())),
|
||||
Some(Err(e)) => error.set(Some(format!("Server error: {}", e))),
|
||||
None => {}
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<div class="mb-3">
|
||||
<label for=id.clone() class="form-label">{trl(label)}</label>
|
||||
<input
|
||||
type="file"
|
||||
id=id
|
||||
class="form-control"
|
||||
accept=IMAGE_ACCEPT
|
||||
node_ref=input_ref
|
||||
on:change=move |ev| {
|
||||
let input: HtmlInputElement = event_target(&ev);
|
||||
let chosen = input.files().and_then(|files| files.get(0));
|
||||
// Forget the previous selection, but keep the file in the input
|
||||
if let Some(url) = selected.get_untracked() {
|
||||
let _ = Url::revoke_object_url(&url);
|
||||
}
|
||||
selected.set(None);
|
||||
file.set(None);
|
||||
error.set(None);
|
||||
|
||||
// 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")()));
|
||||
} else if chosen.size() > MAX_IMAGE_SIZE as f64 {
|
||||
error.set(Some(trl("Image is too large, 5 MB at most")()));
|
||||
} else {
|
||||
selected.set(Url::create_object_url_with_blob(&chosen).ok().filter(|url| is_object_url(url)));
|
||||
file.set(Some(chosen));
|
||||
}
|
||||
}
|
||||
}
|
||||
/>
|
||||
{move || error.get().map(|e| view! {
|
||||
<div class="text-danger mt-1">{e}</div>
|
||||
})}
|
||||
<div class="mt-2">
|
||||
{move || if file.get().is_some() {
|
||||
view! {
|
||||
<button type="button" class="btn btn-sm btn-primary me-2" disabled=move || upload.pending().get()
|
||||
on:click=move |_| {
|
||||
if let (Some(chosen), Ok(data)) = (file.get_untracked(), FormData::new()) {
|
||||
// The kind has to be sent before the file
|
||||
let _ = data.append_with_str("kind", kind.name());
|
||||
let _ = data.append_with_blob_and_filename("file", &chosen, &chosen.name());
|
||||
error.set(None);
|
||||
upload.dispatch_local(data);
|
||||
}
|
||||
}>
|
||||
{trl("Upload")}
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" on:click=move |_| clear()>
|
||||
{trl("Cancel")}
|
||||
</button>
|
||||
}.into_any()
|
||||
} else if exists.get() {
|
||||
view! {
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" on:click=move |_| delete.show()>
|
||||
{trl("Delete")}
|
||||
</button>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {<span></span>}.into_any()
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample of the public page in the look being edited. It uses the same classes and the same
|
||||
/// stylesheet (assets/css/public.css) as the page itself.
|
||||
#[component]
|
||||
fn preview(colors: RwSignal<Colors>, appearance: ReadSignal<Appearance>) -> impl IntoView {
|
||||
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 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
|
||||
let set_focus = move |ev: MouseEvent| {
|
||||
let Some(banner) = ev.current_target().and_then(|target| target.dyn_into::<Element>().ok()) else {
|
||||
return
|
||||
};
|
||||
let rect = banner.get_bounding_client_rect();
|
||||
if rect.width() <= 0.0 || rect.height() <= 0.0 {
|
||||
return
|
||||
}
|
||||
|
||||
let percent = |position: f64, size: f64| ((position / size) * 100.0).round().clamp(0.0, 100.0) as i16;
|
||||
let x = percent(ev.client_x() as f64 - rect.left(), rect.width());
|
||||
let y = percent(ev.client_y() as f64 - rect.top(), rect.height());
|
||||
look.banner.update(|banner| {
|
||||
banner.focus_x = x;
|
||||
banner.focus_y = y;
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="public-page public-preview" style=move || colors.get().style()>
|
||||
{move || {
|
||||
let app = appearance.get();
|
||||
app.banner.is_some().then(|| view! {
|
||||
<div class="public-banner" style=app.banner_style()>
|
||||
<h1 class="public-banner-title">{app.title.clone().unwrap_or_default()}</h1>
|
||||
</div>
|
||||
})
|
||||
}}
|
||||
<div class="public-page public-preview" style=move || look.colors.get().style()>
|
||||
<Show when=has_banner>
|
||||
<div class="public-banner" style=move || banner_style(image(), &look.banner.get()) on:click=set_focus>
|
||||
{move || logo().map(|url| view! {
|
||||
<img class="public-banner-logo" src=url alt=""/>
|
||||
})}
|
||||
<h1 class="public-banner-title">{title}</h1>
|
||||
<Show when=move || image().is_some()>
|
||||
<span class="public-banner-focus" style=move || {
|
||||
let banner = look.banner.get().sanitized();
|
||||
format!("left: {}%; top: {}%", banner.focus_x, banner.focus_y)
|
||||
}></span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="public-banner-text" inner_html=move || appearance.get().text.unwrap_or_default()></div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
@@ -150,17 +344,7 @@ fn preview(colors: RwSignal<Colors>, appearance: ReadSignal<Appearance>) -> impl
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn color_settings(colors: RwSignal<Colors>, saved: RwSignal<Colors>) -> impl IntoView {
|
||||
let save = ServerAction::<UpdateColors>::new();
|
||||
let sent = StoredValue::new(Colors::default());
|
||||
let dirty = move || colors.get() != saved.get();
|
||||
|
||||
Effect::new(move |_| {
|
||||
if let Some(Ok(ApiResponse::Data(_))) = save.value().get() {
|
||||
saved.set(sent.get_value());
|
||||
}
|
||||
});
|
||||
|
||||
fn color_settings(colors: RwSignal<Colors>) -> impl IntoView {
|
||||
view! {
|
||||
<div class="form-label">{trl("Palettes")}</div>
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
@@ -182,25 +366,90 @@ fn color_settings(colors: RwSignal<Colors>, saved: RwSignal<Colors>) -> impl Int
|
||||
</button>
|
||||
}).collect_view()}
|
||||
</div>
|
||||
<ColorInput id="color_primary" label="Primary colour" colors=colors
|
||||
get=|c| c.primary.clone() set=|c, v| c.primary = v/>
|
||||
<ColorInput id="color_background" label="Page background" colors=colors
|
||||
get=|c| c.background.clone() set=|c, v| c.background = v/>
|
||||
<ColorInput id="color_card" label="Card background" colors=colors
|
||||
get=|c| c.card.clone() set=|c, v| c.card = v/>
|
||||
<ColorInput id="color_text" label="Text colour" colors=colors
|
||||
get=|c| c.text.clone() set=|c, v| c.text = v/>
|
||||
<ColorInput id="color_primary" label="Primary colour"
|
||||
value=move || colors.get().primary on_change=move |v| colors.update(|c| c.primary = v)/>
|
||||
<ColorInput id="color_background" label="Page background"
|
||||
value=move || colors.get().background on_change=move |v| colors.update(|c| c.background = v)/>
|
||||
<ColorInput id="color_card" label="Card background"
|
||||
value=move || colors.get().card on_change=move |v| colors.update(|c| c.card = v)/>
|
||||
<ColorInput id="color_text" label="Text colour"
|
||||
value=move || colors.get().text on_change=move |v| colors.update(|c| c.text = v)/>
|
||||
{move || colors.get().contrast_issues().into_iter().map(|issue| view! {
|
||||
<div class="text-warning mb-1"><i class="bx bx-error"></i>" "{trl(issue.message())}</div>
|
||||
}).collect_view()}
|
||||
<div class="mt-3">
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn banner_settings(
|
||||
look: Look,
|
||||
appearance: ReadSignal<Appearance>,
|
||||
uploads: RwSignal<u32>,
|
||||
delete_banner: DialogOpener,
|
||||
delete_logo: DialogOpener) -> impl IntoView {
|
||||
let banner = look.banner;
|
||||
|
||||
view! {
|
||||
<ImageUpload kind=ImageKind::Banner label="Banner image" selected=look.new_banner uploads=uploads
|
||||
exists=move || appearance.get().banner_url().is_some() delete=delete_banner/>
|
||||
<ImageUpload kind=ImageKind::Logo label="Logo" selected=look.new_logo uploads=uploads
|
||||
exists=move || appearance.get().logo_url().is_some() delete=delete_logo/>
|
||||
<Choice label="Height" options=vec![("low", "Low"), ("medium", "Medium"), ("high", "High")]
|
||||
value=move || banner.get().height on_change=move |v| banner.update(|b| b.height = v)/>
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<label for="banner_dim" class="form-label mb-0">{trl("Darkening")}</label>
|
||||
<input
|
||||
type="range"
|
||||
id="banner_dim"
|
||||
class="form-range w-50"
|
||||
min="0"
|
||||
max=MAX_DIM.to_string()
|
||||
step="5"
|
||||
prop:value=move || banner.get().dim.to_string()
|
||||
on:input=move |ev| {
|
||||
if let Ok(dim) = event_target_value(&ev).parse::<i16>() {
|
||||
banner.update(|b| b.dim = dim);
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<ColorInput id="title_color" label="Title colour"
|
||||
value=move || banner.get().title_color on_change=move |v| banner.update(|b| b.title_color = v)/>
|
||||
<Choice label="Title alignment" options=vec![("left", "Left"), ("center", "Centre")]
|
||||
value=move || banner.get().title_align on_change=move |v| banner.update(|b| b.title_align = v)/>
|
||||
<div class="text-muted mt-3">
|
||||
{trl("Click into the banner in the preview to choose the part of the image that stays visible.")}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves colours and banner options together, they make one look
|
||||
#[component]
|
||||
fn save_bar(look: Look, saved: RwSignal<(Colors, BannerOptions)>) -> impl IntoView {
|
||||
let save = ServerAction::<UpdateStyle>::new();
|
||||
let sent = StoredValue::new((Colors::default(), BannerOptions::default()));
|
||||
let current = move || (look.colors.get(), look.banner.get());
|
||||
let dirty = move || current() != saved.get();
|
||||
|
||||
Effect::new(move |_| {
|
||||
if let Some(Ok(ApiResponse::Data(_))) = save.value().get() {
|
||||
saved.set(sent.get_value());
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<div>
|
||||
<button type="button" class="btn btn-primary me-2" on:click=move |_| {
|
||||
sent.set_value(colors.get());
|
||||
save.dispatch(UpdateColors { colors: colors.get() });
|
||||
let (colors, banner) = current();
|
||||
sent.set_value((colors.clone(), banner.clone()));
|
||||
save.dispatch(UpdateStyle { colors, banner });
|
||||
}>
|
||||
{trl("Save")}
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" on:click=move |_| colors.set(Colors::default())>
|
||||
<button type="button" class="btn btn-outline-secondary" on:click=move |_| {
|
||||
look.colors.set(Colors::default());
|
||||
look.banner.set(BannerOptions::default());
|
||||
}>
|
||||
{trl("Reset to default")}
|
||||
</button>
|
||||
</div>
|
||||
@@ -212,7 +461,7 @@ fn color_settings(colors: RwSignal<Colors>, saved: RwSignal<Colors>) -> impl Int
|
||||
|
||||
match save.value().get() {
|
||||
Some(Ok(ApiResponse::Data(_))) => view! {
|
||||
<span class="text-success">{trl("Colours saved")}</span>
|
||||
<span class="text-success">{trl("Settings saved")}</span>
|
||||
}.into_any(),
|
||||
_ => view! {<span></span>}.into_any()
|
||||
}
|
||||
@@ -234,22 +483,32 @@ fn color_settings(colors: RwSignal<Colors>, saved: RwSignal<Colors>) -> impl Int
|
||||
pub fn appearance() -> impl IntoView {
|
||||
let title_edit = DialogOpener::new();
|
||||
let text_edit = DialogOpener::new();
|
||||
let del_dialog = DialogOpener::new();
|
||||
let appearance = Resource::new(move || title_edit.visible() || text_edit.visible() || del_dialog.visible(), |_| get_appearance());
|
||||
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()),
|
||||
|_| get_appearance());
|
||||
let app_edit = RwSignal::new(Appearance::default());
|
||||
let colors = RwSignal::new(Colors::default());
|
||||
let saved_colors = RwSignal::new(Colors::default());
|
||||
let look = Look {
|
||||
colors: RwSignal::new(Colors::default()),
|
||||
banner: RwSignal::new(BannerOptions::default()),
|
||||
new_banner: RwSignal::new(None),
|
||||
new_logo: RwSignal::new(None),
|
||||
};
|
||||
let saved = RwSignal::new((Colors::default(), BannerOptions::default()));
|
||||
let app_preview = RwSignal::new(Appearance::default());
|
||||
let colors_loaded = StoredValue::new(false);
|
||||
let look_loaded = StoredValue::new(false);
|
||||
|
||||
// Runs in the browser only, so the server and the browser render the same markup first.
|
||||
// The data are loaded again after every dialog; that must not throw away colours being edited.
|
||||
// The data are loaded again after every dialog and upload; that must not throw away a look being edited.
|
||||
Effect::new(move |_| {
|
||||
if let Some(Ok(app)) = appearance.get() {
|
||||
if !colors_loaded.get_value() {
|
||||
colors.set(app.colors.clone());
|
||||
saved_colors.set(app.colors.clone());
|
||||
colors_loaded.set_value(true);
|
||||
if !look_loaded.get_value() {
|
||||
look.colors.set(app.colors.clone());
|
||||
look.banner.set(app.banner_options.clone());
|
||||
saved.set((app.colors.clone(), app.banner_options.clone()));
|
||||
look_loaded.set_value(true);
|
||||
}
|
||||
app_preview.set(app);
|
||||
}
|
||||
@@ -258,47 +517,29 @@ pub fn appearance() -> impl IntoView {
|
||||
view! {
|
||||
<EditTitle opener=title_edit appearance=app_edit.read_only()/>
|
||||
<EditText opener=text_edit appearance=app_edit.read_only()/>
|
||||
<DeleteBanner opener=del_dialog/>
|
||||
<DeleteBanner opener=del_banner/>
|
||||
<DeleteLogo opener=del_logo/>
|
||||
<h1>{trl("Appearance settings")}</h1>
|
||||
<div class="row mb-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{trl("Top banner")}</h5>
|
||||
<form method="post" action="/upload" enctype="multipart/form-data">
|
||||
<label for="banner_file">{trl("Banner file")}</label>
|
||||
<input id="banner_file" type="file" class="form-control" name="file_to_upload"/>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{trl("Upload")}
|
||||
</button>
|
||||
</form><br/>
|
||||
<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());
|
||||
let has_banner = app.banner.is_some();
|
||||
let title = app.title.clone().unwrap_or_default();
|
||||
let style = app.banner_style();
|
||||
view! {
|
||||
<div>
|
||||
<Show when=move || has_banner>
|
||||
<div class="public-banner" style=style.clone()>
|
||||
<h1 class="public-banner-title">{title.clone()}</h1>
|
||||
</div>
|
||||
</Show>
|
||||
<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>
|
||||
<button class="btn btn-danger" on:click=move |_| del_dialog.show()>
|
||||
{trl("Delete")}
|
||||
</button>
|
||||
<br/><br/>
|
||||
<div inner_html={app.text.unwrap_or("<< TEXT >>".to_string())}>
|
||||
|
||||
</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>
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
@@ -314,18 +555,30 @@ pub fn appearance() -> impl IntoView {
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-lg-8 order-lg-2 mb-3">
|
||||
<div class="card h-100">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{trl("Preview of the public page")}</h5>
|
||||
<Preview colors=colors appearance=app_preview.read_only()/>
|
||||
<Preview look=look appearance=app_preview.read_only()/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4 order-lg-1 mb-3">
|
||||
<div class="card h-100">
|
||||
<div class="col-lg-4 order-lg-1">
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{trl("Colours")}</h5>
|
||||
<ColorSettings colors=colors saved=saved_colors/>
|
||||
<ColorSettings colors=look.colors/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{trl("Banner")}</h5>
|
||||
<BannerSettings look=look appearance=app_preview.read_only() uploads=uploads
|
||||
delete_banner=del_banner delete_logo=del_logo/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<SaveBar look=look saved=saved/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+7
-6
@@ -167,16 +167,17 @@ pub fn Public() -> impl IntoView {
|
||||
{move || {
|
||||
appearance.get().map(|a| match a {
|
||||
Ok(a) => {
|
||||
let has_banner = a.banner.is_some();
|
||||
let title = a.title.clone().unwrap_or_default();
|
||||
let style = a.banner_style();
|
||||
view! {
|
||||
<div>
|
||||
<Show when=move || has_banner>
|
||||
<div class="public-banner" style=style.clone()>
|
||||
<h1 class="public-banner-title">{title.clone()}</h1>
|
||||
{a.has_banner().then(|| view! {
|
||||
<div class="public-banner" style=a.banner_style()>
|
||||
{a.logo_url().map(|url| view! {
|
||||
<img class="public-banner-logo" src=url alt=""/>
|
||||
})}
|
||||
<h1 class="public-banner-title">{title}</h1>
|
||||
</div>
|
||||
</Show>
|
||||
})}
|
||||
<div>
|
||||
<div class="public-banner-text" inner_html={a.text.unwrap_or("".to_string())}>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user