Skip to main content

solid_pod_rs_forge/
hosted_edit.rs

1//! `<prefix>/api/hosted/<hex>/<uuid>`: a podless agent's words, hosted by the
2//! forge.
3//!
4//! Follows the JSS `forge` `apiHosted` contract (cited by name only): `GET`
5//! is public (like a public pod resource), `PUT` / `PATCH` (edit) and
6//! `DELETE` (removal) are **author only**, meaning the same `did:nostr`
7//! identity that wrote the body. An edit only ever changes `body` (and stamps
8//! `edited`); every other field of the stored document is kept, so a signed
9//! request cannot rewrite authorship or the `hosted` flag.
10
11use 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
21/// Largest hosted body, in bytes (JSS `ISSUE_BODY_CAP`).
22pub const HOSTED_BODY_CAP: usize = 64 * 1024;
23
24/// `Date#toISOString` for unix seconds (`1970-01-01T00:00:00.000Z`).
25///
26/// # Examples
27///
28/// ```
29/// use solid_pod_rs_forge::hosted_edit::iso8601;
30/// assert_eq!(iso8601(0), "1970-01-01T00:00:00.000Z");
31/// assert_eq!(iso8601(1_700_000_000), "2023-11-14T22:13:20.000Z");
32/// assert_eq!(iso8601(951_782_400), "2000-02-29T00:00:00.000Z");
33/// ```
34#[must_use]
35pub fn iso8601(secs: u64) -> String {
36    let days = (secs / 86_400) as i64;
37    let rem = secs % 86_400;
38    // Civil-from-days (proleptic Gregorian), H. Hinnant's algorithm.
39    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/// The document stored for a hosted issue or comment (JSS `ForgeIssue` /
58/// `ForgeComment`).
59#[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    /// Public URL of a `hosted:` ref: `<origin><prefix>/api/hosted/<hex>/<id>`
90    /// (anything that is not a `hosted:` ref is returned unchanged).
91    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    /// `GET|PUT|PATCH|DELETE /api/hosted/<hex>/<uuid>` (JSS `apiHosted`).
103    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                    // The UUID is not a credential: under `private_repos` only
124                    // the author of a hosted body may read it back over HTTP
125                    // (security review L6). Pages render bodies server-side.
126                    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                // Only `body` (and the `edited` stamp) change. A legacy bare
176                // body (not a JSON object) is upgraded to a document that
177                // records the author from the verified identity.
178                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    // JSS "another agent cannot edit someone else's hosted content (403)"
273    #[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        // a pod agent is not the did:nostr author either
298        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    // JSS "the author edits their hosted content -> only body changes, edited stamp added"
321    #[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        // validation
349        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    // JSS "another agent cannot delete ... (403)" and "the author deletes"
399    #[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        // L6: another authenticated agent holding the UUID is refused; only
477        // the author reads the body back over HTTP.
478        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}