forked from
atscan.net/plcbundle-rs
High-performance implementation of plcbundle written in Rust
1// Utility functions for validation and common operations
2
3use crate::constants;
4use axum::http::{HeaderMap, HeaderValue, Uri};
5
6/// Check if a path is a common browser file that should be ignored
7pub fn is_common_browser_file(path: &str) -> bool {
8 let common_files = [
9 "favicon.ico",
10 "robots.txt",
11 "sitemap.xml",
12 "apple-touch-icon.png",
13 ".well-known",
14 ];
15 let common_extensions = [
16 ".ico", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".css", ".js", ".woff", ".woff2", ".ttf",
17 ".eot", ".xml", ".txt", ".html",
18 ];
19
20 for file in &common_files {
21 if path == *file || path.starts_with(file) {
22 return true;
23 }
24 }
25
26 for ext in &common_extensions {
27 if path.ends_with(ext) {
28 return true;
29 }
30 }
31
32 false
33}
34
35/// Validate if input is a valid DID or handle
36pub fn is_valid_did_or_handle(input: &str) -> bool {
37 if input.is_empty() {
38 return false;
39 }
40
41 // If it's a DID
42 if input.starts_with("did:") {
43 // Only accept did:plc: method
44 if !input.starts_with("did:plc:") {
45 return false;
46 }
47 return true;
48 }
49
50 // Not a DID - validate as handle
51 // Must have at least one dot
52 if !input.contains('.') {
53 return false;
54 }
55
56 // Must not have invalid characters
57 for c in input.chars() {
58 if !(c.is_ascii_lowercase()
59 || c.is_ascii_uppercase()
60 || c.is_ascii_digit()
61 || c == '.'
62 || c == '-')
63 {
64 return false;
65 }
66 }
67
68 // Basic length check
69 if input.len() > 253 {
70 return false;
71 }
72
73 // Must not start or end with dot or hyphen
74 if input.starts_with('.')
75 || input.ends_with('.')
76 || input.starts_with('-')
77 || input.ends_with('-')
78 {
79 return false;
80 }
81
82 true
83}
84
85/// Parse operation pointer: "bundle:position" or global position
86pub fn parse_operation_pointer(pointer: &str) -> anyhow::Result<(u32, usize)> {
87 // Check if it's "bundle:position" format
88 if let Some(colon_pos) = pointer.find(':') {
89 let bundle_str = &pointer[..colon_pos];
90 let pos_str = &pointer[colon_pos + 1..];
91
92 let bundle_num: u32 = bundle_str
93 .parse()
94 .map_err(|_| anyhow::anyhow!("Invalid bundle number: {}", bundle_str))?;
95 let position: usize = pos_str
96 .parse()
97 .map_err(|_| anyhow::anyhow!("Invalid position: {}", pos_str))?;
98
99 if bundle_num < 1 {
100 anyhow::bail!("Bundle number must be >= 1");
101 }
102
103 return Ok((bundle_num, position));
104 }
105
106 // Parse as global position
107 let global_pos: u64 = pointer.parse().map_err(|_| {
108 anyhow::anyhow!("Invalid position: must be number or 'bundle:position' format")
109 })?;
110
111 if global_pos < constants::BUNDLE_SIZE as u64 {
112 // Small numbers are shorthand for bundle 1
113 return Ok((1, global_pos as usize));
114 }
115
116 // Convert global position to bundle + position
117 let (bundle_num, position) = crate::constants::global_to_bundle_position(global_pos);
118
119 Ok((bundle_num, position))
120}
121
122/// Extract base URL from request headers and URI
123pub fn extract_base_url(headers: &HeaderMap, uri: &Uri) -> String {
124 if let Some(host_str) = headers.get("host").and_then(|h| h.to_str().ok()) {
125 // Check if request is HTTPS (from X-Forwarded-Proto or X-Forwarded-Ssl)
126 let is_https = headers
127 .get("x-forwarded-proto")
128 .and_then(|v| v.to_str().ok())
129 .map(|s| s == "https")
130 .unwrap_or(false)
131 || headers
132 .get("x-forwarded-ssl")
133 .and_then(|v| v.to_str().ok())
134 .map(|s| s == "on")
135 .unwrap_or(false);
136
137 let scheme = if is_https { "https" } else { "http" };
138 return format!("{}://{}", scheme, host_str);
139 }
140
141 if let Some(authority) = uri.authority() {
142 format!("http://{}", authority)
143 } else {
144 "http://127.0.0.1:8080".to_string()
145 }
146}
147
148/// Create headers for bundle file download
149pub fn bundle_download_headers(content_type: &'static str, filename: &str) -> HeaderMap {
150 let mut headers = HeaderMap::new();
151 headers.insert("Content-Type", HeaderValue::from_static(content_type));
152 headers.insert(
153 "Content-Disposition",
154 HeaderValue::from_str(&format!("attachment; filename={}", filename)).unwrap(),
155 );
156 headers
157}
158
159/// Parse duration string (e.g., "60s", "5m", "1h") into Duration
160pub fn parse_duration(s: &str) -> anyhow::Result<tokio::time::Duration> {
161 use anyhow::Context;
162 use tokio::time::Duration;
163
164 // Simple parser: "60s", "5m", "1h"
165 let s = s.trim();
166 if let Some(stripped) = s.strip_suffix('s') {
167 let secs: u64 = stripped.parse().context("Invalid duration format")?;
168 Ok(Duration::from_secs(secs))
169 } else if let Some(stripped) = s.strip_suffix('m') {
170 let mins: u64 = stripped.parse().context("Invalid duration format")?;
171 Ok(Duration::from_secs(mins * 60))
172 } else if let Some(stripped) = s.strip_suffix('h') {
173 let hours: u64 = stripped.parse().context("Invalid duration format")?;
174 Ok(Duration::from_secs(hours * 3600))
175 } else {
176 // Try parsing as seconds
177 let secs: u64 = s.parse().context("Invalid duration format")?;
178 Ok(Duration::from_secs(secs))
179 }
180}