#![forbid(unsafe_code)]
#![warn(missing_docs)]
#![warn(rust_2018_idioms)]
pub mod api;
pub mod api_issues;
pub mod auth;
pub mod bodies;
pub mod compare;
pub mod config;
pub mod config_file;
pub mod diff;
pub mod error;
pub mod forks;
pub mod gitmark;
pub mod gitrun;
pub mod guard;
pub mod hosted;
pub mod hosted_edit;
pub mod html;
pub mod labels;
pub mod locks;
pub mod markdown;
#[cfg(feature = "anchoring")]
pub mod marks_ui;
pub mod merge;
#[cfg(feature = "mirror")]
pub mod mirror;
#[cfg(feature = "announce")]
pub mod nip34;
pub mod ownership;
pub mod preview;
mod prgit;
mod privacy;
#[cfg(feature = "announce")]
pub mod publish;
pub mod pulls;
pub mod releases;
pub mod repo;
pub mod request;
pub mod router;
pub mod search;
mod sizecap;
pub mod spine;
pub mod token;
#[cfg(feature = "anchoring")]
pub mod trail;
pub mod web_edit;
pub mod xlogin;
pub const WIDGET_PAGE_HEADER: &str = "x-forge-csp";
#[cfg(test)]
mod readfeat_tests;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use solid_pod_rs_git::service::{GitHttpService, GitRequest};
pub use bodies::{HostedReader, LoopbackFetch};
pub use config::ForgeConfig;
pub use error::ForgeError;
pub use hosted::HostedStore;
pub use ownership::ForgeAgent;
pub use request::{esc, ForgeRequest, ForgeResponse};
pub use router::{parse_route, Route};
pub use spine::{FsSpineStore, SpineStore};
pub use token::TokenError;
pub mod prelude {
pub use crate::config::ForgeConfig;
pub use crate::error::ForgeError;
pub use crate::ownership::ForgeAgent;
pub use crate::request::{ForgeRequest, ForgeResponse};
pub use crate::ForgeService;
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn load_or_create_token_key(plugin_dir: &Path) -> Result<[u8; 32], ForgeError> {
let path = plugin_dir.join(".forge-token-key");
if let Ok(bytes) = std::fs::read(&path) {
if bytes.len() == 32 {
let mut key = [0u8; 32];
key.copy_from_slice(&bytes);
return Ok(key);
}
}
let mut key = [0u8; 32];
key[..16].copy_from_slice(uuid::Uuid::new_v4().as_bytes());
key[16..].copy_from_slice(uuid::Uuid::new_v4().as_bytes());
std::fs::write(&path, key)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
}
Ok(key)
}
#[derive(Clone)]
pub struct ForgeService {
cfg: ForgeConfig,
plugin_dir: PathBuf,
repo_root: PathBuf,
git: Arc<GitHttpService>,
spine: Arc<dyn SpineStore>,
loopback: Option<Arc<dyn LoopbackFetch>>,
hosted: Arc<HostedStore>,
token_key: Arc<[u8; 32]>,
nip98_replay: solid_pod_rs::auth::replay::Nip98ReplayCache,
#[cfg(feature = "announce")]
announcer: Arc<nip34::Announcer>,
#[cfg(feature = "anchoring")]
trails: Arc<trail::TrailStore>,
pr: pulls::PrExt,
locks: Arc<locks::ForgeLocks>,
}
impl std::fmt::Debug for ForgeService {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ForgeService")
.field("prefix", &self.cfg.normalized_prefix())
.field("plugin_dir", &self.plugin_dir)
.finish()
}
}
impl ForgeService {
pub fn new(cfg: ForgeConfig, plugin_dir: impl Into<PathBuf>) -> Result<Self, ForgeError> {
cfg.validate_chain()?;
let plugin_dir = plugin_dir.into();
let repo_root = plugin_dir.join("repos");
for sub in ["repos", "issues", "pulls", "hosted", "marks"] {
std::fs::create_dir_all(plugin_dir.join(sub))?;
}
let git = Arc::new(GitHttpService::new(repo_root.clone()));
let spine: Arc<dyn SpineStore> = Arc::new(FsSpineStore::new(plugin_dir.clone()));
let hosted = Arc::new(HostedStore::new(plugin_dir.clone()));
let token_key = Arc::new(load_or_create_token_key(&plugin_dir)?);
if cfg.open_edit {
tracing::warn!(
"forge: config.open_edit is ON: anyone can edit repos via the web-edit endpoint \
(no owner signature required). DEMO ONLY; never enable on a real forge."
);
}
#[cfg(feature = "announce")]
let announcer = Arc::new(nip34::Announcer::new(&cfg, &plugin_dir)?);
#[cfg(feature = "anchoring")]
let trails = Arc::new(trail::TrailStore::new(&cfg, &plugin_dir)?);
Ok(Self {
cfg,
plugin_dir,
repo_root,
git,
spine,
loopback: None,
hosted,
token_key,
nip98_replay: solid_pod_rs::auth::replay::Nip98ReplayCache::from_env(),
#[cfg(feature = "announce")]
announcer,
#[cfg(feature = "anchoring")]
trails,
pr: pulls::PrExt::default(),
locks: Arc::new(locks::ForgeLocks::default()),
})
}
#[must_use]
pub fn locks(&self) -> &locks::ForgeLocks {
&self.locks
}
#[must_use]
pub fn config(&self) -> &ForgeConfig {
&self.cfg
}
#[must_use]
pub fn with_loopback(mut self, lb: Arc<dyn LoopbackFetch>) -> Self {
self.loopback = Some(lb);
self
}
#[must_use]
pub fn with_spine(mut self, spine: Arc<dyn SpineStore>) -> Self {
self.spine = spine;
self
}
#[must_use]
pub fn token_key(&self) -> &[u8; 32] {
&self.token_key
}
pub async fn resolve_agent(&self, req: &ForgeRequest) -> ForgeAgent {
let pinned = self.pinned_host_url(req.host_url.as_deref());
if pinned.as_deref() != req.host_url.as_deref() {
let mut r = req.clone();
r.host_url = pinned;
return auth::resolve_agent(
&r,
self.token_key.as_ref(),
&self.nip98_replay,
now_secs(),
)
.await;
}
auth::resolve_agent(req, self.token_key.as_ref(), &self.nip98_replay, now_secs()).await
}
fn pinned_host_url(&self, host_url: Option<&str>) -> Option<String> {
match self.cfg.base_url.as_deref() {
Some(b) => {
let b = b.trim_end_matches('/');
let end = b
.find("://")
.map(|i| i + 3)
.and_then(|s| b[s..].find('/').map(|j| s + j))
.unwrap_or(b.len());
Some(b[..end].to_string())
}
None => host_url.map(str::to_string),
}
}
#[must_use]
pub fn prefix(&self) -> String {
self.cfg.normalized_prefix()
}
#[must_use]
pub fn plugin_dir(&self) -> &Path {
&self.plugin_dir
}
pub async fn handle(
&self,
mut req: ForgeRequest,
agent: ForgeAgent,
) -> Result<ForgeResponse, ForgeError> {
req.host_url = self.pinned_host_url(req.host_url.as_deref());
if req.method.eq_ignore_ascii_case("OPTIONS") {
return Ok(ForgeResponse {
status: 204,
headers: vec![
("access-control-allow-origin".into(), "*".into()),
(
"access-control-allow-methods".into(),
"GET, POST, PUT, PATCH, DELETE, OPTIONS".into(),
),
(
"access-control-allow-headers".into(),
"Content-Type, Authorization".into(),
),
],
body: bytes::Bytes::new(),
});
}
let prefix = self.cfg.normalized_prefix();
let Some(rel) = router::strip_prefix(&prefix, &req.path) else {
return Ok(ForgeResponse::error(404, "not under forge prefix"));
};
if let Err(e) = guard::percent_guard(&rel) {
return Ok(self.finish_page(e.to_response()));
}
let route = parse_route(&rel);
if self.cfg.private_repos {
if let Some(denied) = self.private_gate(&rel, &route, &agent) {
return Ok(self.finish_page(denied));
}
}
#[cfg(feature = "anchoring")]
let scope = route
.repo_page_scope()
.map(|(o, r)| (o.to_string(), r.to_string()));
let result = self.dispatch(route, &req, &agent).await;
#[cfg(feature = "anchoring")]
let result = match (scope, result) {
(Some((o, r)), Ok(resp)) => Ok(self.decorate_repo_page(&o, &r, resp).await),
(_, other) => other,
};
Ok(self.finish_page(result.unwrap_or_else(|e| e.to_response())))
}
fn finish_page(&self, mut resp: ForgeResponse) -> ForgeResponse {
let is_html = resp
.headers
.iter()
.any(|(k, v)| k.eq_ignore_ascii_case("content-type") && v.starts_with("text/html"));
if is_html {
let widget = resp
.headers
.iter()
.any(|(k, v)| k.eq_ignore_ascii_case(WIDGET_PAGE_HEADER) && v == "widget");
resp.headers
.retain(|(k, _)| !k.eq_ignore_ascii_case(WIDGET_PAGE_HEADER));
let csp = if widget {
xlogin::build_csp(&self.cfg.csp_connect)
} else {
let nonce = uuid::Uuid::new_v4().simple().to_string();
xlogin::build_strict_csp(&self.cfg.csp_connect, &nonce)
};
resp.headers.push(("content-security-policy".into(), csp));
if !resp
.headers
.iter()
.any(|(k, _)| k.eq_ignore_ascii_case("x-content-type-options"))
{
resp.headers
.push(("x-content-type-options".into(), "nosniff".into()));
}
}
resp
}
async fn dispatch(
&self,
route: Route,
req: &ForgeRequest,
_agent: &ForgeAgent,
) -> Result<ForgeResponse, ForgeError> {
match route {
Route::Index => self.h_index_filtered(req, _agent).await,
Route::OwnerIndex { owner } => self.h_owner(&owner).await,
Route::GitSmart { rel_path } => self.h_git_smart(req, _agent, &rel_path).await,
Route::RepoOverview { owner, repo } => self.h_overview(&owner, &repo).await,
Route::Tree {
owner,
repo,
rev,
path,
} => self.h_tree(&owner, &repo, &rev, &path).await,
Route::Blob {
owner,
repo,
rev,
path,
} => self.h_blob(&owner, &repo, &rev, &path).await,
Route::Raw {
owner,
repo,
rev,
path,
} => self.h_raw(&owner, &repo, &rev, &path).await,
Route::Commits { owner, repo, rev } => self.h_commits(req, &owner, &repo, &rev).await,
Route::Commit { owner, repo, sha } => self.h_commit(&owner, &repo, &sha).await,
Route::Branches { owner, repo } => self.h_branches(&owner, &repo).await,
Route::Tags { owner, repo } => self.h_tags(&owner, &repo).await,
Route::BlocktrailsJson { owner, repo } => self.h_blocktrails_json(&owner, &repo).await,
#[cfg(feature = "anchoring")]
Route::ApiMarks { owner, repo, tail } => {
self.h_api_marks(req, _agent, &owner, &repo, &tail).await
}
#[cfg(feature = "anchoring")]
Route::Marks { owner, repo } => self.h_marks_page(req, &owner, &repo).await,
#[cfg(not(feature = "anchoring"))]
Route::ApiMarks { .. } | Route::Marks { .. } => Err(ForgeError::Unsupported(
"anchoring is not compiled into this build".into(),
)),
Route::ApiMirror { owner, repo } => Ok(self.h_api_mirror(&owner, &repo)),
Route::ApiNostr { owner, repo } => self.h_api_nostr(req, &owner, &repo).await,
Route::ApiAnnounce { owner, repo } => {
self.h_api_announce(req, _agent, &owner, &repo).await
}
Route::Issues { owner, repo } => {
if req.method.eq_ignore_ascii_case("POST") {
self.h_issue_create(req, _agent, &owner, &repo).await
} else {
self.h_issues_list(req, &owner, &repo).await
}
}
Route::IssueNew { owner, repo } => self.h_issue_new_form(&owner, &repo).await,
Route::IssueDetail { owner, repo, num } => {
if req.method.eq_ignore_ascii_case("POST") {
self.h_issue_comment(req, _agent, &owner, &repo, num).await
} else {
self.h_issue_detail(&owner, &repo, num).await
}
}
Route::ApiReposList => self.h_api_repo_list(req, _agent, None).await,
Route::ApiToken => self.h_api_token(req, _agent).await,
Route::ApiHosted { hex, id } => self.h_api_hosted(req, _agent, &hex, &id).await,
Route::ApiFork { owner, repo } => self.h_api_fork(req, _agent, &owner, &repo).await,
Route::ApiCompare { owner, repo, spec } => {
self.h_api_compare(_agent, &owner, &repo, &spec).await
}
Route::ApiPulls { owner, repo, tail } => {
self.h_api_pulls(req, _agent, &owner, &repo, &tail).await
}
Route::Compare { owner, repo, spec } => {
self.h_compare_page(_agent, &owner, &repo, &spec).await
}
Route::Pulls { owner, repo, tail } => {
self.h_pulls_page(req, _agent, &owner, &repo, &tail).await
}
Route::Xlogin => Ok(xlogin::xlogin_response(&req.method)),
Route::ApiOwnerRepos { owner } => self.h_api_repo_list(req, _agent, Some(&owner)).await,
Route::ApiRepo { owner, repo } => self.h_api_repo(req, _agent, &owner, &repo).await,
Route::ApiRepoAction {
owner,
repo,
action,
tail,
} => {
self.h_api_repo_action(req, _agent, &owner, &repo, &action, &tail)
.await
}
Route::RepoSearch { owner, repo } => self.h_search_page(req, &owner, &repo).await,
Route::RepoReleases { owner, repo } => self.h_releases_page(&owner, &repo).await,
Route::RepoArchive { owner, repo, spec } => self.h_archive(&owner, &repo, &spec).await,
Route::ApiEdit { owner, repo } => Ok(self.h_api_edit(req, _agent, &owner, &repo).await),
Route::ApiPreview { owner, repo } => {
Ok(self.h_api_preview(req, _agent, &owner, &repo).await)
}
_ => Err(ForgeError::NotFound("not implemented".into())),
}
}
async fn resolve_repo(&self, owner: &str, repo: &str) -> Result<PathBuf, ForgeError> {
let dir = repo::repo_git_dir(&self.repo_root, owner, repo)?;
if !tokio::fs::metadata(&dir)
.await
.map(|m| m.is_dir())
.unwrap_or(false)
{
return Err(ForgeError::NotFound(format!("repo {owner}/{repo}")));
}
Ok(dir)
}
async fn h_owner(&self, owner: &str) -> Result<ForgeResponse, ForgeError> {
let repos = repo::list_owner(&self.repo_root, owner).await;
if repos.is_empty() {
return Err(ForgeError::NotFound(format!("owner {owner}")));
}
let page = html::owner_page(&self.prefix(), owner, &repos);
let pairs: Vec<(String, String)> = repos
.iter()
.map(|r| (owner.to_string(), r.clone()))
.collect();
Ok(ForgeResponse::html(
200,
self.decorate_listing(&page, &pairs),
))
}
async fn h_git_smart(
&self,
req: &ForgeRequest,
agent: &ForgeAgent,
rel_path: &str,
) -> Result<ForgeResponse, ForgeError> {
let git_req = GitRequest {
method: req.method.clone(),
path: rel_path.to_string(),
query: req.query.clone(),
headers: req.headers.clone(),
body: req.raw_body.clone(),
host_url: req.host_url.clone(),
};
if git_req.is_write() {
let owner = rel_path
.trim_start_matches('/')
.split('/')
.next()
.unwrap_or("");
if !agent.can_write_namespace(owner) {
return Err(ForgeError::Forbidden(format!(
"cannot push into namespace '{owner}'"
)));
}
#[cfg(feature = "mirror")]
{
let name = rel_path
.trim_start_matches('/')
.split('/')
.nth(1)
.map_or("", |n| n.strip_suffix(".git").unwrap_or(n));
if mirror::MirrorStore::new(&self.plugin_dir)
.read(owner, name)
.is_some()
{
return Err(ForgeError::Forbidden(format!(
"'{owner}/{name}' is a mirror and read-only"
)));
}
}
}
#[cfg(feature = "anchoring")]
let pushed = git_req.method.eq_ignore_ascii_case("POST")
&& rel_path
.trim_end_matches('/')
.ends_with("/git-receive-pack");
let resp = self.git.handle(git_req).await?;
#[cfg(feature = "anchoring")]
if pushed && resp.status < 300 {
let mut parts = rel_path.trim_start_matches('/').split('/');
if let (Some(owner), Some(name)) = (parts.next(), parts.next()) {
let name = name.strip_suffix(".git").unwrap_or(name);
self.record_pushed_tip(owner, name).await;
}
}
Ok(ForgeResponse {
status: resp.status,
headers: resp.headers,
body: resp.body,
})
}
async fn h_overview(&self, owner: &str, repo: &str) -> Result<ForgeResponse, ForgeError> {
let dir = self.resolve_repo(owner, repo).await?;
let prefix = self.prefix();
let open = spine::issues::load_issue_index(self.spine.as_ref(), owner, repo)
.await
.map(|i| i.count(spine::issues::IssueState::Open))
.unwrap_or(0);
let description = api::repo_description(&dir).await;
let extras = html::repo_extras(&prefix, owner, repo, &description, open);
if !repo::browse::has_commits(&dir).await {
let page = html::repo_overview_page(&prefix, owner, repo, "main", &[], None);
let page = self.decorate_overview(page, owner, repo).await;
return Ok(ForgeResponse::html(
200,
html::inject_after_header(&page, &extras),
));
}
let branch = repo::browse::default_branch(&dir).await;
let entries = repo::browse::list_tree(&dir, &branch, "").await?;
let readme = self.find_readme(&dir, &branch, &entries).await;
let page =
html::repo_overview_page(&prefix, owner, repo, &branch, &entries, readme.as_deref());
let page = self.decorate_overview(page, owner, repo).await;
Ok(ForgeResponse::html(
200,
html::inject_after_header(&page, &extras),
))
}
async fn find_readme(
&self,
dir: &Path,
rev: &str,
entries: &[repo::browse::TreeEntry],
) -> Option<String> {
let candidate = entries.iter().find(|e| {
e.kind == repo::browse::EntryKind::File && {
let n = e.name.to_ascii_lowercase();
n == "readme" || n == "readme.md" || n == "readme.txt"
}
})?;
let bytes =
repo::browse::read_blob(dir, rev, &candidate.name, self.cfg.max_body_bytes as u64)
.await
.ok()?;
if request::looks_textual(&bytes) {
Some(String::from_utf8_lossy(&bytes).into_owned())
} else {
None
}
}
async fn h_tree(
&self,
owner: &str,
repo: &str,
rev: &str,
path: &str,
) -> Result<ForgeResponse, ForgeError> {
let dir = self.resolve_repo(owner, repo).await?;
let entries = repo::browse::list_tree(&dir, rev, path).await?;
Ok(ForgeResponse::html(
200,
html::tree_page(&self.prefix(), owner, repo, rev, path, &entries),
))
}
async fn h_blob(
&self,
owner: &str,
repo: &str,
rev: &str,
path: &str,
) -> Result<ForgeResponse, ForgeError> {
let dir = self.resolve_repo(owner, repo).await?;
const BLOB_VIEW_MAX: u64 = 1024 * 1024;
let content = match repo::browse::read_blob(&dir, rev, path, BLOB_VIEW_MAX).await {
Ok(bytes) => {
if request::looks_textual(&bytes) {
Some(String::from_utf8_lossy(&bytes).into_owned())
} else {
None
}
}
Err(ForgeError::BadRequest(_)) => None,
Err(e) => return Err(e),
};
Ok(ForgeResponse::html(
200,
html::blob_page(&self.prefix(), owner, repo, rev, path, content.as_deref()),
))
}
async fn h_raw(
&self,
owner: &str,
repo: &str,
rev: &str,
path: &str,
) -> Result<ForgeResponse, ForgeError> {
let dir = self.resolve_repo(owner, repo).await?;
const RAW_MAX: u64 = 25 * 1024 * 1024;
let bytes = repo::browse::read_blob(&dir, rev, path, RAW_MAX).await?;
let is_text = request::looks_textual(&bytes);
let filename = path.rsplit('/').next().unwrap_or("download");
Ok(ForgeResponse::raw_bytes(
bytes::Bytes::from(bytes),
is_text,
filename,
))
}
async fn h_commits(
&self,
req: &ForgeRequest,
owner: &str,
repo: &str,
rev: &str,
) -> Result<ForgeResponse, ForgeError> {
let dir = self.resolve_repo(owner, repo).await?;
let page: u32 = req
.query_param("page")
.and_then(|p| p.parse().ok())
.unwrap_or(1)
.max(1);
let (commits, has_next) = repo::browse::commit_log(&dir, rev, page, 50).await?;
Ok(ForgeResponse::html(
200,
html::commits_page(&self.prefix(), owner, repo, rev, &commits, page, has_next),
))
}
async fn h_commit(
&self,
owner: &str,
repo: &str,
sha: &str,
) -> Result<ForgeResponse, ForgeError> {
let dir = self.resolve_repo(owner, repo).await?;
let meta = solid_pod_rs_git::api::resolve_commit(&dir, sha).await?;
let patch = repo::browse::commit_patch(&dir, &meta.hash).await?;
Ok(ForgeResponse::html(
200,
html::commit_page(&self.prefix(), owner, repo, &meta, &patch),
))
}
async fn h_branches(&self, owner: &str, repo: &str) -> Result<ForgeResponse, ForgeError> {
let dir = self.resolve_repo(owner, repo).await?;
let info = solid_pod_rs_git::api::git_branches(&dir).await?;
Ok(ForgeResponse::html(
200,
html::refs_page(
&self.prefix(),
owner,
repo,
"Branches",
Some(&info.current),
&info.local,
"commits",
),
))
}
#[cfg(feature = "mirror")]
fn h_api_mirror(&self, owner: &str, repo: &str) -> ForgeResponse {
match mirror::MirrorStore::new(&self.plugin_dir).read(owner, repo) {
Some(rec) => ForgeResponse::json(
200,
&serde_json::to_value(&rec).unwrap_or(serde_json::Value::Null),
),
None => ForgeResponse::json(404, &serde_json::json!({"error": "not a mirror"})),
}
}
#[cfg(not(feature = "announce"))]
#[allow(clippy::unused_self, clippy::unused_async)]
async fn h_api_nostr(
&self,
_req: &ForgeRequest,
_owner: &str,
_repo: &str,
) -> Result<ForgeResponse, ForgeError> {
Err(ForgeError::Unsupported(
"NIP-34 emission needs the `announce` feature".into(),
))
}
#[cfg(not(feature = "announce"))]
#[allow(clippy::unused_self, clippy::unused_async)]
async fn h_api_announce(
&self,
_req: &ForgeRequest,
_agent: &ForgeAgent,
_owner: &str,
_repo: &str,
) -> Result<ForgeResponse, ForgeError> {
Err(ForgeError::Unsupported(
"NIP-34 emission needs the `announce` feature".into(),
))
}
#[cfg(not(feature = "mirror"))]
#[allow(clippy::unused_self)]
fn h_api_mirror(&self, _owner: &str, _repo: &str) -> ForgeResponse {
ForgeResponse::json(404, &serde_json::json!({"error": "not a mirror"}))
}
async fn h_blocktrails_json(
&self,
owner: &str,
repo: &str,
) -> Result<ForgeResponse, ForgeError> {
#[cfg(feature = "anchoring")]
if let Some(doc) = self.forge_blocktrails_json(owner, repo)? {
let mut resp = ForgeResponse::with_type(
200,
"application/json; charset=utf-8",
bytes::Bytes::from(doc),
);
resp.headers
.push(("access-control-allow-origin".into(), "*".into()));
resp.headers
.push(("x-content-type-options".into(), "nosniff".into()));
return Ok(resp);
}
let dir = self.resolve_repo(owner, repo).await?;
let branch = repo::browse::default_branch(&dir).await;
let (status, body) = match gitmark::read_repo_trail(&dir, &branch).await {
Some(trail) => (200, trail.document_json()),
None => (
404,
serde_json::json!({"error": "anchoring is not enabled for this repository"})
.to_string(),
),
};
let mut resp = ForgeResponse::with_type(
status,
"application/json; charset=utf-8",
bytes::Bytes::from(body),
);
resp.headers
.push(("access-control-allow-origin".into(), "*".into()));
resp.headers
.push(("x-content-type-options".into(), "nosniff".into()));
Ok(resp)
}
async fn h_tags(&self, owner: &str, repo: &str) -> Result<ForgeResponse, ForgeError> {
let dir = self.resolve_repo(owner, repo).await?;
let tags = repo::browse::list_tags(&dir).await?;
let page = html::refs_page(&self.prefix(), owner, repo, "Tags", None, &tags, "tree");
Ok(ForgeResponse::html(
200,
html::inject_after_header(&page, &html::releases_link(&self.prefix(), owner, repo)),
))
}
async fn h_issues_list(
&self,
req: &ForgeRequest,
owner: &str,
repo: &str,
) -> Result<ForgeResponse, ForgeError> {
self.resolve_repo(owner, repo).await?;
let idx = spine::issues::load_issue_index(self.spine.as_ref(), owner, repo).await?;
let filter = match req.query_param("state").as_deref() {
Some("closed") => spine::issues::IssueState::Closed,
_ => spine::issues::IssueState::Open,
};
let label = req
.query_param("label")
.map(|l| request::percent_decode(&l))
.filter(|l| !l.is_empty());
let mut issues = idx.by_state(filter);
if let Some(l) = &label {
issues.retain(|i| i.labels.iter().any(|x| x == l));
}
let defs = labels::effective_labels(&idx);
Ok(ForgeResponse::html(
200,
html::issues_list_page(
&self.prefix(),
owner,
repo,
filter,
idx.count(spine::issues::IssueState::Open),
idx.count(spine::issues::IssueState::Closed),
&issues,
&defs,
),
))
}
async fn h_issue_new_form(&self, owner: &str, repo: &str) -> Result<ForgeResponse, ForgeError> {
self.resolve_repo(owner, repo).await?;
Ok(ForgeResponse::html(
200,
html::issue_new_page(&self.prefix(), owner, repo),
))
}
async fn h_issue_detail(
&self,
owner: &str,
repo: &str,
num: u64,
) -> Result<ForgeResponse, ForgeError> {
self.resolve_repo(owner, repo).await?;
let idx = spine::issues::load_issue_index(self.spine.as_ref(), owner, repo).await?;
let entry = idx
.issues
.get(&num)
.ok_or_else(|| ForgeError::NotFound(format!("issue #{num}")))?;
let truncated = entry.thread.len() > self.cfg.thread_cap;
let hosted: Arc<dyn HostedReader> = self.hosted.clone();
let threads = bodies::render_thread(
&entry.thread,
self.loopback.clone(),
Some(hosted),
&self.cfg,
)
.await;
Ok(ForgeResponse::html(
200,
html::issue_detail_page(
&self.prefix(),
owner,
repo,
entry,
&threads,
truncated,
&labels::effective_labels(&idx),
),
))
}
fn parse_issue_body(req: &ForgeRequest) -> (Option<String>, Option<String>, Option<String>) {
let ct = req.header("content-type").unwrap_or("");
let raw = String::from_utf8_lossy(&req.raw_body);
let field_json = |v: &serde_json::Value, k: &str| {
v.get(k).and_then(|x| x.as_str()).map(|s| s.to_string())
};
if ct.contains("application/json") {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) {
return (
field_json(&v, "title"),
field_json(&v, "resourceUrl"),
field_json(&v, "body"),
);
}
}
let pairs = request::parse_form(&raw);
let field = |k: &str| pairs.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone());
(field("title"), field("resourceUrl"), field("body"))
}
async fn make_pointer(
&self,
req: &ForgeRequest,
agent: &ForgeAgent,
owner: &str,
repo: &str,
resource_url: Option<&str>,
body: Option<&str>,
) -> Result<spine::issues::ThreadPointer, ForgeError> {
match agent {
ForgeAgent::Anonymous => {
Err(ForgeError::Unauthorised("authentication required".into()))
}
ForgeAgent::Nostr { pubkey_hex } => {
let body = body
.map(str::trim)
.filter(|b| !b.is_empty())
.ok_or_else(|| ForgeError::BadRequest("body required".into()))?;
let doc = hosted_edit::hosted_doc(
"ForgeComment",
owner,
repo,
None,
None,
body,
&agent.author_id(),
now_secs(),
);
let rref = self.hosted.write_doc(pubkey_hex, &doc).await?;
Ok(spine::issues::ThreadPointer {
author: agent.author_id(),
resource_url: rref,
at: now_secs(),
hosted: true,
})
}
ForgeAgent::Pod { .. } => {
let url = resource_url
.ok_or_else(|| ForgeError::BadRequest("resourceUrl required".into()))?;
self.verify_pod_pointer(req, agent, owner, repo, url).await
}
}
}
async fn verify_pod_pointer(
&self,
req: &ForgeRequest,
agent: &ForgeAgent,
repo_owner: &str,
repo: &str,
resource_url: &str,
) -> Result<spine::issues::ThreadPointer, ForgeError> {
let caller = agent
.owner()
.ok_or_else(|| ForgeError::Unauthorised("authentication required".into()))?;
let host = req
.host_url
.as_deref()
.ok_or_else(|| ForgeError::BadRequest("missing host".into()))?;
bodies::own_area_ok(resource_url, host, caller, repo_owner, repo)?;
let lb = self
.loopback
.as_ref()
.ok_or_else(|| ForgeError::Unsupported("pod body verification unavailable".into()))?;
match lb
.get(
resource_url,
self.cfg.max_body_bytes,
self.cfg.fetch_timeout_secs,
)
.await
{
bodies::FetchResult::Body(_) => Ok(spine::issues::ThreadPointer {
author: agent.author_id(),
resource_url: resource_url.to_string(),
at: now_secs(),
hosted: false,
}),
bodies::FetchResult::Removed => Err(ForgeError::BadRequest(
"pod body is not publicly readable".into(),
)),
bodies::FetchResult::TooLarge => {
Err(ForgeError::BadRequest("pod body too large".into()))
}
bodies::FetchResult::Error(e) => {
Err(ForgeError::Backend(format!("pod body fetch failed: {e}")))
}
}
}
async fn h_issue_create(
&self,
req: &ForgeRequest,
agent: &ForgeAgent,
owner: &str,
repo: &str,
) -> Result<ForgeResponse, ForgeError> {
self.resolve_repo(owner, repo).await?;
let (title, resource_url, body) = Self::parse_issue_body(req);
let title = title
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.ok_or_else(|| ForgeError::BadRequest("title required".into()))?;
let pointer = self
.make_pointer(
req,
agent,
owner,
repo,
resource_url.as_deref(),
body.as_deref(),
)
.await?;
let _guard = self.locks.issues.lock(owner, repo).await;
let mut idx = spine::issues::load_issue_index(self.spine.as_ref(), owner, repo).await?;
let entry = spine::issues::IssueEntry {
number: 0,
title,
state: spine::issues::IssueState::Open,
author: agent.author_id(),
created_at: now_secs(),
thread: vec![pointer],
labels: Vec::new(),
};
let num = idx.allocate(entry);
spine::issues::save_issue_index(self.spine.as_ref(), owner, repo, &idx).await?;
let location = format!("{}/{}/{}/issues/{}", self.prefix(), owner, repo, num);
Ok(ForgeResponse::redirect(303, &location))
}
async fn h_issue_comment(
&self,
req: &ForgeRequest,
agent: &ForgeAgent,
owner: &str,
repo: &str,
num: u64,
) -> Result<ForgeResponse, ForgeError> {
self.resolve_repo(owner, repo).await?;
let (_title, resource_url, body) = Self::parse_issue_body(req);
let pointer = self
.make_pointer(
req,
agent,
owner,
repo,
resource_url.as_deref(),
body.as_deref(),
)
.await?;
let _guard = self.locks.issues.lock(owner, repo).await;
let mut idx = spine::issues::load_issue_index(self.spine.as_ref(), owner, repo).await?;
let entry = idx
.issues
.get_mut(&num)
.ok_or_else(|| ForgeError::NotFound(format!("issue #{num}")))?;
entry.thread.push(pointer);
spine::issues::save_issue_index(self.spine.as_ref(), owner, repo, &idx).await?;
let location = format!("{}/{}/{}/issues/{}", self.prefix(), owner, repo, num);
Ok(ForgeResponse::redirect(303, &location))
}
async fn h_api_token(
&self,
req: &ForgeRequest,
agent: &ForgeAgent,
) -> Result<ForgeResponse, ForgeError> {
if !req.method.eq_ignore_ascii_case("POST") {
return Err(ForgeError::NotFound("token requires POST".into()));
}
let iat = now_secs();
let ttl = self.cfg.token_ttl_secs;
let token = token::mint(self.token_key.as_ref(), agent, iat, ttl).ok_or_else(|| {
ForgeError::Unauthorised("authentication required to mint a token".into())
})?;
let v = serde_json::json!({
"token": token,
"tokenType": "Bearer",
"expiresIn": ttl,
"exp": iat + ttl,
"agent": agent.author_id(),
});
Ok(ForgeResponse::json(200, &v))
}
}
#[cfg(test)]
mod tests {
use super::*;
use bytes::Bytes;
use tempfile::TempDir;
fn req(method: &str, path: &str) -> ForgeRequest {
ForgeRequest {
method: method.into(),
path: path.into(),
query: String::new(),
headers: vec![],
raw_body: Bytes::new(),
host_url: Some("https://pod.example".into()),
}
}
#[test]
fn widget_pages_keep_the_widget_csp_and_others_get_a_nonce() {
let (_td, svc) = service();
let csp = |r: &ForgeResponse| {
r.headers
.iter()
.find(|(k, _)| k == "content-security-policy")
.map(|(_, v)| v.clone())
.unwrap_or_default()
};
let plain = svc.finish_page(ForgeResponse::html(200, "<p>hi</p>".to_string()));
let widget = svc.finish_page(
ForgeResponse::html(200, "<p>w</p>".to_string())
.with_header(WIDGET_PAGE_HEADER, "widget"),
);
let mention = svc.finish_page(ForgeResponse::html(
200,
"<p><script src=\"/forge/xlogin.js\"></p>".to_string(),
));
assert!(csp(&mention).contains("script-src 'self' 'nonce-"));
assert!(widget.headers.iter().all(|(k, _)| k != WIDGET_PAGE_HEADER));
assert!(csp(&plain).contains("script-src 'self' 'nonce-"));
assert!(!csp(&plain).contains("unsafe-inline' 'self'"));
assert!(csp(&widget).contains("script-src 'unsafe-inline' 'self' https://esm.sh"));
let again = svc.finish_page(ForgeResponse::html(200, "<p>hi</p>".to_string()));
assert_ne!(csp(&plain), csp(&again), "the nonce is fresh per response");
}
#[tokio::test]
async fn nip98_url_comes_from_base_url_not_a_forged_host() {
use solid_pod_rs::auth::nip98;
const SK: &str = "5555555555555555555555555555555555555555555555555555555555555555";
let td = TempDir::new().unwrap();
let cfg = ForgeConfig {
base_url: Some("https://pod.example/".into()),
..ForgeConfig::default()
};
let svc = ForgeService::new(cfg, td.path()).unwrap();
let signed_for = |url: &str, t: u64| {
let tok = nip98::mint(url, "GET", SK, t).unwrap();
let mut r = req("GET", "/forge/x");
r.headers = vec![("authorization".into(), format!("Nostr {tok}"))];
r.host_url = Some("https://evil.example".into());
r
};
let now = now_secs();
let replayed = signed_for("https://evil.example/forge/x", now);
assert_eq!(svc.resolve_agent(&replayed).await, ForgeAgent::Anonymous);
let honest = signed_for("https://pod.example/forge/x", now + 1);
assert!(matches!(
svc.resolve_agent(&honest).await,
ForgeAgent::Nostr { .. }
));
}
fn service() -> (TempDir, ForgeService) {
let td = TempDir::new().unwrap();
let svc = ForgeService::new(ForgeConfig::default(), td.path()).unwrap();
(td, svc)
}
#[tokio::test]
async fn new_creates_layout() {
let (td, _svc) = service();
for sub in ["repos", "issues", "pulls", "hosted", "marks"] {
assert!(td.path().join(sub).is_dir(), "{sub} dir must exist");
}
}
#[tokio::test]
async fn index_renders_empty() {
let (_td, svc) = service();
let r = svc
.handle(req("GET", "/forge"), ForgeAgent::Anonymous)
.await
.unwrap();
assert_eq!(r.status, 200);
let body = String::from_utf8(r.body.to_vec()).unwrap();
assert!(body.contains("No repositories yet"));
}
#[tokio::test]
async fn index_lists_pushed_repo() {
let (td, svc) = service();
tokio::fs::create_dir_all(td.path().join("repos/alice/demo.git"))
.await
.unwrap();
let r = svc
.handle(req("GET", "/forge"), ForgeAgent::Anonymous)
.await
.unwrap();
let body = String::from_utf8(r.body.to_vec()).unwrap();
assert!(body.contains("/forge/alice/demo"));
let r2 = svc
.handle(req("GET", "/forge/alice"), ForgeAgent::Anonymous)
.await
.unwrap();
assert_eq!(r2.status, 200);
}
#[tokio::test]
async fn unknown_owner_is_404() {
let (_td, svc) = service();
let r = svc
.handle(req("GET", "/forge/ghost"), ForgeAgent::Anonymous)
.await
.unwrap();
assert_eq!(r.status, 404);
}
#[tokio::test]
async fn options_preflight() {
let (_td, svc) = service();
let r = svc
.handle(req("OPTIONS", "/forge/x"), ForgeAgent::Anonymous)
.await
.unwrap();
assert_eq!(r.status, 204);
assert!(r
.headers
.iter()
.any(|(k, _)| k.eq_ignore_ascii_case("access-control-allow-methods")));
}
#[tokio::test]
async fn outside_prefix_is_404() {
let (_td, svc) = service();
let r = svc
.handle(req("GET", "/other/thing"), ForgeAgent::Anonymous)
.await
.unwrap();
assert_eq!(r.status, 404);
}
#[tokio::test]
async fn unimplemented_route_is_404_for_now() {
let (_td, svc) = service();
let r = svc
.handle(
req("GET", "/forge/alice/repo/issues"),
ForgeAgent::Anonymous,
)
.await
.unwrap();
assert_eq!(r.status, 404);
}
fn git_available() -> bool {
std::process::Command::new("git")
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn run_git(dir: &std::path::Path, args: &[&str]) {
let ok = std::process::Command::new("git")
.args(args)
.current_dir(dir)
.env("GIT_CONFIG_NOSYSTEM", "1")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
assert!(ok, "git {args:?} failed in {}", dir.display());
}
fn seed_repo(td: &TempDir, owner: &str, name: &str) {
let work = td.path().join(format!("work-{owner}-{name}"));
std::fs::create_dir_all(&work).unwrap();
run_git(&work, &["init", "-b", "main"]);
std::fs::write(work.join("README.md"), "# Demo\nhello forge\n").unwrap();
std::fs::create_dir_all(work.join("src")).unwrap();
std::fs::write(work.join("src/lib.rs"), "pub fn f() -> u8 { 42 }\n").unwrap();
run_git(&work, &["add", "-A"]);
run_git(
&work,
&[
"-c",
"user.email=t@e.st",
"-c",
"user.name=Tester",
"commit",
"-m",
"initial commit",
],
);
run_git(&work, &["tag", "v1.0"]);
let bare = td
.path()
.join("repos")
.join(owner)
.join(format!("{name}.git"));
std::fs::create_dir_all(bare.parent().unwrap()).unwrap();
run_git(
td.path(),
&[
"clone",
"--bare",
work.to_str().unwrap(),
bare.to_str().unwrap(),
],
);
}
async fn body(svc: &ForgeService, path: &str) -> (u16, String) {
let r = svc
.handle(req("GET", path), ForgeAgent::Anonymous)
.await
.unwrap();
(r.status, String::from_utf8_lossy(&r.body).into_owned())
}
#[tokio::test]
async fn overview_lists_tree_and_readme() {
if !git_available() {
return;
}
let (td, svc) = service();
seed_repo(&td, "alice", "demo");
let (status, html) = body(&svc, "/forge/alice/demo").await;
assert_eq!(status, 200);
assert!(html.contains("alice/demo"));
assert!(html.contains("README.md"));
assert!(html.contains("src"));
assert!(html.contains("hello forge"));
}
#[tokio::test]
async fn blocktrails_json_serves_a_repo_held_trail_with_cors() {
if !git_available() {
return;
}
let (td, svc) = service();
seed_repo(&td, "casey", "agent");
seed_repo(&td, "casey", "demo");
let header = |r: &ForgeResponse, k: &str| {
r.headers
.iter()
.find(|(n, _)| n == k)
.map(|(_, v)| v.clone())
};
let none = svc
.handle(
req("GET", "/forge/casey/demo/blocktrails.json"),
ForgeAgent::Anonymous,
)
.await
.unwrap();
assert_eq!(none.status, 404);
assert_eq!(
header(&none, "access-control-allow-origin").as_deref(),
Some("*")
);
assert!(String::from_utf8_lossy(&none.body).contains("anchoring is not enabled"));
let (c0, c1) = ("1".repeat(40), "2".repeat(40));
let doc = serde_json::json!({
"@type": "Blocktrail", "version": "0.0.3", "profile": "gitmark",
"pubkeyBase": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa",
"chain": "txbt4", "states": [c0, c1],
"txo": [
format!("txo:txbt4:{}:0?amount=9700&commit={c0}", "ab".repeat(32)),
format!("txo:txbt4:{}:0?amount=9400&commit={c1}", "cd".repeat(32)),
],
});
let work = td.path().join("work-casey-agent");
std::fs::write(work.join("blocktrails.json"), doc.to_string()).unwrap();
run_git(&work, &["add", "-A"]);
run_git(
&work,
&[
"-c",
"user.email=t@e.st",
"-c",
"user.name=Tester",
"commit",
"-m",
"trail",
],
);
let bare = td.path().join("repos").join("casey").join("agent.git");
run_git(&work, &["push", bare.to_str().unwrap(), "main"]);
let held = svc
.handle(
req("GET", "/forge/casey/agent/blocktrails.json"),
ForgeAgent::Anonymous,
)
.await
.unwrap();
assert_eq!(held.status, 200);
assert_eq!(
header(&held, "access-control-allow-origin").as_deref(),
Some("*")
);
assert_eq!(
header(&held, "x-content-type-options").as_deref(),
Some("nosniff")
);
let served: serde_json::Value = serde_json::from_slice(&held.body).unwrap();
assert_eq!(served["profile"], "gitmark");
assert_eq!(served["states"], serde_json::json!([c0, c1]));
assert_eq!(served["pubkeyBase"], doc["pubkeyBase"]);
}
#[tokio::test]
async fn api_mirror_is_404_for_a_repo_that_is_not_a_mirror() {
let (_td, svc) = service();
let r = svc
.handle(
req(
"GET",
&format!("/forge/api/repos/{}/agent/mirror", "ab".repeat(32)),
),
ForgeAgent::Anonymous,
)
.await
.unwrap();
assert_eq!(r.status, 404);
assert!(String::from_utf8_lossy(&r.body).contains("not a mirror"));
}
#[cfg(feature = "mirror")]
#[tokio::test]
async fn api_mirror_serves_the_record_before_the_repo_exists() {
let (td, svc) = service();
let pubkey = "ab".repeat(32);
let dir = td.path().join("mirror").join(&pubkey);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("agent.json"),
serde_json::json!({
"pubkey": pubkey, "name": "agent", "clone": null, "events": 1,
"lastSeen": null, "lastEvent": null, "lastFetchAt": null,
"lastFetch": null, "announceAt": null, "clones": []
})
.to_string(),
)
.unwrap();
assert!(!td
.path()
.join("repos")
.join(&pubkey)
.join("agent.git")
.exists());
let r = svc
.handle(
req("GET", &format!("/forge/api/repos/{pubkey}/agent/mirror")),
ForgeAgent::Anonymous,
)
.await
.unwrap();
assert_eq!(r.status, 200);
let v: serde_json::Value = serde_json::from_slice(&r.body).unwrap();
assert_eq!(v["name"], "agent");
assert_eq!(v["events"], 1);
}
#[tokio::test]
async fn tree_blob_raw_roundtrip() {
if !git_available() {
return;
}
let (td, svc) = service();
seed_repo(&td, "alice", "demo");
let (s1, tree) = body(&svc, "/forge/alice/demo/tree/main/src").await;
assert_eq!(s1, 200);
assert!(tree.contains("lib.rs"));
let (s2, blob) = body(&svc, "/forge/alice/demo/blob/main/src/lib.rs").await;
assert_eq!(s2, 200);
assert!(blob.contains("pub fn f"));
let r = svc
.handle(
req("GET", "/forge/alice/demo/raw/main/src/lib.rs"),
ForgeAgent::Anonymous,
)
.await
.unwrap();
assert_eq!(r.status, 200);
let ct = r
.headers
.iter()
.find(|(k, _)| k == "content-type")
.map(|(_, v)| v.clone())
.unwrap();
assert!(ct.starts_with("text/plain"));
assert_eq!(
String::from_utf8_lossy(&r.body),
"pub fn f() -> u8 { 42 }\n"
);
}
#[tokio::test]
async fn commits_branches_tags_commit_view() {
if !git_available() {
return;
}
let (td, svc) = service();
seed_repo(&td, "alice", "demo");
let (s1, commits) = body(&svc, "/forge/alice/demo/commits/main").await;
assert_eq!(s1, 200);
assert!(commits.contains("initial commit"));
let (s2, branches) = body(&svc, "/forge/alice/demo/branches").await;
assert_eq!(s2, 200);
assert!(branches.contains("main"));
assert!(branches.contains("default"));
let (s3, tags) = body(&svc, "/forge/alice/demo/tags").await;
assert_eq!(s3, 200);
assert!(tags.contains("v1.0"));
let dir = td.path().join("repos/alice/demo.git");
let out = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(&dir)
.output()
.unwrap();
let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
let (s4, commit) = body(&svc, &format!("/forge/alice/demo/commit/{sha}")).await;
assert_eq!(s4, 200);
assert!(commit.contains("initial commit"));
assert!(commit.contains("README.md"));
}
#[tokio::test]
async fn browse_rejects_bad_ref_and_missing_repo() {
if !git_available() {
return;
}
let (td, svc) = service();
seed_repo(&td, "alice", "demo");
let (s1, _) = body(&svc, "/forge/alice/ghost").await;
assert_eq!(s1, 404);
let (s2, _) = body(&svc, "/forge/alice/demo/tree/--upload-pack/x").await;
assert_eq!(s2, 400);
let (s3, _) = body(&svc, "/forge/alice/demo/blob/main/nope.txt").await;
assert_eq!(s3, 404);
}
#[tokio::test]
async fn empty_repo_overview_renders() {
if !git_available() {
return;
}
let (td, svc) = service();
let bare = td.path().join("repos/bob/empty.git");
std::fs::create_dir_all(&bare).unwrap();
run_git(
td.path(),
&["init", "--bare", "-b", "main", bare.to_str().unwrap()],
);
let (status, html) = body(&svc, "/forge/bob/empty").await;
assert_eq!(status, 200);
assert!(html.contains("empty"));
}
use std::collections::HashMap;
use std::sync::Mutex;
struct MockLb {
present: Mutex<HashMap<String, Vec<u8>>>,
}
impl MockLb {
fn new() -> Self {
Self {
present: Mutex::new(HashMap::new()),
}
}
fn put(&self, url: &str, body: &[u8]) {
self.present
.lock()
.unwrap()
.insert(url.into(), body.to_vec());
}
fn remove(&self, url: &str) {
self.present.lock().unwrap().remove(url);
}
}
#[async_trait::async_trait]
impl bodies::LoopbackFetch for MockLb {
async fn get(&self, url: &str, _max: usize, _to: u64) -> bodies::FetchResult {
match self.present.lock().unwrap().get(url) {
Some(b) => bodies::FetchResult::Body(b.clone()),
None => bodies::FetchResult::Removed,
}
}
}
fn pod_agent(user: &str) -> ForgeAgent {
ForgeAgent::Pod {
webid: format!("https://pod.example/{user}/profile/card#me"),
username: user.to_string(),
}
}
fn post_form(path: &str, form: &str) -> ForgeRequest {
ForgeRequest {
method: "POST".into(),
path: path.into(),
query: String::new(),
headers: vec![(
"content-type".into(),
"application/x-www-form-urlencoded".into(),
)],
raw_body: Bytes::from(form.to_string()),
host_url: Some("https://pod.example".into()),
}
}
fn issues_service() -> (TempDir, ForgeService, Arc<MockLb>) {
let td = TempDir::new().unwrap();
let mock = Arc::new(MockLb::new());
let svc = ForgeService::new(ForgeConfig::default(), td.path())
.unwrap()
.with_loopback(mock.clone() as Arc<dyn bodies::LoopbackFetch>);
(td, svc, mock)
}
#[tokio::test]
async fn issue_create_list_detail_and_deletion() {
if !git_available() {
return;
}
let (td, svc, mock) = issues_service();
seed_repo(&td, "alice", "demo");
let url = "https://pod.example/alice/public/forge/alice--demo/issue-1.jsonld";
mock.put(url, b"the issue body text");
let r = svc
.handle(
post_form(
"/forge/alice/demo/issues",
&format!(
"title=First+bug&resourceUrl={}",
url.replace(':', "%3A").replace('/', "%2F")
),
),
pod_agent("alice"),
)
.await
.unwrap();
assert_eq!(r.status, 303);
let loc = r
.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("location"))
.map(|(_, v)| v.clone())
.unwrap();
assert_eq!(loc, "/forge/alice/demo/issues/1");
let (s, list) = body(&svc, "/forge/alice/demo/issues").await;
assert_eq!(s, 200);
assert!(list.contains("First bug"));
assert!(list.contains("1 open"));
let (s2, detail) = body(&svc, "/forge/alice/demo/issues/1").await;
assert_eq!(s2, 200);
assert!(detail.contains("the issue body text"));
mock.remove(url);
let (_s3, detail2) = body(&svc, "/forge/alice/demo/issues/1").await;
assert!(detail2.contains("content removed by its author"));
}
#[tokio::test]
async fn issue_create_rejects_foreign_area_and_ssrf() {
if !git_available() {
return;
}
let (td, svc, mock) = issues_service();
seed_repo(&td, "alice", "demo");
mock.put(
"https://pod.example/bob/public/forge/alice--demo/x.jsonld",
b"body",
);
let bad = "https://pod.example/bob/public/forge/alice--demo/x.jsonld";
let r = svc
.handle(
post_form(
"/forge/alice/demo/issues",
&format!(
"title=x&resourceUrl={}",
bad.replace(':', "%3A").replace('/', "%2F")
),
),
pod_agent("alice"),
)
.await
.unwrap();
assert_eq!(r.status, 403);
let ssrf = "http://169.254.169.254/alice/public/forge/alice--demo/x.jsonld";
let r2 = svc
.handle(
post_form(
"/forge/alice/demo/issues",
&format!(
"title=x&resourceUrl={}",
ssrf.replace(':', "%3A").replace('/', "%2F")
),
),
pod_agent("alice"),
)
.await
.unwrap();
assert!(r2.status == 403 || r2.status == 400);
}
#[tokio::test]
async fn comment_appends_to_thread() {
if !git_available() {
return;
}
let (td, svc, mock) = issues_service();
seed_repo(&td, "alice", "demo");
let open = "https://pod.example/alice/public/forge/alice--demo/i1.jsonld";
mock.put(open, b"opening");
svc.handle(
post_form(
"/forge/alice/demo/issues",
&format!(
"title=t&resourceUrl={}",
open.replace(':', "%3A").replace('/', "%2F")
),
),
pod_agent("alice"),
)
.await
.unwrap();
let c = "https://pod.example/bob/public/forge/alice--demo/c1.jsonld";
mock.put(c, b"a helpful comment");
let r = svc
.handle(
post_form(
"/forge/alice/demo/issues/1",
&format!("resourceUrl={}", c.replace(':', "%3A").replace('/', "%2F")),
),
pod_agent("bob"),
)
.await
.unwrap();
assert_eq!(r.status, 303);
let (_s, detail) = body(&svc, "/forge/alice/demo/issues/1").await;
assert!(detail.contains("opening"));
assert!(detail.contains("a helpful comment"));
assert!(detail.contains("commented"));
}
#[tokio::test]
async fn issue_create_without_loopback_fails_closed() {
if !git_available() {
return;
}
let (td, svc) = service();
seed_repo(&td, "alice", "demo");
let url = "https://pod.example/alice/public/forge/alice--demo/x.jsonld";
let r = svc
.handle(
post_form(
"/forge/alice/demo/issues",
&format!(
"title=x&resourceUrl={}",
url.replace(':', "%3A").replace('/', "%2F")
),
),
pod_agent("alice"),
)
.await
.unwrap();
assert_eq!(r.status, 501);
}
fn hex64() -> String {
"d".repeat(64)
}
fn nostr_agent(hex: &str) -> ForgeAgent {
ForgeAgent::Nostr {
pubkey_hex: hex.to_string(),
}
}
fn post_json(path: &str, json: &str) -> ForgeRequest {
ForgeRequest {
method: "POST".into(),
path: path.into(),
query: String::new(),
headers: vec![("content-type".into(), "application/json".into())],
raw_body: Bytes::from(json.to_string()),
host_url: Some("https://pod.example".into()),
}
}
#[tokio::test]
async fn token_mint_then_resolves() {
let (_td, svc) = service();
let agent = nostr_agent(&hex64());
let r = svc
.handle(post_form("/forge/api/token", ""), agent.clone())
.await
.unwrap();
assert_eq!(r.status, 200);
let v: serde_json::Value = serde_json::from_slice(&r.body).unwrap();
let token = v["token"].as_str().unwrap().to_string();
assert!(token.starts_with("f1."));
let follow = ForgeRequest {
method: "GET".into(),
path: "/forge/x".into(),
query: String::new(),
headers: vec![("authorization".into(), format!("Bearer {token}"))],
raw_body: Bytes::new(),
host_url: Some("https://pod.example".into()),
};
assert_eq!(svc.resolve_agent(&follow).await, agent);
}
#[tokio::test]
async fn token_mint_anonymous_is_401() {
let (_td, svc) = service();
let r = svc
.handle(post_form("/forge/api/token", ""), ForgeAgent::Anonymous)
.await
.unwrap();
assert_eq!(r.status, 401);
}
#[tokio::test]
async fn podless_nostr_issue_uses_hosted_store() {
if !git_available() {
return;
}
let (td, svc) = service();
let hex = hex64();
seed_repo(&td, &hex, "proj");
let r = svc
.handle(
post_json(
&format!("/forge/{hex}/proj/issues"),
"{\"title\":\"podless bug\",\"body\":\"no pod here\"}",
),
nostr_agent(&hex),
)
.await
.unwrap();
assert_eq!(r.status, 303, "podless issue create should redirect");
let (s, detail) = body(&svc, &format!("/forge/{hex}/proj/issues/1")).await;
assert_eq!(s, 200);
assert!(detail.contains("no pod here"));
assert!(detail.contains("podless bug"));
}
#[tokio::test]
async fn hosted_api_get_and_owner_only_delete() {
let (td, svc) = service();
let hex = hex64();
let store = HostedStore::new(td.path());
let rref = store.write(&hex, b"{\"body\":\"hi\"}").await.unwrap();
let (h, u) = HostedStore::parse_ref(&rref).unwrap();
let (s, got) = body(&svc, &format!("/forge/api/hosted/{h}/{u}")).await;
assert_eq!(s, 200);
assert!(got.contains("hi"));
let del_other = ForgeRequest {
method: "DELETE".into(),
path: format!("/forge/api/hosted/{h}/{u}"),
query: String::new(),
headers: vec![],
raw_body: Bytes::new(),
host_url: Some("https://pod.example".into()),
};
let r1 = svc
.handle(del_other.clone(), nostr_agent(&"e".repeat(64)))
.await
.unwrap();
assert_eq!(r1.status, 403);
let r2 = svc.handle(del_other, nostr_agent(&hex)).await.unwrap();
assert_eq!(r2.status, 200);
let (s2, _) = body(&svc, &format!("/forge/api/hosted/{h}/{u}")).await;
assert_eq!(s2, 404);
}
#[tokio::test]
async fn namespace_push_guard_blocks_foreign_push() {
let (_td, svc) = service();
let push = ForgeRequest {
method: "POST".into(),
path: "/forge/alice/demo.git/git-receive-pack".into(),
query: String::new(),
headers: vec![],
raw_body: Bytes::new(),
host_url: Some("https://pod.example".into()),
};
let r = svc
.handle(push.clone(), nostr_agent(&hex64()))
.await
.unwrap();
assert_eq!(r.status, 403);
let r_anon = svc.handle(push, ForgeAgent::Anonymous).await.unwrap();
assert_eq!(r_anon.status, 403);
}
#[tokio::test]
async fn namespace_push_guard_allows_own_push() {
let (_td, svc) = service();
let hex = hex64();
let push = ForgeRequest {
method: "POST".into(),
path: format!("/forge/{hex}/demo.git/git-receive-pack"),
query: String::new(),
headers: vec![],
raw_body: Bytes::new(),
host_url: Some("https://pod.example".into()),
};
let r = svc.handle(push, nostr_agent(&hex)).await.unwrap();
assert_ne!(r.status, 403, "own-namespace push must pass the guard");
}
#[cfg(feature = "mirror")]
#[tokio::test]
async fn a_mirrored_repository_refuses_even_its_owners_push() {
let (td, svc) = service();
let hex = hex64();
let dir = td.path().join("mirror").join(&hex);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("demo.json"),
serde_json::json!({
"pubkey": hex, "name": "demo", "clone": null, "events": 1,
"lastSeen": null, "lastEvent": null, "lastFetchAt": null,
"lastFetch": null, "announceAt": null, "clones": []
})
.to_string(),
)
.unwrap();
let push = ForgeRequest {
method: "POST".into(),
path: format!("/forge/{hex}/demo.git/git-receive-pack"),
query: String::new(),
headers: vec![],
raw_body: Bytes::new(),
host_url: Some("https://pod.example".into()),
};
let r = svc.handle(push, nostr_agent(&hex)).await.unwrap();
assert_eq!(r.status, 403);
assert!(String::from_utf8_lossy(&r.body).contains("read-only"));
}
}