diff --git a/mnemosyne/src/main.rs b/mnemosyne/src/main.rs index 2f39fa4..04733b9 100644 --- a/mnemosyne/src/main.rs +++ b/mnemosyne/src/main.rs @@ -1,23 +1,29 @@ //! Tangled knot binary — boots a Postgres-backed Git HTTP server. //! -//! Serves a single demo repository whose DID is configured via -//! `MNEMOSYNE_REPO_DID`. The repository is registered in the `repos` table -//! on startup (idempotent) and seeded with a hello-world commit if empty. +//! Two URL forms are accepted for every Git smart-HTTP endpoint: //! -//! Smart-HTTP `GET /info/refs` and `POST /git-upload-pack` are wired at the -//! root and under a single-segment prefix so `jj git clone -//! http://localhost:PORT/whatever` works. Multi-repo routing (/{did}/{name}) -//! comes in the next step. +//! `/:repo_did/...` — direct lookup by repo DID +//! `/:owner_did/:repo_name/...` — lookup by owner + name, resolved to DID first +//! +//! Both collapse to the same per-request `PgBackend` once the DID is known. +//! A 404 is returned for any unknown repo. use std::sync::Arc; use anyhow::Context; +use axum::body::Bytes; +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; use axum::routing::{get, post}; use axum::Router; use clap::Parser; -use mnemosyne_postgres::{ensure_repo, migrate, PgBackend}; +use gix_hash::Kind as HashKind; +use mnemosyne_postgres::{ensure_repo, lookup_repo_by_did, migrate, resolve_repo_did, PgBackend}; +use mnemosyne_protocol::info_refs::InfoRefsQuery; use mnemosyne_protocol::{info_refs, upload_pack, RepoState}; use sqlx::postgres::PgPoolOptions; +use sqlx::PgPool; use tokio::net::TcpListener; mod seed; @@ -34,10 +40,6 @@ struct Config { bind: String, /// DID that identifies the seeded demo repository. - /// - /// This becomes the primary key in the `repos` table and the scope key - /// for all objects and refs in Postgres. Use a stable value across - /// restarts so previously stored data is still reachable. #[arg( long, default_value = "did:web:localhost:repos:hello-world", @@ -58,6 +60,109 @@ struct Config { repo_name: String, } +/// Server-level state: shared across all requests. +/// +/// Per-request state (`RepoState`) is constructed on each handler call by +/// looking up the repo DID and scoping a fresh `PgBackend` to it. +#[derive(Clone)] +struct AppState { + pool: PgPool, + object_hash: HashKind, +} + +impl AppState { + fn repo_state(&self, repo_did: String) -> RepoState { + RepoState { + backend: Arc::new(PgBackend::with_pool( + self.pool.clone(), + repo_did, + self.object_hash, + )), + object_hash: self.object_hash, + } + } +} + +// --------------------------------------------------------------------------- +// Not-found helper +// --------------------------------------------------------------------------- + +fn repo_not_found() -> axum::response::Response { + (StatusCode::NOT_FOUND, "repository not found\n").into_response() +} + +// --------------------------------------------------------------------------- +// Git smart-HTTP handlers — /:repo_did form +// --------------------------------------------------------------------------- + +async fn git_info_refs( + State(app): State, + Path(repo_did): Path, + query: Query, +) -> impl IntoResponse { + let Some(repo_did) = lookup_repo_by_did(&app.pool, &repo_did).await.ok().flatten() else { + return repo_not_found(); + }; + info_refs::handler(State(app.repo_state(repo_did)), query) + .await + .into_response() +} + +async fn git_upload_pack( + State(app): State, + Path(repo_did): Path, + body: Bytes, +) -> impl IntoResponse { + let Some(repo_did) = lookup_repo_by_did(&app.pool, &repo_did).await.ok().flatten() else { + return repo_not_found(); + }; + upload_pack::handler(State(app.repo_state(repo_did)), body) + .await + .into_response() +} + +// --------------------------------------------------------------------------- +// Git smart-HTTP handlers — /:owner_did/:repo_name form +// --------------------------------------------------------------------------- + +async fn git_info_refs_named( + State(app): State, + Path((owner_did, repo_name)): Path<(String, String)>, + query: Query, +) -> impl IntoResponse { + let Some(repo_did) = resolve_repo_did(&app.pool, &owner_did, &repo_name) + .await + .ok() + .flatten() + else { + return repo_not_found(); + }; + info_refs::handler(State(app.repo_state(repo_did)), query) + .await + .into_response() +} + +async fn git_upload_pack_named( + State(app): State, + Path((owner_did, repo_name)): Path<(String, String)>, + body: Bytes, +) -> impl IntoResponse { + let Some(repo_did) = resolve_repo_did(&app.pool, &owner_did, &repo_name) + .await + .ok() + .flatten() + else { + return repo_not_found(); + }; + upload_pack::handler(State(app.repo_state(repo_did)), body) + .await + .into_response() +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + #[tokio::main(flavor = "multi_thread")] async fn main() -> anyhow::Result<()> { let cfg = Config::parse(); @@ -74,29 +179,34 @@ async fn main() -> anyhow::Result<()> { .await .context("ensure demo repo")?; + // Seed a hello-world commit if the repo has no refs yet. let backend = Arc::new(PgBackend::with_pool( - pool, + pool.clone(), &cfg.repo_did, - gix_hash::Kind::Sha1, + HashKind::Sha1, )); seed::seed_if_empty(&backend) .await .context("seed initial commit")?; - let state = RepoState { - backend, - object_hash: gix_hash::Kind::Sha1, + let state = AppState { + pool, + object_hash: HashKind::Sha1, }; - // axum 0.8 doesn't allow catch-all params mid-path, so we register - // both the bare suffixes (no prefix) and a single-segment prefix - // (e.g. `/repo/info/refs`). Full /{owner_did}/{repo_name} routing - // comes in the next step once repo creation is wired up. let app = Router::new() - .route("/info/refs", get(info_refs::handler)) - .route("/git-upload-pack", post(upload_pack::handler)) - .route("/{_repo}/info/refs", get(info_refs::handler)) - .route("/{_repo}/git-upload-pack", post(upload_pack::handler)) + // /:repo_did/... + .route("/{repo_did}/info/refs", get(git_info_refs)) + .route("/{repo_did}/git-upload-pack", post(git_upload_pack)) + // /:owner_did/:repo_name/... + .route( + "/{owner_did}/{repo_name}/info/refs", + get(git_info_refs_named), + ) + .route( + "/{owner_did}/{repo_name}/git-upload-pack", + post(git_upload_pack_named), + ) .with_state(state); let listener = TcpListener::bind(&cfg.bind) diff --git a/mnemosyne_postgres/src/lib.rs b/mnemosyne_postgres/src/lib.rs index 4809f40..a971939 100644 --- a/mnemosyne_postgres/src/lib.rs +++ b/mnemosyne_postgres/src/lib.rs @@ -106,6 +106,22 @@ pub async fn ensure_repo( Ok(()) } +/// Check whether a repo DID exists in the `repos` table. +/// +/// Returns `Some(repo_did)` if found, `None` otherwise. Useful for the +/// `/:repo_did` routing form where the DID is supplied directly. +pub async fn lookup_repo_by_did( + pool: &PgPool, + repo_did: &str, +) -> Result, PgError> { + let row: Option<(String,)> = + sqlx::query_as("SELECT did FROM repos WHERE did = $1") + .bind(repo_did) + .fetch_optional(pool) + .await?; + Ok(row.map(|(did,)| did)) +} + /// Look up a repo DID by owner DID and repo name. /// /// Returns `None` if no matching repo exists.