Branding and templates: per-domain, tenant and server logos, /logo, operator calendar email templates and RSVP page (BT-1 to BT-26)

Logos resolve domain, then tenant, then server-wide, then the built-in, with
subdomains finding their domain. GET /logo serves a data-URL image, redirects
to a URL logo without fetching it, sandboxes SVG, and answers 404 when no
custom logo applies. Emails embed the first PNG, JPEG or GIF logo. Logo and
template writes are checked; stored templates are read at send time, always
escaped, and fall back to the built-in with a build warning when they don't
parse. The RSVP page is served byte for byte with a CSP and no-referrer. The
sign-in and RSVP pages load the logo through an image element. MT-22's
session logo follows the chain to the server-wide logo.
Acceptance tests 1 to 17; test 18 written as the ignored branding_compat.
This commit is contained in:
2026-09-18 22:27:19 -07:00
parent ecbdfd533b
commit 0bc6b03dcd
29 changed files with 1772 additions and 91 deletions
+11
View File
@@ -100,6 +100,17 @@ impl GroupwareConfig {
let dr = bp.setting_infallible::<DataRetention>().await;
let system = bp.setting_infallible::<SystemSettings>().await;
// inbuxa: BT-19: a stored template that doesn't parse is reported at
// start and on each reload; the built-in is used meanwhile
inbuxa_features::branding::templates::warn_unusable::<CalendarTemplateVariable>(
"CalendarAlarm.template",
alarm.template.as_deref(),
);
inbuxa_features::branding::templates::warn_unusable::<CalendarTemplateVariable>(
"CalendarScheduling.emailTemplate",
sched.email_template.as_deref(),
);
GroupwareConfig {
max_request_size: dav.request_max_size as usize,
dead_property_size: dav.dead_property_max_size.map(|v| v as usize),
+60
View File
@@ -0,0 +1,60 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Which logo applies to a domain name (branding spec BT-1, BT-2). The rules
//! live in `inbuxa_features::branding::logo`; this finds the domain through
//! the server's domain cache and reads the three levels from the registry
//! each time, so a change shows at once on every node (BT-10).
use crate::Server;
use inbuxa_features::branding::logo::{self, Logo, Source};
use registry::schema::structs::{Domain, Enterprise, Tenant};
use types::id::Id;
impl Server {
/// The logos that apply to a domain name, most specific first. An unknown
/// name gets what a known domain with no logo of its own gets (BT-6).
pub async fn logos_for(&self, name: &str) -> trc::Result<Vec<Logo>> {
let mut domain = None;
for candidate in logo::lookup_names(name) {
if let Some(found) = self.domain(&candidate).await? {
domain = Some(found);
break;
}
}
let registry = self.registry();
let domain_logo = match &domain {
Some(domain) => registry
.object::<Domain>(Id::from(domain.id))
.await?
.and_then(|d| d.logo),
None => None,
};
let tenant_id = domain.as_ref().and_then(|d| d.id_tenant);
let tenant_logo = match tenant_id {
Some(tenant_id) => registry
.object::<Tenant>(Id::from(tenant_id))
.await?
.and_then(|t| t.logo),
None => None,
};
let server_logo = registry
.object::<Enterprise>(Id::singleton())
.await?
.and_then(|e| e.logo_url);
Ok(logo::chain([
(
Source::Domain(domain.as_ref().map_or(u32::MAX, |d| d.id)),
domain_logo.as_deref(),
),
(
Source::Tenant(tenant_id.unwrap_or(u32::MAX)),
tenant_logo.as_deref(),
),
(Source::Server, server_logo.as_deref()),
]))
}
}
+19 -2
View File
@@ -18,6 +18,7 @@ use store::{BlobStore, InMemoryStore, RegistryStore, SearchStore, Store};
pub mod archive;
pub mod blob;
pub mod branding; // inbuxa: branding BT-1, BT-2
pub mod dav;
pub mod document;
pub mod encryption;
@@ -95,11 +96,27 @@ impl Server {
self.registry().count_object(ObjectType::Domain).await
}
// inbuxa: BT-9: the first logo mail can carry inline; none leaves the
// built-in INBUXA logo
#[cfg(not(feature = "enterprise"))]
pub async fn logo_resource(
&self,
_: &str,
domain: &str,
) -> trc::Result<Option<crate::manager::application::Resource<Vec<u8>>>> {
Ok(None)
Ok(self
.logos_for(domain)
.await?
.into_iter()
.find(|logo| logo.is_embeddable())
.and_then(|logo| match logo {
inbuxa_features::branding::logo::Logo::Image {
content_type,
bytes,
} => Some(crate::manager::application::Resource::new(
content_type,
bytes,
)),
inbuxa_features::branding::logo::Logo::Url(_) => None,
}))
}
}
+1
View File
@@ -15,6 +15,7 @@ utils = { path = "../utils" }
ahash = { version = "0.8.12", features = ["serde"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
base64 = "0.23"
[dev-dependencies]
tokio = { version = "1.53", features = ["macros", "rt"] }
+307
View File
@@ -0,0 +1,307 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Logo values: what may be written (BT-3), how stored ones are read
//! (BT-4), and which logo applies (BT-1, BT-9). The server never fetches a
//! logo URL (BT-7): a URL is only ever handed on.
use base64::{Engine, engine::general_purpose::STANDARD};
/// The largest image a data URL may hold (BT-3).
pub const MAX_IMAGE_SIZE: usize = 256 * 1024;
/// The image types a logo may be (BT-3).
pub const TYPES: &[&str] = &[
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/svg+xml",
];
/// A logo, read from a stored value.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Logo {
/// A URL, handed to browsers and mail clients as it is.
Url(String),
/// An image, from a data URL or bare base64.
Image {
content_type: &'static str,
bytes: Vec<u8>,
},
}
impl Logo {
/// Whether mail can carry it inline: PNG, JPEG or GIF only (BT-9).
pub fn is_embeddable(&self) -> bool {
matches!(
self,
Logo::Image {
content_type: "image/png" | "image/jpeg" | "image/gif",
..
}
)
}
}
/// The type an image's bytes show, if one of `TYPES`.
pub fn sniff(bytes: &[u8]) -> Option<&'static str> {
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
Some("image/png")
} else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
Some("image/jpeg")
} else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
Some("image/gif")
} else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
Some("image/webp")
} else if is_svg(bytes) {
Some("image/svg+xml")
} else {
None
}
}
/// SVG is text: an `<svg` element near the start, after any BOM, XML
/// declaration, comments or doctype.
fn is_svg(bytes: &[u8]) -> bool {
let head = &bytes[..bytes.len().min(4096)];
let Ok(text) = std::str::from_utf8(head).or_else(|err| {
// The cut may split a character
std::str::from_utf8(&head[..err.valid_up_to()])
}) else {
return false;
};
let text = text.trim_start_matches('\u{feff}').trim_start();
text.starts_with('<') && text.to_ascii_lowercase().contains("<svg")
}
fn has_scheme(value: &str, scheme: &str) -> bool {
value
.get(..scheme.len())
.is_some_and(|head| head.eq_ignore_ascii_case(scheme))
}
/// A URL a browser may load: the scheme, a host, and no spaces or controls.
fn is_url(value: &str, scheme: &str) -> bool {
has_scheme(value, scheme)
&& value[scheme.len()..]
.strip_prefix("//")
.and_then(|rest| rest.chars().next())
.is_some_and(|c| !matches!(c, '/' | '?' | '#'))
&& !value.chars().any(|c| c.is_whitespace() || c.is_control())
}
/// A `data:` URL's type and decoded bytes, when it's base64.
fn data_url(value: &str) -> Result<(String, Vec<u8>), &'static str> {
let rest = value.get(5..).ok_or("not a data URL")?;
let (header, data) = rest.split_once(',').ok_or("a data URL needs a comma")?;
let mut params = header.split(';');
let media_type = params.next().unwrap_or_default().trim().to_ascii_lowercase();
if !params.any(|p| p.trim().eq_ignore_ascii_case("base64")) {
return Err("a data URL logo must be base64");
}
let data = data
.chars()
.filter(|c| !c.is_ascii_whitespace())
.collect::<String>();
let bytes = STANDARD
.decode(data.as_bytes())
.map_err(|_| "the data URL isn't valid base64")?;
Ok((media_type, bytes))
}
/// Checks a logo being written (BT-3): an `https:` URL, or a base64 data URL
/// of one of `TYPES`, at most `MAX_IMAGE_SIZE`, whose bytes are that type.
pub fn check(value: &str) -> Result<(), String> {
if has_scheme(value, "data:") {
let (media_type, bytes) = data_url(value).map_err(str::to_string)?;
let Some(declared) = TYPES.iter().find(|t| **t == media_type) else {
return Err(format!(
"A logo must be PNG, JPEG, GIF, WebP or SVG, not {media_type:?}."
));
};
if bytes.len() > MAX_IMAGE_SIZE {
return Err(format!(
"The logo is {} KiB; the limit is 256 KiB.",
bytes.len().div_ceil(1024)
));
}
match sniff(&bytes) {
Some(found) if found == *declared => Ok(()),
_ => Err(format!("The logo's bytes aren't {declared}.")),
}
} else if is_url(value, "https:") {
Ok(())
} else {
Err("A logo must be an https: URL or a base64 data: URL of an image.".to_string())
}
}
/// Reads a stored logo (BT-4): anything `check` accepts, an `http:` URL, a
/// base64 data URL of an image, or bare base64 whose bytes are an image.
/// `None` for a value that is none of these, or empty.
pub fn read(value: &str) -> Option<Logo> {
let value = value.trim();
if value.is_empty() {
None
} else if has_scheme(value, "data:") {
let (media_type, bytes) = data_url(value).ok()?;
let found = sniff(&bytes)?;
// The bytes decide, so a mislabelled image is still served as what it is
(media_type.starts_with("image/")).then_some(Logo::Image {
content_type: found,
bytes,
})
} else if is_url(value, "https:") || is_url(value, "http:") {
Some(Logo::Url(value.to_string()))
} else {
let data = value
.chars()
.filter(|c| !c.is_ascii_whitespace())
.collect::<String>();
let bytes = STANDARD.decode(data.as_bytes()).ok()?;
sniff(&bytes).map(|content_type| Logo::Image {
content_type,
bytes,
})
}
}
/// Where a logo value came from, for the warning about an unusable one.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Source {
Domain(u32),
Tenant(u32),
Server,
}
/// The logos that apply, most specific first (BT-1): the domain's, its
/// tenant's, then the server-wide one. Unusable values are skipped with a
/// `registry.build-warning` (BT-4). The built-in logo is the caller's last
/// resort.
pub fn chain<'x>(
candidates: impl IntoIterator<Item = (Source, Option<&'x str>)>,
) -> Vec<Logo> {
let mut logos = Vec::new();
for (source, value) in candidates {
let Some(value) = value.filter(|v| !v.trim().is_empty()) else {
continue;
};
match read(value) {
Some(logo) => logos.push(logo),
None => trc::event!(
Registry(trc::RegistryEvent::BuildWarning),
Details = format!("Unusable logo on {source:?}, skipped (BT-4)")
),
}
}
logos
}
/// The names to try for a domain name D (BT-1): D, then D without its
/// leftmost label while at least two labels remain. Lowercase.
pub fn lookup_names(name: &str) -> Vec<String> {
let mut name = name.trim().trim_end_matches('.').to_ascii_lowercase();
// A Host header may carry a port
if let Some((host, port)) = name.rsplit_once(':')
&& !host.contains(':')
&& port.chars().all(|c| c.is_ascii_digit())
{
name = host.to_string();
}
let mut names = Vec::new();
let mut rest = name.as_str();
while !rest.is_empty() {
names.push(rest.to_string());
match rest.split_once('.') {
Some((_, parent)) if parent.contains('.') => rest = parent,
_ => break,
}
}
names
}
#[cfg(test)]
mod tests {
use super::*;
const PNG: &[u8] = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR";
const JPEG: &[u8] = &[0xFF, 0xD8, 0xFF, 0xE0, 0, 0x10];
fn data(media_type: &str, bytes: &[u8]) -> String {
format!("data:{media_type};base64,{}", STANDARD.encode(bytes))
}
#[test]
fn writes() {
assert!(check("https://example.org/logo.png").is_ok());
assert!(check(&data("image/png", PNG)).is_ok());
assert!(check(&data("image/svg+xml", b"<?xml version=\"1.0\"?><svg/>")).is_ok());
for bad in [
"javascript:alert(1)".to_string(),
"http://example.org/logo.png".to_string(),
"https://".to_string(),
"https://exa mple.org/".to_string(),
"data:text/html,<b>x</b>".to_string(),
data("text/html", b"<b>x</b>"),
data("image/png", JPEG),
data("image/png", &[PNG, &vec![0u8; 300 * 1024]].concat()),
"admin".to_string(),
] {
assert!(check(&bad).is_err(), "{bad:.60}");
}
}
#[test]
fn reads() {
assert_eq!(
read("http://example.org/l.png"),
Some(Logo::Url("http://example.org/l.png".into()))
);
assert_eq!(
read(&STANDARD.encode(PNG)),
Some(Logo::Image {
content_type: "image/png",
bytes: PNG.to_vec()
})
);
// Mislabelled: served as what it is
assert_eq!(
read(&data("image/png", JPEG)),
Some(Logo::Image {
content_type: "image/jpeg",
bytes: JPEG.to_vec()
})
);
assert_eq!(read("admin"), None);
assert_eq!(read(""), None);
assert_eq!(read(&data("text/html", b"<b>x</b>")), None);
}
#[test]
fn chain_order_and_embedding() {
let png = data("image/png", PNG);
let svg = data("image/svg+xml", b"<svg xmlns='http://www.w3.org/2000/svg'/>");
let logos = chain([
(Source::Domain(1), Some("admin")),
(Source::Tenant(2), Some(svg.as_str())),
(Source::Server, Some(png.as_str())),
]);
assert_eq!(logos.len(), 2);
assert!(!logos[0].is_embeddable());
assert!(logos[1].is_embeddable());
}
#[test]
fn names() {
assert_eq!(
lookup_names("Mail.Example.COM:8443"),
vec!["mail.example.com", "example.com"]
);
assert_eq!(lookup_names("example.com"), vec!["example.com"]);
assert_eq!(lookup_names("localhost"), vec!["localhost"]);
}
}
+13
View File
@@ -0,0 +1,13 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Branding and templates (`docs/spec/features/branding-and-templates.md`):
//! logos per domain, tenant and server, and the operator's calendar email
//! templates and RSVP page.
pub mod logo;
pub mod templates;
pub mod writes;
+266
View File
@@ -0,0 +1,266 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Operator email templates and the RSVP page (BT-11 to BT-22). Templates
//! are read from the registry each time an email is rendered, so a change
//! takes effect at once (BT-18).
use registry::schema::structs::{CalendarAlarm, CalendarScheduling};
use std::{fmt::Debug, hash::Hash, str::FromStr};
use store::RegistryStore;
use types::id::Id;
use utils::template::{Template, TemplateItem};
/// The largest email template (BT-15).
pub const MAX_TEMPLATE_SIZE: usize = 256 * 1024;
/// The largest RSVP page (BT-22).
pub const MAX_PAGE_SIZE: usize = 1024 * 1024;
/// The variables the server sets, for either template (BT-13).
pub const VARIABLES: &[&str] = &[
"page_title",
"lang",
"dir",
"logo_cid",
"header",
"color",
"event_title",
"event_description",
"event_details",
"key",
"value",
"link",
"changed",
"old_value",
"attendees_title",
"attendees",
"action_name",
"action_url",
"rsvp",
"actions",
"footer",
];
#[derive(Clone, Copy, PartialEq, Eq)]
enum Block {
If,
Each,
}
/// A block token's kind and variable: `if name` or `each name`.
fn block<'x>(spec: &'x str, token: &str) -> Result<(Block, &'x str), String> {
let spec = spec.trim();
if let Some(name) = spec.strip_prefix("if ") {
Ok((Block::If, name.trim()))
} else if let Some(name) = spec.strip_prefix("each ") {
Ok((Block::Each, name.trim()))
} else {
Err(format!("Unknown block {{{{{token}}}}}."))
}
}
/// Walks a template's tokens as BT-12 defines the language. `allow_raw`
/// lets `{{!name}}` through, for stored templates (BT-14).
fn walk(template: &str, allow_raw: bool) -> Result<(), String> {
let mut stack: Vec<(Block, &str)> = Vec::new();
let mut rest = template;
while let Some(start) = rest.find("{{") {
let after = &rest[start + 2..];
let end = after.find("}}").ok_or("A {{ is never closed.")?;
let token = &after[..end];
if token.contains('\n') || token.contains('\r') {
return Err(format!("A token spans lines: {{{{{}", token.trim()));
}
rest = &after[end + 2..];
let token = token.trim();
let name = if let Some(spec) = token.strip_prefix('#') {
let (kind, name) = block(spec, token)?;
if kind == Block::Each && stack.iter().any(|(k, _)| *k == Block::Each) {
return Err(format!("{{{{#each {name}}}}} is inside another #each."));
}
stack.push((kind, name));
name
} else if let Some(spec) = token.strip_prefix('/') {
let (kind, name) = block(spec, token)?;
match stack.pop() {
Some((open_kind, open_name)) if open_kind == kind && open_name == name => name,
Some((_, open_name)) => {
return Err(format!(
"{{{{{token}}}}} doesn't close the open block {open_name}."
));
}
None => return Err(format!("{{{{{token}}}}} closes no open block.")),
}
} else if let Some(name) = token.strip_prefix('!') {
if !allow_raw {
return Err(format!(
"{{{{{token}}}}}: raw output isn't allowed; values are always escaped."
));
}
name.trim()
} else {
token
};
if !VARIABLES.contains(&name) {
return Err(format!("Unknown variable {name:?}."));
}
}
match stack.last() {
Some((_, name)) => Err(format!("The block {name} is never closed.")),
None => Ok(()),
}
}
/// Checks an alarm or iMIP template being written (BT-15).
pub fn check(template: &str) -> Result<(), String> {
if template.len() > MAX_TEMPLATE_SIZE {
return Err(format!(
"The template is {} KiB; the limit is 256 KiB.",
template.len().div_ceil(1024)
));
}
walk(template, false)
}
/// Checks an RSVP page being written (BT-22). Its content is the
/// operator's own; only its size is limited.
pub fn check_page(page: &str) -> Result<(), String> {
if page.len() > MAX_PAGE_SIZE {
Err(format!(
"The page is {} KiB; the limit is 1 MiB.",
page.len().div_ceil(1024)
))
} else {
Ok(())
}
}
/// Parses a stored template for rendering: every value escaped, `{{!…}}`
/// included (BT-14).
pub fn parse<T: FromStr + Eq + Hash + Debug>(template: &str) -> Result<Template<T>, String> {
walk(template, true)?;
let mut parsed = Template::<T>::parse(template)?;
for item in &mut parsed.items {
if let TemplateItem::Variable { escape, .. } = item {
*escape = true;
}
}
Ok(parsed)
}
/// Which email template.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Which {
/// `x:CalendarAlarm.template`.
Alarm,
/// `x:CalendarScheduling.emailTemplate`.
Invite,
}
/// The stored template's text, if one is set.
pub async fn stored_text(registry: &RegistryStore, which: Which) -> trc::Result<Option<String>> {
Ok(match which {
Which::Alarm => registry
.object::<CalendarAlarm>(Id::singleton())
.await?
.and_then(|o| o.template),
Which::Invite => registry
.object::<CalendarScheduling>(Id::singleton())
.await?
.and_then(|o| o.email_template),
}
.filter(|t| !t.trim().is_empty()))
}
/// The operator's template to render with, if one is set and parses
/// (BT-11). One that doesn't leaves the built-in in use (BT-19); the warning
/// comes from `warn_unusable`, at start and each settings reload.
pub async fn stored<T: FromStr + Eq + Hash + Debug>(
registry: &RegistryStore,
which: Which,
) -> trc::Result<Option<Template<T>>> {
Ok(stored_text(registry, which)
.await?
.and_then(|text| parse(&text).ok()))
}
/// BT-19: a stored template that doesn't parse is reported, and the
/// built-in is used.
pub fn warn_unusable<T: FromStr + Eq + Hash + Debug>(field: &str, text: Option<&str>) {
if let Some(text) = text.filter(|t| !t.trim().is_empty())
&& let Err(err) = parse::<T>(text)
{
trc::event!(
Registry(trc::RegistryEvent::BuildWarning),
Details = format!("{field} doesn't parse, so the built-in is used (BT-19): {err}")
);
}
}
/// The operator's RSVP page, if one is set (BT-20). Served byte for byte.
pub async fn rsvp_page(registry: &RegistryStore) -> trc::Result<Option<String>> {
Ok(registry
.object::<CalendarScheduling>(Id::singleton())
.await?
.and_then(|o| o.http_rsvp_template)
.filter(|t| !t.is_empty()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn writes() {
assert!(check("<p>{{header}}</p>{{#each attendees}}{{key}}{{#if link}}x{{/if link}}{{/each attendees}}").is_ok());
for (bad, why) in [
("{{#if header}}x", "never closed"),
("{{unknown}}", "Unknown variable"),
("{{!header}}", "raw output"),
("{{#each actions}}{{#each attendees}}{{/each attendees}}{{/each actions}}", "inside another"),
("{{/if header}}", "closes no open"),
("{{#if header}}{{/if footer}}", "doesn't close"),
("{{#if header}}{{/each header}}", "doesn't close"),
("{{hea\nder}}", "spans lines"),
("{{header", "never closed"),
] {
let err = check(bad).unwrap_err();
assert!(err.contains(why), "{bad}: {err}");
}
assert!(check(&"x".repeat(300 * 1024)).unwrap_err().contains("256 KiB"));
}
#[test]
fn stored_raw_is_escaped() {
let template = parse::<String>("{{!event_title}}|{{event_title}}").unwrap();
let mut vars = utils::template::Variables::<String, String>::new();
vars.insert_single("event_title".into(), "<b>x</b>".into());
assert_eq!(template.eval(&vars), "&lt;b&gt;x&lt;/b&gt;|&lt;b&gt;x&lt;/b&gt;");
}
#[test]
fn built_ins_use_only_known_variables() {
for (name, text) in [
(
"alarm",
include_str!("../../../../resources/html-templates/calendar-alarm.html"),
),
(
"invite",
include_str!("../../../../resources/html-templates/calendar-invite.html"),
),
] {
walk(text, true).unwrap_or_else(|err| panic!("{name}: {err}"));
}
}
#[test]
fn page_limit() {
assert!(check_page("{{page_title}}").is_ok());
assert!(check_page(&"x".repeat(MAX_PAGE_SIZE + 1)).is_err());
}
}
+60
View File
@@ -0,0 +1,60 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! What a registry write may set for a logo or template (BT-3, BT-15,
//! BT-22). Only a changed value is checked, so data from before the fork
//! never blocks an unrelated change (BT-4).
use crate::branding::{logo, templates};
use jmap_proto::error::set::SetError;
use registry::schema::prelude::{Object, ObjectInner, Property};
/// The logo and template fields an object carries, with their checks.
fn fields(inner: &ObjectInner) -> Vec<(Property, Option<&str>, fn(&str) -> Result<(), String>)> {
match inner {
ObjectInner::Enterprise(o) => vec![(Property::LogoUrl, o.logo_url.as_deref(), logo::check)],
ObjectInner::Domain(o) => vec![(Property::Logo, o.logo.as_deref(), logo::check)],
ObjectInner::Tenant(o) => vec![(Property::Logo, o.logo.as_deref(), logo::check)],
ObjectInner::OAuthClient(o) => vec![(Property::Logo, o.logo.as_deref(), logo::check)],
ObjectInner::CalendarAlarm(o) => {
vec![(Property::Template, o.template.as_deref(), templates::check)]
}
ObjectInner::CalendarScheduling(o) => vec![
(
Property::EmailTemplate,
o.email_template.as_deref(),
templates::check,
),
(
Property::HttpRsvpTemplate,
o.http_rsvp_template.as_deref(),
templates::check_page,
),
],
_ => vec![],
}
}
/// Refuses a new or changed logo or template that breaks its rules, with
/// `invalidProperties` naming the field.
pub fn check(old: Option<&Object>, new: &Object) -> Result<(), SetError<Property>> {
let before = old.map(|old| fields(&old.inner)).unwrap_or_default();
for (property, value, check) in fields(&new.inner) {
let Some(value) = value else { continue };
let unchanged = before
.iter()
.any(|(p, v, _)| *p == property && *v == Some(value));
if unchanged {
continue;
}
if let Err(err) = check(value) {
return Err(SetError::invalid_properties()
.with_property(property)
.with_description(err));
}
}
Ok(())
}
+1
View File
@@ -18,6 +18,7 @@
//! it. It works on registry objects and the store directly, never on
//! `common::Server`.
pub mod branding;
pub mod masked_email;
pub mod tenancy;
pub mod undelete;
+15 -24
View File
@@ -8,9 +8,10 @@
//!
//! Its domain's logo if set, else its tenant's. The value is returned as
//! stored, a URL or a data URL: the server never fetches a logo URL itself
//! (MT-23). Branding extends the chain past the tenant (BT-1).
//! (MT-23). Branding extends the chain past the tenant to the server-wide
//! logo (BT-2); none means the client's built-in INBUXA logo.
use registry::schema::structs::{Account, Domain, Tenant};
use registry::schema::structs::{Account, Domain, Enterprise, Tenant};
use store::RegistryStore;
use types::id::Id;
@@ -28,29 +29,19 @@ pub async fn for_account(registry: &RegistryStore, account_id: u32) -> trc::Resu
Some(tenant_id) => registry.object::<Tenant>(tenant_id).await?,
None => None,
};
Ok(applicable(
// BT-2: past the tenant, the server-wide logo; each value as stored, and
// an unusable one skipped (BT-4)
let server = registry
.object::<Enterprise>(Id::singleton())
.await?
.and_then(|e| e.logo_url);
Ok([
domain.as_ref().and_then(|d| d.logo.as_deref()),
tenant.as_ref().and_then(|t| t.logo.as_deref()),
)
server.as_deref(),
]
.into_iter()
.flatten()
.find(|value| crate::branding::logo::read(value).is_some())
.map(str::to_string))
}
/// The logo that applies, from the principal's domain's and tenant's logos.
pub fn applicable<'x>(domain: Option<&'x str>, tenant: Option<&'x str>) -> Option<&'x str> {
domain
.filter(|logo| !logo.is_empty())
.or(tenant.filter(|logo| !logo.is_empty()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn domain_then_tenant() {
assert_eq!(applicable(Some("d"), Some("t")), Some("d"));
assert_eq!(applicable(None, Some("t")), Some("t"));
assert_eq!(applicable(Some(""), Some("t")), Some("t"));
assert_eq!(applicable(None, None), None);
}
}
+1
View File
@@ -25,6 +25,7 @@ mail-builder = { version = "1.0" }
mail-auth = { version = "0.13", features = ["generate", "arc"] }
tokio = { version = "1.53", features = ["rt"] }
hyper = { version = "1.11.1", features = ["server", "http1", "http2"] }
inbuxa-features = { path = "../features" }
hyper-util = { version = "0.1.20", features = ["tokio"] }
http-body-util = "0.1.5"
async-stream = "0.3.6"
+95
View File
@@ -0,0 +1,95 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `GET /logo` (branding spec BT-5 to BT-8) and the RSVP page's answer
//! (BT-20, BT-21).
use common::Server;
use http_proto::{HttpRequest, HttpResponse};
use hyper::{
StatusCode,
header::{self, CONTENT_ENCODING},
};
use inbuxa_features::branding::{logo::Logo, templates};
const LOGO_CACHE: &str = "public, max-age=300";
const SVG_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
const RSVP_CSP: &str = concat!(
"default-src 'self'; script-src 'self' 'unsafe-inline'; ",
"style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; ",
"connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"
);
/// The domain a `/logo` request asks about: `?domain=`, else the `Host`
/// (BT-2).
fn requested_domain(req: &HttpRequest) -> String {
req.uri()
.query()
.and_then(|query| {
http_proto::form_urlencoded::parse(query.as_bytes())
.find(|(key, _)| key == "domain")
.map(|(_, value)| value.into_owned())
})
.filter(|domain| !domain.is_empty())
.or_else(|| {
req.headers()
.get(header::HOST)
.and_then(|host| host.to_str().ok())
.map(str::to_string)
})
.unwrap_or_default()
}
/// `GET /logo`: the image, a redirect to a URL logo, or `404` when no
/// custom logo applies. The server never fetches a URL (BT-7).
pub async fn logo(server: &Server, req: &HttpRequest) -> trc::Result<HttpResponse> {
let response = match server
.logos_for(&requested_domain(req))
.await?
.into_iter()
.next()
{
Some(Logo::Image {
content_type,
bytes,
}) => {
let response = HttpResponse::new(StatusCode::OK)
.with_content_type(content_type)
.with_binary_body(bytes);
if content_type == "image/svg+xml" {
// BT-8: a scripted SVG must never run on the server's origin
response.with_header(header::CONTENT_SECURITY_POLICY, SVG_CSP)
} else {
response
}
}
Some(Logo::Url(url)) => {
HttpResponse::new(StatusCode::FOUND).with_header(header::LOCATION, url)
}
None => HttpResponse::new(StatusCode::NOT_FOUND),
};
Ok(response
.with_cache_control(LOGO_CACHE)
.with_header(header::X_CONTENT_TYPE_OPTIONS, "nosniff")
.with_header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*"))
}
/// `GET /calendar/rsvp`: the operator's page byte for byte if one is set,
/// else the built-in, with headers that keep the token on the server
/// (BT-20, BT-21).
pub async fn rsvp_page(server: &Server, built_in_gzipped: &'static [u8]) -> trc::Result<HttpResponse> {
let response = match templates::rsvp_page(server.registry()).await? {
Some(page) => HttpResponse::new(StatusCode::OK).with_binary_body(page.into_bytes()),
None => HttpResponse::new(StatusCode::OK)
.with_header(CONTENT_ENCODING, "gzip")
.with_binary_body(built_in_gzipped),
};
Ok(response
.with_content_type("text/html; charset=utf-8")
.with_no_store()
.with_header(header::REFERRER_POLICY, "no-referrer")
.with_header(header::CONTENT_SECURITY_POLICY, RSVP_CSP))
}
+1
View File
@@ -8,6 +8,7 @@
pub mod api;
pub mod auth;
pub mod branding; // inbuxa: branding
pub mod form;
pub mod request;
+8 -6
View File
@@ -474,14 +474,16 @@ impl ParseHttp for Server {
&& req.method() == Method::GET
&& path.next().unwrap_or_default() == "rsvp"
{
return Ok(HttpResponse::new(StatusCode::OK)
.with_content_type("text/html; charset=utf-8")
.with_header(CONTENT_ENCODING, "gzip")
.with_binary_body(RSVP_PAGE)
.with_no_store());
// inbuxa: BT-20, BT-21
return crate::branding::rsvp_page(self, RSVP_PAGE).await;
}
}
// inbuxa: BT-5: the logo that applies, anonymous
"logo" if req.method() == Method::GET => {
self.is_http_anonymous_request_allowed(session.remote_ip)
.await?;
return crate::branding::logo(self, &req).await;
}
"autodiscover" | "Autodiscover" | "AutoDiscover" => {
let document_name = path.next().unwrap_or_default();
if req.method() == Method::POST
+16
View File
@@ -572,6 +572,22 @@ impl RegistrySet for Server {
}
};
// inbuxa: BT-3, BT-15, BT-22: logos and templates follow their rules
let before = match &modification {
Modification::Update { object, .. }
| Modification::Create {
object: Some(object),
..
} => Some(object),
Modification::Create { object: None, .. } => None,
};
if let Err(err) =
inbuxa_features::branding::writes::check(before, &new_object)
{
set.failed(modification, err);
continue 'outer;
}
// inbuxa: UD-16: a kept account's addresses stay its own
if let Some(err) =
crate::inbuxa::deleted_account::reserved(self, stored, &new_object).await?
+10 -1
View File
@@ -553,8 +553,17 @@ async fn build_template(
};
// inbuxa: BT-11, BT-18: the operator's template, read now, else the built-in
#[cfg(not(feature = "enterprise"))]
let template = &server.core.groupware.alarms_template;
let custom = inbuxa_features::branding::templates::stored(
server.registry(),
inbuxa_features::branding::templates::Which::Alarm,
)
.await
.ok()
.flatten();
#[cfg(not(feature = "enterprise"))]
let template = custom.as_ref().unwrap_or(&server.core.groupware.alarms_template);
let formatter = TextFormatter::new(account_info.locale().as_str())?;
let locale = formatter.locale;
+10 -1
View File
@@ -323,8 +323,17 @@ pub async fn build_itip_template(
summary: &ItipSummary,
logo_cid: &str,
) -> trc::Result<Details> {
// inbuxa: BT-11, BT-18: the operator's template, read now, else the built-in
#[cfg(not(feature = "enterprise"))]
let template = &server.core.groupware.itip_template;
let custom = inbuxa_features::branding::templates::stored(
server.registry(),
inbuxa_features::branding::templates::Which::Invite,
)
.await
.ok()
.flatten();
#[cfg(not(feature = "enterprise"))]
let template = custom.as_ref().unwrap_or(&server.core.groupware.itip_template);
let formatter = TextFormatter::new(account_info.locale().as_str())?;
let locale = formatter.locale;