autumn_plugin_meta_pixel/
plugin.rs1use std::borrow::Cow;
4use std::fmt::Write as _;
5
6use autumn_web::app::AppBuilder;
7use autumn_web::config::AutumnConfig;
8use autumn_web::plugin::Plugin;
9use autumn_web::plugin_contract::PluginContract;
10use autumn_web::{AppState, AutumnError};
11
12use crate::assets::ASSETS;
13use crate::config::{CspCheck, MetaPixelConfig, SECTION};
14use crate::csp::{has_strict_dynamic, has_trusted_types, missing_sources, with_meta_pixel_sources};
15use crate::error::MetaPixelError;
16use crate::pixel::Runtime;
17
18pub const PLUGIN_NAME: &str = "autumn-plugin-meta-pixel";
20
21#[derive(Debug, Clone, Default)]
31pub struct MetaPixelPlugin {
32 config: Option<MetaPixelConfig>,
33 policy_version: Option<u32>,
34}
35
36impl MetaPixelPlugin {
37 #[must_use]
39 pub const fn new() -> Self {
40 Self {
41 config: None,
42 policy_version: None,
43 }
44 }
45
46 #[must_use]
48 pub const fn with_config(config: MetaPixelConfig) -> Self {
49 Self {
50 config: Some(config),
51 policy_version: None,
52 }
53 }
54
55 #[must_use]
59 pub const fn consent_policy_version(mut self, version: u32) -> Self {
60 self.policy_version = Some(version);
61 self
62 }
63
64 pub fn start(self, state: &AppState) -> Result<(), MetaPixelError> {
73 let mut config = match self.config {
74 Some(c) => c,
75 None => MetaPixelConfig::load(Some(state.profile()).filter(|p| *p != "default"))?,
77 };
78 if let Some(version) = self.policy_version {
79 config.consent_policy_version = version;
80 }
81 config.validate()?;
82 let on = config.enabled && !config.pixel_ids.is_empty();
83 if config.enabled && config.pixel_ids.is_empty() {
84 tracing::warn!("meta_pixel: enabled, but pixel_ids is empty; the pixel is off");
85 }
86 if on {
87 check_csp(&config, &state.config())?;
88 }
89 tracing::info!(
90 on,
91 pixels = config.pixel_ids.len(),
92 require_consent = config.require_consent,
93 "meta_pixel started"
94 );
95 state.insert_extension(Runtime::new(config));
96 Ok(())
97 }
98}
99
100fn check_csp(config: &MetaPixelConfig, app: &AutumnConfig) -> Result<(), MetaPixelError> {
102 if config.csp_check == CspCheck::Off {
103 return Ok(());
104 }
105 let headers = &app.security.headers;
106 let csp = &headers.content_security_policy;
107 let origin = config
108 .script_origin()
109 .ok_or_else(|| MetaPixelError::Config("script_url has no https origin".to_owned()))?;
110 let gaps = missing_sources(csp, &origin);
111 let strict = has_strict_dynamic(csp);
112 let trusted_types = has_trusted_types(csp);
113 if gaps.is_empty() && !strict && !trusted_types {
114 return Ok(());
115 }
116 let mut problems: Vec<String> = gaps.iter().map(ToString::to_string).collect();
117 if strict {
118 problems.push(
119 "script-src has 'strict-dynamic', so the browser blocks the loader; \
120 remove 'strict-dynamic', or set csp_check = \"off\" and load the pixel yourself"
121 .to_owned(),
122 );
123 }
124 if trusted_types {
125 problems.push(
126 "the policy has require-trusted-types-for, so the browser blocks the loader; \
127 remove it, or set csp_check = \"off\" and load the pixel yourself"
128 .to_owned(),
129 );
130 }
131 let mut message = format!(
132 "the Content-Security-Policy blocks the pixel: {}.",
133 problems.join("; ")
134 );
135 if !gaps.is_empty() {
136 let fixed = toml::Value::String(with_meta_pixel_sources(csp, &origin));
137 let _ = write!(
139 message,
140 " Set this in autumn.toml:\n[security.headers]\ncontent_security_policy = {fixed}"
141 );
142 if headers.csp_nonce.enabled {
143 message.push_str(
144 "\nNote: an explicit content_security_policy turns off the automatic nonce. \
145 Then inline scripts with a CspNonce do not run, and style-src goes back to \
146 'unsafe-inline'. Add your own nonce sources if you need them.",
147 );
148 }
149 }
150 match config.csp_check {
151 CspCheck::Warn => {
152 tracing::warn!("meta_pixel: {message}");
153 Ok(())
154 }
155 _ => Err(MetaPixelError::Csp(message)),
156 }
157}
158
159impl Plugin for MetaPixelPlugin {
160 fn name(&self) -> Cow<'static, str> {
161 Cow::Borrowed(PLUGIN_NAME)
162 }
163
164 fn contract(&self) -> Option<PluginContract> {
165 Some(
166 PluginContract::new(env!("CARGO_PKG_NAME"))
167 .plugin_version(env!("CARGO_PKG_VERSION"))
168 .autumn_web("0.10"),
169 )
170 }
171
172 fn build(self, app: AppBuilder) -> AppBuilder {
173 app.config_section(SECTION)
174 .plugin_assets(&ASSETS)
175 .on_startup(move |state| {
176 let plugin = self.clone();
177 async move {
178 plugin
179 .start(&state)
180 .map_err(AutumnError::internal_server_error)
181 }
182 })
183 }
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189 use crate::config::CspCheck;
190
191 fn cfg() -> MetaPixelConfig {
192 MetaPixelConfig::with_pixel_ids(["111"])
193 }
194
195 #[test]
196 fn default_csp_fails_with_the_fix() {
197 let err = check_csp(&cfg(), &AutumnConfig::default())
198 .unwrap_err()
199 .to_string();
200 assert!(
201 err.contains("script-src does not allow https://connect.facebook.net"),
202 "{err}"
203 );
204 assert!(err.contains("[security.headers]"), "{err}");
205 assert!(err.contains("content_security_policy = \""), "{err}");
206 assert!(err.contains("https://www.facebook.com"), "{err}");
207 }
208
209 #[test]
210 fn fixed_csp_passes() {
211 let mut app = AutumnConfig::default();
212 app.security.headers.content_security_policy = crate::csp::with_meta_pixel_sources(
213 &app.security.headers.content_security_policy,
214 "https://connect.facebook.net",
215 );
216 check_csp(&cfg(), &app).unwrap();
217 app.security.headers.content_security_policy = String::new();
218 check_csp(&cfg(), &app).unwrap();
219 }
220
221 #[test]
222 fn warn_and_off_do_not_fail() {
223 let mut c = cfg();
224 c.csp_check = CspCheck::Warn;
225 check_csp(&c, &AutumnConfig::default()).unwrap();
226 c.csp_check = CspCheck::Off;
227 check_csp(&c, &AutumnConfig::default()).unwrap();
228 }
229
230 #[test]
231 fn nonce_note_and_strict_dynamic() {
232 let mut app = AutumnConfig::default();
233 app.security.headers.csp_nonce.enabled = true;
234 let err = check_csp(&cfg(), &app).unwrap_err().to_string();
235 assert!(err.contains("nonce"), "{err}");
236 app.security.headers.content_security_policy =
237 "script-src 'self' 'strict-dynamic' https://connect.facebook.net; img-src *; connect-src *".into();
238 let err = check_csp(&cfg(), &app).unwrap_err().to_string();
239 assert!(err.contains("strict-dynamic"), "{err}");
240 }
241
242 #[test]
243 fn trusted_types_fails_with_a_reason() {
244 let mut app = AutumnConfig::default();
245 app.security.headers.content_security_policy =
246 "default-src 'self' https:; require-trusted-types-for 'script'".into();
247 let err = check_csp(&cfg(), &app).unwrap_err().to_string();
248 assert!(err.contains("require-trusted-types-for"), "{err}");
249 }
250
251 #[test]
252 fn nonce_note_names_the_cost() {
253 let mut app = AutumnConfig::default();
254 app.security.headers.csp_nonce.enabled = true;
255 let err = check_csp(&cfg(), &app).unwrap_err().to_string();
256 assert!(err.contains("CspNonce"), "{err}");
257 assert!(err.contains("'unsafe-inline'"), "{err}");
258 }
259
260 #[test]
261 fn consent_policy_version_builder_wins_over_config() {
262 let p = MetaPixelPlugin::with_config(cfg()).consent_policy_version(3);
263 assert_eq!(p.config.as_ref().unwrap().consent_policy_version, 1);
264 assert_eq!(p.policy_version, Some(3));
265 }
266
267 #[test]
268 fn contract_names_autumn_08() {
269 let c = MetaPixelPlugin::new().contract().unwrap();
270 let text = format!("{c:?}");
271 assert!(text.contains("autumn-plugin-meta-pixel"), "{text}");
272 assert!(text.contains("0.10"), "{text}");
273 assert_eq!(MetaPixelPlugin::new().name(), PLUGIN_NAME);
274 }
275}