Skip to main content

reinhardt_middleware/
broken_link.rs

1//! Broken link detection middleware
2//!
3//! Detects and logs 404 errors that originate from internal links (same domain).
4//! Useful for identifying broken links on your site before users encounter them.
5//!
6//! ## Email Notifications
7//!
8//! This middleware can send email notifications to managers when broken links are
9//! detected. The canonical entry point is
10//! `BrokenLinkEmailsMiddleware::from_settings`, which copies
11//! `Settings::managers` into [`BrokenLinkConfig::managers`] once at middleware
12//! construction time. When no `Settings` instance is available, callers may
13//! configure recipients directly via [`BrokenLinkConfig::with_emails`]; the
14//! middleware then synthesizes anonymous `Contact` entries from those addresses.
15
16use async_trait::async_trait;
17use hyper::StatusCode;
18use hyper::header::{REFERER, USER_AGENT};
19use regex::Regex;
20use reinhardt_conf::settings;
21use reinhardt_http::{Handler, Middleware, Request, Response, Result};
22use serde::{Deserialize, Serialize};
23use std::borrow::Cow;
24use std::sync::Arc;
25
26/// Configuration for broken link detection
27#[non_exhaustive]
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct BrokenLinkConfig {
30	/// Enable or disable broken link detection
31	pub enabled: bool,
32	/// Email addresses to notify (if configured)
33	pub email_addresses: Vec<String>,
34	/// Path patterns to ignore (regex)
35	pub ignored_paths: Vec<String>,
36	/// User-Agent patterns to ignore (e.g., bots)
37	pub ignored_user_agents: Vec<String>,
38	/// Managers to notify when a broken link is detected
39	///
40	/// Resolved from `Settings::managers` at middleware construction time via
41	/// `BrokenLinkConfig::from_settings`. When empty, the middleware falls
42	/// back to converting [`BrokenLinkConfig::email_addresses`] into anonymous
43	/// `Contact` entries.
44	pub managers: Vec<settings::Contact>,
45}
46
47impl BrokenLinkConfig {
48	/// Create a new default configuration
49	///
50	/// # Examples
51	///
52	/// ```
53	/// use reinhardt_middleware::BrokenLinkConfig;
54	///
55	/// let config = BrokenLinkConfig::new();
56	/// assert!(config.enabled);
57	/// ```
58	pub fn new() -> Self {
59		Self {
60			enabled: true,
61			email_addresses: Vec::new(),
62			ignored_paths: vec![
63				// Common paths to ignore
64				"/favicon.ico".to_string(),
65				"/robots.txt".to_string(),
66				"/.well-known/.*".to_string(),
67			],
68			ignored_user_agents: vec![
69				// Common bots/crawlers to ignore
70				"bot".to_string(),
71				"crawler".to_string(),
72				"spider".to_string(),
73				"slurp".to_string(),
74			],
75			managers: Vec::new(),
76		}
77	}
78
79	/// Disable broken link detection
80	///
81	/// # Examples
82	///
83	/// ```
84	/// use reinhardt_middleware::BrokenLinkConfig;
85	///
86	/// let config = BrokenLinkConfig::new().disabled();
87	/// assert!(!config.enabled);
88	/// ```
89	pub fn disabled(mut self) -> Self {
90		self.enabled = false;
91		self
92	}
93
94	/// Add email addresses for notifications
95	///
96	/// # Examples
97	///
98	/// ```
99	/// use reinhardt_middleware::BrokenLinkConfig;
100	///
101	/// let config = BrokenLinkConfig::new()
102	///     .with_emails(vec!["admin@example.com".to_string()]);
103	/// ```
104	pub fn with_emails(mut self, emails: Vec<String>) -> Self {
105		self.email_addresses = emails;
106		self
107	}
108
109	/// Add additional paths to ignore
110	///
111	/// # Examples
112	///
113	/// ```
114	/// use reinhardt_middleware::BrokenLinkConfig;
115	///
116	/// let config = BrokenLinkConfig::new()
117	///     .with_ignored_paths(vec!["/admin/.*".to_string()]);
118	/// ```
119	pub fn with_ignored_paths(mut self, paths: Vec<String>) -> Self {
120		self.ignored_paths.extend(paths);
121		self
122	}
123
124	/// Add additional user agents to ignore
125	///
126	/// # Examples
127	///
128	/// ```
129	/// use reinhardt_middleware::BrokenLinkConfig;
130	///
131	/// let config = BrokenLinkConfig::new()
132	///     .with_ignored_user_agents(vec!["CustomBot".to_string()]);
133	/// ```
134	pub fn with_ignored_user_agents(mut self, user_agents: Vec<String>) -> Self {
135		self.ignored_user_agents.extend(user_agents);
136		self
137	}
138}
139
140impl Default for BrokenLinkConfig {
141	fn default() -> Self {
142		Self::new()
143	}
144}
145
146/// Middleware for detecting broken internal links
147///
148/// Logs 404 errors that originate from internal referrers (same domain).
149///
150/// # Examples
151///
152/// ```
153/// use std::sync::Arc;
154/// use reinhardt_middleware::{BrokenLinkEmailsMiddleware, BrokenLinkConfig};
155/// use reinhardt_http::{Handler, Middleware, Request, Response};
156/// use hyper::{StatusCode, Method, Version, HeaderMap};
157/// use bytes::Bytes;
158///
159/// struct NotFoundHandler;
160///
161/// #[async_trait::async_trait]
162/// impl Handler for NotFoundHandler {
163///     async fn handle(&self, _request: Request) -> reinhardt_core::exception::Result<Response> {
164///         Ok(Response::new(StatusCode::NOT_FOUND))
165///     }
166/// }
167///
168/// # tokio_test::block_on(async {
169/// let config = BrokenLinkConfig::new();
170/// let middleware = BrokenLinkEmailsMiddleware::new(config);
171/// let handler = Arc::new(NotFoundHandler);
172///
173/// let mut headers = HeaderMap::new();
174/// headers.insert(hyper::header::REFERER, "http://example.com/page".parse().unwrap());
175/// headers.insert(hyper::header::HOST, "example.com".parse().unwrap());
176///
177/// let request = Request::builder()
178///     .method(Method::GET)
179///     .uri("/missing")
180///     .version(Version::HTTP_11)
181///     .headers(headers)
182///     .body(Bytes::new())
183///     .build()
184///     .unwrap();
185///
186/// let response = middleware.process(request, handler).await.unwrap();
187/// assert_eq!(response.status, StatusCode::NOT_FOUND);
188/// # });
189/// ```
190pub struct BrokenLinkEmailsMiddleware {
191	config: BrokenLinkConfig,
192	ignored_path_regexes: Vec<Regex>,
193	ignored_ua_regexes: Vec<Regex>,
194}
195
196impl BrokenLinkEmailsMiddleware {
197	/// Create a new BrokenLinkEmailsMiddleware with the given configuration
198	///
199	/// # Examples
200	///
201	/// ```
202	/// use reinhardt_middleware::{BrokenLinkEmailsMiddleware, BrokenLinkConfig};
203	///
204	/// let config = BrokenLinkConfig::new();
205	/// let middleware = BrokenLinkEmailsMiddleware::new(config);
206	/// ```
207	pub fn new(config: BrokenLinkConfig) -> Self {
208		let ignored_path_regexes = config
209			.ignored_paths
210			.iter()
211			.filter_map(|p| Regex::new(p).ok())
212			.collect();
213
214		let ignored_ua_regexes = config
215			.ignored_user_agents
216			.iter()
217			.filter_map(|ua| Regex::new(&format!("(?i){}", ua)).ok())
218			.collect();
219
220		Self {
221			config,
222			ignored_path_regexes,
223			ignored_ua_regexes,
224		}
225	}
226
227	/// Check if the path should be ignored
228	fn is_ignored_path(&self, path: &str) -> bool {
229		self.ignored_path_regexes.iter().any(|re| re.is_match(path))
230	}
231
232	/// Check if the user agent should be ignored
233	fn is_ignored_user_agent(&self, user_agent: &str) -> bool {
234		self.ignored_ua_regexes
235			.iter()
236			.any(|re| re.is_match(user_agent))
237	}
238
239	/// Extract domain from URL
240	fn extract_domain(url: &str) -> Option<String> {
241		if let Ok(parsed) = url::Url::parse(url) {
242			parsed.host_str().map(|h| h.to_string())
243		} else {
244			None
245		}
246	}
247
248	/// Check if the referrer is from the same domain (internal link)
249	fn is_internal_referrer(&self, referer: &str, host: &str) -> bool {
250		if let Some(referer_domain) = Self::extract_domain(referer) {
251			// Normalize domains (remove www. prefix for comparison)
252			let normalized_referer = referer_domain.trim_start_matches("www.");
253			let normalized_host = host.trim_start_matches("www.");
254			normalized_referer == normalized_host
255		} else {
256			false
257		}
258	}
259
260	/// Log a broken link and send email notifications
261	async fn log_broken_link(&self, path: &str, referer: &str) {
262		// Log to standard logging system
263		log::warn!("Broken link detected: {} (from: {})", path, referer);
264
265		// Managers are resolved once at construction time via
266		// `BrokenLinkConfig::from_settings`. When no settings were provided,
267		// fall back to converting legacy `email_addresses` into anonymous
268		// `Contact` entries so existing direct-construction callers continue
269		// to receive notifications.
270		let managers: Cow<'_, [settings::Contact]> = if !self.config.managers.is_empty() {
271			Cow::Borrowed(&self.config.managers)
272		} else {
273			Cow::Owned(
274				self.config
275					.email_addresses
276					.iter()
277					.map(|email| settings::Contact::new("", email.clone()))
278					.collect(),
279			)
280		};
281
282		#[cfg(feature = "broken-link-email")]
283		if !managers.is_empty() {
284			let subject = format!("Broken link detected: {}", path);
285			let body = format!(
286				"A broken link was detected on your site:\n\n\
287				 Broken URL: {}\n\
288				 Referrer: {}\n\n\
289				 Please check and fix this link.",
290				path, referer
291			);
292
293			// Send to all managers asynchronously (non-blocking)
294			for manager in managers.iter() {
295				let email = manager.email.clone();
296				let subject_clone = subject.clone();
297				let body_clone = body.clone();
298
299				// Schedule email sending in a separate task to avoid blocking
300				// Note: Uses default SMTP config (localhost:25). Configure via SmtpConfig for production.
301				tokio::spawn(async move {
302					// `SmtpConfig` is deprecated in favor of the `EmailSettings`
303					// fragment; this placeholder default is kept during the 0.2
304					// compatibility window.
305					#[allow(deprecated)]
306					let config = reinhardt_mail::SmtpConfig::default();
307					let backend = match reinhardt_mail::SmtpBackend::new(config) {
308						Ok(backend) => backend,
309						Err(e) => {
310							log::error!(
311								"Failed to create SMTP backend for broken link email: {}",
312								e
313							);
314							return;
315						}
316					};
317					match reinhardt_mail::send_mail_with_backend(
318						subject_clone,
319						body_clone,
320						"noreply@example.com", // Default sender
321						vec![email.clone()],
322						None,
323						&backend,
324					)
325					.await
326					{
327						Ok(_) => {
328							log::info!("Broken link email notification sent to: {}", email);
329						}
330						Err(e) => {
331							log::error!("Failed to send broken link email to {}: {}", email, e);
332						}
333					}
334				});
335			}
336		}
337
338		#[cfg(not(feature = "broken-link-email"))]
339		if !managers.is_empty() {
340			log::debug!(
341				"Broken link email notification skipped because the broken-link-email feature is disabled"
342			);
343		}
344	}
345}
346
347impl Default for BrokenLinkEmailsMiddleware {
348	fn default() -> Self {
349		Self::new(BrokenLinkConfig::default())
350	}
351}
352
353#[async_trait]
354impl Middleware for BrokenLinkEmailsMiddleware {
355	async fn process(&self, request: Request, handler: Arc<dyn Handler>) -> Result<Response> {
356		// Extract necessary information before moving request
357		let path = request.uri.path().to_string();
358		let referer = request
359			.headers
360			.get(REFERER)
361			.and_then(|r| r.to_str().ok())
362			.map(|s| s.to_string());
363		let host = request
364			.headers
365			.get(hyper::header::HOST)
366			.and_then(|h| h.to_str().ok())
367			.map(|s| s.to_string());
368		let user_agent = request
369			.headers
370			.get(USER_AGENT)
371			.and_then(|ua| ua.to_str().ok())
372			.map(|s| s.to_string());
373
374		// Convert errors to responses so post-processing always runs,
375		// even when invoked outside MiddlewareChain. (#3244)
376		let response = match handler.handle(request).await {
377			Ok(resp) => resp,
378			Err(e) => Response::from(e),
379		};
380
381		// Check if we should process this request/response
382		if !self.config.enabled || response.status != StatusCode::NOT_FOUND {
383			return Ok(response);
384		}
385
386		// Check if path should be ignored
387		if self.is_ignored_path(&path) {
388			return Ok(response);
389		}
390
391		// Check if user agent should be ignored
392		if let Some(ua) = user_agent
393			&& self.is_ignored_user_agent(&ua)
394		{
395			return Ok(response);
396		}
397
398		// Check if there's a referrer and host
399		if let (Some(referer_str), Some(host_str)) = (referer, host) {
400			// Only log if it's an internal referrer
401			if self.is_internal_referrer(&referer_str, &host_str) {
402				self.log_broken_link(&path, &referer_str).await;
403			}
404		}
405
406		Ok(response)
407	}
408}
409
410#[cfg(test)]
411mod tests {
412	use super::*;
413	use bytes::Bytes;
414	use hyper::{HeaderMap, Method, StatusCode, Version};
415
416	struct NotFoundHandler;
417
418	#[async_trait]
419	impl Handler for NotFoundHandler {
420		async fn handle(&self, _request: Request) -> Result<Response> {
421			Ok(Response::new(StatusCode::NOT_FOUND))
422		}
423	}
424
425	struct OkHandler;
426
427	#[async_trait]
428	impl Handler for OkHandler {
429		async fn handle(&self, _request: Request) -> Result<Response> {
430			Ok(Response::new(StatusCode::OK).with_body(Bytes::from("OK")))
431		}
432	}
433
434	#[tokio::test]
435	async fn test_internal_404_detected() {
436		let config = BrokenLinkConfig::new();
437		let middleware = BrokenLinkEmailsMiddleware::new(config);
438		let handler = Arc::new(NotFoundHandler);
439
440		let mut headers = HeaderMap::new();
441		headers.insert(REFERER, "http://example.com/page".parse().unwrap());
442		headers.insert(hyper::header::HOST, "example.com".parse().unwrap());
443
444		let request = Request::builder()
445			.method(Method::GET)
446			.uri("/missing")
447			.version(Version::HTTP_11)
448			.headers(headers)
449			.body(Bytes::new())
450			.build()
451			.unwrap();
452
453		let response = middleware.process(request, handler).await.unwrap();
454
455		assert_eq!(response.status, StatusCode::NOT_FOUND);
456		// In a real scenario, we'd check logs or email was sent
457	}
458
459	#[tokio::test]
460	async fn test_external_404_ignored() {
461		let config = BrokenLinkConfig::new();
462		let middleware = BrokenLinkEmailsMiddleware::new(config);
463		let handler = Arc::new(NotFoundHandler);
464
465		let mut headers = HeaderMap::new();
466		headers.insert(REFERER, "http://external.com/page".parse().unwrap());
467		headers.insert(hyper::header::HOST, "example.com".parse().unwrap());
468
469		let request = Request::builder()
470			.method(Method::GET)
471			.uri("/missing")
472			.version(Version::HTTP_11)
473			.headers(headers)
474			.body(Bytes::new())
475			.build()
476			.unwrap();
477
478		let response = middleware.process(request, handler).await.unwrap();
479
480		assert_eq!(response.status, StatusCode::NOT_FOUND);
481		// External referrer should not trigger detection
482	}
483
484	#[tokio::test]
485	async fn test_no_referrer_ignored() {
486		let config = BrokenLinkConfig::new();
487		let middleware = BrokenLinkEmailsMiddleware::new(config);
488		let handler = Arc::new(NotFoundHandler);
489
490		let mut headers = HeaderMap::new();
491		headers.insert(hyper::header::HOST, "example.com".parse().unwrap());
492
493		let request = Request::builder()
494			.method(Method::GET)
495			.uri("/missing")
496			.version(Version::HTTP_11)
497			.headers(headers)
498			.body(Bytes::new())
499			.build()
500			.unwrap();
501
502		let response = middleware.process(request, handler).await.unwrap();
503
504		assert_eq!(response.status, StatusCode::NOT_FOUND);
505		// No referrer should not trigger detection
506	}
507
508	#[tokio::test]
509	async fn test_ignored_path() {
510		let config = BrokenLinkConfig::new();
511		let middleware = BrokenLinkEmailsMiddleware::new(config);
512		let handler = Arc::new(NotFoundHandler);
513
514		let mut headers = HeaderMap::new();
515		headers.insert(REFERER, "http://example.com/page".parse().unwrap());
516		headers.insert(hyper::header::HOST, "example.com".parse().unwrap());
517
518		let request = Request::builder()
519			.method(Method::GET)
520			.uri("/favicon.ico")
521			.version(Version::HTTP_11)
522			.headers(headers)
523			.body(Bytes::new())
524			.build()
525			.unwrap();
526
527		let response = middleware.process(request, handler).await.unwrap();
528
529		assert_eq!(response.status, StatusCode::NOT_FOUND);
530		// favicon.ico is in ignored paths
531	}
532
533	#[tokio::test]
534	async fn test_ignored_user_agent() {
535		let config = BrokenLinkConfig::new();
536		let middleware = BrokenLinkEmailsMiddleware::new(config);
537		let handler = Arc::new(NotFoundHandler);
538
539		let mut headers = HeaderMap::new();
540		headers.insert(REFERER, "http://example.com/page".parse().unwrap());
541		headers.insert(hyper::header::HOST, "example.com".parse().unwrap());
542		headers.insert(USER_AGENT, "Googlebot/2.1".parse().unwrap());
543
544		let request = Request::builder()
545			.method(Method::GET)
546			.uri("/missing")
547			.version(Version::HTTP_11)
548			.headers(headers)
549			.body(Bytes::new())
550			.build()
551			.unwrap();
552
553		let response = middleware.process(request, handler).await.unwrap();
554
555		assert_eq!(response.status, StatusCode::NOT_FOUND);
556		// Bot user agents should be ignored
557	}
558
559	#[tokio::test]
560	async fn test_200_response_ignored() {
561		let config = BrokenLinkConfig::new();
562		let middleware = BrokenLinkEmailsMiddleware::new(config);
563		let handler = Arc::new(OkHandler);
564
565		let mut headers = HeaderMap::new();
566		headers.insert(REFERER, "http://example.com/page".parse().unwrap());
567		headers.insert(hyper::header::HOST, "example.com".parse().unwrap());
568
569		let request = Request::builder()
570			.method(Method::GET)
571			.uri("/existing")
572			.version(Version::HTTP_11)
573			.headers(headers)
574			.body(Bytes::new())
575			.build()
576			.unwrap();
577
578		let response = middleware.process(request, handler).await.unwrap();
579
580		assert_eq!(response.status, StatusCode::OK);
581		// 200 responses should not trigger detection
582	}
583
584	#[tokio::test]
585	async fn test_www_subdomain_handling() {
586		let config = BrokenLinkConfig::new();
587		let middleware = BrokenLinkEmailsMiddleware::new(config);
588		let handler = Arc::new(NotFoundHandler);
589
590		let mut headers = HeaderMap::new();
591		headers.insert(REFERER, "http://www.example.com/page".parse().unwrap());
592		headers.insert(hyper::header::HOST, "example.com".parse().unwrap());
593
594		let request = Request::builder()
595			.method(Method::GET)
596			.uri("/missing")
597			.version(Version::HTTP_11)
598			.headers(headers)
599			.body(Bytes::new())
600			.build()
601			.unwrap();
602
603		let response = middleware.process(request, handler).await.unwrap();
604
605		assert_eq!(response.status, StatusCode::NOT_FOUND);
606		// www.example.com should be treated as same domain as example.com
607	}
608
609	#[tokio::test]
610	async fn test_disabled_config() {
611		let config = BrokenLinkConfig::new().disabled();
612		let middleware = BrokenLinkEmailsMiddleware::new(config);
613		let handler = Arc::new(NotFoundHandler);
614
615		let mut headers = HeaderMap::new();
616		headers.insert(REFERER, "http://example.com/page".parse().unwrap());
617		headers.insert(hyper::header::HOST, "example.com".parse().unwrap());
618
619		let request = Request::builder()
620			.method(Method::GET)
621			.uri("/missing")
622			.version(Version::HTTP_11)
623			.headers(headers)
624			.body(Bytes::new())
625			.build()
626			.unwrap();
627
628		let response = middleware.process(request, handler).await.unwrap();
629
630		assert_eq!(response.status, StatusCode::NOT_FOUND);
631		// Disabled config should not trigger detection
632	}
633
634	#[tokio::test]
635	async fn test_custom_ignored_paths() {
636		let config = BrokenLinkConfig::new().with_ignored_paths(vec!["/admin/.*".to_string()]);
637		let middleware = BrokenLinkEmailsMiddleware::new(config);
638		let handler = Arc::new(NotFoundHandler);
639
640		let mut headers = HeaderMap::new();
641		headers.insert(REFERER, "http://example.com/page".parse().unwrap());
642		headers.insert(hyper::header::HOST, "example.com".parse().unwrap());
643
644		let request = Request::builder()
645			.method(Method::GET)
646			.uri("/admin/missing")
647			.version(Version::HTTP_11)
648			.headers(headers)
649			.body(Bytes::new())
650			.build()
651			.unwrap();
652
653		let response = middleware.process(request, handler).await.unwrap();
654
655		assert_eq!(response.status, StatusCode::NOT_FOUND);
656		// Custom ignored paths should work
657	}
658
659	#[tokio::test]
660	async fn test_email_configuration() {
661		let config = BrokenLinkConfig::new().with_emails(vec!["admin@example.com".to_string()]);
662		let middleware = BrokenLinkEmailsMiddleware::new(config);
663
664		assert_eq!(middleware.config.email_addresses.len(), 1);
665		assert_eq!(middleware.config.email_addresses[0], "admin@example.com");
666	}
667}