1use serde_json::{json, Value};
12
13use crate::api::{api_err, json_resp, read_json_body, unauthorised, JSON_BODY_CAP};
14use crate::error::ForgeError;
15use crate::hosted::HostedStore;
16use crate::ownership::ForgeAgent;
17use crate::request::{ForgeRequest, ForgeResponse};
18use crate::ForgeService;
19use solid_pod_rs::did_nostr_types::is_valid_hex_pubkey;
20
21pub const HOSTED_BODY_CAP: usize = 64 * 1024;
23
24#[must_use]
35pub fn iso8601(secs: u64) -> String {
36 let days = (secs / 86_400) as i64;
37 let rem = secs % 86_400;
38 let z = days + 719_468;
40 let era = z.div_euclid(146_097);
41 let doe = z.rem_euclid(146_097);
42 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
43 let y = yoe + era * 400;
44 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
45 let mp = (5 * doy + 2) / 153;
46 let d = doy - (153 * mp + 2) / 5 + 1;
47 let m = if mp < 10 { mp + 3 } else { mp - 9 };
48 let y = if m <= 2 { y + 1 } else { y };
49 format!(
50 "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.000Z",
51 rem / 3600,
52 (rem % 3600) / 60,
53 rem % 60
54 )
55}
56
57#[must_use]
60#[allow(clippy::too_many_arguments)]
61pub fn hosted_doc(
62 kind: &str,
63 owner: &str,
64 repo: &str,
65 issue: Option<u64>,
66 title: Option<&str>,
67 body: &str,
68 author: &str,
69 now: u64,
70) -> Value {
71 let mut doc = json!({
72 "type": kind,
73 "repo": format!("{owner}/{repo}"),
74 "body": body,
75 "published": iso8601(now),
76 "author": author,
77 "hosted": true,
78 });
79 if let Some(n) = issue {
80 doc["issue"] = json!(n);
81 }
82 if let Some(t) = title {
83 doc["title"] = json!(t);
84 }
85 doc
86}
87
88impl ForgeService {
89 pub(crate) fn hosted_public_url(&self, req: &ForgeRequest, rref: &str) -> String {
92 match HostedStore::parse_ref(rref) {
93 Some((hex, id)) if rref.starts_with(crate::hosted::REF_SCHEME) => format!(
94 "{}{}/api/hosted/{hex}/{id}",
95 self.origin_of(req),
96 self.prefix()
97 ),
98 _ => rref.to_string(),
99 }
100 }
101
102 pub(crate) async fn h_api_hosted(
104 &self,
105 req: &ForgeRequest,
106 agent: &ForgeAgent,
107 hex: &str,
108 id: &str,
109 ) -> Result<ForgeResponse, ForgeError> {
110 if !is_valid_hex_pubkey(hex)
111 || HostedStore::parse_ref(&format!("hosted:{hex}/{id}")).is_none()
112 {
113 return Ok(api_err(404, "not found"));
114 }
115 let m = req.method.to_ascii_uppercase();
116 let is_author = matches!(agent, ForgeAgent::Nostr { pubkey_hex } if pubkey_hex == hex);
117 match m.as_str() {
118 "GET" | "HEAD" => {
119 if self.cfg.private_repos {
120 if matches!(agent, ForgeAgent::Anonymous) {
121 return Ok(unauthorised());
122 }
123 if !is_author {
127 return Ok(api_err(403, "private forge"));
128 }
129 }
130 match self
131 .hosted
132 .read(hex, id, self.cfg.max_body_bytes.max(HOSTED_BODY_CAP))
133 .await?
134 {
135 Some(bytes) => Ok(ForgeResponse {
136 status: 200,
137 headers: vec![
138 (
139 "content-type".into(),
140 "application/json; charset=utf-8".into(),
141 ),
142 ("x-content-type-options".into(), "nosniff".into()),
143 ("access-control-allow-origin".into(), "*".into()),
144 ],
145 body: bytes::Bytes::from(bytes),
146 }),
147 None => Ok(api_err(404, "not found")),
148 }
149 }
150 "PUT" | "PATCH" => {
151 let incoming = if req.raw_body.len() <= JSON_BODY_CAP {
152 read_json_body(req)
153 } else {
154 None
155 };
156 if matches!(agent, ForgeAgent::Anonymous) {
157 return Ok(unauthorised());
158 }
159 if !is_author {
160 return Ok(api_err(403, "only the author may edit hosted content"));
161 }
162 let Some(existing) = self.hosted.read(hex, id, JSON_BODY_CAP).await? else {
163 return Ok(api_err(404, "not found"));
164 };
165 let Some(body) = incoming
166 .as_ref()
167 .and_then(|m| m.get("body"))
168 .and_then(Value::as_str)
169 else {
170 return Ok(api_err(400, "body (string) required"));
171 };
172 if body.len() > HOSTED_BODY_CAP {
173 return Ok(api_err(413, "body too large"));
174 }
175 let mut doc = match serde_json::from_slice::<Value>(&existing) {
179 Ok(v @ Value::Object(_)) => v,
180 _ => hosted_doc(
181 "ForgeComment",
182 "",
183 "",
184 None,
185 None,
186 "",
187 &agent.author_id(),
188 crate::now_secs(),
189 ),
190 };
191 doc["body"] = json!(body);
192 doc["edited"] = json!(iso8601(crate::now_secs()));
193 self.hosted
194 .replace(hex, id, &serde_json::to_vec(&doc)?)
195 .await?;
196 tracing::info!("forge: hosted content {hex}/{id} edited by its author");
197 Ok(json_resp(200, &json!({"edited": true})))
198 }
199 "DELETE" => {
200 if matches!(agent, ForgeAgent::Anonymous) {
201 return Ok(unauthorised());
202 }
203 if !is_author {
204 return Ok(api_err(403, "only the author may delete hosted content"));
205 }
206 if !self.hosted.delete(hex, id).await? {
207 return Ok(api_err(404, "not found"));
208 }
209 tracing::info!("forge: hosted content {hex}/{id} deleted by its author");
210 Ok(json_resp(200, &json!({"deleted": true})))
211 }
212 _ => Ok(api_err(405, "method not allowed")),
213 }
214 }
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220 use crate::api::tests::rq;
221 use crate::ForgeConfig;
222
223 const HEX_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
224 const HEX_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
225
226 fn nostr(h: &str) -> ForgeAgent {
227 ForgeAgent::Nostr {
228 pubkey_hex: h.into(),
229 }
230 }
231
232 async fn seed(svc: &ForgeService) -> (String, String) {
233 let rref = svc
234 .hosted
235 .write_doc(
236 HEX_A,
237 &hosted_doc(
238 "ForgeIssue",
239 "a",
240 "nrepo",
241 Some(1),
242 Some("t"),
243 "signed with **schnorr**",
244 &format!("did:nostr:{HEX_A}"),
245 10,
246 ),
247 )
248 .await
249 .unwrap();
250 HostedStore::parse_ref(&rref).unwrap()
251 }
252
253 async fn call(
254 svc: &ForgeService,
255 m: &str,
256 hex: &str,
257 id: &str,
258 body: &str,
259 a: ForgeAgent,
260 ) -> ForgeResponse {
261 svc.handle(rq(m, &format!("/forge/api/hosted/{hex}/{id}"), "", body), a)
262 .await
263 .unwrap()
264 }
265
266 fn svc() -> (tempfile::TempDir, ForgeService) {
267 let td = tempfile::TempDir::new().unwrap();
268 let s = ForgeService::new(ForgeConfig::default(), td.path()).unwrap();
269 (td, s)
270 }
271
272 #[tokio::test]
274 async fn other_agents_cannot_edit() {
275 let (_td, s) = svc();
276 let (h, id) = seed(&s).await;
277 let r = call(
278 &s,
279 "PUT",
280 &h,
281 &id,
282 r#"{"body":"not my words"}"#,
283 nostr(HEX_B),
284 )
285 .await;
286 assert_eq!(r.status, 403);
287 let r = call(
288 &s,
289 "PATCH",
290 &h,
291 &id,
292 r#"{"body":"x"}"#,
293 ForgeAgent::Anonymous,
294 )
295 .await;
296 assert_eq!(r.status, 401);
297 let pod = ForgeAgent::Pod {
299 webid: "https://p/x#me".into(),
300 username: HEX_A.into(),
301 };
302 assert_eq!(
303 call(&s, "PATCH", &h, &id, r#"{"body":"x"}"#, pod)
304 .await
305 .status,
306 403
307 );
308 let doc: Value = serde_json::from_slice(
309 &call(&s, "GET", &h, &id, "", ForgeAgent::Anonymous)
310 .await
311 .body,
312 )
313 .unwrap();
314 assert!(
315 doc["body"].as_str().unwrap().contains("**schnorr**"),
316 "original untouched"
317 );
318 }
319
320 #[tokio::test]
322 async fn author_edit_changes_only_body() {
323 let (_td, s) = svc();
324 let (h, id) = seed(&s).await;
325 for m in ["PUT", "PATCH"] {
326 let body = json!({"body": format!("edited via {m}"), "author": format!("did:nostr:{HEX_B}"), "hosted": false}).to_string();
327 let r = call(&s, m, &h, &id, &body, nostr(HEX_A)).await;
328 assert_eq!(r.status, 200);
329 assert_eq!(
330 serde_json::from_slice::<Value>(&r.body).unwrap()["edited"],
331 true
332 );
333 let doc: Value = serde_json::from_slice(
334 &call(&s, "GET", &h, &id, "", ForgeAgent::Anonymous)
335 .await
336 .body,
337 )
338 .unwrap();
339 assert_eq!(doc["body"], format!("edited via {m}"));
340 assert_eq!(
341 doc["author"],
342 format!("did:nostr:{HEX_A}"),
343 "author NOT rewritten"
344 );
345 assert_eq!(doc["hosted"], true, "hosted flag NOT rewritten");
346 assert!(doc["edited"].as_str().unwrap().ends_with('Z'));
347 }
348 assert_eq!(
350 call(&s, "PUT", &h, &id, r#"{"nope":1}"#, nostr(HEX_A))
351 .await
352 .status,
353 400
354 );
355 assert_eq!(
356 call(&s, "PUT", &h, &id, "junk", nostr(HEX_A)).await.status,
357 400
358 );
359 let big = json!({"body": "x".repeat(HOSTED_BODY_CAP + 1)}).to_string();
360 assert_eq!(
361 call(&s, "PUT", &h, &id, &big, nostr(HEX_A)).await.status,
362 413
363 );
364 let missing = "11111111-1111-4111-8111-111111111111";
365 assert_eq!(
366 call(&s, "PUT", &h, missing, r#"{"body":"x"}"#, nostr(HEX_A))
367 .await
368 .status,
369 404
370 );
371 }
372
373 #[tokio::test]
374 async fn legacy_raw_bodies_are_upgraded_on_edit() {
375 let (_td, s) = svc();
376 let rref = s.hosted.write(HEX_A, b"legacy words").await.unwrap();
377 let (h, id) = HostedStore::parse_ref(&rref).unwrap();
378 let r = call(
379 &s,
380 "PATCH",
381 &h,
382 &id,
383 r#"{"body":"new words"}"#,
384 nostr(HEX_A),
385 )
386 .await;
387 assert_eq!(r.status, 200);
388 let doc: Value = serde_json::from_slice(
389 &call(&s, "GET", &h, &id, "", ForgeAgent::Anonymous)
390 .await
391 .body,
392 )
393 .unwrap();
394 assert_eq!(doc["body"], "new words");
395 assert_eq!(doc["author"], format!("did:nostr:{HEX_A}"));
396 }
397
398 #[tokio::test]
400 async fn delete_is_author_only_and_returns_200_json() {
401 let (_td, s) = svc();
402 let (h, id) = seed(&s).await;
403 assert_eq!(
404 call(&s, "DELETE", &h, &id, "", nostr(HEX_B)).await.status,
405 403
406 );
407 assert_eq!(
408 call(&s, "DELETE", &h, &id, "", ForgeAgent::Anonymous)
409 .await
410 .status,
411 401
412 );
413 assert_eq!(
414 call(&s, "GET", &h, &id, "", ForgeAgent::Anonymous)
415 .await
416 .status,
417 200,
418 "still there"
419 );
420 let r = call(&s, "DELETE", &h, &id, "", nostr(HEX_A)).await;
421 assert_eq!(r.status, 200);
422 assert_eq!(
423 serde_json::from_slice::<Value>(&r.body).unwrap()["deleted"],
424 true
425 );
426 assert_eq!(
427 call(&s, "GET", &h, &id, "", ForgeAgent::Anonymous)
428 .await
429 .status,
430 404,
431 "the words are gone"
432 );
433 assert_eq!(
434 call(&s, "DELETE", &h, &id, "", nostr(HEX_A)).await.status,
435 404
436 );
437 }
438
439 #[tokio::test]
440 async fn bad_segments_and_methods() {
441 let (_td, s) = svc();
442 assert_eq!(
443 call(&s, "GET", "short", "x", "", ForgeAgent::Anonymous)
444 .await
445 .status,
446 404
447 );
448 assert_eq!(
449 call(&s, "GET", HEX_A, "not a uuid!", "", ForgeAgent::Anonymous)
450 .await
451 .status,
452 404
453 );
454 let (h, id) = seed(&s).await;
455 assert_eq!(
456 call(&s, "POST", &h, &id, "", nostr(HEX_A)).await.status,
457 405
458 );
459 }
460
461 #[tokio::test]
462 async fn private_forge_hosted_get_needs_an_agent() {
463 let td = tempfile::TempDir::new().unwrap();
464 let cfg = ForgeConfig {
465 private_repos: true,
466 ..Default::default()
467 };
468 let s = ForgeService::new(cfg, td.path()).unwrap();
469 let (h, id) = seed(&s).await;
470 assert_eq!(
471 call(&s, "GET", &h, &id, "", ForgeAgent::Anonymous)
472 .await
473 .status,
474 401
475 );
476 assert_eq!(call(&s, "GET", &h, &id, "", nostr(HEX_B)).await.status, 403);
479 assert_eq!(call(&s, "GET", &h, &id, "", nostr(HEX_A)).await.status, 200);
480 }
481
482 #[test]
483 fn public_url_uses_base_url_override() {
484 let td = tempfile::TempDir::new().unwrap();
485 let cfg = ForgeConfig {
486 base_url: Some("https://pub.example".into()),
487 ..Default::default()
488 };
489 let s = ForgeService::new(cfg, td.path()).unwrap();
490 let id = "0b9f3f0e-1111-4222-8333-444455556666";
491 let r = rq("GET", "/forge/x", "", "");
492 assert_eq!(
493 s.hosted_public_url(&r, &format!("hosted:{HEX_A}/{id}")),
494 format!("https://pub.example/forge/api/hosted/{HEX_A}/{id}")
495 );
496 assert_eq!(
497 s.hosted_public_url(&r, "https://pod/x.jsonld"),
498 "https://pod/x.jsonld"
499 );
500 }
501}