1use std::path::PathBuf;
2
3use serde::{Deserialize, Serialize};
4
5use super::{files::write_text_file_atomic, hypha_dir, HyphaConfig};
6
7#[derive(Debug, Clone, Deserialize, Serialize)]
8pub struct SynapseNode {
9 pub url: String,
10 #[serde(skip_serializing_if = "Option::is_none")]
11 pub token_secret: Option<String>,
12}
13
14pub struct ResolvedSynapse {
16 pub url: String,
17 pub token_secret: Option<String>,
18}
19
20fn validate_synapse_domain(domain: &str) -> Result<(), crate::sink::HyphaError> {
21 use crate::sink::HyphaError;
22
23 if domain.is_empty() {
24 return Err(HyphaError::new(
25 "invalid_synapse_domain",
26 "Synapse domain must not be empty",
27 ));
28 }
29 if domain.chars().any(|c| c.is_control()) {
30 return Err(HyphaError::new(
31 "invalid_synapse_domain",
32 format!(
33 "Invalid synapse domain '{}': contains control characters",
34 domain
35 ),
36 ));
37 }
38
39 let mut components = std::path::Path::new(domain).components();
40 let single_normal_component =
41 matches!(components.next(), Some(std::path::Component::Normal(_)))
42 && components.next().is_none();
43 if !single_normal_component {
44 return Err(HyphaError::new(
45 "invalid_synapse_domain",
46 format!(
47 "Invalid synapse domain '{}': must be a single path segment",
48 domain
49 ),
50 ));
51 }
52
53 Ok(())
54}
55
56pub fn synapse_node_dir(domain: &str) -> PathBuf {
58 hypha_dir().join("synapse").join(domain)
59}
60
61pub fn load_synapse_node(domain: &str) -> Option<SynapseNode> {
63 if validate_synapse_domain(domain).is_err() {
64 return None;
65 }
66 let path = synapse_node_dir(domain).join("config.toml");
67 let content = std::fs::read_to_string(&path).ok()?;
68 toml::from_str(&content).ok()
69}
70
71pub fn save_synapse_node(domain: &str, node: &SynapseNode) -> Result<(), crate::sink::HyphaError> {
73 use crate::sink::HyphaError;
74
75 validate_synapse_domain(domain)?;
76 let dir = synapse_node_dir(domain);
77 std::fs::create_dir_all(&dir).map_err(|e| {
78 HyphaError::new(
79 "synapse_node_save_failed",
80 format!("Failed to create synapse node directory: {}", e),
81 )
82 })?;
83 #[cfg(unix)]
84 {
85 use std::os::unix::fs::PermissionsExt;
86 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).map_err(|e| {
87 HyphaError::new(
88 "synapse_node_save_failed",
89 format!("Failed to protect synapse node directory: {}", e),
90 )
91 })?;
92 }
93
94 let content = toml::to_string_pretty(node).map_err(|e| {
95 HyphaError::new(
96 "synapse_node_save_failed",
97 format!("Failed to serialize node config: {}", e),
98 )
99 })?;
100 let path = dir.join("config.toml");
101 write_text_file_atomic(
102 &path,
103 &content,
104 0o600,
105 "synapse_node_save_failed",
106 "synapse node config",
107 )
108}
109
110pub fn remove_synapse_node(domain: &str) -> Result<(), crate::sink::HyphaError> {
112 use crate::sink::HyphaError;
113
114 validate_synapse_domain(domain)?;
115 let dir = synapse_node_dir(domain);
116 if dir.exists() {
117 std::fs::remove_dir_all(&dir).map_err(|e| {
118 HyphaError::new(
119 "synapse_node_remove_failed",
120 format!("Failed to remove synapse node directory: {}", e),
121 )
122 })?;
123 }
124 Ok(())
125}
126
127pub fn list_synapse_domains() -> Vec<String> {
129 let synapse_dir = hypha_dir().join("synapse");
130 let entries = match std::fs::read_dir(&synapse_dir) {
131 Ok(e) => e,
132 Err(_) => return Vec::new(),
133 };
134
135 let mut domains: Vec<String> = entries
136 .filter_map(|e| e.ok())
137 .filter(|e| e.path().join("config.toml").exists())
138 .filter_map(|e| e.file_name().into_string().ok())
139 .collect();
140 domains.sort();
141 domains
142}
143
144pub fn domain_from_url(url: &str) -> Result<String, crate::sink::HyphaError> {
146 use crate::sink::HyphaError;
147
148 let parsed = reqwest::Url::parse(url)
149 .map_err(|e| HyphaError::new("invalid_url", format!("Invalid URL '{}': {}", url, e)))?;
150 let domain = parsed
151 .host_str()
152 .map(|h| h.to_string())
153 .ok_or_else(|| HyphaError::new("invalid_url", format!("URL '{}' has no host", url)))?;
154 validate_synapse_domain(&domain)?;
155 Ok(domain)
156}
157
158fn is_anonymous_transport_host(host: &str) -> bool {
159 let host_lc = host.to_ascii_lowercase();
160 host_lc.ends_with(".onion") || host_lc.ends_with(".i2p")
161}
162
163fn is_ip_literal_host(host: &str) -> bool {
164 let host = host
165 .strip_prefix('[')
166 .and_then(|h| h.strip_suffix(']'))
167 .unwrap_or(host);
168 host.parse::<std::net::IpAddr>().is_ok()
169}
170
171pub fn validate_synapse_url(url: &str) -> Result<(), crate::sink::HyphaError> {
177 use crate::sink::HyphaError;
178
179 let parsed = reqwest::Url::parse(url).map_err(|e| {
180 HyphaError::new(
181 "invalid_synapse_url",
182 format!("Invalid synapse URL '{}': {}", url, e),
183 )
184 })?;
185 let host = parsed.host_str().ok_or_else(|| {
186 HyphaError::new(
187 "invalid_synapse_url",
188 format!("Invalid synapse URL '{}': missing host", url),
189 )
190 })?;
191
192 if is_ip_literal_host(host) {
193 return Err(HyphaError::new(
194 "invalid_synapse_url",
195 format!(
196 "IP literal hosts are rejected for synapse URL '{}'; use a domain name",
197 url
198 ),
199 ));
200 }
201
202 match parsed.scheme() {
203 "https" => {}
204 "http" if is_anonymous_transport_host(host) => {}
205 "http" => {
206 return Err(HyphaError::new(
207 "invalid_synapse_url",
208 format!(
209 "Insecure cleartext transport rejected for synapse URL '{}'; use https, or http only for .onion/.i2p",
210 url
211 ),
212 ));
213 }
214 "ws" | "ftp" => {
215 return Err(HyphaError::new(
216 "invalid_synapse_url",
217 format!(
218 "Insecure cleartext scheme '{}' rejected for synapse URL '{}'",
219 parsed.scheme(),
220 url
221 ),
222 ));
223 }
224 _ => {}
225 }
226
227 Ok(())
228}
229
230pub fn resolve_synapse(
232 value: Option<&str>,
233 token_override: Option<&str>,
234) -> Result<ResolvedSynapse, crate::sink::HyphaError> {
235 use crate::sink::HyphaError;
236
237 let mut resolved = match value {
238 Some(v) if reqwest::Url::parse(v).is_ok() => {
239 validate_synapse_url(v)?;
240 let domain = domain_from_url(v)?;
241 let node = load_synapse_node(&domain);
242 ResolvedSynapse {
243 url: v.to_string(),
244 token_secret: node.and_then(|n| n.token_secret),
245 }
246 }
247 Some(domain) => {
248 validate_synapse_domain(domain)?;
249 match load_synapse_node(domain) {
250 Some(node) => {
251 validate_synapse_url(&node.url)?;
252 ResolvedSynapse {
253 url: node.url,
254 token_secret: node.token_secret,
255 }
256 }
257 None => {
258 return Err(HyphaError::with_hint(
259 "synapse_not_found",
260 format!("Synapse '{}' not found", domain),
261 "run: hypha synapse add <url>",
262 ));
263 }
264 }
265 }
266 None => {
267 let config = HyphaConfig::load()?;
268 match &config.defaults.synapse {
269 Some(default_domain) => match load_synapse_node(default_domain) {
270 Some(node) => {
271 validate_synapse_url(&node.url)?;
272 ResolvedSynapse {
273 url: node.url,
274 token_secret: node.token_secret,
275 }
276 }
277 None => {
278 return Err(HyphaError::with_hint(
279 "synapse_not_found",
280 format!("Default synapse '{}' not found", default_domain),
281 "run: hypha synapse add <url>",
282 ));
283 }
284 },
285 None => {
286 return Err(HyphaError::with_hint(
287 "synapse_not_configured",
288 "No synapse specified and no default configured",
289 "use -s <url> or run: hypha synapse add <url> && hypha synapse use <domain>",
290 ));
291 }
292 }
293 }
294 };
295
296 if let Ok(ts) = std::env::var("SYNAPSE_TOKEN_SECRET") {
297 resolved.token_secret = if ts.is_empty() { None } else { Some(ts) };
298 }
299
300 if let Some(ts) = token_override {
301 resolved.token_secret = if ts.is_empty() {
302 None
303 } else {
304 Some(ts.to_string())
305 };
306 }
307
308 Ok(resolved)
309}