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
+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