use std::borrow::Cow;
use std::fmt::Write as _;
use autumn_web::app::AppBuilder;
use autumn_web::config::AutumnConfig;
use autumn_web::plugin::Plugin;
use autumn_web::plugin_contract::PluginContract;
use autumn_web::{AppState, AutumnError};
use crate::assets::ASSETS;
use crate::config::{CspCheck, MetaPixelConfig, SECTION};
use crate::csp::{has_strict_dynamic, has_trusted_types, missing_sources, with_meta_pixel_sources};
use crate::error::MetaPixelError;
use crate::pixel::Runtime;
pub const PLUGIN_NAME: &str = "autumn-plugin-meta-pixel";
#[derive(Debug, Clone, Default)]
pub struct MetaPixelPlugin {
config: Option<MetaPixelConfig>,
policy_version: Option<u32>,
}
impl MetaPixelPlugin {
#[must_use]
pub const fn new() -> Self {
Self {
config: None,
policy_version: None,
}
}
#[must_use]
pub const fn with_config(config: MetaPixelConfig) -> Self {
Self {
config: Some(config),
policy_version: None,
}
}
#[must_use]
pub const fn consent_policy_version(mut self, version: u32) -> Self {
self.policy_version = Some(version);
self
}
pub fn start(self, state: &AppState) -> Result<(), MetaPixelError> {
let mut config = match self.config {
Some(c) => c,
None => MetaPixelConfig::load(Some(state.profile()).filter(|p| *p != "default"))?,
};
if let Some(version) = self.policy_version {
config.consent_policy_version = version;
}
config.validate()?;
let on = config.enabled && !config.pixel_ids.is_empty();
if config.enabled && config.pixel_ids.is_empty() {
tracing::warn!("meta_pixel: enabled, but pixel_ids is empty; the pixel is off");
}
if on {
check_csp(&config, &state.config())?;
}
tracing::info!(
on,
pixels = config.pixel_ids.len(),
require_consent = config.require_consent,
"meta_pixel started"
);
state.insert_extension(Runtime::new(config));
Ok(())
}
}
fn check_csp(config: &MetaPixelConfig, app: &AutumnConfig) -> Result<(), MetaPixelError> {
if config.csp_check == CspCheck::Off {
return Ok(());
}
let headers = &app.security.headers;
let csp = &headers.content_security_policy;
let origin = config
.script_origin()
.ok_or_else(|| MetaPixelError::Config("script_url has no https origin".to_owned()))?;
let gaps = missing_sources(csp, &origin);
let strict = has_strict_dynamic(csp);
let trusted_types = has_trusted_types(csp);
if gaps.is_empty() && !strict && !trusted_types {
return Ok(());
}
let mut problems: Vec<String> = gaps.iter().map(ToString::to_string).collect();
if strict {
problems.push(
"script-src has 'strict-dynamic', so the browser blocks the loader; \
remove 'strict-dynamic', or set csp_check = \"off\" and load the pixel yourself"
.to_owned(),
);
}
if trusted_types {
problems.push(
"the policy has require-trusted-types-for, so the browser blocks the loader; \
remove it, or set csp_check = \"off\" and load the pixel yourself"
.to_owned(),
);
}
let mut message = format!(
"the Content-Security-Policy blocks the pixel: {}.",
problems.join("; ")
);
if !gaps.is_empty() {
let fixed = toml::Value::String(with_meta_pixel_sources(csp, &origin));
let _ = write!(
message,
" Set this in autumn.toml:\n[security.headers]\ncontent_security_policy = {fixed}"
);
if headers.csp_nonce.enabled {
message.push_str(
"\nNote: an explicit content_security_policy turns off the automatic nonce. \
Then inline scripts with a CspNonce do not run, and style-src goes back to \
'unsafe-inline'. Add your own nonce sources if you need them.",
);
}
}
match config.csp_check {
CspCheck::Warn => {
tracing::warn!("meta_pixel: {message}");
Ok(())
}
_ => Err(MetaPixelError::Csp(message)),
}
}
impl Plugin for MetaPixelPlugin {
fn name(&self) -> Cow<'static, str> {
Cow::Borrowed(PLUGIN_NAME)
}
fn contract(&self) -> Option<PluginContract> {
Some(
PluginContract::new(env!("CARGO_PKG_NAME"))
.plugin_version(env!("CARGO_PKG_VERSION"))
.autumn_web("0.10"),
)
}
fn build(self, app: AppBuilder) -> AppBuilder {
app.config_section(SECTION)
.plugin_assets(&ASSETS)
.on_startup(move |state| {
let plugin = self.clone();
async move {
plugin
.start(&state)
.map_err(AutumnError::internal_server_error)
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::CspCheck;
fn cfg() -> MetaPixelConfig {
MetaPixelConfig::with_pixel_ids(["111"])
}
#[test]
fn default_csp_fails_with_the_fix() {
let err = check_csp(&cfg(), &AutumnConfig::default())
.unwrap_err()
.to_string();
assert!(
err.contains("script-src does not allow https://connect.facebook.net"),
"{err}"
);
assert!(err.contains("[security.headers]"), "{err}");
assert!(err.contains("content_security_policy = \""), "{err}");
assert!(err.contains("https://www.facebook.com"), "{err}");
}
#[test]
fn fixed_csp_passes() {
let mut app = AutumnConfig::default();
app.security.headers.content_security_policy = crate::csp::with_meta_pixel_sources(
&app.security.headers.content_security_policy,
"https://connect.facebook.net",
);
check_csp(&cfg(), &app).unwrap();
app.security.headers.content_security_policy = String::new();
check_csp(&cfg(), &app).unwrap();
}
#[test]
fn warn_and_off_do_not_fail() {
let mut c = cfg();
c.csp_check = CspCheck::Warn;
check_csp(&c, &AutumnConfig::default()).unwrap();
c.csp_check = CspCheck::Off;
check_csp(&c, &AutumnConfig::default()).unwrap();
}
#[test]
fn nonce_note_and_strict_dynamic() {
let mut app = AutumnConfig::default();
app.security.headers.csp_nonce.enabled = true;
let err = check_csp(&cfg(), &app).unwrap_err().to_string();
assert!(err.contains("nonce"), "{err}");
app.security.headers.content_security_policy =
"script-src 'self' 'strict-dynamic' https://connect.facebook.net; img-src *; connect-src *".into();
let err = check_csp(&cfg(), &app).unwrap_err().to_string();
assert!(err.contains("strict-dynamic"), "{err}");
}
#[test]
fn trusted_types_fails_with_a_reason() {
let mut app = AutumnConfig::default();
app.security.headers.content_security_policy =
"default-src 'self' https:; require-trusted-types-for 'script'".into();
let err = check_csp(&cfg(), &app).unwrap_err().to_string();
assert!(err.contains("require-trusted-types-for"), "{err}");
}
#[test]
fn nonce_note_names_the_cost() {
let mut app = AutumnConfig::default();
app.security.headers.csp_nonce.enabled = true;
let err = check_csp(&cfg(), &app).unwrap_err().to_string();
assert!(err.contains("CspNonce"), "{err}");
assert!(err.contains("'unsafe-inline'"), "{err}");
}
#[test]
fn consent_policy_version_builder_wins_over_config() {
let p = MetaPixelPlugin::with_config(cfg()).consent_policy_version(3);
assert_eq!(p.config.as_ref().unwrap().consent_policy_version, 1);
assert_eq!(p.policy_version, Some(3));
}
#[test]
fn contract_names_autumn_08() {
let c = MetaPixelPlugin::new().contract().unwrap();
let text = format!("{c:?}");
assert!(text.contains("autumn-plugin-meta-pixel"), "{text}");
assert!(text.contains("0.10"), "{text}");
assert_eq!(MetaPixelPlugin::new().name(), PLUGIN_NAME);
}
}