1use 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#[non_exhaustive]
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct BrokenLinkConfig {
30 pub enabled: bool,
32 pub email_addresses: Vec<String>,
34 pub ignored_paths: Vec<String>,
36 pub ignored_user_agents: Vec<String>,
38 pub managers: Vec<settings::Contact>,
45}
46
47impl BrokenLinkConfig {
48 pub fn new() -> Self {
59 Self {
60 enabled: true,
61 email_addresses: Vec::new(),
62 ignored_paths: vec![
63 "/favicon.ico".to_string(),
65 "/robots.txt".to_string(),
66 "/.well-known/.*".to_string(),
67 ],
68 ignored_user_agents: vec![
69 "bot".to_string(),
71 "crawler".to_string(),
72 "spider".to_string(),
73 "slurp".to_string(),
74 ],
75 managers: Vec::new(),
76 }
77 }
78
79 pub fn disabled(mut self) -> Self {
90 self.enabled = false;
91 self
92 }
93
94 pub fn with_emails(mut self, emails: Vec<String>) -> Self {
105 self.email_addresses = emails;
106 self
107 }
108
109 pub fn with_ignored_paths(mut self, paths: Vec<String>) -> Self {
120 self.ignored_paths.extend(paths);
121 self
122 }
123
124 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
146pub struct BrokenLinkEmailsMiddleware {
191 config: BrokenLinkConfig,
192 ignored_path_regexes: Vec<Regex>,
193 ignored_ua_regexes: Vec<Regex>,
194}
195
196impl BrokenLinkEmailsMiddleware {
197 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 fn is_ignored_path(&self, path: &str) -> bool {
229 self.ignored_path_regexes.iter().any(|re| re.is_match(path))
230 }
231
232 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 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 fn is_internal_referrer(&self, referer: &str, host: &str) -> bool {
250 if let Some(referer_domain) = Self::extract_domain(referer) {
251 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 async fn log_broken_link(&self, path: &str, referer: &str) {
262 log::warn!("Broken link detected: {} (from: {})", path, referer);
264
265 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 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 tokio::spawn(async move {
302 #[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", 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 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 let response = match handler.handle(request).await {
377 Ok(resp) => resp,
378 Err(e) => Response::from(e),
379 };
380
381 if !self.config.enabled || response.status != StatusCode::NOT_FOUND {
383 return Ok(response);
384 }
385
386 if self.is_ignored_path(&path) {
388 return Ok(response);
389 }
390
391 if let Some(ua) = user_agent
393 && self.is_ignored_user_agent(&ua)
394 {
395 return Ok(response);
396 }
397
398 if let (Some(referer_str), Some(host_str)) = (referer, host) {
400 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 }
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 }
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 }
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 }
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 }
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 }
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 }
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 }
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 }
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}