1use std::sync::Arc;
21
22use async_trait::async_trait;
23use tokio::sync::Semaphore;
24use tokio::task::JoinSet;
25
26use crate::config::ForgeConfig;
27use crate::error::ForgeError;
28use crate::request::looks_textual;
29use crate::spine::issues::ThreadPointer;
30
31#[derive(Debug, Clone)]
33pub enum FetchResult {
34 Body(Vec<u8>),
36 Removed,
38 TooLarge,
40 Error(String),
42}
43
44#[async_trait]
48pub trait LoopbackFetch: Send + Sync {
49 async fn get(&self, url: &str, max_bytes: usize, timeout_secs: u64) -> FetchResult;
52}
53
54#[async_trait]
58pub trait HostedReader: Send + Sync {
59 async fn read_hosted(&self, resource_ref: &str, max_bytes: usize) -> FetchResult;
61}
62
63#[must_use]
68pub fn forge_area_prefix(host: &str, caller: &str, repo_owner: &str, repo: &str) -> String {
69 let host = host.trim_end_matches('/');
70 format!("{host}/{caller}/public/forge/{repo_owner}--{repo}/")
71}
72
73pub fn own_area_ok(
79 resource_url: &str,
80 host: &str,
81 caller: &str,
82 repo_owner: &str,
83 repo: &str,
84) -> Result<(), ForgeError> {
85 let prefix = forge_area_prefix(host, caller, repo_owner, repo);
86 if !resource_url.starts_with(&prefix) {
87 return Err(ForgeError::Forbidden(format!(
88 "resourceUrl must be under {prefix}"
89 )));
90 }
91 if resource_url.contains("..") {
92 return Err(ForgeError::PathTraversal(resource_url.to_string()));
93 }
94 if !(resource_url.ends_with(".jsonld") || resource_url.ends_with(".json")) {
96 return Err(ForgeError::BadRequest(
97 "resourceUrl must be a .jsonld/.json body".into(),
98 ));
99 }
100 Ok(())
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum BodyOutcome {
106 Present(String),
108 Removed,
110 TooLarge,
112 Unavailable(String),
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct RenderedThread {
119 pub author: String,
121 pub at: u64,
123 pub body: BodyOutcome,
125}
126
127fn to_outcome(res: FetchResult) -> BodyOutcome {
128 match res {
129 FetchResult::Body(bytes) => {
130 if looks_textual(&bytes) {
131 BodyOutcome::Present(String::from_utf8_lossy(&bytes).into_owned())
132 } else {
133 BodyOutcome::Unavailable("non-textual body".into())
134 }
135 }
136 FetchResult::Removed => BodyOutcome::Removed,
137 FetchResult::TooLarge => BodyOutcome::TooLarge,
138 FetchResult::Error(e) => BodyOutcome::Unavailable(e),
139 }
140}
141
142async fn resolve_one(
143 p: &ThreadPointer,
144 loopback: Option<&Arc<dyn LoopbackFetch>>,
145 hosted: Option<&Arc<dyn HostedReader>>,
146 max_bytes: usize,
147 timeout_secs: u64,
148) -> BodyOutcome {
149 if p.hosted {
150 match hosted {
151 Some(h) => to_outcome(h.read_hosted(&p.resource_url, max_bytes).await),
152 None => BodyOutcome::Unavailable("hosted store unavailable".into()),
153 }
154 } else {
155 match loopback {
156 Some(lb) => to_outcome(lb.get(&p.resource_url, max_bytes, timeout_secs).await),
157 None => BodyOutcome::Unavailable("loopback fetch unavailable".into()),
158 }
159 }
160}
161
162pub async fn render_thread(
166 pointers: &[ThreadPointer],
167 loopback: Option<Arc<dyn LoopbackFetch>>,
168 hosted: Option<Arc<dyn HostedReader>>,
169 cfg: &ForgeConfig,
170) -> Vec<RenderedThread> {
171 let n = pointers.len().min(cfg.thread_cap);
172 if n == 0 {
173 return Vec::new();
174 }
175 let sem = Arc::new(Semaphore::new(cfg.fetch_concurrency.max(1)));
176 let mut set: JoinSet<(usize, RenderedThread)> = JoinSet::new();
177
178 for (i, p) in pointers.iter().take(n).enumerate() {
179 let sem = sem.clone();
180 let p = p.clone();
181 let loopback = loopback.clone();
182 let hosted = hosted.clone();
183 let max = cfg.max_body_bytes;
184 let to = cfg.fetch_timeout_secs;
185 set.spawn(async move {
186 let _permit = sem.acquire_owned().await.ok();
188 let outcome = resolve_one(&p, loopback.as_ref(), hosted.as_ref(), max, to).await;
189 (
190 i,
191 RenderedThread {
192 author: p.author.clone(),
193 at: p.at,
194 body: outcome,
195 },
196 )
197 });
198 }
199
200 let mut slots: Vec<Option<RenderedThread>> = (0..n).map(|_| None).collect();
201 while let Some(joined) = set.join_next().await {
202 if let Ok((i, rt)) = joined {
203 slots[i] = Some(rt);
204 }
205 }
206 slots.into_iter().flatten().collect()
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212 use std::collections::HashMap;
213 use std::sync::Mutex;
214
215 #[test]
216 fn own_area_accepts_valid_and_rejects_others() {
217 let host = "https://pod.example";
218 let ok = "https://pod.example/alice/public/forge/alice--demo/issue-1.jsonld";
220 assert!(own_area_ok(ok, host, "alice", "alice", "demo").is_ok());
221
222 let comment = "https://pod.example/bob/public/forge/alice--demo/comment-1.jsonld";
225 assert!(own_area_ok(comment, host, "bob", "alice", "demo").is_ok());
226
227 assert!(matches!(
229 own_area_ok(comment, host, "alice", "alice", "demo"),
230 Err(ForgeError::Forbidden(_))
231 ));
232
233 let ssrf = "http://169.254.169.254/alice/public/forge/alice--demo/x.jsonld";
235 assert!(own_area_ok(ssrf, host, "alice", "alice", "demo").is_err());
236
237 let trav = "https://pod.example/alice/public/forge/alice--demo/../../secret.jsonld";
239 assert!(matches!(
240 own_area_ok(trav, host, "alice", "alice", "demo"),
241 Err(ForgeError::PathTraversal(_))
242 ));
243
244 let notbody = "https://pod.example/alice/public/forge/alice--demo/index.html";
246 assert!(matches!(
247 own_area_ok(notbody, host, "alice", "alice", "demo"),
248 Err(ForgeError::BadRequest(_))
249 ));
250 }
251
252 struct MockLoopback {
254 map: Mutex<HashMap<String, FetchResult>>,
255 }
256 impl MockLoopback {
257 fn new(pairs: &[(&str, FetchResult)]) -> Self {
258 let mut m = HashMap::new();
259 for (k, v) in pairs {
260 m.insert((*k).to_string(), v.clone());
261 }
262 Self { map: Mutex::new(m) }
263 }
264 }
265 #[async_trait]
266 impl LoopbackFetch for MockLoopback {
267 async fn get(&self, url: &str, _max: usize, _to: u64) -> FetchResult {
268 self.map
269 .lock()
270 .unwrap()
271 .get(url)
272 .cloned()
273 .unwrap_or(FetchResult::Removed)
274 }
275 }
276
277 fn ptr(url: &str) -> ThreadPointer {
278 ThreadPointer {
279 author: "did:nostr:abc".into(),
280 resource_url: url.into(),
281 at: 10,
282 hosted: false,
283 }
284 }
285
286 #[tokio::test]
287 async fn render_thread_fetches_present_and_removed_in_order() {
288 let lb: Arc<dyn LoopbackFetch> = Arc::new(MockLoopback::new(&[
289 ("u1", FetchResult::Body(b"first body".to_vec())),
290 ("u3", FetchResult::Body(b"third body".to_vec())),
291 ]));
293 let cfg = ForgeConfig::default();
294 let pointers = vec![ptr("u1"), ptr("u2"), ptr("u3")];
295 let out = render_thread(&pointers, Some(lb), None, &cfg).await;
296 assert_eq!(out.len(), 3);
297 assert_eq!(out[0].body, BodyOutcome::Present("first body".into()));
298 assert_eq!(out[1].body, BodyOutcome::Removed);
299 assert_eq!(out[2].body, BodyOutcome::Present("third body".into()));
300 }
301
302 #[tokio::test]
303 async fn render_thread_without_resolver_is_unavailable() {
304 let cfg = ForgeConfig::default();
305 let out = render_thread(&[ptr("u1")], None, None, &cfg).await;
306 assert!(matches!(out[0].body, BodyOutcome::Unavailable(_)));
307 }
308
309 #[tokio::test]
310 async fn render_thread_honours_thread_cap() {
311 let lb: Arc<dyn LoopbackFetch> = Arc::new(MockLoopback::new(&[(
312 "u",
313 FetchResult::Body(b"x".to_vec()),
314 )]));
315 let cfg = ForgeConfig {
316 thread_cap: 2,
317 ..Default::default()
318 };
319 let pointers = vec![ptr("u"), ptr("u"), ptr("u"), ptr("u")];
320 let out = render_thread(&pointers, Some(lb), None, &cfg).await;
321 assert_eq!(out.len(), 2, "must cap at thread_cap");
322 }
323
324 #[tokio::test]
325 async fn too_large_and_error_map_through() {
326 let lb: Arc<dyn LoopbackFetch> = Arc::new(MockLoopback::new(&[
327 ("big", FetchResult::TooLarge),
328 ("err", FetchResult::Error("boom".into())),
329 ]));
330 let cfg = ForgeConfig::default();
331 let out = render_thread(&[ptr("big"), ptr("err")], Some(lb), None, &cfg).await;
332 assert_eq!(out[0].body, BodyOutcome::TooLarge);
333 assert_eq!(out[1].body, BodyOutcome::Unavailable("boom".into()));
334 }
335}