use std::path::PathBuf;
use std::sync::Arc;
use crate::storage::{BoxedStorage, LocalStorage, StorageError};
use super::org::Org;
pub const BRAND_STORAGE_ROOT_ENV: &str = "RUSTANGO_BRAND_STORAGE_DIR";
const DEFAULT_BRAND_STORAGE_ROOT: &str = "./var/brand";
pub const MAX_BRAND_BYTES_ENV: &str = "RUSTANGO_BRAND_MAX_BYTES";
pub const DEFAULT_MAX_BRAND_BYTES: usize = 1_048_576;
#[must_use]
pub fn brand_storage_root() -> PathBuf {
PathBuf::from(
std::env::var(BRAND_STORAGE_ROOT_ENV)
.unwrap_or_else(|_| DEFAULT_BRAND_STORAGE_ROOT.to_owned()),
)
}
#[must_use]
pub fn max_brand_bytes() -> usize {
std::env::var(MAX_BRAND_BYTES_ENV)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(DEFAULT_MAX_BRAND_BYTES)
}
#[must_use]
pub fn default_brand_storage() -> BoxedStorage {
Arc::new(LocalStorage::new(brand_storage_root()))
}
#[derive(Debug, thiserror::Error)]
pub enum BrandError {
#[error("file too large: {actual} bytes (max {max})")]
TooLarge { actual: usize, max: usize },
#[error("unsupported content type `{0}`")]
UnsupportedContentType(String),
#[error("invalid slug")]
InvalidSlug,
#[error("invalid filename")]
InvalidFilename,
#[error("asset not found")]
NotFound,
#[error("storage error: {0}")]
Storage(#[from] StorageError),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BrandAssetKind {
Logo,
Favicon,
}
impl BrandAssetKind {
#[must_use]
pub const fn stem(self) -> &'static str {
match self {
Self::Logo => "logo",
Self::Favicon => "favicon",
}
}
}
const ALLOWED_CONTENT_TYPES: &[(&str, &str)] = &[
("image/png", "png"),
("image/jpeg", "jpg"),
("image/webp", "webp"),
("image/x-icon", "ico"),
("image/vnd.microsoft.icon", "ico"),
];
fn storage_key(slug: &str, filename: &str) -> Result<String, BrandError> {
if !is_safe_slug(slug) {
return Err(BrandError::InvalidSlug);
}
if !is_safe_filename(filename) {
return Err(BrandError::InvalidFilename);
}
Ok(format!("{slug}/{filename}"))
}
fn is_safe_slug(slug: &str) -> bool {
!slug.is_empty()
&& slug.len() <= 64
&& slug
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
}
fn is_safe_filename(filename: &str) -> bool {
!filename.is_empty()
&& filename.len() <= 64
&& !filename.contains("..")
&& !filename.contains('/')
&& !filename.contains('\\')
&& filename
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
}
#[must_use]
pub fn content_type_for(filename: &str) -> Option<&'static str> {
let ext = std::path::Path::new(filename)
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)?;
Some(match ext.as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"webp" => "image/webp",
"ico" => "image/x-icon",
_ => return None,
})
}
pub async fn save_brand_asset(
slug: &str,
kind: BrandAssetKind,
bytes: &[u8],
content_type: Option<&str>,
storage: &BoxedStorage,
) -> Result<String, BrandError> {
let max = max_brand_bytes();
if bytes.len() > max {
return Err(BrandError::TooLarge {
actual: bytes.len(),
max,
});
}
let ct = content_type.unwrap_or("application/octet-stream").trim();
let ext = ALLOWED_CONTENT_TYPES
.iter()
.find_map(|(c, e)| (c.eq_ignore_ascii_case(ct)).then_some(*e))
.ok_or_else(|| BrandError::UnsupportedContentType(ct.to_owned()))?;
let filename = format!("{}.{ext}", kind.stem());
let key = storage_key(slug, &filename)?;
storage.save(&key, bytes).await?;
Ok(filename)
}
pub async fn load_brand_asset(
slug: &str,
filename: &str,
storage: &BoxedStorage,
) -> Result<(Vec<u8>, &'static str), BrandError> {
let key = storage_key(slug, filename)?;
let bytes = storage.load(&key).await.map_err(|e| match e {
StorageError::NotFound(_) => BrandError::NotFound,
other => other.into(),
})?;
let ct = content_type_for(filename).ok_or(BrandError::NotFound)?;
Ok((bytes, ct))
}
#[must_use]
pub fn validate_hex_color(s: &str) -> Option<String> {
let s = s.trim();
let rest = s.strip_prefix('#')?;
let valid = rest.chars().all(|c| c.is_ascii_hexdigit());
if !valid {
return None;
}
match rest.len() {
6 => Some(format!("#{}", rest.to_ascii_lowercase())),
3 => {
let mut out = String::with_capacity(7);
out.push('#');
for c in rest.chars() {
let lc = c.to_ascii_lowercase();
out.push(lc);
out.push(lc);
}
Some(out)
}
_ => None,
}
}
#[must_use]
pub fn validate_theme_mode(s: &str) -> Option<&'static str> {
match s.trim() {
"light" => Some("light"),
"dark" => Some("dark"),
"auto" | "" => Some("auto"),
_ => None,
}
}
#[must_use]
pub fn build_brand_css(org: &Org) -> Option<String> {
build_brand_css_from_color(org.primary_color.as_deref())
}
#[must_use]
pub fn build_op_brand_css(primary_color: Option<&str>) -> Option<String> {
build_brand_css_from_color(primary_color)
}
fn build_brand_css_from_color(primary_color: Option<&str>) -> Option<String> {
let raw = primary_color?;
let hex = validate_hex_color(raw)?;
let hover = shade(&hex, 0.85);
let soft = tint(&hex, 0.88);
let mut out = String::with_capacity(160);
out.push_str("--color-accent: ");
out.push_str(&hex);
out.push_str("; --color-accent-hover: ");
out.push_str(&hover);
out.push_str("; --color-accent-bg-soft: ");
out.push_str(&soft);
out.push(';');
Some(out)
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn shade(hex: &str, factor: f64) -> String {
let (r, g, b) = parse_rgb(hex).unwrap_or((176, 74, 44));
let mul = |c: u8| ((f64::from(c) * factor).round().clamp(0.0, 255.0)) as u8;
format!("#{:02x}{:02x}{:02x}", mul(r), mul(g), mul(b))
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn tint(hex: &str, factor: f64) -> String {
let (r, g, b) = parse_rgb(hex).unwrap_or((176, 74, 44));
let mix = |c: u8| {
let v = f64::from(c) + (255.0 - f64::from(c)) * factor;
v.round().clamp(0.0, 255.0) as u8
};
format!("#{:02x}{:02x}{:02x}", mix(r), mix(g), mix(b))
}
fn parse_rgb(hex: &str) -> Option<(u8, u8, u8)> {
let rest = hex.strip_prefix('#')?;
if rest.len() != 6 {
return None;
}
let r = u8::from_str_radix(&rest[0..2], 16).ok()?;
let g = u8::from_str_radix(&rest[2..4], 16).ok()?;
let b = u8::from_str_radix(&rest[4..6], 16).ok()?;
Some((r, g, b))
}
#[must_use]
pub fn brand_asset_url(slug: &str, path: Option<&str>, storage: &BoxedStorage) -> Option<String> {
let p = path?;
if p.is_empty() || !is_safe_filename(p) || !is_safe_slug(slug) {
return None;
}
let key = format!("{slug}/{p}");
if let Some(direct) = storage.url(&key) {
return Some(direct);
}
Some(format!("/__brand__/{slug}/{p}"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn hex_color_accepts_six_digit() {
assert_eq!(validate_hex_color("#B04A2C").as_deref(), Some("#b04a2c"));
assert_eq!(validate_hex_color("#abcdef").as_deref(), Some("#abcdef"));
}
#[test]
fn hex_color_accepts_three_digit() {
assert_eq!(validate_hex_color("#fff").as_deref(), Some("#ffffff"));
assert_eq!(validate_hex_color("#A1B").as_deref(), Some("#aa11bb"));
}
#[test]
fn hex_color_rejects_garbage() {
assert_eq!(validate_hex_color(""), None);
assert_eq!(validate_hex_color("javascript:alert(1)"), None);
assert_eq!(validate_hex_color("</style><script>"), None);
assert_eq!(validate_hex_color("#zzzzzz"), None);
assert_eq!(validate_hex_color("#1234"), None);
assert_eq!(validate_hex_color("rgb(0,0,0)"), None);
}
#[test]
fn theme_mode_accepts_known_values() {
assert_eq!(validate_theme_mode("light"), Some("light"));
assert_eq!(validate_theme_mode("dark"), Some("dark"));
assert_eq!(validate_theme_mode("auto"), Some("auto"));
assert_eq!(validate_theme_mode(""), Some("auto"));
assert_eq!(validate_theme_mode("solarized"), None);
}
#[test]
fn build_brand_css_is_none_when_no_color() {
assert_eq!(build_brand_css_from_color(None), None);
assert_eq!(build_brand_css_from_color(Some("")), None);
assert_eq!(build_brand_css_from_color(Some("not-a-color")), None);
}
#[test]
fn build_brand_css_emits_safe_assignments() {
let css = build_brand_css_from_color(Some("#b04a2c")).unwrap();
assert!(css.starts_with("--color-accent: #b04a2c"), "got: {css}");
assert!(css.contains("--color-accent-hover: #"));
assert!(css.contains("--color-accent-bg-soft: #"));
assert!(!css.contains('<'));
assert!(!css.contains('>'));
}
#[test]
fn safe_slug_rejects_traversal() {
assert!(is_safe_slug("acme"));
assert!(is_safe_slug("acme-corp"));
assert!(is_safe_slug("a_b_c"));
assert!(!is_safe_slug(""));
assert!(!is_safe_slug("../etc/passwd"));
assert!(!is_safe_slug("/abs"));
assert!(!is_safe_slug("a b"));
}
#[test]
fn safe_filename_rejects_traversal() {
assert!(is_safe_filename("logo.png"));
assert!(is_safe_filename("favicon.ico"));
assert!(!is_safe_filename(""));
assert!(!is_safe_filename("../logo.png"));
assert!(!is_safe_filename("a/b.png"));
assert!(!is_safe_filename("a\\b.png"));
assert!(!is_safe_filename("évil.png")); }
#[test]
fn content_type_for_known_extensions() {
assert_eq!(content_type_for("logo.png"), Some("image/png"));
assert_eq!(content_type_for("logo.PNG"), Some("image/png"));
assert_eq!(content_type_for("logo.jpg"), Some("image/jpeg"));
assert_eq!(content_type_for("logo.webp"), Some("image/webp"));
assert_eq!(content_type_for("favicon.ico"), Some("image/x-icon"));
assert_eq!(content_type_for("evil.svg"), None);
assert_eq!(content_type_for("evil.exe"), None);
assert_eq!(content_type_for("noext"), None);
}
#[test]
fn brand_asset_url_falls_back_to_path_when_storage_has_no_url() {
let storage: BoxedStorage = Arc::new(crate::storage::InMemoryStorage::new());
assert_eq!(
brand_asset_url("acme", Some("logo.png"), &storage).as_deref(),
Some("/__brand__/acme/logo.png"),
);
}
#[test]
fn brand_asset_url_prefers_direct_url_when_storage_exposes_one() {
let storage: BoxedStorage = Arc::new(
LocalStorage::new(PathBuf::from("/tmp/_rustango_brand_test"))
.with_base_url("https://cdn.example.com/brand"),
);
assert_eq!(
brand_asset_url("acme", Some("logo.png"), &storage).as_deref(),
Some("https://cdn.example.com/brand/acme/logo.png"),
);
}
#[test]
fn brand_asset_url_drops_invalid() {
let storage: BoxedStorage = Arc::new(crate::storage::InMemoryStorage::new());
assert_eq!(brand_asset_url("acme", None, &storage), None);
assert_eq!(brand_asset_url("acme", Some(""), &storage), None);
assert_eq!(
brand_asset_url("acme", Some("../etc/passwd"), &storage),
None
);
assert_eq!(brand_asset_url("../bad", Some("logo.png"), &storage), None);
}
#[test]
fn shade_darkens_within_range() {
let dark = shade("#b04a2c", 0.5);
assert_eq!(dark, "#582516");
}
#[test]
fn tint_lightens_toward_white() {
let soft = tint("#b04a2c", 1.0);
assert_eq!(soft, "#ffffff");
let none = tint("#b04a2c", 0.0);
assert_eq!(none, "#b04a2c");
}
fn env_guard() -> std::sync::MutexGuard<'static, ()> {
static M: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
M.get_or_init(|| std::sync::Mutex::new(()))
.lock()
.unwrap_or_else(|p| p.into_inner())
}
#[tokio::test]
async fn save_and_load_round_trip_in_memory() {
let _g = env_guard();
std::env::remove_var(MAX_BRAND_BYTES_ENV);
use crate::storage::InMemoryStorage;
let storage: BoxedStorage = Arc::new(InMemoryStorage::new());
let bytes = b"\x89PNG\r\n\x1a\nfake-png-bytes";
let saved = save_brand_asset(
"acme",
BrandAssetKind::Logo,
bytes,
Some("image/png"),
&storage,
)
.await
.unwrap();
assert_eq!(saved, "logo.png");
let (loaded, ct) = load_brand_asset("acme", &saved, &storage).await.unwrap();
assert_eq!(&loaded, bytes);
assert_eq!(ct, "image/png");
}
#[tokio::test]
async fn save_rejects_oversize() {
let _g = env_guard();
std::env::set_var(MAX_BRAND_BYTES_ENV, "10");
let storage: BoxedStorage = Arc::new(crate::storage::InMemoryStorage::new());
let bytes = vec![0_u8; 100];
let err = save_brand_asset(
"acme",
BrandAssetKind::Logo,
&bytes,
Some("image/png"),
&storage,
)
.await
.unwrap_err();
std::env::remove_var(MAX_BRAND_BYTES_ENV);
assert!(matches!(err, BrandError::TooLarge { .. }), "got: {err}");
}
#[tokio::test]
async fn save_rejects_unsupported_content_type() {
use crate::storage::InMemoryStorage;
let storage: BoxedStorage = Arc::new(InMemoryStorage::new());
let err = save_brand_asset(
"acme",
BrandAssetKind::Logo,
b"...",
Some("text/html"),
&storage,
)
.await
.unwrap_err();
assert!(
matches!(err, BrandError::UnsupportedContentType(_)),
"got: {err}",
);
}
#[tokio::test]
async fn save_rejects_path_traversal_slug() {
use crate::storage::InMemoryStorage;
let storage: BoxedStorage = Arc::new(InMemoryStorage::new());
let err = save_brand_asset(
"../etc",
BrandAssetKind::Logo,
b"x",
Some("image/png"),
&storage,
)
.await
.unwrap_err();
assert!(matches!(err, BrandError::InvalidSlug), "got: {err}");
}
}