1#![allow(dead_code)]
17
18mod acl;
19mod auth;
20mod conditional;
21mod container;
22mod content_negotiation;
23mod contexts;
24mod did;
25mod notifications;
26mod patch;
27mod payments;
28mod provision;
29mod quota;
30mod remote_storage;
31mod storage;
32mod webid;
33
34use acl::{
35 coerce_required_mode_for_acl, evaluate_access, find_effective_acl, wac_allow_header, AccessMode,
36};
37use base64::Engine as _;
38use worker::*;
39
40const MAX_BODY_SIZE: u64 = 50 * 1024 * 1024;
42
43fn parse_pod_route(path: &str) -> Option<(&str, &str)> {
46 let rest = path.strip_prefix("/pods/")?;
47 if rest.len() < 64 {
48 return None;
49 }
50 let (pubkey, remainder) = rest.split_at(64);
51 if !pubkey.bytes().all(|b| b.is_ascii_hexdigit()) {
53 return None;
54 }
55 if !remainder.is_empty() && !remainder.starts_with('/') {
57 return None;
58 }
59 let resource_path = if remainder.is_empty() { "/" } else { remainder };
60 Some((pubkey, resource_path))
61}
62
63fn is_acl_path(path: &str) -> bool {
65 path.ends_with(".acl")
66}
67
68fn is_provision_path(path: &str) -> bool {
70 path == "/.provision"
71}
72
73fn method_str(m: &Method) -> &'static str {
75 match m {
76 Method::Get => "GET",
77 Method::Head => "HEAD",
78 Method::Post => "POST",
79 Method::Put => "PUT",
80 Method::Delete => "DELETE",
81 Method::Options => "OPTIONS",
82 Method::Patch => "PATCH",
83 Method::Connect => "CONNECT",
84 Method::Trace => "TRACE",
85 _ => "GET",
86 }
87}
88
89fn cors_headers(env: &Env) -> Headers {
91 let origin = env
92 .var("EXPECTED_ORIGIN")
93 .map(|v| v.to_string())
94 .unwrap_or_else(|_| "https://example.com".to_string());
95
96 let headers = Headers::new();
97 headers.set("Access-Control-Allow-Origin", &origin).ok();
98 headers
99 .set(
100 "Access-Control-Allow-Methods",
101 "GET, PUT, POST, DELETE, PATCH, HEAD, OPTIONS",
102 )
103 .ok();
104 headers
105 .set(
106 "Access-Control-Allow-Headers",
107 "Content-Type, Authorization, Slug, If-Match, If-None-Match, Range",
108 )
109 .ok();
110 headers.set("Access-Control-Max-Age", "86400").ok();
111 headers
112 .set(
113 "Access-Control-Expose-Headers",
114 "ETag, Accept-Ranges, Content-Range, Link, Location, WAC-Allow",
115 )
116 .ok();
117 headers
118}
119
120fn add_ldp_headers(headers: &Headers, is_container: bool, resource_path: &str) {
124 let mut link_parts = Vec::new();
125
126 if is_container {
127 link_parts.push("<http://www.w3.org/ns/ldp#BasicContainer>; rel=\"type\"".to_string());
128 link_parts.push("<http://www.w3.org/ns/ldp#Resource>; rel=\"type\"".to_string());
129 } else {
130 link_parts.push("<http://www.w3.org/ns/ldp#Resource>; rel=\"type\"".to_string());
131 }
132
133 if !is_acl_path(resource_path) {
135 let acl_link = format!("<{resource_path}.acl>; rel=\"acl\"");
136 link_parts.push(acl_link);
137 }
138
139 headers.set("Link", &link_parts.join(", ")).ok();
140 headers.set("Accept-Ranges", "bytes").ok();
141}
142
143fn add_wac_allow(
145 headers: &Headers,
146 acl_doc: Option<&acl::AclDocument>,
147 agent_uri: Option<&str>,
148 resource_path: &str,
149) {
150 let value = wac_allow_header(acl_doc, agent_uri, resource_path);
151 headers.set("WAC-Allow", &value).ok();
152}
153
154fn add_cache_control(headers: &Headers, resource_path: &str) {
161 let value = if resource_path.starts_with("/media/") {
162 "public, max-age=31536000, immutable"
163 } else {
164 "public, max-age=300, must-revalidate"
165 };
166 headers.set("Cache-Control", value).ok();
167}
168
169fn json_error(env: &Env, message: &str, status: u16) -> Result<Response> {
171 let body = serde_json::json!({ "error": message });
172 let json_str = serde_json::to_string(&body).map_err(|e| Error::RustError(e.to_string()))?;
173 let cors = cors_headers(env);
174 let resp = Response::ok(json_str)?
175 .with_status(status)
176 .with_headers(cors);
177 resp.headers().set("Content-Type", "application/json").ok();
178 Ok(resp)
179}
180
181const NIP05_RL_LIMIT: u32 = 60;
188const NIP05_RL_WINDOW_SECS: u64 = 60;
189
190async fn rl_nostr_json(kv: &worker::kv::KvStore, ip: &str) -> bool {
191 let bucket = (js_sys::Date::now() as u64) / (NIP05_RL_WINDOW_SECS * 1000);
192 let key = format!("rl:nostr_json:{ip}:{bucket}");
193
194 let current: u32 = match kv.get(&key).text().await {
195 Ok(Some(val)) => val.parse().unwrap_or(0),
196 _ => 0,
197 };
198 if current >= NIP05_RL_LIMIT {
199 return false;
200 }
201
202 let next = (current + 1).to_string();
203 if let Ok(builder) = kv.put(&key, &next) {
204 let _ = builder.expiration_ttl(NIP05_RL_WINDOW_SECS).execute().await;
205 }
206 true
207}
208
209fn build_did_nostr_document(pubkey_hex: &str, pod_base: &str) -> serde_json::Value {
215 match did::NostrPubkey::from_hex(pubkey_hex) {
216 Ok(pk) => {
217 let pod_url = format!("{pod_base}/pods/{pubkey_hex}/");
218 let webid_url = format!("{pod_url}profile/card#me");
219 did::render_did_document_tier3(
220 &pk,
221 Some(&webid_url),
222 &pod_url,
223 None, None, None, )
227 }
228 Err(_) => serde_json::json!({ "error": "invalid pubkey" }),
229 }
230}
231
232fn json_ok(env: &Env, body: &serde_json::Value, status: u16) -> Result<Response> {
234 let json_str = serde_json::to_string(body).map_err(|e| Error::RustError(e.to_string()))?;
235 let cors = cors_headers(env);
236 let resp = Response::ok(json_str)?
237 .with_status(status)
238 .with_headers(cors);
239 resp.headers().set("Content-Type", "application/json").ok();
240 Ok(resp)
241}
242
243#[event(fetch)]
244async fn fetch(mut req: Request, env: Env, _ctx: Context) -> Result<Response> {
245 nostr_bbs_rate_limit::ensure_replay_schema(&env, "REPLAY_DB").await;
246 payments::ensure_payment_schema(&env, "REPLAY_DB").await;
247
248 if req.method() == Method::Options {
250 return Ok(Response::empty()?
251 .with_status(204)
252 .with_headers(cors_headers(&env)));
253 }
254
255 let url = req.url()?;
256 let path = url.path();
257
258 if path == "/health" {
260 return json_ok(
261 &env,
262 &serde_json::json!({
263 "status": "ok",
264 "service": "pod-api",
265 "runtime": "workers-rs",
266 "version": "6.0.0",
267 "features": [
268 "ldp-containers",
269 "conditional-requests",
270 "quota",
271 "webid",
272 "acl-crud",
273 "pod-provisioning",
274 "wac-allow",
275 "jsonld-native",
276 "content-negotiation",
277 "remote-storage",
278 "solid-notifications",
279 "webfinger",
280 "nip-05",
281 "payments"
282 ]
283 }),
284 200,
285 );
286 }
287
288 if path == "/.well-known/webfinger" {
294 let resource = url
295 .query_pairs()
296 .find(|(k, _)| k == "resource")
297 .map(|(_, v)| v.to_string())
298 .unwrap_or_default();
299 if let Some(pk) = remote_storage::parse_webfinger_resource(&resource) {
300 let host = url.host_str().unwrap_or("example.test");
301 let pod_base = format!("https://{host}");
302 let body = remote_storage::webfinger_response(&pk, host, &pod_base);
303 let json_str =
304 serde_json::to_string(&body).map_err(|e| Error::RustError(e.to_string()))?;
305 let cors = cors_headers(&env);
306 let resp = Response::ok(json_str)?.with_headers(cors);
307 resp.headers()
308 .set("Content-Type", "application/jrd+json")
309 .ok();
310 return Ok(resp);
311 }
312 return json_error(&env, "Invalid resource parameter", 400);
313 }
314
315 if path == "/.well-known/solid" {
317 let host = url.host_str().unwrap_or("example.test");
318 let body = remote_storage::solid_discovery(&format!("https://{host}"));
319 let json_str = serde_json::to_string(&body).map_err(|e| Error::RustError(e.to_string()))?;
320 let cors = cors_headers(&env);
321 let resp = Response::ok(json_str)?.with_headers(cors);
322 resp.headers().set("Content-Type", "application/json").ok();
323 return Ok(resp);
324 }
325
326 if path == "/.well-known/nostr.json" {
328 let kv = env.kv("POD_META")?;
333 let ip = req
334 .headers()
335 .get("CF-Connecting-IP")
336 .ok()
337 .flatten()
338 .unwrap_or_else(|| "unknown".to_string());
339 if !rl_nostr_json(&kv, &ip).await {
340 let cors = cors_headers(&env);
341 let resp = Response::ok(r#"{"error":"Too many requests"}"#)?
342 .with_status(429)
343 .with_headers(cors);
344 resp.headers().set("Content-Type", "application/json").ok();
345 resp.headers().set("Retry-After", "60").ok();
346 return Ok(resp);
347 }
348
349 let name = url
350 .query_pairs()
351 .find(|(k, _)| k == "name")
352 .map(|(_, v)| v.to_string())
353 .unwrap_or_default();
354 if name.is_empty() {
355 return json_error(&env, "Missing name parameter", 400);
356 }
357 let key = format!("nip05:{name}");
359 let pubkey = kv.get(&key).text().await.ok().flatten();
360 if let Some(pk) = pubkey {
361 let body = remote_storage::nostr_json(&pk, &name);
362 let json_str =
363 serde_json::to_string(&body).map_err(|e| Error::RustError(e.to_string()))?;
364 let cors = cors_headers(&env);
365 let resp = Response::ok(json_str)?.with_headers(cors);
366 resp.headers().set("Content-Type", "application/json").ok();
367 resp.headers().set("Access-Control-Allow-Origin", "*").ok();
368 return Ok(resp);
369 }
370 return json_error(&env, "Name not found", 404);
371 }
372
373 if let Some(rest) = path.strip_prefix("/.well-known/did/nostr/") {
377 if let Some(pk) = rest.strip_suffix(".json") {
378 if pk.len() == 64 && pk.bytes().all(|b| b.is_ascii_hexdigit()) {
380 let host = url.host_str().unwrap_or("example.test");
381 let pod_base = format!("https://{host}");
382 let did_doc = build_did_nostr_document(pk, &pod_base);
383 let json_str =
384 serde_json::to_string(&did_doc).map_err(|e| Error::RustError(e.to_string()))?;
385 let cors = cors_headers(&env);
386 let resp = Response::ok(json_str)?.with_headers(cors);
387 resp.headers()
388 .set("Content-Type", "application/did+json")
389 .ok();
390 return Ok(resp);
391 }
392 return json_error(&env, "Invalid pubkey in DID path", 400);
393 }
394 }
395
396 if path == "/.well-known/webledgers/webledgers.json" {
398 let host = url.host_str().unwrap_or("example.test");
399 let body = payments::webledgers_discovery(&format!("https://{host}"));
400 return json_ok(&env, &body, 200);
401 }
402
403 if path.starts_with("/pay/") {
407 let pay_config = load_pay_config(&env);
408 if pay_config.enabled {
409 let method = req.method();
410 let pay_auth_header = req.headers().get("Authorization").ok().flatten();
411
412 let pay_body: Option<Vec<u8>> = if method == Method::Post {
413 req.bytes().await.ok()
414 } else {
415 None
416 };
417
418 let expected_origin = env
419 .var("EXPECTED_ORIGIN")
420 .map(|v| v.to_string())
421 .unwrap_or_else(|_| "https://example.com".to_string());
422 let request_url = format!("{expected_origin}{path}");
423 let requester_pubkey: Option<String> = if let Some(ref header) = pay_auth_header {
424 let method_name = method_str(&method);
425 let body_ref = pay_body.as_deref();
426 auth::verify_nip98_replay(header, &request_url, method_name, body_ref, &env)
427 .await
428 .ok()
429 .map(|t| t.pubkey)
430 } else {
431 None
432 };
433
434 let pay_db = env
435 .d1("REPLAY_DB")
436 .map_err(|e| Error::RustError(format!("REPLAY_DB D1 binding missing: {e}")))?;
437 if let Some(result) = payments::handle_pay_route(
438 path,
439 &method,
440 requester_pubkey.as_deref(),
441 pay_body.as_deref(),
442 &pay_db,
443 &env,
444 &pay_config,
445 )
446 .await
447 {
448 let resp = result?;
449 resp.headers()
450 .set("Access-Control-Allow-Origin", &expected_origin)
451 .ok();
452 return Ok(resp);
453 }
454 }
455 return json_error(&env, "Not found", 404);
456 }
457
458 let (owner_pubkey, resource_path) = match parse_pod_route(path) {
460 Some(parsed) => parsed,
461 None => return json_error(&env, "Not found", 404),
462 };
463
464 let owner_pubkey = owner_pubkey.to_string();
466 let resource_path = resource_path.to_string();
467 let method = req.method();
468 let req_headers = req.headers().clone();
469 let auth_header = req_headers.get("Authorization").ok().flatten();
470 let slug_header = req_headers.get("Slug").ok().flatten();
471 let accept_header = req_headers.get("Accept").ok().flatten();
472 let content_type = req_headers
473 .get("Content-Type")
474 .ok()
475 .flatten()
476 .unwrap_or_else(|| "application/octet-stream".to_string());
477 let content_length: u64 = req_headers
478 .get("Content-Length")
479 .ok()
480 .flatten()
481 .and_then(|s| s.parse().ok())
482 .unwrap_or(0);
483
484 let body_bytes: Option<Vec<u8>> = match method {
486 Method::Put | Method::Post | Method::Patch => req.bytes().await.ok(),
487 _ => None,
488 };
489
490 let expected_origin = env
492 .var("EXPECTED_ORIGIN")
493 .map(|v| v.to_string())
494 .unwrap_or_else(|_| "https://example.com".to_string());
495 let request_url = format!("{expected_origin}{path}");
496
497 let requester_pubkey: Option<String> = if let Some(ref header) = auth_header {
498 let method_name = method_str(&method);
499 let body_ref = body_bytes.as_deref();
500 match auth::verify_nip98_replay(header, &request_url, method_name, body_ref, &env).await {
501 Ok(token) => {
502 if let Some(webid_uri) = extract_webid_tag_from_header(header) {
506 if !did::verify_webid_tag(&webid_uri, &token.pubkey) {
507 return json_error(&env, "NIP-98 webid tag identity mismatch", 401);
508 }
509 }
510 Some(token.pubkey)
511 }
512 Err(_) => None,
513 }
514 } else {
515 None
516 };
517
518 let kv = env.kv("POD_META")?;
519 let bucket = env.bucket("PODS")?;
520 let quota_db = env
521 .d1("REPLAY_DB")
522 .map_err(|e| Error::RustError(format!("REPLAY_DB D1 binding missing: {e}")))?;
523
524 let agent_uri = requester_pubkey
525 .as_ref()
526 .map(|pk| format!("did:nostr:{pk}"));
527
528 if is_provision_path(&resource_path) {
532 if method != Method::Post {
533 return json_error(&env, "Method not allowed; use POST", 405);
534 }
535
536 let req_pk = match requester_pubkey.as_ref() {
538 Some(pk) => pk.clone(),
539 None => return json_error(&env, "Authentication required", 401),
540 };
541
542 let is_owner = req_pk == owner_pubkey;
544 let is_admin = is_admin_user(&env, &req_pk).await;
545 if !is_owner && !is_admin {
546 return json_error(&env, "Only the pod owner or admin can provision", 403);
547 }
548
549 if provision::pod_exists(&bucket, &owner_pubkey).await {
551 return json_error(&env, "Pod already provisioned", 409);
552 }
553
554 let display_name: Option<String> = body_bytes
556 .as_deref()
557 .and_then(|b| serde_json::from_slice::<serde_json::Value>(b).ok())
558 .and_then(|v| {
559 v.get("display_name")
560 .and_then(|n| n.as_str())
561 .map(String::from)
562 });
563
564 let pod_base = expected_origin.clone();
565 provision::provision_pod(
566 &bucket,
567 &kv,
568 &owner_pubkey,
569 &pod_base,
570 display_name.as_deref(),
571 )
572 .await?;
573
574 let pod_url = format!("{expected_origin}/pods/{owner_pubkey}/");
575 let webid_url = format!("{expected_origin}/pods/{owner_pubkey}/profile/card#me");
576 return json_ok(
577 &env,
578 &serde_json::json!({
579 "status": "provisioned",
580 "podUrl": pod_url,
581 "webId": webid_url,
582 "didNostr": format!("did:nostr:{owner_pubkey}"),
583 "containers": ["profile/", "public/", "private/", "inbox/", "settings/"]
584 }),
585 201,
586 );
587 }
588
589 if is_acl_path(&resource_path) {
593 return handle_acl_request(
594 &env,
595 &bucket,
596 &kv,
597 &owner_pubkey,
598 &resource_path,
599 &method,
600 &req_headers,
601 body_bytes,
602 content_length,
603 requester_pubkey.as_deref(),
604 agent_uri.as_deref(),
605 )
606 .await;
607 }
608
609 let required_mode = coerce_required_mode_for_acl(&resource_path, method_str(&method));
616 let acl_doc = find_effective_acl(&bucket, &kv, &owner_pubkey, &resource_path).await;
617
618 let has_access = evaluate_access(
619 acl_doc.as_ref(),
620 agent_uri.as_deref(),
621 &resource_path,
622 required_mode,
623 );
624
625 if !has_access {
626 return if requester_pubkey.is_some() {
627 json_error(&env, "Forbidden", 403)
628 } else {
629 json_error(&env, "Authentication required", 401)
630 };
631 }
632
633 let is_container_path = container::is_container(&resource_path);
635
636 let r2_key = format!("pods/{owner_pubkey}{resource_path}");
638
639 match method {
640 Method::Get | Method::Head => {
641 if is_container_path {
643 let listing =
644 container::list_container(&bucket, &owner_pubkey, &resource_path).await?;
645 let json_str =
646 serde_json::to_string(&listing).map_err(|e| Error::RustError(e.to_string()))?;
647 let cors = cors_headers(&env);
648 let resp = Response::ok(json_str)?.with_headers(cors);
649 resp.headers()
650 .set("Content-Type", "application/ld+json")
651 .ok();
652 add_ldp_headers(resp.headers(), true, &resource_path);
653 add_wac_allow(
654 resp.headers(),
655 acl_doc.as_ref(),
656 agent_uri.as_deref(),
657 &resource_path,
658 );
659 add_cache_control(resp.headers(), &resource_path);
660 return Ok(resp);
661 }
662
663 if resource_path == "/profile/card" {
666 let html = match bucket.get(&r2_key).execute().await? {
667 Some(obj) => {
668 let body = obj
669 .body()
670 .ok_or_else(|| Error::RustError("R2 object has no body".into()))?;
671 let bytes = body.bytes().await?;
672 String::from_utf8(bytes).unwrap_or_else(|_| {
673 webid::generate_webid_html(&owner_pubkey, None, &expected_origin)
674 })
675 }
676 None => webid::generate_webid_html(&owner_pubkey, None, &expected_origin),
677 };
678 let cors = cors_headers(&env);
679 let resp = Response::ok(html)?.with_headers(cors);
680 resp.headers().set("Content-Type", "text/html").ok();
681 add_ldp_headers(resp.headers(), false, &resource_path);
682 add_wac_allow(
683 resp.headers(),
684 acl_doc.as_ref(),
685 agent_uri.as_deref(),
686 &resource_path,
687 );
688 add_cache_control(resp.headers(), &resource_path);
689 return Ok(resp);
690 }
691
692 let object = match bucket.get(&r2_key).execute().await? {
694 Some(obj) => obj,
695 None => return json_error(&env, "Not found", 404),
696 };
697
698 let stored_content_type = object
699 .http_metadata()
700 .content_type
701 .unwrap_or_else(|| "application/octet-stream".to_string());
702 let obj_content_type =
703 content_negotiation::negotiate(accept_header.as_deref(), &stored_content_type);
704 let etag = object.etag();
705 let cors = cors_headers(&env);
706
707 if let Some(status) = conditional::check_preconditions(&req_headers, &etag) {
709 let resp = Response::empty()?.with_status(status).with_headers(cors);
710 resp.headers().set("ETag", &format!("\"{etag}\"")).ok();
711 add_ldp_headers(resp.headers(), false, &resource_path);
712 add_wac_allow(
713 resp.headers(),
714 acl_doc.as_ref(),
715 agent_uri.as_deref(),
716 &resource_path,
717 );
718 return Ok(resp);
719 }
720
721 if method == Method::Head {
722 let resp = Response::empty()?.with_headers(cors);
723 resp.headers().set("Content-Type", &obj_content_type).ok();
724 resp.headers().set("ETag", &format!("\"{etag}\"")).ok();
725 resp.headers().set("Vary", "Accept").ok();
726 add_ldp_headers(resp.headers(), false, &resource_path);
727 add_wac_allow(
728 resp.headers(),
729 acl_doc.as_ref(),
730 agent_uri.as_deref(),
731 &resource_path,
732 );
733 add_cache_control(resp.headers(), &resource_path);
734 return Ok(resp);
735 }
736
737 let body = object
738 .body()
739 .ok_or_else(|| Error::RustError("R2 object has no body".to_string()))?;
740 let bytes = body.bytes().await?;
741
742 if let Some((start, end)) = conditional::parse_range(&req_headers, bytes.len() as u64) {
744 let slice = &bytes[start as usize..=end as usize];
745 let resp = Response::from_bytes(slice.to_vec())?
746 .with_status(206)
747 .with_headers(cors);
748 resp.headers().set("Content-Type", &obj_content_type).ok();
749 resp.headers().set("ETag", &format!("\"{etag}\"")).ok();
750 resp.headers()
751 .set(
752 "Content-Range",
753 &format!("bytes {start}-{end}/{}", bytes.len()),
754 )
755 .ok();
756 add_ldp_headers(resp.headers(), false, &resource_path);
757 add_wac_allow(
758 resp.headers(),
759 acl_doc.as_ref(),
760 agent_uri.as_deref(),
761 &resource_path,
762 );
763 add_cache_control(resp.headers(), &resource_path);
764 return Ok(resp);
765 }
766
767 let resp = Response::from_bytes(bytes)?.with_headers(cors);
768 resp.headers().set("Content-Type", &obj_content_type).ok();
769 resp.headers().set("ETag", &format!("\"{etag}\"")).ok();
770 resp.headers().set("Vary", "Accept").ok();
771 add_ldp_headers(resp.headers(), false, &resource_path);
772 add_wac_allow(
773 resp.headers(),
774 acl_doc.as_ref(),
775 agent_uri.as_deref(),
776 &resource_path,
777 );
778 add_cache_control(resp.headers(), &resource_path);
779 Ok(resp)
780 }
781
782 Method::Put => {
783 if is_container_path {
785 return json_error(&env, "Cannot PUT to a container; use POST", 405);
786 }
787
788 if content_length > MAX_BODY_SIZE {
789 return json_error(
790 &env,
791 &format!("Body exceeds {} byte limit", MAX_BODY_SIZE),
792 413,
793 );
794 }
795
796 let data = body_bytes.unwrap_or_default();
797 let data_len = data.len() as u64;
798 if data_len > MAX_BODY_SIZE {
799 return json_error(
800 &env,
801 &format!("Body exceeds {} byte limit", MAX_BODY_SIZE),
802 413,
803 );
804 }
805
806 if let Ok(Some(existing)) = bucket.get(&r2_key).execute().await {
808 let etag = existing.etag();
809 if let Some(status) = conditional::check_preconditions(&req_headers, &etag) {
810 return json_error(
811 &env,
812 if status == 412 {
813 "Precondition failed"
814 } else {
815 "Not modified"
816 },
817 status,
818 );
819 }
820 }
821
822 if let Err(e) = quota::check_and_reserve_d1("a_db, &owner_pubkey, data_len).await {
824 return json_error(&env, &e.to_string(), 413);
825 }
826
827 if resource_path == "/profile/card" {
829 if let Err(msg) = validate_webid_html(&data) {
830 return json_error(&env, &msg, 422);
831 }
832 }
833
834 bucket
835 .put(&r2_key, data)
836 .http_metadata(HttpMetadata {
837 content_type: Some(content_type),
838 ..Default::default()
839 })
840 .execute()
841 .await?;
842
843 notifications::notify_change(&kv, &owner_pubkey, &resource_path, "Update").await;
845
846 let resp_body = serde_json::json!({ "status": "ok" });
847 let resp = json_ok(&env, &resp_body, 201)?;
848 add_ldp_headers(resp.headers(), false, &resource_path);
849 add_wac_allow(
850 resp.headers(),
851 acl_doc.as_ref(),
852 agent_uri.as_deref(),
853 &resource_path,
854 );
855 Ok(resp)
856 }
857
858 Method::Post => {
859 if !is_container_path {
861 if content_length > MAX_BODY_SIZE {
863 return json_error(
864 &env,
865 &format!("Body exceeds {} byte limit", MAX_BODY_SIZE),
866 413,
867 );
868 }
869
870 let data = body_bytes.unwrap_or_default();
871 let data_len = data.len() as u64;
872 if data_len > MAX_BODY_SIZE {
873 return json_error(
874 &env,
875 &format!("Body exceeds {} byte limit", MAX_BODY_SIZE),
876 413,
877 );
878 }
879
880 if let Err(e) =
881 quota::check_and_reserve_d1("a_db, &owner_pubkey, data_len).await
882 {
883 return json_error(&env, &e.to_string(), 413);
884 }
885
886 bucket
887 .put(&r2_key, data)
888 .http_metadata(HttpMetadata {
889 content_type: Some(content_type),
890 ..Default::default()
891 })
892 .execute()
893 .await?;
894
895 notifications::notify_change(&kv, &owner_pubkey, &resource_path, "Update").await;
897
898 let resp_body = serde_json::json!({ "status": "ok" });
899 let resp = json_ok(&env, &resp_body, 201)?;
900 add_ldp_headers(resp.headers(), false, &resource_path);
901 add_wac_allow(
902 resp.headers(),
903 acl_doc.as_ref(),
904 agent_uri.as_deref(),
905 &resource_path,
906 );
907 return Ok(resp);
908 }
909
910 if content_length > MAX_BODY_SIZE {
912 return json_error(
913 &env,
914 &format!("Body exceeds {} byte limit", MAX_BODY_SIZE),
915 413,
916 );
917 }
918
919 let data = body_bytes.unwrap_or_default();
920 let data_len = data.len() as u64;
921 if data_len > MAX_BODY_SIZE {
922 return json_error(
923 &env,
924 &format!("Body exceeds {} byte limit", MAX_BODY_SIZE),
925 413,
926 );
927 }
928
929 if let Err(e) = quota::check_and_reserve_d1("a_db, &owner_pubkey, data_len).await {
930 return json_error(&env, &e.to_string(), 413);
931 }
932
933 let child_path = container::resolve_slug(&resource_path, slug_header.as_deref());
934 let child_r2_key = format!("pods/{owner_pubkey}{child_path}");
935
936 bucket
937 .put(&child_r2_key, data)
938 .http_metadata(HttpMetadata {
939 content_type: Some(content_type),
940 ..Default::default()
941 })
942 .execute()
943 .await?;
944
945 notifications::notify_change(&kv, &owner_pubkey, &child_path, "Create").await;
947
948 let location = format!("/pods/{owner_pubkey}{child_path}");
949 let resp_body = serde_json::json!({
950 "status": "created",
951 "path": child_path,
952 "location": location,
953 });
954 let resp = json_ok(&env, &resp_body, 201)?;
955 resp.headers().set("Location", &location).ok();
956 add_ldp_headers(resp.headers(), false, &resource_path);
957 add_wac_allow(
958 resp.headers(),
959 acl_doc.as_ref(),
960 agent_uri.as_deref(),
961 &resource_path,
962 );
963 Ok(resp)
964 }
965
966 Method::Patch => {
967 if is_container_path {
969 return json_error(&env, "Cannot PATCH a container", 405);
970 }
971
972 let patch_data = body_bytes.unwrap_or_default();
973
974 let operations: Vec<patch::PatchOperation> = serde_json::from_slice(&patch_data)
976 .map_err(|e| Error::RustError(format!("Invalid JSON Patch: {e}")))?;
977
978 let current_bytes = match bucket.get(&r2_key).execute().await? {
980 Some(obj) => {
981 let body = obj
982 .body()
983 .ok_or_else(|| Error::RustError("R2 object has no body".into()))?;
984 body.bytes().await?
985 }
986 None => return json_error(&env, "Not found", 404),
987 };
988
989 let mut document: serde_json::Value = serde_json::from_slice(¤t_bytes)
990 .map_err(|e| Error::RustError(format!("Resource is not JSON: {e}")))?;
991
992 patch::apply_patches(&mut document, &operations)
994 .map_err(|e| Error::RustError(format!("Patch failed: {e}")))?;
995
996 let updated =
997 serde_json::to_vec(&document).map_err(|e| Error::RustError(e.to_string()))?;
998 let updated_len = updated.len() as u64;
999
1000 let size_delta = updated_len as i64 - current_bytes.len() as i64;
1002 if size_delta > 0 {
1003 if let Err(e) =
1004 quota::check_and_reserve_d1("a_db, &owner_pubkey, size_delta as u64).await
1005 {
1006 return json_error(&env, &e.to_string(), 413);
1007 }
1008 }
1009
1010 if resource_path == "/profile/card" {
1012 if let Err(msg) = validate_webid_html(&updated) {
1013 return json_error(&env, &msg, 422);
1014 }
1015 }
1016
1017 bucket
1018 .put(&r2_key, updated)
1019 .http_metadata(HttpMetadata {
1020 content_type: Some("application/ld+json".into()),
1021 ..Default::default()
1022 })
1023 .execute()
1024 .await?;
1025
1026 if size_delta < 0 {
1028 quota::update_usage_d1("a_db, &owner_pubkey, size_delta)
1029 .await
1030 .ok();
1031 }
1032
1033 notifications::notify_change(&kv, &owner_pubkey, &resource_path, "Update").await;
1035
1036 let resp_body = serde_json::json!({ "status": "ok" });
1037 let resp = json_ok(&env, &resp_body, 200)?;
1038 add_ldp_headers(resp.headers(), false, &resource_path);
1039 add_wac_allow(
1040 resp.headers(),
1041 acl_doc.as_ref(),
1042 agent_uri.as_deref(),
1043 &resource_path,
1044 );
1045 Ok(resp)
1046 }
1047
1048 Method::Delete => {
1049 let deleted_size: u64 = match bucket.get(&r2_key).execute().await? {
1051 Some(obj) => obj.size(),
1052 None => return json_error(&env, "Not found", 404),
1053 };
1054
1055 bucket.delete(&r2_key).await?;
1056
1057 quota::update_usage_d1("a_db, &owner_pubkey, -(deleted_size as i64))
1059 .await
1060 .ok();
1061
1062 notifications::notify_change(&kv, &owner_pubkey, &resource_path, "Delete").await;
1064
1065 let resp_body = serde_json::json!({ "status": "deleted" });
1066 let resp = json_ok(&env, &resp_body, 200)?;
1067 add_ldp_headers(resp.headers(), false, &resource_path);
1068 add_wac_allow(
1069 resp.headers(),
1070 acl_doc.as_ref(),
1071 agent_uri.as_deref(),
1072 &resource_path,
1073 );
1074 Ok(resp)
1075 }
1076
1077 _ => json_error(&env, "Method not allowed", 405),
1078 }
1079}
1080
1081#[allow(clippy::too_many_arguments)]
1090async fn handle_acl_request(
1091 env: &Env,
1092 bucket: &Bucket,
1093 kv: &kv::KvStore,
1094 owner_pubkey: &str,
1095 acl_path: &str,
1096 method: &Method,
1097 req_headers: &Headers,
1098 body_bytes: Option<Vec<u8>>,
1099 content_length: u64,
1100 requester_pubkey: Option<&str>,
1101 agent_uri: Option<&str>,
1102) -> Result<Response> {
1103 let r2_key = format!("pods/{owner_pubkey}{acl_path}");
1104
1105 let parent_path = acl_path.strip_suffix(".acl").unwrap_or(acl_path);
1107 let parent_path = if parent_path.is_empty() {
1109 "/"
1110 } else {
1111 parent_path
1112 };
1113
1114 let parent_acl = find_effective_acl(bucket, kv, owner_pubkey, parent_path).await;
1116
1117 match *method {
1118 Method::Get | Method::Head => {
1119 let can_read = evaluate_access(
1121 parent_acl.as_ref(),
1122 agent_uri,
1123 parent_path,
1124 AccessMode::Read,
1125 ) || evaluate_access(
1126 parent_acl.as_ref(),
1127 agent_uri,
1128 parent_path,
1129 AccessMode::Control,
1130 );
1131
1132 if !can_read {
1133 return if requester_pubkey.is_some() {
1134 json_error(env, "Forbidden", 403)
1135 } else {
1136 json_error(env, "Authentication required", 401)
1137 };
1138 }
1139
1140 let object = match bucket.get(&r2_key).execute().await? {
1141 Some(obj) => obj,
1142 None => return json_error(env, "No ACL document found", 404),
1143 };
1144
1145 let etag = object.etag();
1146 let cors = cors_headers(env);
1147
1148 if let Some(status) = conditional::check_preconditions(req_headers, &etag) {
1149 let resp = Response::empty()?.with_status(status).with_headers(cors);
1150 resp.headers().set("ETag", &format!("\"{etag}\"")).ok();
1151 return Ok(resp);
1152 }
1153
1154 if *method == Method::Head {
1155 let resp = Response::empty()?.with_headers(cors);
1156 resp.headers()
1157 .set("Content-Type", "application/ld+json")
1158 .ok();
1159 resp.headers().set("ETag", &format!("\"{etag}\"")).ok();
1160 add_cache_control(resp.headers(), acl_path);
1161 return Ok(resp);
1162 }
1163
1164 let body = object
1165 .body()
1166 .ok_or_else(|| Error::RustError("R2 object has no body".into()))?;
1167 let bytes = body.bytes().await?;
1168 let resp = Response::from_bytes(bytes)?.with_headers(cors);
1169 resp.headers()
1170 .set("Content-Type", "application/ld+json")
1171 .ok();
1172 resp.headers().set("ETag", &format!("\"{etag}\"")).ok();
1173 add_wac_allow(resp.headers(), parent_acl.as_ref(), agent_uri, parent_path);
1174 add_cache_control(resp.headers(), acl_path);
1175 Ok(resp)
1176 }
1177
1178 Method::Put => {
1179 let has_control = evaluate_access(
1181 parent_acl.as_ref(),
1182 agent_uri,
1183 parent_path,
1184 AccessMode::Control,
1185 );
1186
1187 if !has_control {
1188 return if requester_pubkey.is_some() {
1189 json_error(env, "acl:Control required to modify ACL", 403)
1190 } else {
1191 json_error(env, "Authentication required", 401)
1192 };
1193 }
1194
1195 if content_length > MAX_BODY_SIZE {
1196 return json_error(
1197 env,
1198 &format!("Body exceeds {} byte limit", MAX_BODY_SIZE),
1199 413,
1200 );
1201 }
1202
1203 let data = body_bytes.unwrap_or_default();
1204
1205 if serde_json::from_slice::<acl::AclDocument>(&data).is_err() {
1207 return json_error(
1208 env,
1209 "Invalid ACL document: must be valid JSON-LD with @graph",
1210 422,
1211 );
1212 }
1213
1214 bucket
1215 .put(&r2_key, data)
1216 .http_metadata(HttpMetadata {
1217 content_type: Some("application/ld+json".into()),
1218 ..Default::default()
1219 })
1220 .execute()
1221 .await?;
1222
1223 let resp_body = serde_json::json!({ "status": "ok" });
1224 json_ok(env, &resp_body, 201)
1225 }
1226
1227 Method::Delete => {
1228 let has_control = evaluate_access(
1230 parent_acl.as_ref(),
1231 agent_uri,
1232 parent_path,
1233 AccessMode::Control,
1234 );
1235
1236 if !has_control {
1237 return if requester_pubkey.is_some() {
1238 json_error(env, "acl:Control required to delete ACL", 403)
1239 } else {
1240 json_error(env, "Authentication required", 401)
1241 };
1242 }
1243
1244 if bucket.get(&r2_key).execute().await?.is_none() {
1246 return json_error(env, "ACL document not found", 404);
1247 }
1248
1249 bucket.delete(&r2_key).await?;
1250
1251 let resp_body = serde_json::json!({ "status": "deleted" });
1252 json_ok(env, &resp_body, 200)
1253 }
1254
1255 _ => json_error(env, "Method not allowed on ACL resource", 405),
1256 }
1257}
1258
1259fn validate_webid_html(data: &[u8]) -> Result<(), String> {
1268 let text =
1269 std::str::from_utf8(data).map_err(|_| "WebID profile must be valid UTF-8".to_string())?;
1270
1271 if !text.contains("application/ld+json") {
1272 return Err(
1273 "WebID profile must contain a <script type=\"application/ld+json\"> block".to_string(),
1274 );
1275 }
1276
1277 if let Some(start) = text.find("application/ld+json") {
1279 if let Some(tag_end) = text[start..].find('>') {
1281 let json_start = start + tag_end + 1;
1282 if let Some(script_end) = text[json_start..].find("</script>") {
1283 let json_str = text[json_start..json_start + script_end].trim();
1284 serde_json::from_str::<serde_json::Value>(json_str)
1285 .map_err(|e| format!("Invalid JSON-LD in WebID profile: {e}"))?;
1286 }
1287 }
1288 }
1289
1290 Ok(())
1291}
1292
1293fn extract_webid_tag_from_header(auth_header: &str) -> Option<String> {
1307 let b64 = auth_header.strip_prefix("Nostr ")?;
1308 let bytes = base64::engine::general_purpose::STANDARD
1309 .decode(b64.trim())
1310 .ok()?;
1311 let event: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
1312 let tags = event.get("tags")?.as_array()?;
1313 for tag in tags {
1314 let arr = tag.as_array()?;
1315 if arr.first()?.as_str() == Some("webid") {
1316 if let Some(uri) = arr.get(1).and_then(|v| v.as_str()) {
1317 return Some(uri.to_string());
1318 }
1319 }
1320 }
1321 None
1322}
1323
1324fn load_pay_config(env: &Env) -> payments::PayConfig {
1329 let enabled = env
1330 .var("PAY_ENABLED")
1331 .map(|v| {
1332 let s = v.to_string();
1333 s == "true" || s == "1"
1334 })
1335 .unwrap_or(false);
1336 let cost_sats = env
1337 .var("PAY_COST_SATS")
1338 .ok()
1339 .and_then(|v| v.to_string().parse().ok())
1340 .unwrap_or(1);
1341
1342 let token = env.var("PAY_TOKEN_TICKER").ok().map(|ticker_var| {
1343 let ticker = ticker_var.to_string();
1344 let rate = env
1345 .var("PAY_TOKEN_RATE")
1346 .ok()
1347 .and_then(|v| v.to_string().parse().ok())
1348 .unwrap_or(10);
1349 let supply = env
1350 .var("PAY_TOKEN_SUPPLY")
1351 .ok()
1352 .and_then(|v| v.to_string().parse().ok())
1353 .unwrap_or(1_000_000);
1354 let issuer = env
1355 .var("PAY_TOKEN_ISSUER")
1356 .ok()
1357 .map(|v| v.to_string())
1358 .unwrap_or_default();
1359 payments::TokenConfig {
1360 ticker,
1361 rate,
1362 supply,
1363 issuer,
1364 }
1365 });
1366
1367 payments::PayConfig {
1368 enabled,
1369 cost_sats,
1370 token,
1371 chains: vec![
1372 payments::ChainConfig::bitcoin_mainnet(),
1373 payments::ChainConfig::bitcoin_testnet4(),
1374 payments::ChainConfig::bitcoin_signet(),
1375 ],
1376 }
1377}
1378
1379async fn is_admin_user(env: &Env, pubkey: &str) -> bool {
1389 #[derive(serde::Deserialize)]
1390 struct IsAdminRow {
1391 is_admin: i32,
1392 }
1393
1394 let db = match env.d1("REPLAY_DB") {
1395 Ok(db) => db,
1396 Err(_) => return false,
1397 };
1398
1399 if let Ok(stmt) = db
1400 .prepare("SELECT is_admin FROM members WHERE pubkey = ?1")
1401 .bind(&[wasm_bindgen::JsValue::from_str(pubkey)])
1402 {
1403 if let Ok(Some(row)) = stmt.first::<IsAdminRow>(None).await {
1404 if row.is_admin == 1 {
1405 return true;
1406 }
1407 }
1408 }
1409
1410 if let Ok(stmt) = db
1411 .prepare("SELECT is_admin FROM whitelist WHERE pubkey = ?1")
1412 .bind(&[wasm_bindgen::JsValue::from_str(pubkey)])
1413 {
1414 if let Ok(Some(row)) = stmt.first::<IsAdminRow>(None).await {
1415 return row.is_admin == 1;
1416 }
1417 }
1418
1419 false
1420}
1421
1422#[cfg(test)]
1427mod tests {
1428 use super::*;
1429
1430 #[test]
1431 fn parse_pod_route_valid() {
1432 let pubkey = "a".repeat(64);
1433 let path = format!("/pods/{pubkey}/profile/card");
1434 let (pk, rp) = parse_pod_route(&path).unwrap();
1435 assert_eq!(pk, pubkey);
1436 assert_eq!(rp, "/profile/card");
1437 }
1438
1439 #[test]
1440 fn parse_pod_route_root() {
1441 let pubkey = "b".repeat(64);
1442 let path = format!("/pods/{pubkey}");
1443 let (pk, rp) = parse_pod_route(&path).unwrap();
1444 assert_eq!(pk, pubkey);
1445 assert_eq!(rp, "/");
1446 }
1447
1448 #[test]
1449 fn parse_pod_route_with_trailing_slash() {
1450 let pubkey = "c".repeat(64);
1451 let path = format!("/pods/{pubkey}/");
1452 let (pk, rp) = parse_pod_route(&path).unwrap();
1453 assert_eq!(pk, pubkey);
1454 assert_eq!(rp, "/");
1455 }
1456
1457 #[test]
1458 fn parse_pod_route_invalid_hex() {
1459 let path = format!("/pods/{}/file", "x".repeat(64));
1460 assert!(parse_pod_route(&path).is_none());
1461 }
1462
1463 #[test]
1464 fn parse_pod_route_short_pubkey() {
1465 assert!(parse_pod_route("/pods/abc/file").is_none());
1466 }
1467
1468 #[test]
1469 fn parse_pod_route_wrong_prefix() {
1470 assert!(parse_pod_route("/api/something").is_none());
1471 }
1472
1473 #[test]
1474 fn parse_pod_route_no_slash_after_pubkey() {
1475 let pubkey = "d".repeat(64);
1476 let path = format!("/pods/{pubkey}extra");
1477 assert!(parse_pod_route(&path).is_none());
1478 }
1479
1480 #[test]
1481 fn parse_pod_route_container_path() {
1482 let pubkey = "e".repeat(64);
1483 let path = format!("/pods/{pubkey}/media/");
1484 let (pk, rp) = parse_pod_route(&path).unwrap();
1485 assert_eq!(pk, pubkey);
1486 assert_eq!(rp, "/media/");
1487 }
1488
1489 #[test]
1490 fn is_acl_path_detects_acl_suffix() {
1491 assert!(is_acl_path("/public/.acl"));
1492 assert!(is_acl_path("/.acl"));
1493 assert!(is_acl_path("/profile/card.acl"));
1494 assert!(!is_acl_path("/public/"));
1495 assert!(!is_acl_path("/profile/card"));
1496 assert!(!is_acl_path("/acl/resource"));
1497 }
1498
1499 #[test]
1500 fn is_provision_path_detects_endpoint() {
1501 assert!(is_provision_path("/.provision"));
1502 assert!(!is_provision_path("/provision"));
1503 assert!(!is_provision_path("/.provision/extra"));
1504 assert!(!is_provision_path("/public/.provision"));
1505 }
1506
1507 #[test]
1508 fn validate_webid_html_accepts_valid() {
1509 let html = r##"<!DOCTYPE html>
1510<html>
1511<head>
1512 <script type="application/ld+json">
1513 {"@context": {"foaf": "http://xmlns.com/foaf/0.1/"}, "@id": "#me", "@type": "foaf:Person"}
1514 </script>
1515</head>
1516<body></body>
1517</html>"##;
1518 assert!(validate_webid_html(html.as_bytes()).is_ok());
1519 }
1520
1521 #[test]
1522 fn validate_webid_html_rejects_no_jsonld() {
1523 let html = "<!DOCTYPE html><html><body>No JSON-LD here</body></html>";
1524 assert!(validate_webid_html(html.as_bytes()).is_err());
1525 }
1526
1527 #[test]
1528 fn validate_webid_html_rejects_invalid_utf8() {
1529 let bad_bytes: &[u8] = &[0xff, 0xfe, 0xfd];
1530 assert!(validate_webid_html(bad_bytes).is_err());
1531 }
1532
1533 #[test]
1534 fn validate_webid_html_rejects_invalid_jsonld() {
1535 let html = r##"<!DOCTYPE html>
1536<html>
1537<head>
1538 <script type="application/ld+json">
1539 {not valid json}
1540 </script>
1541</head>
1542<body></body>
1543</html>"##;
1544 assert!(validate_webid_html(html.as_bytes()).is_err());
1545 }
1546}