feat(web): add git browse backend and frontend (tree, commits, README)

Backend: Add ephemeral event synthesis for git browse kinds (20100-20103)
in synthesis.rs, with REST browse endpoints in browse.rs for tree listing,
blob content, commit log, and README. Hook synthesis into the bridge query
path so the web frontend can fetch git data via Nostr filters.

Frontend: Add RepoTreeSection, RepoCommitsSection, and RepoReadmeSection
components to the repo detail page. README renders as styled markdown via
react-markdown + remark-gfm. Add query-http.ts for NIP-98 authenticated
HTTP bridge queries and use-git-browse.ts hooks. Extract relativeTime
into shared utility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Wes
2026-05-12 11:41:29 -07:00
parent f8d04456b9
commit 13e07eba9c
19 changed files with 1775 additions and 151 deletions

1
Cargo.lock generated
View File

@@ -4145,6 +4145,7 @@ dependencies = [
"metrics-exporter-prometheus",
"moka",
"nostr",
"percent-encoding",
"rand 0.10.1",
"redis",
"serde",

View File

@@ -121,6 +121,17 @@ pub const KIND_PRESENCE_UPDATE: u32 = 20001;
pub const KIND_PAIRING: u32 = 24134;
/// Ephemeral: typing indicator for a channel.
pub const KIND_TYPING_INDICATOR: u32 = 20002;
// Git browse events (ephemeral, synthesized on query — never client-submitted)
/// Git tree listing (ephemeral, synthesized on query).
pub const KIND_GIT_TREE: u32 = 20100;
/// Git blob metadata (ephemeral, synthesized on query).
pub const KIND_GIT_BLOB: u32 = 20101;
/// Git commit log (ephemeral, synthesized on query).
pub const KIND_GIT_COMMIT_LOG: u32 = 20102;
/// Git README content (ephemeral, synthesized on query).
pub const KIND_GIT_README: u32 = 20103;
/// Ephemeral: owner-scoped encrypted agent observer telemetry and control frame.
pub const KIND_AGENT_OBSERVER_FRAME: u32 = 24200;
@@ -346,6 +357,10 @@ pub const ALL_KINDS: &[u32] = &[
KIND_NIP29_GROUP_ROLES,
KIND_PRESENCE_UPDATE,
KIND_TYPING_INDICATOR,
KIND_GIT_TREE,
KIND_GIT_BLOB,
KIND_GIT_COMMIT_LOG,
KIND_GIT_README,
KIND_BLOSSOM_AUTH,
KIND_PAIRING,
KIND_AGENT_OBSERVER_FRAME,
@@ -479,12 +494,18 @@ pub const fn is_command_kind(kind: u32) -> bool {
)
}
/// Returns `true` if `kind` is a relay-only enrichment kind (40900–40902).
/// Returns `true` if `kind` is a relay-only kind (enrichments + synthesized git browse).
/// Client submission of these kinds must be rejected.
pub const fn is_relay_only_kind(kind: u32) -> bool {
matches!(
kind,
KIND_ENRICHMENT | KIND_CHANNEL_SUMMARY | KIND_PRESENCE_SNAPSHOT
KIND_ENRICHMENT
| KIND_CHANNEL_SUMMARY
| KIND_PRESENCE_SNAPSHOT
| KIND_GIT_TREE
| KIND_GIT_BLOB
| KIND_GIT_COMMIT_LOG
| KIND_GIT_README
)
}

View File

@@ -53,6 +53,7 @@ url = { workspace = true }
moka = { workspace = true }
metrics = { workspace = true }
metrics-exporter-prometheus = { workspace = true }
percent-encoding = "2.3.2"
[features]
dev = ["sprout-auth/dev"]

View File

@@ -217,6 +217,15 @@ pub async fn query_events(
return Ok(Json(Value::Array(presence_events)));
}
// ── Git browse: synthesize kind:20100-20103 from git data (ephemeral) ──
if let Some(git_events) = super::git::synthesis::synthesize_git_browse(&state, &filters).await {
tracing::debug!(
count = git_events.len(),
"git browse synthesis: returning events"
);
return Ok(Json(Value::Array(git_events)));
}
// Execute each filter and collect results, enforcing channel access.
let mut events: Vec<Value> = Vec::new();
for filter in &filters {

View File

@@ -0,0 +1,546 @@
//! Git browse API — unauthenticated read-only endpoints for the web portal.
//!
//! **Auth note:** These REST endpoints are intentionally unauthenticated (public
//! read-only) for the web portal, unlike the git transport routes which require
//! NIP-98 auth. The primary consumption path for the SPA is the event synthesis
//! pipeline (bridge.rs → synthesis.rs), which IS authenticated via NIP-98. These
//! REST endpoints serve as a simpler fallback (curl-friendly, cacheable). Auth
//! can be added here later if the product decision changes.
//!
//! Four endpoints:
//! - `GET /api/repos/{owner}/{repo}/tree/{ref}/{*path}` — list directory entries
//! - `GET /api/repos/{owner}/{repo}/blob/{ref}/{*path}` — read file content
//! - `GET /api/repos/{owner}/{repo}/commits/{ref}` — commit log
//! - `GET /api/repos/{owner}/{repo}/readme/{ref}` — README file content
use std::path::Path;
use std::sync::Arc;
use axum::{
extract::{Path as AxumPath, Query, State},
http::{header, StatusCode},
response::{IntoResponse, Response},
routing::get,
Json, Router,
};
use serde::{Deserialize, Serialize};
use tokio::process::Command;
use tracing::error;
use crate::api::api_error;
use crate::state::AppState;
use super::transport::{harden_git_env, validate_repo_path};
/// Max file size for blob endpoint (1 MB).
const MAX_BLOB_SIZE: u64 = 1024 * 1024;
// ── Shared Helpers ──────────────────────────────────────────────────────────
#[allow(clippy::result_large_err)]
pub(crate) fn validate_ref(git_ref: &str) -> Result<(), Response> {
if git_ref.is_empty() {
return Err(api_error(StatusCode::BAD_REQUEST, "ref must not be empty").into_response());
}
if git_ref.contains('\0')
|| git_ref.contains("..")
|| git_ref.contains('\\')
|| git_ref.as_bytes().iter().any(|&b| b < 0x20)
{
return Err(
api_error(StatusCode::BAD_REQUEST, "ref contains invalid characters").into_response(),
);
}
Ok(())
}
#[allow(clippy::result_large_err)]
pub(crate) fn validate_tree_path(path: &str) -> Result<(), Response> {
// Empty path is fine (root tree)
if path.is_empty() {
return Ok(());
}
if path.contains('\0') || path.as_bytes().iter().any(|&b| b < 0x20) {
return Err(
api_error(StatusCode::BAD_REQUEST, "path contains invalid characters").into_response(),
);
}
if path.starts_with('/') {
return Err(
api_error(StatusCode::BAD_REQUEST, "path must not be absolute").into_response(),
);
}
// Check each path component for ".."
for component in path.split('/') {
if component == ".." {
return Err(
api_error(StatusCode::BAD_REQUEST, "path traversal not allowed").into_response(),
);
}
}
Ok(())
}
/// Run a git command against a bare repo, acquiring the semaphore first.
/// Returns stdout bytes on success, or an error response.
pub(crate) async fn run_git(
state: &AppState,
repo_path: &Path,
args: &[&str],
) -> Result<Vec<u8>, Response> {
let _permit = state.git_semaphore.acquire().await.map_err(|_| {
api_error(
StatusCode::SERVICE_UNAVAILABLE,
"git service temporarily unavailable",
)
.into_response()
})?;
let mut cmd = Command::new("git");
cmd.arg("-C").arg(repo_path);
cmd.args(args);
harden_git_env(&mut cmd);
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let output = cmd.output().await.map_err(|e| {
error!("failed to spawn git: {e}");
api_error(StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response()
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
// Distinguish "not found" errors from real failures
if stderr.contains("not a valid object name")
|| stderr.contains("does not exist")
|| stderr.contains("Not a valid object name")
|| stderr.contains("bad revision")
|| stderr.contains("fatal: not a git repository")
|| stderr.contains("not a tree object")
{
return Err(api_error(StatusCode::NOT_FOUND, "not found").into_response());
}
error!("git command failed: {stderr}");
return Err(
api_error(StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response(),
);
}
Ok(output.stdout)
}
/// Verify a ref exists using `git rev-parse --verify`.
pub(crate) async fn verify_ref(
state: &AppState,
repo_path: &Path,
git_ref: &str,
) -> Result<(), Response> {
run_git(state, repo_path, &["rev-parse", "--verify", git_ref]).await?;
Ok(())
}
#[allow(clippy::result_large_err)]
pub(crate) fn resolve_repo(
owner: &str,
repo: &str,
state: &AppState,
) -> Result<std::path::PathBuf, Response> {
let validated = validate_repo_path(owner, repo, &state.config.git_repo_path)?;
let path = validated.repo_path;
if !path.exists() {
return Err(api_error(StatusCode::NOT_FOUND, "repository not found").into_response());
}
Ok(path)
}
// ── Reusable parsing functions (used by both REST endpoints and event synthesis) ──
/// Parse `git ls-tree --long` output into tree entries.
pub(crate) fn parse_tree_output(stdout: &[u8]) -> Vec<TreeEntry> {
let text = String::from_utf8_lossy(stdout);
let mut entries = Vec::new();
for line in text.lines() {
if line.is_empty() {
continue;
}
let Some((meta, name)) = line.split_once('\t') else {
continue;
};
let parts: Vec<&str> = meta.split_whitespace().collect();
if parts.len() < 4 {
continue;
}
let mode = parts[0];
let entry_type = parts[1];
let sha = parts[2];
let size_str = parts[3];
let size = if entry_type == "blob" {
size_str.parse::<u64>().ok()
} else {
None
};
entries.push(TreeEntry {
name: name.to_string(),
entry_type: entry_type.to_string(),
mode: mode.to_string(),
size,
sha: sha.to_string(),
});
}
entries
}
/// Parse `git log --format=...` output into commit entries.
pub(crate) fn parse_commit_log_output(stdout: &[u8]) -> Vec<CommitEntry> {
let text = String::from_utf8_lossy(stdout);
let mut commits = Vec::new();
for line in text.lines() {
if line.is_empty() {
continue;
}
let fields: Vec<&str> = line.splitn(6, '\0').collect();
if fields.len() < 6 {
continue;
}
let parents = if fields[4].is_empty() {
vec![]
} else {
fields[4].split(' ').map(|s| s.to_string()).collect()
};
commits.push(CommitEntry {
sha: fields[0].to_string(),
author: fields[1].to_string(),
email: fields[2].to_string(),
timestamp: fields[3].parse().unwrap_or(0),
parents,
message: fields[5].to_string(),
});
}
commits
}
/// Blob metadata returned by `resolve_blob_metadata`.
pub(crate) struct BlobMetadata {
pub size: u64,
pub is_binary: bool,
}
/// Resolve blob metadata (size, binary detection) for a file path at a given ref.
pub(crate) async fn resolve_blob_metadata(
state: &AppState,
repo_path: &std::path::Path,
git_ref: &str,
file_path: &str,
) -> Result<BlobMetadata, Response> {
let ls_output = run_git(state, repo_path, &["ls-tree", git_ref, file_path]).await?;
let ls_text = String::from_utf8_lossy(&ls_output);
let ls_line = ls_text
.lines()
.next()
.ok_or_else(|| api_error(StatusCode::NOT_FOUND, "file not found").into_response())?;
let Some((meta, _)) = ls_line.split_once('\t') else {
return Err(api_error(StatusCode::NOT_FOUND, "file not found").into_response());
};
let parts: Vec<&str> = meta.split_whitespace().collect();
if parts.len() < 3 || parts[1] != "blob" {
return Err(api_error(StatusCode::BAD_REQUEST, "path is not a file").into_response());
}
let sha = parts[2];
let size_output = run_git(state, repo_path, &["cat-file", "-s", sha]).await?;
let size: u64 = String::from_utf8_lossy(&size_output)
.trim()
.parse()
.unwrap_or(0);
// Binary detection: read first 512 bytes
let is_binary = if size > 0 && size <= MAX_BLOB_SIZE {
let content = run_git(state, repo_path, &["cat-file", "-p", sha]).await?;
content.iter().take(512).any(|&b| b == 0)
} else {
// For oversized files, assume binary to be safe
size > MAX_BLOB_SIZE
};
Ok(BlobMetadata { size, is_binary })
}
/// Try to find and read a README file at a given ref.
///
/// Enforces the same `MAX_BLOB_SIZE` (1 MB) limit as the blob endpoint
/// to prevent memory exhaustion on oversized README files.
pub(crate) async fn find_readme(
state: &AppState,
repo_path: &std::path::Path,
git_ref: &str,
) -> Option<ReadmeResponse> {
let candidates = ["README.md", "README", "README.rst", "README.txt"];
for filename in candidates {
let target = format!("{git_ref}:{filename}");
// Check size before reading to avoid loading oversized files into memory.
let Ok(size_output) = run_git(state, repo_path, &["cat-file", "-s", &target]).await else {
continue;
};
let size: u64 = String::from_utf8_lossy(&size_output)
.trim()
.parse()
.unwrap_or(0);
if size > MAX_BLOB_SIZE {
continue;
}
if let Ok(content) = run_git(state, repo_path, &["cat-file", "-p", &target]).await {
let text = String::from_utf8_lossy(&content).into_owned();
return Some(ReadmeResponse {
filename: filename.to_string(),
content: text,
});
}
}
None
}
pub(crate) const COMMITS_PER_PAGE: u32 = 20;
// ── Tree Endpoint ───────────────────────────────────────────────────────────
#[derive(Serialize, Deserialize, Clone)]
pub(crate) struct TreeEntry {
pub name: String,
#[serde(rename = "type")]
pub entry_type: String,
pub mode: String,
pub size: Option<u64>,
pub sha: String,
}
/// `GET /api/repos/{owner}/{repo}/tree/{ref}/{*path}`
async fn tree_handler(
State(state): State<Arc<AppState>>,
AxumPath((owner, repo, git_ref, path)): AxumPath<(String, String, String, String)>,
) -> Result<Json<Vec<TreeEntry>>, Response> {
validate_ref(&git_ref)?;
validate_tree_path(&path)?;
let repo_path = resolve_repo(&owner, &repo, &state)?;
verify_ref(&state, &repo_path, &git_ref).await?;
let target = if path.is_empty() {
git_ref.clone()
} else {
let clean_path = path.strip_suffix('/').unwrap_or(&path);
format!("{git_ref}:{clean_path}")
};
let stdout = run_git(&state, &repo_path, &["ls-tree", "--long", &target]).await?;
Ok(Json(parse_tree_output(&stdout)))
}
/// `GET /api/repos/{owner}/{repo}/tree/{ref}` — root tree (no path).
async fn tree_root_handler(
State(state): State<Arc<AppState>>,
AxumPath((owner, repo, git_ref)): AxumPath<(String, String, String)>,
) -> Result<Json<Vec<TreeEntry>>, Response> {
validate_ref(&git_ref)?;
let repo_path = resolve_repo(&owner, &repo, &state)?;
verify_ref(&state, &repo_path, &git_ref).await?;
let stdout = run_git(&state, &repo_path, &["ls-tree", "--long", &git_ref]).await?;
Ok(Json(parse_tree_output(&stdout)))
}
// ── Blob Endpoint ───────────────────────────────────────────────────────────
/// `GET /api/repos/{owner}/{repo}/blob/{ref}/{*path}`
async fn blob_handler(
State(state): State<Arc<AppState>>,
AxumPath((owner, repo, git_ref, path)): AxumPath<(String, String, String, String)>,
) -> Result<Response, Response> {
validate_ref(&git_ref)?;
validate_tree_path(&path)?;
if path.is_empty() {
return Err(
api_error(StatusCode::BAD_REQUEST, "path is required for blob").into_response(),
);
}
let repo_path = resolve_repo(&owner, &repo, &state)?;
verify_ref(&state, &repo_path, &git_ref).await?;
let clean_path = path.strip_suffix('/').unwrap_or(&path);
// Resolve blob SHA via ls-tree
let ls_output = run_git(&state, &repo_path, &["ls-tree", &git_ref, clean_path]).await?;
let ls_text = String::from_utf8_lossy(&ls_output);
let ls_line = ls_text
.lines()
.next()
.ok_or_else(|| api_error(StatusCode::NOT_FOUND, "file not found").into_response())?;
let Some((meta, _name)) = ls_line.split_once('\t') else {
return Err(api_error(StatusCode::NOT_FOUND, "file not found").into_response());
};
let parts: Vec<&str> = meta.split_whitespace().collect();
if parts.len() < 3 {
return Err(api_error(StatusCode::NOT_FOUND, "file not found").into_response());
}
let obj_type = parts[1];
let sha = parts[2];
if obj_type != "blob" {
return Err(api_error(StatusCode::BAD_REQUEST, "path is not a file").into_response());
}
// Check size before reading content
let size_output = run_git(&state, &repo_path, &["cat-file", "-s", sha]).await?;
let size_str = String::from_utf8_lossy(&size_output).trim().to_string();
let size: u64 = size_str.parse().unwrap_or(0);
if size > MAX_BLOB_SIZE {
return Ok((
StatusCode::PAYLOAD_TOO_LARGE,
[(
header::HeaderName::from_static("x-file-size"),
size.to_string(),
)],
Json(serde_json::json!({ "error": "file too large", "size": size })),
)
.into_response());
}
// Read content
let content = run_git(&state, &repo_path, &["cat-file", "-p", sha]).await?;
// Binary detection: check first 512 bytes for NUL byte
let is_binary = content.iter().take(512).any(|&b| b == 0);
if is_binary {
Ok((
StatusCode::OK,
[
(header::CONTENT_TYPE, "application/octet-stream".to_string()),
(
header::HeaderName::from_static("x-file-size"),
size.to_string(),
),
],
content,
)
.into_response())
} else {
Ok((
StatusCode::OK,
[
(
header::CONTENT_TYPE,
"text/plain; charset=utf-8".to_string(),
),
(
header::HeaderName::from_static("x-file-size"),
size.to_string(),
),
],
content,
)
.into_response())
}
}
// ── Commits Endpoint ────────────────────────────────────────────────────────
#[derive(Serialize, Deserialize, Clone)]
pub(crate) struct CommitEntry {
pub sha: String,
pub author: String,
pub email: String,
pub timestamp: i64,
pub parents: Vec<String>,
pub message: String,
}
#[derive(Deserialize)]
struct CommitsQuery {
page: Option<u32>,
}
/// `GET /api/repos/{owner}/{repo}/commits/{ref}`
async fn commits_handler(
State(state): State<Arc<AppState>>,
AxumPath((owner, repo, git_ref)): AxumPath<(String, String, String)>,
Query(query): Query<CommitsQuery>,
) -> Result<Json<Vec<CommitEntry>>, Response> {
validate_ref(&git_ref)?;
let repo_path = resolve_repo(&owner, &repo, &state)?;
verify_ref(&state, &repo_path, &git_ref).await?;
let page = query.page.unwrap_or(0);
let skip = page * COMMITS_PER_PAGE;
let skip_str = skip.to_string();
let limit_str = COMMITS_PER_PAGE.to_string();
let stdout = run_git(
&state,
&repo_path,
&[
"log",
"--format=%H%x00%an%x00%ae%x00%at%x00%P%x00%s",
&format!("-n{limit_str}"),
&format!("--skip={skip_str}"),
&git_ref,
],
)
.await?;
Ok(Json(parse_commit_log_output(&stdout)))
}
// ── Readme Endpoint ─────────────────────────────────────────────────────────
#[derive(Serialize, Deserialize, Clone)]
pub(crate) struct ReadmeResponse {
pub filename: String,
pub content: String,
}
/// `GET /api/repos/{owner}/{repo}/readme/{ref}`
async fn readme_handler(
State(state): State<Arc<AppState>>,
AxumPath((owner, repo, git_ref)): AxumPath<(String, String, String)>,
) -> Result<Json<ReadmeResponse>, Response> {
validate_ref(&git_ref)?;
let repo_path = resolve_repo(&owner, &repo, &state)?;
verify_ref(&state, &repo_path, &git_ref).await?;
match find_readme(&state, &repo_path, &git_ref).await {
Some(readme) => Ok(Json(readme)),
None => Err(api_error(StatusCode::NOT_FOUND, "no readme found").into_response()),
}
}
// ── Router ──────────────────────────────────────────────────────────────────
/// Build the git browse router with all 4 read-only endpoints.
pub fn browse_router(state: Arc<AppState>) -> Router {
Router::new()
.route(
"/api/repos/{owner}/{repo}/tree/{ref}",
get(tree_root_handler),
)
.route(
"/api/repos/{owner}/{repo}/tree/{ref}/{*path}",
get(tree_handler),
)
.route(
"/api/repos/{owner}/{repo}/blob/{ref}/{*path}",
get(blob_handler),
)
.route(
"/api/repos/{owner}/{repo}/commits/{ref}",
get(commits_handler),
)
.route(
"/api/repos/{owner}/{repo}/readme/{ref}",
get(readme_handler),
)
.with_state(state)
}

View File

@@ -22,10 +22,13 @@ use tower_http::limit::RequestBodyLimitLayer;
use crate::state::AppState;
pub mod browse;
pub mod hook;
pub mod policy;
pub mod synthesis;
pub mod transport;
pub use browse::browse_router;
pub use transport::git_router;
/// Middleware that rejects requests from non-loopback addresses.

View File

@@ -0,0 +1,375 @@
//! Git browse event synthesis — synthesize ephemeral Nostr events from git data.
//!
//! Called from the bridge query path. On any git error, returns `None` to fall
//! through to the normal DB query (which returns empty for ephemeral kinds).
use serde_json::Value;
use tracing::{debug, warn};
use sprout_core::kind::{KIND_GIT_BLOB, KIND_GIT_COMMIT_LOG, KIND_GIT_README, KIND_GIT_TREE};
use crate::state::AppState;
use super::browse::{
find_readme, parse_commit_log_output, parse_tree_output, resolve_blob_metadata, resolve_repo,
run_git, validate_ref, validate_tree_path, verify_ref, COMMITS_PER_PAGE,
};
const GIT_BROWSE_KINDS: &[u32] = &[
KIND_GIT_TREE,
KIND_GIT_BLOB,
KIND_GIT_COMMIT_LOG,
KIND_GIT_README,
];
/// Check if all filters target git browse kinds. If so, synthesize events.
/// Returns `Some(events)` if handled, `None` to fall through to normal query.
pub(crate) async fn synthesize_git_browse(
state: &AppState,
filters: &[nostr::Filter],
) -> Option<Vec<Value>> {
// Only intercept if every filter targets exactly one git browse kind.
let mut results: Vec<Value> = Vec::new();
for filter in filters {
let kinds = match filter.kinds.as_ref() {
Some(k) => k,
None => {
debug!("git browse synthesis: filter has no kinds, skipping");
return None;
}
};
if kinds.len() != 1 {
debug!(
len = kinds.len(),
"git browse synthesis: filter has != 1 kind, skipping"
);
return None;
}
let kind_val = match kinds.iter().next() {
Some(k) => k,
None => {
debug!("git browse synthesis: kinds set empty after len check");
return None;
}
};
let kind = kind_val.as_u16() as u32;
if !GIT_BROWSE_KINDS.contains(&kind) {
debug!(
kind,
"git browse synthesis: kind not a git browse kind, skipping"
);
return None;
}
debug!(kind, "git browse synthesis: intercepted filter");
// Extract required tags: #d = "owner/repo", #r = "ref"
let d_values = match filter
.generic_tags
.get(&nostr::SingleLetterTag::lowercase(nostr::Alphabet::D))
{
Some(v) => v,
None => {
warn!(kind, "git browse synthesis: filter missing #d tag");
return None;
}
};
let d_tag = match d_values.iter().next() {
Some(v) => v.as_str(),
None => {
warn!(kind, "git browse synthesis: #d tag set is empty");
return None;
}
};
let r_values = match filter
.generic_tags
.get(&nostr::SingleLetterTag::lowercase(nostr::Alphabet::R))
{
Some(v) => v,
None => {
warn!(kind, d_tag, "git browse synthesis: filter missing #r tag");
return None;
}
};
let git_ref = match r_values.iter().next() {
Some(v) => v.as_str(),
None => {
warn!(kind, d_tag, "git browse synthesis: #r tag set is empty");
return None;
}
};
debug!(kind, d_tag, git_ref, "git browse synthesis: extracted tags");
// Parse owner/repo from d-tag
let (owner, repo) = match d_tag.split_once('/') {
Some(pair) => pair,
None => {
warn!(d_tag, "git browse synthesis: d-tag missing '/' separator");
return None;
}
};
// Validate inputs — on failure, return None (fall through)
if validate_ref(git_ref).is_err() {
warn!(git_ref, "git browse synthesis: invalid ref");
return None;
}
let repo_path = match resolve_repo(owner, repo, state) {
Ok(path) => {
debug!(?path, "git browse synthesis: resolved repo");
path
}
Err(_) => {
warn!(owner, repo, "git browse synthesis: resolve_repo failed");
return None;
}
};
if verify_ref(state, &repo_path, git_ref).await.is_err() {
warn!(
git_ref,
?repo_path,
"git browse synthesis: ref not found in repo"
);
return None;
}
let event = match kind {
KIND_GIT_TREE => {
// Optional #f tag for path
let path = filter
.generic_tags
.get(&nostr::SingleLetterTag::lowercase(nostr::Alphabet::F))
.and_then(|vs| vs.iter().next())
.map(|s| s.as_str())
.unwrap_or("");
if validate_tree_path(path).is_err() {
warn!(path, "git browse synthesis: invalid tree path");
return None;
}
let target = if path.is_empty() {
git_ref.to_string()
} else {
let clean = path.strip_suffix('/').unwrap_or(path);
format!("{git_ref}:{clean}")
};
let stdout = match run_git(state, &repo_path, &["ls-tree", "--long", &target]).await
{
Ok(out) => out,
Err(_) => {
warn!(?repo_path, target, "git browse synthesis: ls-tree failed");
return None;
}
};
let entries = parse_tree_output(&stdout);
let content = match serde_json::to_string(&entries) {
Ok(c) => c,
Err(e) => {
warn!(%e, "git browse synthesis: failed to serialize tree entries");
return None;
}
};
let mut tags = vec![
nostr::Tag::parse(&["d", d_tag]).ok()?,
nostr::Tag::parse(&["r", git_ref]).ok()?,
];
if !path.is_empty() {
tags.push(nostr::Tag::parse(&["f", path]).ok()?);
}
match build_signed_event(state, KIND_GIT_TREE, &content, tags) {
Some(ev) => ev,
None => {
warn!("git browse synthesis: failed to build signed tree event");
return None;
}
}
}
KIND_GIT_BLOB => {
// Required #f tag for file path
let path = match filter
.generic_tags
.get(&nostr::SingleLetterTag::lowercase(nostr::Alphabet::F))
.and_then(|vs| vs.iter().next())
.map(|s| s.as_str())
{
Some(p) => p,
None => {
warn!("git browse synthesis: blob filter missing #f tag");
return None;
}
};
if path.is_empty() || validate_tree_path(path).is_err() {
warn!(path, "git browse synthesis: invalid blob path");
return None;
}
let clean_path = path.strip_suffix('/').unwrap_or(path);
let meta = match resolve_blob_metadata(state, &repo_path, git_ref, clean_path).await
{
Ok(m) => m,
Err(_) => {
warn!(
clean_path,
"git browse synthesis: resolve_blob_metadata failed"
);
return None;
}
};
let encoded_ref = percent_encoding::utf8_percent_encode(
git_ref,
percent_encoding::NON_ALPHANUMERIC,
);
let blob_url = format!("/api/repos/{owner}/{repo}/blob/{encoded_ref}/{clean_path}");
let tags = vec![
nostr::Tag::parse(&["d", d_tag]).ok()?,
nostr::Tag::parse(&["r", git_ref]).ok()?,
nostr::Tag::parse(&["f", path]).ok()?,
nostr::Tag::parse(&["size", &meta.size.to_string()]).ok()?,
nostr::Tag::parse(&["binary", if meta.is_binary { "true" } else { "false" }])
.ok()?,
nostr::Tag::parse(&["url", &blob_url]).ok()?,
];
// Content is empty — actual file content served by REST blob endpoint
match build_signed_event(state, KIND_GIT_BLOB, "", tags) {
Some(ev) => ev,
None => {
warn!("git browse synthesis: failed to build signed blob event");
return None;
}
}
}
KIND_GIT_COMMIT_LOG => {
// Optional #n tag for page number (avoids #p which collides with pubkey refs)
let page: u32 = filter
.generic_tags
.get(&nostr::SingleLetterTag::lowercase(nostr::Alphabet::N))
.and_then(|vs| vs.iter().next())
.and_then(|s| s.as_str().parse().ok())
.unwrap_or(0);
let skip = page * COMMITS_PER_PAGE;
let limit_str = COMMITS_PER_PAGE.to_string();
let skip_str = skip.to_string();
let stdout = match run_git(
state,
&repo_path,
&[
"log",
"--format=%H%x00%an%x00%ae%x00%at%x00%P%x00%s",
&format!("-n{limit_str}"),
&format!("--skip={skip_str}"),
git_ref,
],
)
.await
{
Ok(out) => {
debug!(bytes = out.len(), "git browse synthesis: git log output");
out
}
Err(_) => {
warn!(git_ref, ?repo_path, "git browse synthesis: git log failed");
return None;
}
};
let commits = parse_commit_log_output(&stdout);
let content = match serde_json::to_string(&commits) {
Ok(c) => c,
Err(e) => {
warn!(%e, "git browse synthesis: failed to serialize commits");
return None;
}
};
let tags = vec![
nostr::Tag::parse(&["d", d_tag]).ok()?,
nostr::Tag::parse(&["r", git_ref]).ok()?,
nostr::Tag::parse(&["page", &page.to_string()]).ok()?,
];
match build_signed_event(state, KIND_GIT_COMMIT_LOG, &content, tags) {
Some(ev) => ev,
None => {
warn!("git browse synthesis: failed to build signed commit event");
return None;
}
}
}
KIND_GIT_README => {
let readme = match find_readme(state, &repo_path, git_ref).await {
Some(r) => r,
None => {
debug!(git_ref, ?repo_path, "git browse synthesis: no README found");
return None;
}
};
let tags = vec![
nostr::Tag::parse(&["d", d_tag]).ok()?,
nostr::Tag::parse(&["r", git_ref]).ok()?,
nostr::Tag::parse(&["filename", &readme.filename]).ok()?,
];
match build_signed_event(state, KIND_GIT_README, &readme.content, tags) {
Some(ev) => ev,
None => {
warn!("git browse synthesis: failed to build signed readme event");
return None;
}
}
}
_ => return None,
};
results.push(event);
}
debug!(count = results.len(), "git browse synthesis: completed");
Some(results)
}
/// Build a synthetic event signed by the relay keypair.
fn build_signed_event(
state: &AppState,
kind: u32,
content: &str,
tags: Vec<nostr::Tag>,
) -> Option<Value> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let event = match nostr::EventBuilder::new(nostr::Kind::Custom(kind as u16), content, tags)
.custom_created_at(nostr::Timestamp::from(now))
.sign_with_keys(&state.relay_keypair)
{
Ok(ev) => ev,
Err(e) => {
warn!(%e, kind, "git browse synthesis: sign_with_keys failed");
return None;
}
};
match serde_json::to_value(&event) {
Ok(v) => Some(v),
Err(e) => {
warn!(%e, kind, "git browse synthesis: serde_json::to_value failed");
None
}
}
}

View File

@@ -189,15 +189,15 @@ impl axum::extract::FromRequestParts<Arc<AppState>> for GitAuth {
// ── Path Validation ──────────────────────────────────────────────────────────
struct ValidatedRepoPath {
repo_path: PathBuf,
pub(crate) struct ValidatedRepoPath {
pub(crate) repo_path: PathBuf,
}
/// Validate and resolve a git repo path from URL parameters.
///
/// Security: allowlist characters, canonicalize, verify under repo root.
#[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers
fn validate_repo_path(
pub(crate) fn validate_repo_path(
owner: &str,
repo: &str,
git_repo_root: &Path,
@@ -256,7 +256,7 @@ fn validate_repo_path(
/// - `GIT_CONFIG_NOSYSTEM=1` — ignore system-wide gitconfig
/// - `GIT_CONFIG_GLOBAL=/dev/null` — prevent reading global gitconfig
/// - `HOME=/dev/null` — prevent reading ~/.gitconfig
fn harden_git_env(cmd: &mut Command) {
pub(crate) fn harden_git_env(cmd: &mut Command) {
cmd.env_clear()
.env("PATH", std::env::var("PATH").unwrap_or_default())
.env("GIT_HTTP_EXPORT_ALL", "1")

View File

@@ -47,6 +47,9 @@ pub fn build_router(state: Arc<AppState>) -> Router {
// ── Git routes: configurable body limit (default 500 MB) ─────────────────
let git_router = api::git::git_router(state.clone());
// ── Git browse routes (unauthenticated read-only) ────────────────────────
let git_browse_router = api::git::browse_router(state.clone());
// ── Internal git policy route (pre-receive hook callback) ────────────────
let git_policy_router = api::git::git_policy_router(state.clone());
@@ -80,6 +83,7 @@ pub fn build_router(state: Arc<AppState>) -> Router {
let mut merged = api_router
.merge(media_router)
.merge(git_router)
.merge(git_browse_router)
.merge(git_policy_router);
// When SPROUT_WEB_DIR is set, serve the SPA as a fallback for unmatched routes.

18
pnpm-lock.yaml generated
View File

@@ -161,7 +161,7 @@ importers:
version: 1.59.1
'@tanstack/router-plugin':
specifier: ^1.167.12
version: 1.167.32(@tanstack/react-router@1.169.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@7.3.2(@types/node@25.6.0)(jiti@1.21.7)(yaml@2.8.4))
version: 1.167.32(@tanstack/react-router@1.169.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.4))
'@tanstack/virtual-file-routes':
specifier: ^1.161.7
version: 1.161.7
@@ -176,7 +176,7 @@ importers:
version: 19.2.3(@types/react@19.2.14)
'@vitejs/plugin-react':
specifier: ^4.6.0
version: 4.7.0(vite@7.3.2(@types/node@25.6.0)(jiti@1.21.7)(yaml@2.8.4))
version: 4.7.0(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.4))
autoprefixer:
specifier: ^10.4.27
version: 10.5.0(postcss@8.5.14)
@@ -197,7 +197,7 @@ importers:
version: 5.9.3
vite:
specifier: ^7.0.4
version: 7.3.2(@types/node@25.6.0)(jiti@1.21.7)(yaml@2.8.4)
version: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.4)
web:
dependencies:
@@ -231,6 +231,12 @@ importers:
react-dom:
specifier: ^19.1.0
version: 19.2.5(react@19.2.5)
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.14)(react@19.2.5)
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@@ -243,7 +249,7 @@ importers:
version: 1.59.1
'@tanstack/router-plugin':
specifier: ^1.167.12
version: 1.167.32(@tanstack/react-router@1.169.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.4))
version: 1.167.32(@tanstack/react-router@1.169.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@7.3.2(@types/node@25.6.0)(jiti@1.21.7)(yaml@2.8.4))
'@tanstack/virtual-file-routes':
specifier: ^1.161.7
version: 1.161.7
@@ -255,7 +261,7 @@ importers:
version: 19.2.3(@types/react@19.2.14)
'@vitejs/plugin-react':
specifier: ^4.6.0
version: 4.7.0(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.4))
version: 4.7.0(vite@7.3.2(@types/node@25.6.0)(jiti@1.21.7)(yaml@2.8.4))
autoprefixer:
specifier: ^10.4.27
version: 10.5.0(postcss@8.5.14)
@@ -273,7 +279,7 @@ importers:
version: 5.9.3
vite:
specifier: ^7.0.4
version: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.4)
version: 7.3.2(@types/node@25.6.0)(jiti@1.21.7)(yaml@2.8.4)
packages:

View File

@@ -26,6 +26,8 @@
"nostr-tools": "^2.23.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0"
},

View File

@@ -0,0 +1,75 @@
import { GitCommitHorizontal } from "lucide-react";
import { relativeTime } from "@/shared/lib/relative-time";
import type { CommitEntry } from "../use-git-browse";
import { useCommits } from "../use-git-browse";
function CommitRow({ commit }: { commit: CommitEntry }) {
return (
<div className="flex items-start gap-3 py-2">
<code className="shrink-0 pt-0.5 font-mono text-xs text-muted-foreground">
{commit.sha.slice(0, 7)}
</code>
<div className="min-w-0 flex-1">
<p className="truncate text-sm">{commit.message}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{commit.author} &middot; {relativeTime(commit.timestamp)}
</p>
</div>
</div>
);
}
export function RepoCommitsSection({
repoId,
owner,
gitRef,
}: {
repoId: string;
owner: string;
gitRef: string;
}) {
const { data: commits, isLoading, error } = useCommits(repoId, owner, gitRef);
if (isLoading) return null;
if (error) {
return (
<div className="mt-8">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold">
<GitCommitHorizontal className="h-4 w-4" />
Commits
</h2>
<p className="text-sm text-destructive">Failed to load commits</p>
</div>
);
}
if (!commits || commits.length === 0) {
return (
<div className="mt-8">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold">
<GitCommitHorizontal className="h-4 w-4" />
Commits
</h2>
<p className="text-sm text-muted-foreground">No commits yet</p>
</div>
);
}
return (
<div className="mt-8">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold">
<GitCommitHorizontal className="h-4 w-4" />
Recent Commits
</h2>
<div className="divide-y divide-border rounded-md border border-border">
{commits.map((commit) => (
<div key={commit.sha} className="px-3">
<CommitRow commit={commit} />
</div>
))}
</div>
</div>
);
}

View File

@@ -11,32 +11,17 @@ import { useEffect, useState } from "react";
import { Link, useParams } from "@tanstack/react-router";
import { toast } from "sonner";
import { relativeTime } from "@/shared/lib/relative-time";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { useRepoRefs } from "../use-repo-refs";
import { useRepo } from "../use-repos";
import { ConnectButton } from "./ConnectButton";
import { PubkeyAvatar } from "./PubkeyAvatar";
import { RepoCommitsSection } from "./RepoCommitsSection";
import { RepoReadmeSection } from "./RepoReadmeSection";
import { RepoRefsSection } from "./RepoRefsSection";
function relativeTime(unix: number): string {
const now = Date.now();
const diff = now - unix * 1000;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 30) {
const months = Math.floor(days / 30);
return months === 1 ? "1 month ago" : `${months} months ago`;
}
if (days > 0) return days === 1 ? "1 day ago" : `${days} days ago`;
if (hours > 0) return hours === 1 ? "1 hour ago" : `${hours} hours ago`;
if (minutes > 0)
return minutes === 1 ? "1 minute ago" : `${minutes} minutes ago`;
return "just now";
}
import { RepoTreeSection } from "./RepoTreeSection";
function CopyableUrl({ url }: { url: string }) {
const [copied, setCopied] = useState(false);
@@ -73,14 +58,17 @@ function CopyableUrl({ url }: { url: string }) {
function DetailSkeleton() {
return (
<div className="mx-auto w-full max-w-3xl px-4 py-8">
<div className="h-5 w-24 animate-pulse rounded bg-muted" />
<div className="mt-6 h-8 w-64 animate-pulse rounded bg-muted" />
<div className="mt-3 h-5 w-96 animate-pulse rounded bg-muted" />
<div className="mt-8 space-y-3">
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
<div className="h-10 w-full animate-pulse rounded bg-muted" />
<div className="mx-auto flex w-full max-w-7xl gap-8 px-4 py-8">
<div className="min-w-0 flex-1">
<div className="h-5 w-24 animate-pulse rounded bg-muted" />
<div className="mt-6 h-8 w-64 animate-pulse rounded bg-muted" />
<div className="mt-3 h-5 w-96 animate-pulse rounded bg-muted" />
<div className="mt-8 space-y-3">
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
<div className="h-10 w-full animate-pulse rounded bg-muted" />
</div>
</div>
<aside className="hidden w-72 shrink-0 lg:block" />
</div>
);
}
@@ -102,7 +90,34 @@ export function RepoDetailPage() {
if (!repo) {
return (
<div className="mx-auto w-full max-w-3xl px-4 py-8">
<div className="mx-auto flex w-full max-w-7xl gap-8 px-4 py-8">
<div className="min-w-0 flex-1">
<Link
to="/"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
Back to repositories
</Link>
<div className="mt-12 text-center">
<BookMarked className="mx-auto h-10 w-10 text-muted-foreground" />
<h1 className="mt-4 text-xl font-semibold">Repository not found</h1>
<p className="mt-1 text-sm text-muted-foreground">
This repository may have been removed or doesn't exist on this
relay.
</p>
</div>
</div>
<aside className="hidden w-72 shrink-0 lg:block" />
</div>
);
}
return (
<div className="mx-auto flex w-full max-w-7xl gap-8 px-4 py-8">
{/* Main content */}
<div className="min-w-0 flex-1">
{/* Back link */}
<Link
to="/"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
@@ -110,109 +125,127 @@ export function RepoDetailPage() {
<ArrowLeft className="h-4 w-4" />
Back to repositories
</Link>
<div className="mt-12 text-center">
<BookMarked className="mx-auto h-10 w-10 text-muted-foreground" />
<h1 className="mt-4 text-xl font-semibold">Repository not found</h1>
<p className="mt-1 text-sm text-muted-foreground">
This repository may have been removed or doesn't exist on this
relay.
{/* Mobile-only connect button */}
<div className="mt-4 lg:hidden">
<ConnectButton className="w-full" />
</div>
{/* Header */}
<div className="mt-6">
<div className="flex items-center gap-3">
<BookMarked className="h-6 w-6 shrink-0 text-muted-foreground" />
<h1 className="text-2xl font-semibold tracking-tight">
{repo.name}
</h1>
<Badge variant="outline">Public</Badge>
</div>
{repo.description && (
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
{repo.description}
</p>
)}
<p className="mt-2 text-xs text-muted-foreground">
Updated {relativeTime(repo.createdAt)}
</p>
</div>
</div>
);
}
return (
<div className="mx-auto w-full max-w-3xl px-4 py-8">
{/* Back link */}
<Link
to="/"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
Back to repositories
</Link>
{/* Refs & HEAD */}
<RepoRefsSection refs={refs} isLoading={refsLoading} />
{/* Header */}
<div className="mt-6">
<div className="flex items-center gap-3">
<BookMarked className="h-6 w-6 shrink-0 text-muted-foreground" />
<h1 className="text-2xl font-semibold tracking-tight">{repo.name}</h1>
<Badge variant="outline">Public</Badge>
</div>
{repo.description && (
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
{repo.description}
</p>
{/* Git content sections — only when we have a default branch */}
{refs?.head?.ref && (
<>
<RepoCommitsSection
repoId={repoId}
owner={repo.owner}
gitRef={refs.head.ref}
/>
<RepoTreeSection
repoId={repoId}
owner={repo.owner}
gitRef={refs.head.ref}
/>
<RepoReadmeSection
repoId={repoId}
owner={repo.owner}
gitRef={refs.head.ref}
/>
</>
)}
{/* Clone URLs */}
{repo.cloneUrls.length > 0 && (
<div className="mt-8">
<h2 className="mb-3 text-sm font-semibold">Clone</h2>
<div className="space-y-2">
{repo.cloneUrls.map((url) => (
<CopyableUrl key={url} url={url} />
))}
</div>
</div>
)}
{/* External link — validate scheme to prevent javascript: XSS */}
{(() => {
if (!repo.webUrl) return null;
let safe: string | null = null;
try {
safe = /^https?:/.test(new URL(repo.webUrl).protocol)
? repo.webUrl
: null;
} catch {
safe = null;
}
if (!safe) return null;
return (
<div className="mt-6">
<Button variant="outline" asChild>
<a href={safe} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4" />
View on web
</a>
</Button>
</div>
);
})()}
{/* Channel link */}
{repo.channelId && (
<div className="mt-8">
<Button variant="outline" asChild>
<a href={`/channels/${repo.channelId}`}>
<MessageSquare className="h-4 w-4" />
View channel
</a>
</Button>
</div>
)}
<p className="mt-2 text-xs text-muted-foreground">
Updated {relativeTime(repo.createdAt)}
</p>
</div>
{/* Refs & HEAD */}
<RepoRefsSection refs={refs} isLoading={refsLoading} />
{/* Sidebar */}
<aside className="hidden w-72 shrink-0 border-l border-border pl-8 lg:block">
<div className="space-y-6">
{/* Open in Sprout */}
<ConnectButton className="w-full" />
{/* Clone URLs */}
{repo.cloneUrls.length > 0 && (
<div className="mt-8">
<h2 className="mb-3 text-sm font-semibold">Clone</h2>
<div className="space-y-2">
{repo.cloneUrls.map((url) => (
<CopyableUrl key={url} url={url} />
))}
{/* People */}
<div>
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold">
<Users className="h-4 w-4" />
People
</h3>
<div className="flex flex-wrap gap-2">
<PubkeyAvatar pubkey={repo.owner} />
{repo.contributors
.filter((c) => c !== repo.owner)
.map((c) => (
<PubkeyAvatar key={c} pubkey={c} />
))}
</div>
</div>
</div>
)}
{/* External link */}
{repo.webUrl && (
<div className="mt-6">
<Button variant="outline" asChild>
<a href={repo.webUrl} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4" />
View on web
</a>
</Button>
</div>
)}
{/* Owner & Contributors */}
<div className="mt-8">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold">
<Users className="h-4 w-4" />
People
</h2>
<div className="flex flex-wrap gap-2">
<PubkeyAvatar pubkey={repo.owner} />
{repo.contributors
.filter((c) => c !== repo.owner)
.map((c) => (
<PubkeyAvatar key={c} pubkey={c} />
))}
</div>
</div>
{/* Channel link */}
{repo.channelId && (
<div className="mt-8">
<Button variant="outline" asChild>
<a href={`/channels/${repo.channelId}`}>
<MessageSquare className="h-4 w-4" />
View channel
</a>
</Button>
</div>
)}
{/* Open in Sprout CTA */}
<div className="mt-8 rounded-lg border border-border bg-muted/30 p-6 text-center">
<p className="mb-3 text-sm text-muted-foreground">
Open this relay in the Sprout desktop app to push code and
collaborate.
</p>
<ConnectButton />
</div>
</aside>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { BookMarked } from "lucide-react";
import { Link } from "@tanstack/react-router";
import { relativeTime } from "@/shared/lib/relative-time";
import { Badge } from "@/shared/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import type { Repo } from "../use-repos";
@@ -10,25 +11,6 @@ function truncateHex(hex: string): string {
return `${hex.slice(0, 8)}...${hex.slice(-4)}`;
}
function relativeTime(unix: number): string {
const now = Date.now();
const diff = now - unix * 1000;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 30) {
const months = Math.floor(days / 30);
return months === 1 ? "1 month ago" : `${months} months ago`;
}
if (days > 0) return days === 1 ? "1 day ago" : `${days} days ago`;
if (hours > 0) return hours === 1 ? "1 hour ago" : `${hours} hours ago`;
if (minutes > 0)
return minutes === 1 ? "1 minute ago" : `${minutes} minutes ago`;
return "just now";
}
export function RepoListItem({ repo }: { repo: Repo }) {
return (
<div className="py-6">

View File

@@ -0,0 +1,143 @@
import { FileText } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { useReadme } from "../use-git-browse";
const markdownComponents = {
h1: ({ children }: { children?: React.ReactNode }) => (
<h1 className="mb-4 mt-6 border-b border-border pb-2 text-2xl font-semibold first:mt-0">
{children}
</h1>
),
h2: ({ children }: { children?: React.ReactNode }) => (
<h2 className="mb-3 mt-6 border-b border-border pb-2 text-xl font-semibold first:mt-0">
{children}
</h2>
),
h3: ({ children }: { children?: React.ReactNode }) => (
<h3 className="mb-2 mt-4 text-lg font-semibold first:mt-0">{children}</h3>
),
h4: ({ children }: { children?: React.ReactNode }) => (
<h4 className="mb-2 mt-4 text-base font-semibold first:mt-0">{children}</h4>
),
p: ({ children }: { children?: React.ReactNode }) => (
<p className="my-3 leading-relaxed first:mt-0 last:mb-0">{children}</p>
),
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
<a
href={href}
className="text-primary underline underline-offset-4 hover:text-primary/80"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
ul: ({ children }: { children?: React.ReactNode }) => (
<ul className="my-3 list-disc space-y-1 pl-6 marker:text-muted-foreground">
{children}
</ul>
),
ol: ({ children }: { children?: React.ReactNode }) => (
<ol className="my-3 list-decimal space-y-1 pl-6 marker:text-muted-foreground">
{children}
</ol>
),
li: ({ children }: { children?: React.ReactNode }) => (
<li className="leading-relaxed [&_p]:my-1">{children}</li>
),
blockquote: ({ children }: { children?: React.ReactNode }) => (
<blockquote className="my-3 border-l-4 border-border pl-4 italic text-muted-foreground">
{children}
</blockquote>
),
code: ({
children,
className,
}: {
children?: React.ReactNode;
className?: string;
}) => {
const isBlock =
typeof className === "string" && className.includes("language-");
if (isBlock) {
return <code className="block text-[13px] leading-6">{children}</code>;
}
return (
<code className="rounded bg-muted px-1.5 py-0.5 text-[13px]">
{children}
</code>
);
},
pre: ({ children }: { children?: React.ReactNode }) => (
<pre className="my-3 overflow-x-auto rounded-md border border-border bg-muted/60 px-4 py-3 text-sm">
{children}
</pre>
),
hr: () => <hr className="my-6 border-border" />,
table: ({ children }: { children?: React.ReactNode }) => (
<div className="my-3 overflow-x-auto rounded-md border border-border">
<table className="w-full border-collapse text-left text-sm">
{children}
</table>
</div>
),
th: ({ children }: { children?: React.ReactNode }) => (
<th className="border-b border-border bg-muted/60 px-3 py-2 font-semibold">
{children}
</th>
),
td: ({ children }: { children?: React.ReactNode }) => (
<td className="border-t border-border px-3 py-2">{children}</td>
),
strong: ({ children }: { children?: React.ReactNode }) => (
<strong className="font-semibold">{children}</strong>
),
img: ({ src, alt }: { src?: string; alt?: string }) => (
<img src={src} alt={alt} className="my-3 max-w-full rounded-md" />
),
};
export function RepoReadmeSection({
repoId,
owner,
gitRef,
}: {
repoId: string;
owner: string;
gitRef: string;
}) {
const { data: readme, isLoading, error } = useReadme(repoId, owner, gitRef);
if (isLoading) return null;
if (error) {
return (
<div className="mt-8">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold">
<FileText className="h-4 w-4" />
README
</h2>
<p className="text-sm text-destructive">Failed to load README</p>
</div>
);
}
if (!readme) return null;
return (
<div className="mt-8">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold">
<FileText className="h-4 w-4" />
{readme.filename}
</h2>
<div className="rounded-md border border-border bg-muted/30 p-6 text-sm text-foreground/90">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownComponents}
>
{readme.content}
</ReactMarkdown>
</div>
</div>
);
}

View File

@@ -0,0 +1,149 @@
import { ChevronRight, FolderClosed, FileText } from "lucide-react";
import { useState } from "react";
import type { TreeEntry } from "../use-git-browse";
import { useTree } from "../use-git-browse";
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function sortEntries(entries: TreeEntry[]): TreeEntry[] {
return [...entries].sort((a, b) => {
// Directories first, then files
if (a.type === "tree" && b.type !== "tree") return -1;
if (a.type !== "tree" && b.type === "tree") return 1;
return a.name.localeCompare(b.name);
});
}
function Breadcrumb({
path,
onNavigate,
}: {
path: string;
onNavigate: (path: string) => void;
}) {
const segments = path.split("/").filter(Boolean);
return (
<div className="mb-3 flex items-center gap-1 text-sm text-muted-foreground">
<button
type="button"
onClick={() => onNavigate("")}
className="hover:text-foreground"
>
root
</button>
{segments.map((segment, i) => {
const segmentPath = segments.slice(0, i + 1).join("/");
return (
<span key={segmentPath} className="flex items-center gap-1">
<ChevronRight className="h-3 w-3" />
<button
type="button"
onClick={() => onNavigate(segmentPath)}
className="hover:text-foreground"
>
{segment}
</button>
</span>
);
})}
</div>
);
}
function TreeRow({
entry,
onNavigate,
}: {
entry: TreeEntry;
onNavigate: (name: string) => void;
}) {
const isDir = entry.type === "tree";
if (isDir) {
return (
<button
type="button"
className="flex w-full items-center gap-3 py-2 text-left hover:bg-muted/50"
onClick={() => onNavigate(entry.name)}
>
<FolderClosed className="h-4 w-4 shrink-0 text-blue-500" />
<span className="min-w-0 flex-1 truncate text-sm">{entry.name}</span>
</button>
);
}
return (
<div className="flex w-full items-center gap-3 py-2">
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-sm">{entry.name}</span>
{entry.size != null && (
<span className="shrink-0 text-xs text-muted-foreground">
{formatSize(entry.size)}
</span>
)}
</div>
);
}
export function RepoTreeSection({
repoId,
owner,
gitRef,
}: {
repoId: string;
owner: string;
gitRef: string;
}) {
const [currentPath, setCurrentPath] = useState("");
const {
data: entries,
isLoading,
error,
} = useTree(repoId, owner, gitRef, currentPath || undefined);
if (isLoading) return null;
if (error) {
return (
<div className="mt-8">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold">
<FolderClosed className="h-4 w-4" />
Files
</h2>
<p className="text-sm text-destructive">Failed to load files</p>
</div>
);
}
if (!entries || entries.length === 0) return null;
const sorted = sortEntries(entries);
function navigateToDir(name: string) {
const newPath = currentPath ? `${currentPath}/${name}` : name;
setCurrentPath(newPath);
}
return (
<div className="mt-8">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold">
<FolderClosed className="h-4 w-4" />
Files
</h2>
{currentPath && (
<Breadcrumb path={currentPath} onNavigate={setCurrentPath} />
)}
<div className="divide-y divide-border rounded-md border border-border">
{sorted.map((entry) => (
<div key={entry.name} className="px-3">
<TreeRow entry={entry} onNavigate={navigateToDir} />
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,152 @@
import { useQuery } from "@tanstack/react-query";
import { queryEventsHttp } from "@/shared/lib/query-http";
import type { NostrFilter } from "@/shared/lib/nostr-client";
import { getTag } from "./use-repos";
// ── Types matching the relay synthesis structs ─────────────────────────────
export interface TreeEntry {
name: string;
type: string;
mode: string;
size: number | null;
sha: string;
}
export interface CommitEntry {
sha: string;
author: string;
email: string;
timestamp: number;
parents: string[];
message: string;
}
export interface BlobMeta {
size: number;
binary: boolean;
url: string;
}
export interface ReadmeData {
filename: string;
content: string;
}
// ── Kind constants (ephemeral git browse events) ───────────────────────────
const KIND_GIT_TREE = 20100;
const KIND_GIT_BLOB = 20101;
const KIND_GIT_COMMIT_LOG = 20102;
const KIND_GIT_README = 20103;
// ── Hooks ──────────────────────────────────────────────────────────────────
/**
* Fetch a directory listing for a repo at a given ref and optional path.
*/
export function useTree(
repoId: string,
owner: string,
gitRef: string,
path?: string,
) {
return useQuery({
queryKey: ["git-tree", owner, repoId, gitRef, path ?? ""],
queryFn: async (): Promise<TreeEntry[]> => {
const filter: NostrFilter = {
kinds: [KIND_GIT_TREE],
"#d": [`${owner}/${repoId}`],
"#r": [gitRef],
...(path ? { "#f": [path] } : {}),
};
const events = await queryEventsHttp(filter);
if (events.length === 0) return [];
return JSON.parse(events[0].content) as TreeEntry[];
},
enabled: !!repoId && !!owner && !!gitRef,
staleTime: 30_000,
});
}
/**
* Fetch blob metadata (size, binary flag, download URL) for a file.
*/
export function useBlob(
repoId: string,
owner: string,
gitRef: string,
path: string,
) {
return useQuery({
queryKey: ["git-blob", owner, repoId, gitRef, path],
queryFn: async (): Promise<BlobMeta | null> => {
const events = await queryEventsHttp({
kinds: [KIND_GIT_BLOB],
"#d": [`${owner}/${repoId}`],
"#r": [gitRef],
"#f": [path],
});
if (events.length === 0) return null;
const e = events[0];
return {
size: Number(getTag(e, "size") ?? "0"),
binary: getTag(e, "binary") === "true",
url: getTag(e, "url") ?? "",
};
},
enabled: !!repoId && !!owner && !!gitRef && !!path,
staleTime: 30_000,
});
}
/**
* Fetch the commit log for a repo at a given ref, with optional pagination.
*/
export function useCommits(
repoId: string,
owner: string,
gitRef: string,
page?: number,
) {
return useQuery({
queryKey: ["git-commits", owner, repoId, gitRef, page ?? 0],
queryFn: async (): Promise<CommitEntry[]> => {
const filter: NostrFilter = {
kinds: [KIND_GIT_COMMIT_LOG],
"#d": [`${owner}/${repoId}`],
"#r": [gitRef],
...(page !== undefined && page > 0 ? { "#n": [String(page)] } : {}),
};
const events = await queryEventsHttp(filter);
if (events.length === 0) return [];
return JSON.parse(events[0].content) as CommitEntry[];
},
enabled: !!repoId && !!owner && !!gitRef,
staleTime: 30_000,
});
}
/**
* Fetch the README for a repo at a given ref.
*/
export function useReadme(repoId: string, owner: string, gitRef: string) {
return useQuery({
queryKey: ["git-readme", owner, repoId, gitRef],
queryFn: async (): Promise<ReadmeData | null> => {
const events = await queryEventsHttp({
kinds: [KIND_GIT_README],
"#d": [`${owner}/${repoId}`],
"#r": [gitRef],
});
if (events.length === 0) return null;
const e = events[0];
return {
filename: getTag(e, "filename") ?? "README",
content: e.content,
};
},
enabled: !!repoId && !!owner && !!gitRef,
staleTime: 30_000,
});
}

View File

@@ -0,0 +1,103 @@
/**
* HTTP bridge client for querying Nostr events via POST /query.
*
* Uses NIP-98 authentication with the same ephemeral keypair as the
* WebSocket client (nostr-client.ts).
*/
import {
generateSecretKey,
getPublicKey,
finalizeEvent,
} from "nostr-tools/pure";
import { relayHttpBaseUrl } from "./relay-url";
import type { NostrEvent, NostrFilter } from "./nostr-client";
/** Lazily-generated ephemeral keypair for NIP-98 auth. */
let _httpSecretKey: Uint8Array | null = null;
function getHttpKey(): Uint8Array {
if (!_httpSecretKey) {
_httpSecretKey = generateSecretKey();
}
return _httpSecretKey;
}
/**
* SHA-256 hex digest of a string, using the Web Crypto API.
*/
async function sha256Hex(data: string): Promise<string> {
const buf = new TextEncoder().encode(data);
const hash = await crypto.subtle.digest("SHA-256", buf);
return Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
/**
* Build a NIP-98 Authorization header value.
*
* Signs a kind:27235 event with the ephemeral key, base64-encodes it,
* and returns the full "Nostr <base64>" string.
*
* Includes a `["payload", sha256hex(body)]` tag per the NIP-98 spec so
* that parallel requests with different bodies produce unique auth events.
*/
async function buildNip98Header(
url: string,
method: string,
body: string,
): Promise<string> {
const sk = getHttpKey();
const payloadHash = await sha256Hex(body);
const event = {
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
["u", url],
["method", method],
["payload", payloadHash],
],
content: "",
pubkey: getPublicKey(sk),
};
const signed = finalizeEvent(event, sk);
const json = JSON.stringify(signed);
const b64 = btoa(json);
return `Nostr ${b64}`;
}
/**
* Query events via the HTTP bridge (POST /query).
*
* This is used for synthesized ephemeral events (git browse kinds 20100-20103)
* that are generated on-the-fly by the relay and never stored in the DB.
*/
export async function queryEventsHttp(
filter: NostrFilter,
): Promise<NostrEvent[]> {
const url = `${relayHttpBaseUrl()}/query`;
const body = JSON.stringify([filter]);
const auth = await buildNip98Header(url, "POST", body);
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: auth,
},
body,
});
if (!res.ok) {
const errBody = await res.text().catch(() => "");
throw new Error(
`HTTP query failed: ${res.status} ${res.statusText}${errBody ? ` — ${errBody}` : ""}`,
);
}
const data: unknown = await res.json();
if (!Array.isArray(data)) {
return [];
}
return data as NostrEvent[];
}

View File

@@ -0,0 +1,19 @@
/** Format a Unix timestamp (seconds) as a human-readable relative time string. */
export function relativeTime(unix: number): string {
const now = Date.now();
const diff = now - unix * 1000;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 30) {
const months = Math.floor(days / 30);
return months === 1 ? "1 month ago" : `${months} months ago`;
}
if (days > 0) return days === 1 ? "1 day ago" : `${days} days ago`;
if (hours > 0) return hours === 1 ? "1 hour ago" : `${hours} hours ago`;
if (minutes > 0)
return minutes === 1 ? "1 minute ago" : `${minutes} minutes ago`;
return "just now";
}