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:
2026-10-12 22:14:07 +02:00
co-authored by Claude Opus 5.5
parent 9da18a67ac
commit 5fcea0cf85
19 changed files with 1005 additions and 331 deletions
+61 -15
View File
@@ -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);