1#![forbid(unsafe_code)]
44#![warn(missing_docs)]
45#![warn(rust_2018_idioms)]
46
47pub mod api;
48pub mod api_issues;
49pub mod auth;
50pub mod bodies;
51pub mod compare;
52pub mod config;
53pub mod config_file;
54pub mod diff;
55pub mod error;
56pub mod forks;
57pub mod gitmark;
58pub mod gitrun;
59pub mod guard;
60pub mod hosted;
61pub mod hosted_edit;
62pub mod html;
63pub mod labels;
64pub mod locks;
65pub mod markdown;
66#[cfg(feature = "anchoring")]
67pub mod marks_ui;
68pub mod merge;
69#[cfg(feature = "mirror")]
70pub mod mirror;
71#[cfg(feature = "announce")]
72pub mod nip34;
73pub mod ownership;
74pub mod preview;
75mod prgit;
76mod privacy;
77#[cfg(feature = "announce")]
78pub mod publish;
79pub mod pulls;
80pub mod releases;
81pub mod repo;
82pub mod request;
83pub mod router;
84pub mod search;
85mod sizecap;
86pub mod spine;
87pub mod token;
88#[cfg(feature = "anchoring")]
89pub mod trail;
90pub mod web_edit;
91pub mod xlogin;
92
93pub const WIDGET_PAGE_HEADER: &str = "x-forge-csp";
97
98#[cfg(test)]
99mod readfeat_tests;
100
101use std::path::{Path, PathBuf};
102use std::sync::Arc;
103use std::time::{SystemTime, UNIX_EPOCH};
104
105use solid_pod_rs_git::service::{GitHttpService, GitRequest};
106
107pub use bodies::{HostedReader, LoopbackFetch};
108pub use config::ForgeConfig;
109pub use error::ForgeError;
110pub use hosted::HostedStore;
111pub use ownership::ForgeAgent;
112pub use request::{esc, ForgeRequest, ForgeResponse};
113pub use router::{parse_route, Route};
114pub use spine::{FsSpineStore, SpineStore};
115pub use token::TokenError;
116
117pub mod prelude {
119 pub use crate::config::ForgeConfig;
120 pub use crate::error::ForgeError;
121 pub use crate::ownership::ForgeAgent;
122 pub use crate::request::{ForgeRequest, ForgeResponse};
123 pub use crate::ForgeService;
124}
125
126fn now_secs() -> u64 {
128 SystemTime::now()
129 .duration_since(UNIX_EPOCH)
130 .map(|d| d.as_secs())
131 .unwrap_or(0)
132}
133
134fn load_or_create_token_key(plugin_dir: &Path) -> Result<[u8; 32], ForgeError> {
139 let path = plugin_dir.join(".forge-token-key");
140 if let Ok(bytes) = std::fs::read(&path) {
141 if bytes.len() == 32 {
142 let mut key = [0u8; 32];
143 key.copy_from_slice(&bytes);
144 return Ok(key);
145 }
146 }
147 let mut key = [0u8; 32];
148 key[..16].copy_from_slice(uuid::Uuid::new_v4().as_bytes());
149 key[16..].copy_from_slice(uuid::Uuid::new_v4().as_bytes());
150 std::fs::write(&path, key)?;
151 #[cfg(unix)]
152 {
153 use std::os::unix::fs::PermissionsExt;
154 let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
155 }
156 Ok(key)
157}
158
159#[derive(Clone)]
162pub struct ForgeService {
163 cfg: ForgeConfig,
164 plugin_dir: PathBuf,
165 repo_root: PathBuf,
166 git: Arc<GitHttpService>,
167 spine: Arc<dyn SpineStore>,
168 loopback: Option<Arc<dyn LoopbackFetch>>,
172 hosted: Arc<HostedStore>,
175 token_key: Arc<[u8; 32]>,
177 nip98_replay: solid_pod_rs::auth::replay::Nip98ReplayCache,
179 #[cfg(feature = "announce")]
181 announcer: Arc<nip34::Announcer>,
182 #[cfg(feature = "anchoring")]
184 trails: Arc<trail::TrailStore>,
185 pr: pulls::PrExt,
187 locks: Arc<locks::ForgeLocks>,
189}
190
191impl std::fmt::Debug for ForgeService {
192 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
193 f.debug_struct("ForgeService")
194 .field("prefix", &self.cfg.normalized_prefix())
195 .field("plugin_dir", &self.plugin_dir)
196 .finish()
197 }
198}
199
200impl ForgeService {
201 pub fn new(cfg: ForgeConfig, plugin_dir: impl Into<PathBuf>) -> Result<Self, ForgeError> {
205 cfg.validate_chain()?;
207 let plugin_dir = plugin_dir.into();
208 let repo_root = plugin_dir.join("repos");
209 for sub in ["repos", "issues", "pulls", "hosted", "marks"] {
210 std::fs::create_dir_all(plugin_dir.join(sub))?;
211 }
212 let git = Arc::new(GitHttpService::new(repo_root.clone()));
213 let spine: Arc<dyn SpineStore> = Arc::new(FsSpineStore::new(plugin_dir.clone()));
214 let hosted = Arc::new(HostedStore::new(plugin_dir.clone()));
215 let token_key = Arc::new(load_or_create_token_key(&plugin_dir)?);
216 if cfg.open_edit {
218 tracing::warn!(
219 "forge: config.open_edit is ON: anyone can edit repos via the web-edit endpoint \
220 (no owner signature required). DEMO ONLY; never enable on a real forge."
221 );
222 }
223 #[cfg(feature = "announce")]
224 let announcer = Arc::new(nip34::Announcer::new(&cfg, &plugin_dir)?);
225 #[cfg(feature = "anchoring")]
226 let trails = Arc::new(trail::TrailStore::new(&cfg, &plugin_dir)?);
227 Ok(Self {
228 cfg,
229 plugin_dir,
230 repo_root,
231 git,
232 spine,
233 loopback: None,
234 hosted,
235 token_key,
236 nip98_replay: solid_pod_rs::auth::replay::Nip98ReplayCache::from_env(),
237 #[cfg(feature = "announce")]
238 announcer,
239 #[cfg(feature = "anchoring")]
240 trails,
241 pr: pulls::PrExt::default(),
242 locks: Arc::new(locks::ForgeLocks::default()),
243 })
244 }
245
246 #[must_use]
251 pub fn locks(&self) -> &locks::ForgeLocks {
252 &self.locks
253 }
254
255 #[must_use]
257 pub fn config(&self) -> &ForgeConfig {
258 &self.cfg
259 }
260
261 #[must_use]
264 pub fn with_loopback(mut self, lb: Arc<dyn LoopbackFetch>) -> Self {
265 self.loopback = Some(lb);
266 self
267 }
268
269 #[must_use]
271 pub fn with_spine(mut self, spine: Arc<dyn SpineStore>) -> Self {
272 self.spine = spine;
273 self
274 }
275
276 #[must_use]
278 pub fn token_key(&self) -> &[u8; 32] {
279 &self.token_key
280 }
281
282 pub async fn resolve_agent(&self, req: &ForgeRequest) -> ForgeAgent {
287 let pinned = self.pinned_host_url(req.host_url.as_deref());
290 if pinned.as_deref() != req.host_url.as_deref() {
291 let mut r = req.clone();
292 r.host_url = pinned;
293 return auth::resolve_agent(
294 &r,
295 self.token_key.as_ref(),
296 &self.nip98_replay,
297 now_secs(),
298 )
299 .await;
300 }
301 auth::resolve_agent(req, self.token_key.as_ref(), &self.nip98_replay, now_secs()).await
302 }
303
304 fn pinned_host_url(&self, host_url: Option<&str>) -> Option<String> {
308 match self.cfg.base_url.as_deref() {
309 Some(b) => {
310 let b = b.trim_end_matches('/');
311 let end = b
312 .find("://")
313 .map(|i| i + 3)
314 .and_then(|s| b[s..].find('/').map(|j| s + j))
315 .unwrap_or(b.len());
316 Some(b[..end].to_string())
317 }
318 None => host_url.map(str::to_string),
319 }
320 }
321
322 #[must_use]
324 pub fn prefix(&self) -> String {
325 self.cfg.normalized_prefix()
326 }
327
328 #[must_use]
330 pub fn plugin_dir(&self) -> &Path {
331 &self.plugin_dir
332 }
333
334 pub async fn handle(
337 &self,
338 mut req: ForgeRequest,
339 agent: ForgeAgent,
340 ) -> Result<ForgeResponse, ForgeError> {
341 req.host_url = self.pinned_host_url(req.host_url.as_deref());
342 if req.method.eq_ignore_ascii_case("OPTIONS") {
344 return Ok(ForgeResponse {
345 status: 204,
346 headers: vec![
347 ("access-control-allow-origin".into(), "*".into()),
348 (
349 "access-control-allow-methods".into(),
350 "GET, POST, PUT, PATCH, DELETE, OPTIONS".into(),
351 ),
352 (
353 "access-control-allow-headers".into(),
354 "Content-Type, Authorization".into(),
355 ),
356 ],
357 body: bytes::Bytes::new(),
358 });
359 }
360
361 let prefix = self.cfg.normalized_prefix();
362 let Some(rel) = router::strip_prefix(&prefix, &req.path) else {
363 return Ok(ForgeResponse::error(404, "not under forge prefix"));
364 };
365 if let Err(e) = guard::percent_guard(&rel) {
367 return Ok(self.finish_page(e.to_response()));
368 }
369 let route = parse_route(&rel);
370 if self.cfg.private_repos {
371 if let Some(denied) = self.private_gate(&rel, &route, &agent) {
372 return Ok(self.finish_page(denied));
373 }
374 }
375
376 #[cfg(feature = "anchoring")]
377 let scope = route
378 .repo_page_scope()
379 .map(|(o, r)| (o.to_string(), r.to_string()));
380 let result = self.dispatch(route, &req, &agent).await;
381 #[cfg(feature = "anchoring")]
382 let result = match (scope, result) {
383 (Some((o, r)), Ok(resp)) => Ok(self.decorate_repo_page(&o, &r, resp).await),
384 (_, other) => other,
385 };
386 Ok(self.finish_page(result.unwrap_or_else(|e| e.to_response())))
387 }
388
389 fn finish_page(&self, mut resp: ForgeResponse) -> ForgeResponse {
392 let is_html = resp
393 .headers
394 .iter()
395 .any(|(k, v)| k.eq_ignore_ascii_case("content-type") && v.starts_with("text/html"));
396 if is_html {
397 let widget = resp
403 .headers
404 .iter()
405 .any(|(k, v)| k.eq_ignore_ascii_case(WIDGET_PAGE_HEADER) && v == "widget");
406 resp.headers
407 .retain(|(k, _)| !k.eq_ignore_ascii_case(WIDGET_PAGE_HEADER));
408 let csp = if widget {
409 xlogin::build_csp(&self.cfg.csp_connect)
410 } else {
411 let nonce = uuid::Uuid::new_v4().simple().to_string();
412 xlogin::build_strict_csp(&self.cfg.csp_connect, &nonce)
413 };
414 resp.headers.push(("content-security-policy".into(), csp));
415 if !resp
416 .headers
417 .iter()
418 .any(|(k, _)| k.eq_ignore_ascii_case("x-content-type-options"))
419 {
420 resp.headers
421 .push(("x-content-type-options".into(), "nosniff".into()));
422 }
423 }
424 resp
425 }
426
427 async fn dispatch(
430 &self,
431 route: Route,
432 req: &ForgeRequest,
433 _agent: &ForgeAgent,
434 ) -> Result<ForgeResponse, ForgeError> {
435 match route {
436 Route::Index => self.h_index_filtered(req, _agent).await,
437 Route::OwnerIndex { owner } => self.h_owner(&owner).await,
438 Route::GitSmart { rel_path } => self.h_git_smart(req, _agent, &rel_path).await,
439 Route::RepoOverview { owner, repo } => self.h_overview(&owner, &repo).await,
440 Route::Tree {
441 owner,
442 repo,
443 rev,
444 path,
445 } => self.h_tree(&owner, &repo, &rev, &path).await,
446 Route::Blob {
447 owner,
448 repo,
449 rev,
450 path,
451 } => self.h_blob(&owner, &repo, &rev, &path).await,
452 Route::Raw {
453 owner,
454 repo,
455 rev,
456 path,
457 } => self.h_raw(&owner, &repo, &rev, &path).await,
458 Route::Commits { owner, repo, rev } => self.h_commits(req, &owner, &repo, &rev).await,
459 Route::Commit { owner, repo, sha } => self.h_commit(&owner, &repo, &sha).await,
460 Route::Branches { owner, repo } => self.h_branches(&owner, &repo).await,
461 Route::Tags { owner, repo } => self.h_tags(&owner, &repo).await,
462 Route::BlocktrailsJson { owner, repo } => self.h_blocktrails_json(&owner, &repo).await,
463 #[cfg(feature = "anchoring")]
464 Route::ApiMarks { owner, repo, tail } => {
465 self.h_api_marks(req, _agent, &owner, &repo, &tail).await
466 }
467 #[cfg(feature = "anchoring")]
468 Route::Marks { owner, repo } => self.h_marks_page(req, &owner, &repo).await,
469 #[cfg(not(feature = "anchoring"))]
470 Route::ApiMarks { .. } | Route::Marks { .. } => Err(ForgeError::Unsupported(
471 "anchoring is not compiled into this build".into(),
472 )),
473 Route::ApiMirror { owner, repo } => Ok(self.h_api_mirror(&owner, &repo)),
474 Route::ApiNostr { owner, repo } => self.h_api_nostr(req, &owner, &repo).await,
475 Route::ApiAnnounce { owner, repo } => {
476 self.h_api_announce(req, _agent, &owner, &repo).await
477 }
478 Route::Issues { owner, repo } => {
479 if req.method.eq_ignore_ascii_case("POST") {
480 self.h_issue_create(req, _agent, &owner, &repo).await
481 } else {
482 self.h_issues_list(req, &owner, &repo).await
483 }
484 }
485 Route::IssueNew { owner, repo } => self.h_issue_new_form(&owner, &repo).await,
486 Route::IssueDetail { owner, repo, num } => {
487 if req.method.eq_ignore_ascii_case("POST") {
488 self.h_issue_comment(req, _agent, &owner, &repo, num).await
489 } else {
490 self.h_issue_detail(&owner, &repo, num).await
491 }
492 }
493 Route::ApiReposList => self.h_api_repo_list(req, _agent, None).await,
494 Route::ApiToken => self.h_api_token(req, _agent).await,
495 Route::ApiHosted { hex, id } => self.h_api_hosted(req, _agent, &hex, &id).await,
496 Route::ApiFork { owner, repo } => self.h_api_fork(req, _agent, &owner, &repo).await,
497 Route::ApiCompare { owner, repo, spec } => {
498 self.h_api_compare(_agent, &owner, &repo, &spec).await
499 }
500 Route::ApiPulls { owner, repo, tail } => {
501 self.h_api_pulls(req, _agent, &owner, &repo, &tail).await
502 }
503 Route::Compare { owner, repo, spec } => {
504 self.h_compare_page(_agent, &owner, &repo, &spec).await
505 }
506 Route::Pulls { owner, repo, tail } => {
507 self.h_pulls_page(req, _agent, &owner, &repo, &tail).await
508 }
509 Route::Xlogin => Ok(xlogin::xlogin_response(&req.method)),
511 Route::ApiOwnerRepos { owner } => self.h_api_repo_list(req, _agent, Some(&owner)).await,
512 Route::ApiRepo { owner, repo } => self.h_api_repo(req, _agent, &owner, &repo).await,
513 Route::ApiRepoAction {
514 owner,
515 repo,
516 action,
517 tail,
518 } => {
519 self.h_api_repo_action(req, _agent, &owner, &repo, &action, &tail)
520 .await
521 }
522 Route::RepoSearch { owner, repo } => self.h_search_page(req, &owner, &repo).await,
523 Route::RepoReleases { owner, repo } => self.h_releases_page(&owner, &repo).await,
524 Route::RepoArchive { owner, repo, spec } => self.h_archive(&owner, &repo, &spec).await,
525 Route::ApiEdit { owner, repo } => Ok(self.h_api_edit(req, _agent, &owner, &repo).await),
526 Route::ApiPreview { owner, repo } => {
527 Ok(self.h_api_preview(req, _agent, &owner, &repo).await)
528 }
529 _ => Err(ForgeError::NotFound("not implemented".into())),
531 }
532 }
533
534 async fn resolve_repo(&self, owner: &str, repo: &str) -> Result<PathBuf, ForgeError> {
537 let dir = repo::repo_git_dir(&self.repo_root, owner, repo)?;
538 if !tokio::fs::metadata(&dir)
539 .await
540 .map(|m| m.is_dir())
541 .unwrap_or(false)
542 {
543 return Err(ForgeError::NotFound(format!("repo {owner}/{repo}")));
544 }
545 Ok(dir)
546 }
547
548 async fn h_owner(&self, owner: &str) -> Result<ForgeResponse, ForgeError> {
551 let repos = repo::list_owner(&self.repo_root, owner).await;
552 if repos.is_empty() {
553 return Err(ForgeError::NotFound(format!("owner {owner}")));
554 }
555 let page = html::owner_page(&self.prefix(), owner, &repos);
556 let pairs: Vec<(String, String)> = repos
557 .iter()
558 .map(|r| (owner.to_string(), r.clone()))
559 .collect();
560 Ok(ForgeResponse::html(
561 200,
562 self.decorate_listing(&page, &pairs),
563 ))
564 }
565
566 async fn h_git_smart(
570 &self,
571 req: &ForgeRequest,
572 agent: &ForgeAgent,
573 rel_path: &str,
574 ) -> Result<ForgeResponse, ForgeError> {
575 let git_req = GitRequest {
576 method: req.method.clone(),
577 path: rel_path.to_string(),
578 query: req.query.clone(),
579 headers: req.headers.clone(),
580 body: req.raw_body.clone(),
581 host_url: req.host_url.clone(),
582 };
583 if git_req.is_write() {
588 let owner = rel_path
589 .trim_start_matches('/')
590 .split('/')
591 .next()
592 .unwrap_or("");
593 if !agent.can_write_namespace(owner) {
594 return Err(ForgeError::Forbidden(format!(
595 "cannot push into namespace '{owner}'"
596 )));
597 }
598 #[cfg(feature = "mirror")]
602 {
603 let name = rel_path
604 .trim_start_matches('/')
605 .split('/')
606 .nth(1)
607 .map_or("", |n| n.strip_suffix(".git").unwrap_or(n));
608 if mirror::MirrorStore::new(&self.plugin_dir)
609 .read(owner, name)
610 .is_some()
611 {
612 return Err(ForgeError::Forbidden(format!(
613 "'{owner}/{name}' is a mirror and read-only"
614 )));
615 }
616 }
617 }
618 #[cfg(feature = "anchoring")]
622 let pushed = git_req.method.eq_ignore_ascii_case("POST")
623 && rel_path
624 .trim_end_matches('/')
625 .ends_with("/git-receive-pack");
626 let resp = self.git.handle(git_req).await?;
627 #[cfg(feature = "anchoring")]
628 if pushed && resp.status < 300 {
629 let mut parts = rel_path.trim_start_matches('/').split('/');
630 if let (Some(owner), Some(name)) = (parts.next(), parts.next()) {
631 let name = name.strip_suffix(".git").unwrap_or(name);
632 self.record_pushed_tip(owner, name).await;
633 }
634 }
635 Ok(ForgeResponse {
636 status: resp.status,
637 headers: resp.headers,
638 body: resp.body,
639 })
640 }
641
642 async fn h_overview(&self, owner: &str, repo: &str) -> Result<ForgeResponse, ForgeError> {
645 let dir = self.resolve_repo(owner, repo).await?;
646 let prefix = self.prefix();
647 let open = spine::issues::load_issue_index(self.spine.as_ref(), owner, repo)
649 .await
650 .map(|i| i.count(spine::issues::IssueState::Open))
651 .unwrap_or(0);
652 let description = api::repo_description(&dir).await;
653 let extras = html::repo_extras(&prefix, owner, repo, &description, open);
654 if !repo::browse::has_commits(&dir).await {
655 let page = html::repo_overview_page(&prefix, owner, repo, "main", &[], None);
656 let page = self.decorate_overview(page, owner, repo).await;
657 return Ok(ForgeResponse::html(
658 200,
659 html::inject_after_header(&page, &extras),
660 ));
661 }
662 let branch = repo::browse::default_branch(&dir).await;
663 let entries = repo::browse::list_tree(&dir, &branch, "").await?;
664 let readme = self.find_readme(&dir, &branch, &entries).await;
666 let page =
667 html::repo_overview_page(&prefix, owner, repo, &branch, &entries, readme.as_deref());
668 let page = self.decorate_overview(page, owner, repo).await;
669 Ok(ForgeResponse::html(
670 200,
671 html::inject_after_header(&page, &extras),
672 ))
673 }
674
675 async fn find_readme(
677 &self,
678 dir: &Path,
679 rev: &str,
680 entries: &[repo::browse::TreeEntry],
681 ) -> Option<String> {
682 let candidate = entries.iter().find(|e| {
683 e.kind == repo::browse::EntryKind::File && {
684 let n = e.name.to_ascii_lowercase();
685 n == "readme" || n == "readme.md" || n == "readme.txt"
686 }
687 })?;
688 let bytes =
689 repo::browse::read_blob(dir, rev, &candidate.name, self.cfg.max_body_bytes as u64)
690 .await
691 .ok()?;
692 if request::looks_textual(&bytes) {
693 Some(String::from_utf8_lossy(&bytes).into_owned())
694 } else {
695 None
696 }
697 }
698
699 async fn h_tree(
700 &self,
701 owner: &str,
702 repo: &str,
703 rev: &str,
704 path: &str,
705 ) -> Result<ForgeResponse, ForgeError> {
706 let dir = self.resolve_repo(owner, repo).await?;
707 let entries = repo::browse::list_tree(&dir, rev, path).await?;
708 Ok(ForgeResponse::html(
709 200,
710 html::tree_page(&self.prefix(), owner, repo, rev, path, &entries),
711 ))
712 }
713
714 async fn h_blob(
715 &self,
716 owner: &str,
717 repo: &str,
718 rev: &str,
719 path: &str,
720 ) -> Result<ForgeResponse, ForgeError> {
721 let dir = self.resolve_repo(owner, repo).await?;
722 const BLOB_VIEW_MAX: u64 = 1024 * 1024;
725 let content = match repo::browse::read_blob(&dir, rev, path, BLOB_VIEW_MAX).await {
726 Ok(bytes) => {
727 if request::looks_textual(&bytes) {
728 Some(String::from_utf8_lossy(&bytes).into_owned())
729 } else {
730 None
731 }
732 }
733 Err(ForgeError::BadRequest(_)) => None,
735 Err(e) => return Err(e),
736 };
737 Ok(ForgeResponse::html(
738 200,
739 html::blob_page(&self.prefix(), owner, repo, rev, path, content.as_deref()),
740 ))
741 }
742
743 async fn h_raw(
744 &self,
745 owner: &str,
746 repo: &str,
747 rev: &str,
748 path: &str,
749 ) -> Result<ForgeResponse, ForgeError> {
750 let dir = self.resolve_repo(owner, repo).await?;
751 const RAW_MAX: u64 = 25 * 1024 * 1024;
753 let bytes = repo::browse::read_blob(&dir, rev, path, RAW_MAX).await?;
754 let is_text = request::looks_textual(&bytes);
755 let filename = path.rsplit('/').next().unwrap_or("download");
756 Ok(ForgeResponse::raw_bytes(
757 bytes::Bytes::from(bytes),
758 is_text,
759 filename,
760 ))
761 }
762
763 async fn h_commits(
764 &self,
765 req: &ForgeRequest,
766 owner: &str,
767 repo: &str,
768 rev: &str,
769 ) -> Result<ForgeResponse, ForgeError> {
770 let dir = self.resolve_repo(owner, repo).await?;
771 let page: u32 = req
772 .query_param("page")
773 .and_then(|p| p.parse().ok())
774 .unwrap_or(1)
775 .max(1);
776 let (commits, has_next) = repo::browse::commit_log(&dir, rev, page, 50).await?;
777 Ok(ForgeResponse::html(
778 200,
779 html::commits_page(&self.prefix(), owner, repo, rev, &commits, page, has_next),
780 ))
781 }
782
783 async fn h_commit(
784 &self,
785 owner: &str,
786 repo: &str,
787 sha: &str,
788 ) -> Result<ForgeResponse, ForgeError> {
789 let dir = self.resolve_repo(owner, repo).await?;
790 let meta = solid_pod_rs_git::api::resolve_commit(&dir, sha).await?;
791 let patch = repo::browse::commit_patch(&dir, &meta.hash).await?;
792 Ok(ForgeResponse::html(
793 200,
794 html::commit_page(&self.prefix(), owner, repo, &meta, &patch),
795 ))
796 }
797
798 async fn h_branches(&self, owner: &str, repo: &str) -> Result<ForgeResponse, ForgeError> {
799 let dir = self.resolve_repo(owner, repo).await?;
800 let info = solid_pod_rs_git::api::git_branches(&dir).await?;
801 Ok(ForgeResponse::html(
802 200,
803 html::refs_page(
804 &self.prefix(),
805 owner,
806 repo,
807 "Branches",
808 Some(&info.current),
809 &info.local,
810 "commits",
811 ),
812 ))
813 }
814
815 #[cfg(feature = "mirror")]
821 fn h_api_mirror(&self, owner: &str, repo: &str) -> ForgeResponse {
822 match mirror::MirrorStore::new(&self.plugin_dir).read(owner, repo) {
823 Some(rec) => ForgeResponse::json(
824 200,
825 &serde_json::to_value(&rec).unwrap_or(serde_json::Value::Null),
826 ),
827 None => ForgeResponse::json(404, &serde_json::json!({"error": "not a mirror"})),
828 }
829 }
830
831 #[cfg(not(feature = "announce"))]
833 #[allow(clippy::unused_self, clippy::unused_async)]
834 async fn h_api_nostr(
835 &self,
836 _req: &ForgeRequest,
837 _owner: &str,
838 _repo: &str,
839 ) -> Result<ForgeResponse, ForgeError> {
840 Err(ForgeError::Unsupported(
841 "NIP-34 emission needs the `announce` feature".into(),
842 ))
843 }
844
845 #[cfg(not(feature = "announce"))]
847 #[allow(clippy::unused_self, clippy::unused_async)]
848 async fn h_api_announce(
849 &self,
850 _req: &ForgeRequest,
851 _agent: &ForgeAgent,
852 _owner: &str,
853 _repo: &str,
854 ) -> Result<ForgeResponse, ForgeError> {
855 Err(ForgeError::Unsupported(
856 "NIP-34 emission needs the `announce` feature".into(),
857 ))
858 }
859
860 #[cfg(not(feature = "mirror"))]
862 #[allow(clippy::unused_self)]
863 fn h_api_mirror(&self, _owner: &str, _repo: &str) -> ForgeResponse {
864 ForgeResponse::json(404, &serde_json::json!({"error": "not a mirror"}))
865 }
866
867 async fn h_blocktrails_json(
873 &self,
874 owner: &str,
875 repo: &str,
876 ) -> Result<ForgeResponse, ForgeError> {
877 #[cfg(feature = "anchoring")]
879 if let Some(doc) = self.forge_blocktrails_json(owner, repo)? {
880 let mut resp = ForgeResponse::with_type(
881 200,
882 "application/json; charset=utf-8",
883 bytes::Bytes::from(doc),
884 );
885 resp.headers
886 .push(("access-control-allow-origin".into(), "*".into()));
887 resp.headers
888 .push(("x-content-type-options".into(), "nosniff".into()));
889 return Ok(resp);
890 }
891 let dir = self.resolve_repo(owner, repo).await?;
892 let branch = repo::browse::default_branch(&dir).await;
893 let (status, body) = match gitmark::read_repo_trail(&dir, &branch).await {
894 Some(trail) => (200, trail.document_json()),
895 None => (
896 404,
897 serde_json::json!({"error": "anchoring is not enabled for this repository"})
898 .to_string(),
899 ),
900 };
901 let mut resp = ForgeResponse::with_type(
902 status,
903 "application/json; charset=utf-8",
904 bytes::Bytes::from(body),
905 );
906 resp.headers
907 .push(("access-control-allow-origin".into(), "*".into()));
908 resp.headers
909 .push(("x-content-type-options".into(), "nosniff".into()));
910 Ok(resp)
911 }
912
913 async fn h_tags(&self, owner: &str, repo: &str) -> Result<ForgeResponse, ForgeError> {
914 let dir = self.resolve_repo(owner, repo).await?;
915 let tags = repo::browse::list_tags(&dir).await?;
916 let page = html::refs_page(&self.prefix(), owner, repo, "Tags", None, &tags, "tree");
917 Ok(ForgeResponse::html(
918 200,
919 html::inject_after_header(&page, &html::releases_link(&self.prefix(), owner, repo)),
920 ))
921 }
922
923 async fn h_issues_list(
926 &self,
927 req: &ForgeRequest,
928 owner: &str,
929 repo: &str,
930 ) -> Result<ForgeResponse, ForgeError> {
931 self.resolve_repo(owner, repo).await?;
932 let idx = spine::issues::load_issue_index(self.spine.as_ref(), owner, repo).await?;
933 let filter = match req.query_param("state").as_deref() {
934 Some("closed") => spine::issues::IssueState::Closed,
935 _ => spine::issues::IssueState::Open,
936 };
937 let label = req
938 .query_param("label")
939 .map(|l| request::percent_decode(&l))
940 .filter(|l| !l.is_empty());
941 let mut issues = idx.by_state(filter);
942 if let Some(l) = &label {
943 issues.retain(|i| i.labels.iter().any(|x| x == l));
944 }
945 let defs = labels::effective_labels(&idx);
946 Ok(ForgeResponse::html(
947 200,
948 html::issues_list_page(
949 &self.prefix(),
950 owner,
951 repo,
952 filter,
953 idx.count(spine::issues::IssueState::Open),
954 idx.count(spine::issues::IssueState::Closed),
955 &issues,
956 &defs,
957 ),
958 ))
959 }
960
961 async fn h_issue_new_form(&self, owner: &str, repo: &str) -> Result<ForgeResponse, ForgeError> {
962 self.resolve_repo(owner, repo).await?;
963 Ok(ForgeResponse::html(
964 200,
965 html::issue_new_page(&self.prefix(), owner, repo),
966 ))
967 }
968
969 async fn h_issue_detail(
970 &self,
971 owner: &str,
972 repo: &str,
973 num: u64,
974 ) -> Result<ForgeResponse, ForgeError> {
975 self.resolve_repo(owner, repo).await?;
976 let idx = spine::issues::load_issue_index(self.spine.as_ref(), owner, repo).await?;
977 let entry = idx
978 .issues
979 .get(&num)
980 .ok_or_else(|| ForgeError::NotFound(format!("issue #{num}")))?;
981 let truncated = entry.thread.len() > self.cfg.thread_cap;
982 let hosted: Arc<dyn HostedReader> = self.hosted.clone();
983 let threads = bodies::render_thread(
984 &entry.thread,
985 self.loopback.clone(),
986 Some(hosted),
987 &self.cfg,
988 )
989 .await;
990 Ok(ForgeResponse::html(
991 200,
992 html::issue_detail_page(
993 &self.prefix(),
994 owner,
995 repo,
996 entry,
997 &threads,
998 truncated,
999 &labels::effective_labels(&idx),
1000 ),
1001 ))
1002 }
1003
1004 fn parse_issue_body(req: &ForgeRequest) -> (Option<String>, Option<String>, Option<String>) {
1008 let ct = req.header("content-type").unwrap_or("");
1009 let raw = String::from_utf8_lossy(&req.raw_body);
1010 let field_json = |v: &serde_json::Value, k: &str| {
1011 v.get(k).and_then(|x| x.as_str()).map(|s| s.to_string())
1012 };
1013 if ct.contains("application/json") {
1014 if let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) {
1015 return (
1016 field_json(&v, "title"),
1017 field_json(&v, "resourceUrl"),
1018 field_json(&v, "body"),
1019 );
1020 }
1021 }
1022 let pairs = request::parse_form(&raw);
1023 let field = |k: &str| pairs.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone());
1024 (field("title"), field("resourceUrl"), field("body"))
1025 }
1026
1027 async fn make_pointer(
1032 &self,
1033 req: &ForgeRequest,
1034 agent: &ForgeAgent,
1035 owner: &str,
1036 repo: &str,
1037 resource_url: Option<&str>,
1038 body: Option<&str>,
1039 ) -> Result<spine::issues::ThreadPointer, ForgeError> {
1040 match agent {
1041 ForgeAgent::Anonymous => {
1042 Err(ForgeError::Unauthorised("authentication required".into()))
1043 }
1044 ForgeAgent::Nostr { pubkey_hex } => {
1045 let body = body
1047 .map(str::trim)
1048 .filter(|b| !b.is_empty())
1049 .ok_or_else(|| ForgeError::BadRequest("body required".into()))?;
1050 let doc = hosted_edit::hosted_doc(
1051 "ForgeComment",
1052 owner,
1053 repo,
1054 None,
1055 None,
1056 body,
1057 &agent.author_id(),
1058 now_secs(),
1059 );
1060 let rref = self.hosted.write_doc(pubkey_hex, &doc).await?;
1061 Ok(spine::issues::ThreadPointer {
1062 author: agent.author_id(),
1063 resource_url: rref,
1064 at: now_secs(),
1065 hosted: true,
1066 })
1067 }
1068 ForgeAgent::Pod { .. } => {
1069 let url = resource_url
1070 .ok_or_else(|| ForgeError::BadRequest("resourceUrl required".into()))?;
1071 self.verify_pod_pointer(req, agent, owner, repo, url).await
1072 }
1073 }
1074 }
1075
1076 async fn verify_pod_pointer(
1080 &self,
1081 req: &ForgeRequest,
1082 agent: &ForgeAgent,
1083 repo_owner: &str,
1084 repo: &str,
1085 resource_url: &str,
1086 ) -> Result<spine::issues::ThreadPointer, ForgeError> {
1087 let caller = agent
1088 .owner()
1089 .ok_or_else(|| ForgeError::Unauthorised("authentication required".into()))?;
1090 let host = req
1091 .host_url
1092 .as_deref()
1093 .ok_or_else(|| ForgeError::BadRequest("missing host".into()))?;
1094 bodies::own_area_ok(resource_url, host, caller, repo_owner, repo)?;
1096 let lb = self
1098 .loopback
1099 .as_ref()
1100 .ok_or_else(|| ForgeError::Unsupported("pod body verification unavailable".into()))?;
1101 match lb
1102 .get(
1103 resource_url,
1104 self.cfg.max_body_bytes,
1105 self.cfg.fetch_timeout_secs,
1106 )
1107 .await
1108 {
1109 bodies::FetchResult::Body(_) => Ok(spine::issues::ThreadPointer {
1110 author: agent.author_id(),
1111 resource_url: resource_url.to_string(),
1112 at: now_secs(),
1113 hosted: false,
1114 }),
1115 bodies::FetchResult::Removed => Err(ForgeError::BadRequest(
1116 "pod body is not publicly readable".into(),
1117 )),
1118 bodies::FetchResult::TooLarge => {
1119 Err(ForgeError::BadRequest("pod body too large".into()))
1120 }
1121 bodies::FetchResult::Error(e) => {
1122 Err(ForgeError::Backend(format!("pod body fetch failed: {e}")))
1123 }
1124 }
1125 }
1126
1127 async fn h_issue_create(
1128 &self,
1129 req: &ForgeRequest,
1130 agent: &ForgeAgent,
1131 owner: &str,
1132 repo: &str,
1133 ) -> Result<ForgeResponse, ForgeError> {
1134 self.resolve_repo(owner, repo).await?;
1135 let (title, resource_url, body) = Self::parse_issue_body(req);
1136 let title = title
1137 .map(|t| t.trim().to_string())
1138 .filter(|t| !t.is_empty())
1139 .ok_or_else(|| ForgeError::BadRequest("title required".into()))?;
1140
1141 let pointer = self
1144 .make_pointer(
1145 req,
1146 agent,
1147 owner,
1148 repo,
1149 resource_url.as_deref(),
1150 body.as_deref(),
1151 )
1152 .await?;
1153
1154 let _guard = self.locks.issues.lock(owner, repo).await;
1155 let mut idx = spine::issues::load_issue_index(self.spine.as_ref(), owner, repo).await?;
1156 let entry = spine::issues::IssueEntry {
1157 number: 0,
1158 title,
1159 state: spine::issues::IssueState::Open,
1160 author: agent.author_id(),
1161 created_at: now_secs(),
1162 thread: vec![pointer],
1163 labels: Vec::new(),
1164 };
1165 let num = idx.allocate(entry);
1166 spine::issues::save_issue_index(self.spine.as_ref(), owner, repo, &idx).await?;
1167
1168 let location = format!("{}/{}/{}/issues/{}", self.prefix(), owner, repo, num);
1169 Ok(ForgeResponse::redirect(303, &location))
1170 }
1171
1172 async fn h_issue_comment(
1173 &self,
1174 req: &ForgeRequest,
1175 agent: &ForgeAgent,
1176 owner: &str,
1177 repo: &str,
1178 num: u64,
1179 ) -> Result<ForgeResponse, ForgeError> {
1180 self.resolve_repo(owner, repo).await?;
1181 let (_title, resource_url, body) = Self::parse_issue_body(req);
1182 let pointer = self
1183 .make_pointer(
1184 req,
1185 agent,
1186 owner,
1187 repo,
1188 resource_url.as_deref(),
1189 body.as_deref(),
1190 )
1191 .await?;
1192
1193 let _guard = self.locks.issues.lock(owner, repo).await;
1194 let mut idx = spine::issues::load_issue_index(self.spine.as_ref(), owner, repo).await?;
1195 let entry = idx
1196 .issues
1197 .get_mut(&num)
1198 .ok_or_else(|| ForgeError::NotFound(format!("issue #{num}")))?;
1199 entry.thread.push(pointer);
1200 spine::issues::save_issue_index(self.spine.as_ref(), owner, repo, &idx).await?;
1201
1202 let location = format!("{}/{}/{}/issues/{}", self.prefix(), owner, repo, num);
1203 Ok(ForgeResponse::redirect(303, &location))
1204 }
1205
1206 async fn h_api_token(
1212 &self,
1213 req: &ForgeRequest,
1214 agent: &ForgeAgent,
1215 ) -> Result<ForgeResponse, ForgeError> {
1216 if !req.method.eq_ignore_ascii_case("POST") {
1217 return Err(ForgeError::NotFound("token requires POST".into()));
1218 }
1219 let iat = now_secs();
1220 let ttl = self.cfg.token_ttl_secs;
1221 let token = token::mint(self.token_key.as_ref(), agent, iat, ttl).ok_or_else(|| {
1222 ForgeError::Unauthorised("authentication required to mint a token".into())
1223 })?;
1224 let v = serde_json::json!({
1225 "token": token,
1226 "tokenType": "Bearer",
1227 "expiresIn": ttl,
1228 "exp": iat + ttl,
1229 "agent": agent.author_id(),
1230 });
1231 Ok(ForgeResponse::json(200, &v))
1232 }
1233}
1234
1235#[cfg(test)]
1236mod tests {
1237 use super::*;
1238 use bytes::Bytes;
1239 use tempfile::TempDir;
1240
1241 fn req(method: &str, path: &str) -> ForgeRequest {
1242 ForgeRequest {
1243 method: method.into(),
1244 path: path.into(),
1245 query: String::new(),
1246 headers: vec![],
1247 raw_body: Bytes::new(),
1248 host_url: Some("https://pod.example".into()),
1249 }
1250 }
1251
1252 #[test]
1255 fn widget_pages_keep_the_widget_csp_and_others_get_a_nonce() {
1256 let (_td, svc) = service();
1257 let csp = |r: &ForgeResponse| {
1258 r.headers
1259 .iter()
1260 .find(|(k, _)| k == "content-security-policy")
1261 .map(|(_, v)| v.clone())
1262 .unwrap_or_default()
1263 };
1264 let plain = svc.finish_page(ForgeResponse::html(200, "<p>hi</p>".to_string()));
1265 let widget = svc.finish_page(
1266 ForgeResponse::html(200, "<p>w</p>".to_string())
1267 .with_header(WIDGET_PAGE_HEADER, "widget"),
1268 );
1269 let mention = svc.finish_page(ForgeResponse::html(
1272 200,
1273 "<p><script src=\"/forge/xlogin.js\"></p>".to_string(),
1274 ));
1275 assert!(csp(&mention).contains("script-src 'self' 'nonce-"));
1276 assert!(widget.headers.iter().all(|(k, _)| k != WIDGET_PAGE_HEADER));
1277 assert!(csp(&plain).contains("script-src 'self' 'nonce-"));
1278 assert!(!csp(&plain).contains("unsafe-inline' 'self'"));
1279 assert!(csp(&widget).contains("script-src 'unsafe-inline' 'self' https://esm.sh"));
1280 let again = svc.finish_page(ForgeResponse::html(200, "<p>hi</p>".to_string()));
1281 assert_ne!(csp(&plain), csp(&again), "the nonce is fresh per response");
1282 }
1283
1284 #[tokio::test]
1287 async fn nip98_url_comes_from_base_url_not_a_forged_host() {
1288 use solid_pod_rs::auth::nip98;
1289 const SK: &str = "5555555555555555555555555555555555555555555555555555555555555555";
1290 let td = TempDir::new().unwrap();
1291 let cfg = ForgeConfig {
1292 base_url: Some("https://pod.example/".into()),
1293 ..ForgeConfig::default()
1294 };
1295 let svc = ForgeService::new(cfg, td.path()).unwrap();
1296 let signed_for = |url: &str, t: u64| {
1297 let tok = nip98::mint(url, "GET", SK, t).unwrap();
1298 let mut r = req("GET", "/forge/x");
1299 r.headers = vec![("authorization".into(), format!("Nostr {tok}"))];
1300 r.host_url = Some("https://evil.example".into());
1301 r
1302 };
1303 let now = now_secs();
1304 let replayed = signed_for("https://evil.example/forge/x", now);
1305 assert_eq!(svc.resolve_agent(&replayed).await, ForgeAgent::Anonymous);
1306 let honest = signed_for("https://pod.example/forge/x", now + 1);
1307 assert!(matches!(
1308 svc.resolve_agent(&honest).await,
1309 ForgeAgent::Nostr { .. }
1310 ));
1311 }
1312
1313 fn service() -> (TempDir, ForgeService) {
1314 let td = TempDir::new().unwrap();
1315 let svc = ForgeService::new(ForgeConfig::default(), td.path()).unwrap();
1316 (td, svc)
1317 }
1318
1319 #[tokio::test]
1320 async fn new_creates_layout() {
1321 let (td, _svc) = service();
1322 for sub in ["repos", "issues", "pulls", "hosted", "marks"] {
1323 assert!(td.path().join(sub).is_dir(), "{sub} dir must exist");
1324 }
1325 }
1326
1327 #[tokio::test]
1328 async fn index_renders_empty() {
1329 let (_td, svc) = service();
1330 let r = svc
1331 .handle(req("GET", "/forge"), ForgeAgent::Anonymous)
1332 .await
1333 .unwrap();
1334 assert_eq!(r.status, 200);
1335 let body = String::from_utf8(r.body.to_vec()).unwrap();
1336 assert!(body.contains("No repositories yet"));
1337 }
1338
1339 #[tokio::test]
1340 async fn index_lists_pushed_repo() {
1341 let (td, svc) = service();
1342 tokio::fs::create_dir_all(td.path().join("repos/alice/demo.git"))
1343 .await
1344 .unwrap();
1345 let r = svc
1346 .handle(req("GET", "/forge"), ForgeAgent::Anonymous)
1347 .await
1348 .unwrap();
1349 let body = String::from_utf8(r.body.to_vec()).unwrap();
1350 assert!(body.contains("/forge/alice/demo"));
1351
1352 let r2 = svc
1354 .handle(req("GET", "/forge/alice"), ForgeAgent::Anonymous)
1355 .await
1356 .unwrap();
1357 assert_eq!(r2.status, 200);
1358 }
1359
1360 #[tokio::test]
1361 async fn unknown_owner_is_404() {
1362 let (_td, svc) = service();
1363 let r = svc
1364 .handle(req("GET", "/forge/ghost"), ForgeAgent::Anonymous)
1365 .await
1366 .unwrap();
1367 assert_eq!(r.status, 404);
1368 }
1369
1370 #[tokio::test]
1371 async fn options_preflight() {
1372 let (_td, svc) = service();
1373 let r = svc
1374 .handle(req("OPTIONS", "/forge/x"), ForgeAgent::Anonymous)
1375 .await
1376 .unwrap();
1377 assert_eq!(r.status, 204);
1378 assert!(r
1379 .headers
1380 .iter()
1381 .any(|(k, _)| k.eq_ignore_ascii_case("access-control-allow-methods")));
1382 }
1383
1384 #[tokio::test]
1385 async fn outside_prefix_is_404() {
1386 let (_td, svc) = service();
1387 let r = svc
1388 .handle(req("GET", "/other/thing"), ForgeAgent::Anonymous)
1389 .await
1390 .unwrap();
1391 assert_eq!(r.status, 404);
1392 }
1393
1394 #[tokio::test]
1395 async fn unimplemented_route_is_404_for_now() {
1396 let (_td, svc) = service();
1397 let r = svc
1398 .handle(
1399 req("GET", "/forge/alice/repo/issues"),
1400 ForgeAgent::Anonymous,
1401 )
1402 .await
1403 .unwrap();
1404 assert_eq!(r.status, 404);
1407 }
1408
1409 fn git_available() -> bool {
1412 std::process::Command::new("git")
1413 .arg("--version")
1414 .stdout(std::process::Stdio::null())
1415 .stderr(std::process::Stdio::null())
1416 .status()
1417 .map(|s| s.success())
1418 .unwrap_or(false)
1419 }
1420
1421 fn run_git(dir: &std::path::Path, args: &[&str]) {
1422 let ok = std::process::Command::new("git")
1423 .args(args)
1424 .current_dir(dir)
1425 .env("GIT_CONFIG_NOSYSTEM", "1")
1426 .stdout(std::process::Stdio::null())
1427 .stderr(std::process::Stdio::null())
1428 .status()
1429 .map(|s| s.success())
1430 .unwrap_or(false);
1431 assert!(ok, "git {args:?} failed in {}", dir.display());
1432 }
1433
1434 fn seed_repo(td: &TempDir, owner: &str, name: &str) {
1437 let work = td.path().join(format!("work-{owner}-{name}"));
1438 std::fs::create_dir_all(&work).unwrap();
1439 run_git(&work, &["init", "-b", "main"]);
1440 std::fs::write(work.join("README.md"), "# Demo\nhello forge\n").unwrap();
1441 std::fs::create_dir_all(work.join("src")).unwrap();
1442 std::fs::write(work.join("src/lib.rs"), "pub fn f() -> u8 { 42 }\n").unwrap();
1443 run_git(&work, &["add", "-A"]);
1444 run_git(
1445 &work,
1446 &[
1447 "-c",
1448 "user.email=t@e.st",
1449 "-c",
1450 "user.name=Tester",
1451 "commit",
1452 "-m",
1453 "initial commit",
1454 ],
1455 );
1456 run_git(&work, &["tag", "v1.0"]);
1457 let bare = td
1458 .path()
1459 .join("repos")
1460 .join(owner)
1461 .join(format!("{name}.git"));
1462 std::fs::create_dir_all(bare.parent().unwrap()).unwrap();
1463 run_git(
1464 td.path(),
1465 &[
1466 "clone",
1467 "--bare",
1468 work.to_str().unwrap(),
1469 bare.to_str().unwrap(),
1470 ],
1471 );
1472 }
1473
1474 async fn body(svc: &ForgeService, path: &str) -> (u16, String) {
1475 let r = svc
1476 .handle(req("GET", path), ForgeAgent::Anonymous)
1477 .await
1478 .unwrap();
1479 (r.status, String::from_utf8_lossy(&r.body).into_owned())
1480 }
1481
1482 #[tokio::test]
1483 async fn overview_lists_tree_and_readme() {
1484 if !git_available() {
1485 return;
1486 }
1487 let (td, svc) = service();
1488 seed_repo(&td, "alice", "demo");
1489 let (status, html) = body(&svc, "/forge/alice/demo").await;
1490 assert_eq!(status, 200);
1491 assert!(html.contains("alice/demo"));
1492 assert!(html.contains("README.md"));
1494 assert!(html.contains("src"));
1495 assert!(html.contains("hello forge"));
1497 }
1498
1499 #[tokio::test]
1500 async fn blocktrails_json_serves_a_repo_held_trail_with_cors() {
1501 if !git_available() {
1502 return;
1503 }
1504 let (td, svc) = service();
1505 seed_repo(&td, "casey", "agent");
1506 seed_repo(&td, "casey", "demo");
1507 let header = |r: &ForgeResponse, k: &str| {
1508 r.headers
1509 .iter()
1510 .find(|(n, _)| n == k)
1511 .map(|(_, v)| v.clone())
1512 };
1513
1514 let none = svc
1516 .handle(
1517 req("GET", "/forge/casey/demo/blocktrails.json"),
1518 ForgeAgent::Anonymous,
1519 )
1520 .await
1521 .unwrap();
1522 assert_eq!(none.status, 404);
1523 assert_eq!(
1524 header(&none, "access-control-allow-origin").as_deref(),
1525 Some("*")
1526 );
1527 assert!(String::from_utf8_lossy(&none.body).contains("anchoring is not enabled"));
1528
1529 let (c0, c1) = ("1".repeat(40), "2".repeat(40));
1531 let doc = serde_json::json!({
1532 "@type": "Blocktrail", "version": "0.0.3", "profile": "gitmark",
1533 "pubkeyBase": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa",
1534 "chain": "txbt4", "states": [c0, c1],
1535 "txo": [
1536 format!("txo:txbt4:{}:0?amount=9700&commit={c0}", "ab".repeat(32)),
1537 format!("txo:txbt4:{}:0?amount=9400&commit={c1}", "cd".repeat(32)),
1538 ],
1539 });
1540 let work = td.path().join("work-casey-agent");
1541 std::fs::write(work.join("blocktrails.json"), doc.to_string()).unwrap();
1542 run_git(&work, &["add", "-A"]);
1543 run_git(
1544 &work,
1545 &[
1546 "-c",
1547 "user.email=t@e.st",
1548 "-c",
1549 "user.name=Tester",
1550 "commit",
1551 "-m",
1552 "trail",
1553 ],
1554 );
1555 let bare = td.path().join("repos").join("casey").join("agent.git");
1556 run_git(&work, &["push", bare.to_str().unwrap(), "main"]);
1557
1558 let held = svc
1559 .handle(
1560 req("GET", "/forge/casey/agent/blocktrails.json"),
1561 ForgeAgent::Anonymous,
1562 )
1563 .await
1564 .unwrap();
1565 assert_eq!(held.status, 200);
1566 assert_eq!(
1567 header(&held, "access-control-allow-origin").as_deref(),
1568 Some("*")
1569 );
1570 assert_eq!(
1571 header(&held, "x-content-type-options").as_deref(),
1572 Some("nosniff")
1573 );
1574 let served: serde_json::Value = serde_json::from_slice(&held.body).unwrap();
1575 assert_eq!(served["profile"], "gitmark");
1576 assert_eq!(served["states"], serde_json::json!([c0, c1]));
1577 assert_eq!(served["pubkeyBase"], doc["pubkeyBase"]);
1578 }
1579
1580 #[tokio::test]
1581 async fn api_mirror_is_404_for_a_repo_that_is_not_a_mirror() {
1582 let (_td, svc) = service();
1583 let r = svc
1584 .handle(
1585 req(
1586 "GET",
1587 &format!("/forge/api/repos/{}/agent/mirror", "ab".repeat(32)),
1588 ),
1589 ForgeAgent::Anonymous,
1590 )
1591 .await
1592 .unwrap();
1593 assert_eq!(r.status, 404);
1594 assert!(String::from_utf8_lossy(&r.body).contains("not a mirror"));
1595 }
1596
1597 #[cfg(feature = "mirror")]
1601 #[tokio::test]
1602 async fn api_mirror_serves_the_record_before_the_repo_exists() {
1603 let (td, svc) = service();
1604 let pubkey = "ab".repeat(32);
1605 let dir = td.path().join("mirror").join(&pubkey);
1606 std::fs::create_dir_all(&dir).unwrap();
1607 std::fs::write(
1608 dir.join("agent.json"),
1609 serde_json::json!({
1610 "pubkey": pubkey, "name": "agent", "clone": null, "events": 1,
1611 "lastSeen": null, "lastEvent": null, "lastFetchAt": null,
1612 "lastFetch": null, "announceAt": null, "clones": []
1613 })
1614 .to_string(),
1615 )
1616 .unwrap();
1617 assert!(!td
1618 .path()
1619 .join("repos")
1620 .join(&pubkey)
1621 .join("agent.git")
1622 .exists());
1623 let r = svc
1624 .handle(
1625 req("GET", &format!("/forge/api/repos/{pubkey}/agent/mirror")),
1626 ForgeAgent::Anonymous,
1627 )
1628 .await
1629 .unwrap();
1630 assert_eq!(r.status, 200);
1631 let v: serde_json::Value = serde_json::from_slice(&r.body).unwrap();
1632 assert_eq!(v["name"], "agent");
1633 assert_eq!(v["events"], 1);
1634 }
1635
1636 #[tokio::test]
1637 async fn tree_blob_raw_roundtrip() {
1638 if !git_available() {
1639 return;
1640 }
1641 let (td, svc) = service();
1642 seed_repo(&td, "alice", "demo");
1643
1644 let (s1, tree) = body(&svc, "/forge/alice/demo/tree/main/src").await;
1646 assert_eq!(s1, 200);
1647 assert!(tree.contains("lib.rs"));
1648
1649 let (s2, blob) = body(&svc, "/forge/alice/demo/blob/main/src/lib.rs").await;
1651 assert_eq!(s2, 200);
1652 assert!(blob.contains("pub fn f"));
1653
1654 let r = svc
1656 .handle(
1657 req("GET", "/forge/alice/demo/raw/main/src/lib.rs"),
1658 ForgeAgent::Anonymous,
1659 )
1660 .await
1661 .unwrap();
1662 assert_eq!(r.status, 200);
1663 let ct = r
1664 .headers
1665 .iter()
1666 .find(|(k, _)| k == "content-type")
1667 .map(|(_, v)| v.clone())
1668 .unwrap();
1669 assert!(ct.starts_with("text/plain"));
1670 assert_eq!(
1671 String::from_utf8_lossy(&r.body),
1672 "pub fn f() -> u8 { 42 }\n"
1673 );
1674 }
1675
1676 #[tokio::test]
1677 async fn commits_branches_tags_commit_view() {
1678 if !git_available() {
1679 return;
1680 }
1681 let (td, svc) = service();
1682 seed_repo(&td, "alice", "demo");
1683
1684 let (s1, commits) = body(&svc, "/forge/alice/demo/commits/main").await;
1685 assert_eq!(s1, 200);
1686 assert!(commits.contains("initial commit"));
1687
1688 let (s2, branches) = body(&svc, "/forge/alice/demo/branches").await;
1689 assert_eq!(s2, 200);
1690 assert!(branches.contains("main"));
1691 assert!(branches.contains("default"));
1692
1693 let (s3, tags) = body(&svc, "/forge/alice/demo/tags").await;
1694 assert_eq!(s3, 200);
1695 assert!(tags.contains("v1.0"));
1696
1697 let dir = td.path().join("repos/alice/demo.git");
1699 let out = std::process::Command::new("git")
1700 .args(["rev-parse", "HEAD"])
1701 .current_dir(&dir)
1702 .output()
1703 .unwrap();
1704 let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
1705 let (s4, commit) = body(&svc, &format!("/forge/alice/demo/commit/{sha}")).await;
1706 assert_eq!(s4, 200);
1707 assert!(commit.contains("initial commit"));
1708 assert!(commit.contains("README.md"));
1709 }
1710
1711 #[tokio::test]
1712 async fn browse_rejects_bad_ref_and_missing_repo() {
1713 if !git_available() {
1714 return;
1715 }
1716 let (td, svc) = service();
1717 seed_repo(&td, "alice", "demo");
1718
1719 let (s1, _) = body(&svc, "/forge/alice/ghost").await;
1721 assert_eq!(s1, 404);
1722
1723 let (s2, _) = body(&svc, "/forge/alice/demo/tree/--upload-pack/x").await;
1725 assert_eq!(s2, 400);
1726
1727 let (s3, _) = body(&svc, "/forge/alice/demo/blob/main/nope.txt").await;
1729 assert_eq!(s3, 404);
1730 }
1731
1732 #[tokio::test]
1733 async fn empty_repo_overview_renders() {
1734 if !git_available() {
1735 return;
1736 }
1737 let (td, svc) = service();
1738 let bare = td.path().join("repos/bob/empty.git");
1740 std::fs::create_dir_all(&bare).unwrap();
1741 run_git(
1742 td.path(),
1743 &["init", "--bare", "-b", "main", bare.to_str().unwrap()],
1744 );
1745 let (status, html) = body(&svc, "/forge/bob/empty").await;
1746 assert_eq!(status, 200);
1747 assert!(html.contains("empty"));
1748 }
1749
1750 use std::collections::HashMap;
1753 use std::sync::Mutex;
1754
1755 struct MockLb {
1756 present: Mutex<HashMap<String, Vec<u8>>>,
1757 }
1758 impl MockLb {
1759 fn new() -> Self {
1760 Self {
1761 present: Mutex::new(HashMap::new()),
1762 }
1763 }
1764 fn put(&self, url: &str, body: &[u8]) {
1765 self.present
1766 .lock()
1767 .unwrap()
1768 .insert(url.into(), body.to_vec());
1769 }
1770 fn remove(&self, url: &str) {
1771 self.present.lock().unwrap().remove(url);
1772 }
1773 }
1774 #[async_trait::async_trait]
1775 impl bodies::LoopbackFetch for MockLb {
1776 async fn get(&self, url: &str, _max: usize, _to: u64) -> bodies::FetchResult {
1777 match self.present.lock().unwrap().get(url) {
1778 Some(b) => bodies::FetchResult::Body(b.clone()),
1779 None => bodies::FetchResult::Removed,
1780 }
1781 }
1782 }
1783
1784 fn pod_agent(user: &str) -> ForgeAgent {
1785 ForgeAgent::Pod {
1786 webid: format!("https://pod.example/{user}/profile/card#me"),
1787 username: user.to_string(),
1788 }
1789 }
1790
1791 fn post_form(path: &str, form: &str) -> ForgeRequest {
1792 ForgeRequest {
1793 method: "POST".into(),
1794 path: path.into(),
1795 query: String::new(),
1796 headers: vec![(
1797 "content-type".into(),
1798 "application/x-www-form-urlencoded".into(),
1799 )],
1800 raw_body: Bytes::from(form.to_string()),
1801 host_url: Some("https://pod.example".into()),
1802 }
1803 }
1804
1805 fn issues_service() -> (TempDir, ForgeService, Arc<MockLb>) {
1807 let td = TempDir::new().unwrap();
1808 let mock = Arc::new(MockLb::new());
1809 let svc = ForgeService::new(ForgeConfig::default(), td.path())
1810 .unwrap()
1811 .with_loopback(mock.clone() as Arc<dyn bodies::LoopbackFetch>);
1812 (td, svc, mock)
1813 }
1814
1815 #[tokio::test]
1816 async fn issue_create_list_detail_and_deletion() {
1817 if !git_available() {
1818 return;
1819 }
1820 let (td, svc, mock) = issues_service();
1821 seed_repo(&td, "alice", "demo");
1822
1823 let url = "https://pod.example/alice/public/forge/alice--demo/issue-1.jsonld";
1824 mock.put(url, b"the issue body text");
1825
1826 let r = svc
1828 .handle(
1829 post_form(
1830 "/forge/alice/demo/issues",
1831 &format!(
1832 "title=First+bug&resourceUrl={}",
1833 url.replace(':', "%3A").replace('/', "%2F")
1834 ),
1835 ),
1836 pod_agent("alice"),
1837 )
1838 .await
1839 .unwrap();
1840 assert_eq!(r.status, 303);
1841 let loc = r
1842 .headers
1843 .iter()
1844 .find(|(k, _)| k.eq_ignore_ascii_case("location"))
1845 .map(|(_, v)| v.clone())
1846 .unwrap();
1847 assert_eq!(loc, "/forge/alice/demo/issues/1");
1848
1849 let (s, list) = body(&svc, "/forge/alice/demo/issues").await;
1851 assert_eq!(s, 200);
1852 assert!(list.contains("First bug"));
1853 assert!(list.contains("1 open"));
1854
1855 let (s2, detail) = body(&svc, "/forge/alice/demo/issues/1").await;
1857 assert_eq!(s2, 200);
1858 assert!(detail.contains("the issue body text"));
1859
1860 mock.remove(url);
1862 let (_s3, detail2) = body(&svc, "/forge/alice/demo/issues/1").await;
1863 assert!(detail2.contains("content removed by its author"));
1864 }
1865
1866 #[tokio::test]
1867 async fn issue_create_rejects_foreign_area_and_ssrf() {
1868 if !git_available() {
1869 return;
1870 }
1871 let (td, svc, mock) = issues_service();
1872 seed_repo(&td, "alice", "demo");
1873 mock.put(
1874 "https://pod.example/bob/public/forge/alice--demo/x.jsonld",
1875 b"body",
1876 );
1877
1878 let bad = "https://pod.example/bob/public/forge/alice--demo/x.jsonld";
1880 let r = svc
1881 .handle(
1882 post_form(
1883 "/forge/alice/demo/issues",
1884 &format!(
1885 "title=x&resourceUrl={}",
1886 bad.replace(':', "%3A").replace('/', "%2F")
1887 ),
1888 ),
1889 pod_agent("alice"),
1890 )
1891 .await
1892 .unwrap();
1893 assert_eq!(r.status, 403);
1894
1895 let ssrf = "http://169.254.169.254/alice/public/forge/alice--demo/x.jsonld";
1897 let r2 = svc
1898 .handle(
1899 post_form(
1900 "/forge/alice/demo/issues",
1901 &format!(
1902 "title=x&resourceUrl={}",
1903 ssrf.replace(':', "%3A").replace('/', "%2F")
1904 ),
1905 ),
1906 pod_agent("alice"),
1907 )
1908 .await
1909 .unwrap();
1910 assert!(r2.status == 403 || r2.status == 400);
1911 }
1912
1913 #[tokio::test]
1914 async fn comment_appends_to_thread() {
1915 if !git_available() {
1916 return;
1917 }
1918 let (td, svc, mock) = issues_service();
1919 seed_repo(&td, "alice", "demo");
1920
1921 let open = "https://pod.example/alice/public/forge/alice--demo/i1.jsonld";
1922 mock.put(open, b"opening");
1923 svc.handle(
1924 post_form(
1925 "/forge/alice/demo/issues",
1926 &format!(
1927 "title=t&resourceUrl={}",
1928 open.replace(':', "%3A").replace('/', "%2F")
1929 ),
1930 ),
1931 pod_agent("alice"),
1932 )
1933 .await
1934 .unwrap();
1935
1936 let c = "https://pod.example/bob/public/forge/alice--demo/c1.jsonld";
1938 mock.put(c, b"a helpful comment");
1939 let r = svc
1940 .handle(
1941 post_form(
1942 "/forge/alice/demo/issues/1",
1943 &format!("resourceUrl={}", c.replace(':', "%3A").replace('/', "%2F")),
1944 ),
1945 pod_agent("bob"),
1946 )
1947 .await
1948 .unwrap();
1949 assert_eq!(r.status, 303);
1950
1951 let (_s, detail) = body(&svc, "/forge/alice/demo/issues/1").await;
1952 assert!(detail.contains("opening"));
1953 assert!(detail.contains("a helpful comment"));
1954 assert!(detail.contains("commented"));
1955 }
1956
1957 #[tokio::test]
1958 async fn issue_create_without_loopback_fails_closed() {
1959 if !git_available() {
1960 return;
1961 }
1962 let (td, svc) = service();
1964 seed_repo(&td, "alice", "demo");
1965 let url = "https://pod.example/alice/public/forge/alice--demo/x.jsonld";
1966 let r = svc
1967 .handle(
1968 post_form(
1969 "/forge/alice/demo/issues",
1970 &format!(
1971 "title=x&resourceUrl={}",
1972 url.replace(':', "%3A").replace('/', "%2F")
1973 ),
1974 ),
1975 pod_agent("alice"),
1976 )
1977 .await
1978 .unwrap();
1979 assert_eq!(r.status, 501);
1980 }
1981
1982 fn hex64() -> String {
1985 "d".repeat(64)
1986 }
1987
1988 fn nostr_agent(hex: &str) -> ForgeAgent {
1989 ForgeAgent::Nostr {
1990 pubkey_hex: hex.to_string(),
1991 }
1992 }
1993
1994 fn post_json(path: &str, json: &str) -> ForgeRequest {
1995 ForgeRequest {
1996 method: "POST".into(),
1997 path: path.into(),
1998 query: String::new(),
1999 headers: vec![("content-type".into(), "application/json".into())],
2000 raw_body: Bytes::from(json.to_string()),
2001 host_url: Some("https://pod.example".into()),
2002 }
2003 }
2004
2005 #[tokio::test]
2006 async fn token_mint_then_resolves() {
2007 let (_td, svc) = service();
2008 let agent = nostr_agent(&hex64());
2010 let r = svc
2011 .handle(post_form("/forge/api/token", ""), agent.clone())
2012 .await
2013 .unwrap();
2014 assert_eq!(r.status, 200);
2015 let v: serde_json::Value = serde_json::from_slice(&r.body).unwrap();
2016 let token = v["token"].as_str().unwrap().to_string();
2017 assert!(token.starts_with("f1."));
2018
2019 let follow = ForgeRequest {
2021 method: "GET".into(),
2022 path: "/forge/x".into(),
2023 query: String::new(),
2024 headers: vec![("authorization".into(), format!("Bearer {token}"))],
2025 raw_body: Bytes::new(),
2026 host_url: Some("https://pod.example".into()),
2027 };
2028 assert_eq!(svc.resolve_agent(&follow).await, agent);
2029 }
2030
2031 #[tokio::test]
2032 async fn token_mint_anonymous_is_401() {
2033 let (_td, svc) = service();
2034 let r = svc
2035 .handle(post_form("/forge/api/token", ""), ForgeAgent::Anonymous)
2036 .await
2037 .unwrap();
2038 assert_eq!(r.status, 401);
2039 }
2040
2041 #[tokio::test]
2042 async fn podless_nostr_issue_uses_hosted_store() {
2043 if !git_available() {
2044 return;
2045 }
2046 let (td, svc) = service();
2047 let hex = hex64();
2048 seed_repo(&td, &hex, "proj");
2049
2050 let r = svc
2052 .handle(
2053 post_json(
2054 &format!("/forge/{hex}/proj/issues"),
2055 "{\"title\":\"podless bug\",\"body\":\"no pod here\"}",
2056 ),
2057 nostr_agent(&hex),
2058 )
2059 .await
2060 .unwrap();
2061 assert_eq!(r.status, 303, "podless issue create should redirect");
2062
2063 let (s, detail) = body(&svc, &format!("/forge/{hex}/proj/issues/1")).await;
2065 assert_eq!(s, 200);
2066 assert!(detail.contains("no pod here"));
2067 assert!(detail.contains("podless bug"));
2068 }
2069
2070 #[tokio::test]
2071 async fn hosted_api_get_and_owner_only_delete() {
2072 let (td, svc) = service();
2073 let hex = hex64();
2074 let store = HostedStore::new(td.path());
2076 let rref = store.write(&hex, b"{\"body\":\"hi\"}").await.unwrap();
2077 let (h, u) = HostedStore::parse_ref(&rref).unwrap();
2078
2079 let (s, got) = body(&svc, &format!("/forge/api/hosted/{h}/{u}")).await;
2081 assert_eq!(s, 200);
2082 assert!(got.contains("hi"));
2083
2084 let del_other = ForgeRequest {
2086 method: "DELETE".into(),
2087 path: format!("/forge/api/hosted/{h}/{u}"),
2088 query: String::new(),
2089 headers: vec![],
2090 raw_body: Bytes::new(),
2091 host_url: Some("https://pod.example".into()),
2092 };
2093 let r1 = svc
2094 .handle(del_other.clone(), nostr_agent(&"e".repeat(64)))
2095 .await
2096 .unwrap();
2097 assert_eq!(r1.status, 403);
2098
2099 let r2 = svc.handle(del_other, nostr_agent(&hex)).await.unwrap();
2101 assert_eq!(r2.status, 200);
2102 let (s2, _) = body(&svc, &format!("/forge/api/hosted/{h}/{u}")).await;
2103 assert_eq!(s2, 404);
2104 }
2105
2106 #[tokio::test]
2107 async fn namespace_push_guard_blocks_foreign_push() {
2108 let (_td, svc) = service();
2109 let push = ForgeRequest {
2112 method: "POST".into(),
2113 path: "/forge/alice/demo.git/git-receive-pack".into(),
2114 query: String::new(),
2115 headers: vec![],
2116 raw_body: Bytes::new(),
2117 host_url: Some("https://pod.example".into()),
2118 };
2119 let r = svc
2120 .handle(push.clone(), nostr_agent(&hex64()))
2121 .await
2122 .unwrap();
2123 assert_eq!(r.status, 403);
2124
2125 let r_anon = svc.handle(push, ForgeAgent::Anonymous).await.unwrap();
2127 assert_eq!(r_anon.status, 403);
2128 }
2129
2130 #[tokio::test]
2131 async fn namespace_push_guard_allows_own_push() {
2132 let (_td, svc) = service();
2135 let hex = hex64();
2136 let push = ForgeRequest {
2137 method: "POST".into(),
2138 path: format!("/forge/{hex}/demo.git/git-receive-pack"),
2139 query: String::new(),
2140 headers: vec![],
2141 raw_body: Bytes::new(),
2142 host_url: Some("https://pod.example".into()),
2143 };
2144 let r = svc.handle(push, nostr_agent(&hex)).await.unwrap();
2145 assert_ne!(r.status, 403, "own-namespace push must pass the guard");
2146 }
2147
2148 #[cfg(feature = "mirror")]
2149 #[tokio::test]
2150 async fn a_mirrored_repository_refuses_even_its_owners_push() {
2151 let (td, svc) = service();
2152 let hex = hex64();
2153 let dir = td.path().join("mirror").join(&hex);
2154 std::fs::create_dir_all(&dir).unwrap();
2155 std::fs::write(
2156 dir.join("demo.json"),
2157 serde_json::json!({
2158 "pubkey": hex, "name": "demo", "clone": null, "events": 1,
2159 "lastSeen": null, "lastEvent": null, "lastFetchAt": null,
2160 "lastFetch": null, "announceAt": null, "clones": []
2161 })
2162 .to_string(),
2163 )
2164 .unwrap();
2165 let push = ForgeRequest {
2166 method: "POST".into(),
2167 path: format!("/forge/{hex}/demo.git/git-receive-pack"),
2168 query: String::new(),
2169 headers: vec![],
2170 raw_body: Bytes::new(),
2171 host_url: Some("https://pod.example".into()),
2172 };
2173 let r = svc.handle(push, nostr_agent(&hex)).await.unwrap();
2174 assert_eq!(r.status, 403);
2175 assert!(String::from_utf8_lossy(&r.body).contains("read-only"));
2176 }
2177}