//! JSON API for `refs/notes/*`. //! //! //! //! The point of this surface is tooling: a build script writing its result onto //! the commit it built, a review bot recording a verdict. It adds no semantics. //! Reads come from the repository through `crate::git::notes`, writes go through //! the same `write_note` the browser form uses, and the authorization, the //! namespace rules and the committer identity are `notes_write`'s, called rather //! than restated. //! //! **Reads answer from the repository, not from the index.** The one exception //! is search, which is a full-text query the tree cannot serve; it reports //! whether the index has ever seen this repository so a caller can tell "no //! matches" from "nothing indexed yet" (`indexed` in the response). //! //! **Writes authenticate by push-scoped personal access token only.** A session //! cookie is refused, exactly as `receive-pack` refuses one (`raw.rs` //! `authorize_push`). These routes cannot carry a CSRF token, since the caller //! is a script rather than a page MNW rendered, so they are registered //! CSRF-skipped; a cookie would then be an ambient credential behind a route //! with no CSRF seal. `Authorization: Basic` cannot be set by a cross-site form, //! so requiring it is the seal. Reads accept either, which is what the browse //! path already does and costs nothing: a GET changes nothing and no CORS policy //! lets another origin read the response. use axum::{ Json, extract::{Path, Query, State}, http::{HeaderMap, StatusCode}, response::IntoResponse, }; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use utoipa::ToSchema; use crate::{ auth::MaybeUserVerified, config::Config, db::{self, GitRepoId}, error::{AppError, Result}, git::notes::{self, GixEngine, Oid}, routes::git::{GitHttpPrincipal, ResolvedRepo, notes_index, notes_write, resolve_repo}, validation, }; /// How far back the attribution walk may look, matching the commit page's /// bound. It is off by default here: it costs a second history walk per note, /// and a script reading notes in bulk has no use for who typed them. const ATTRIBUTION_MAX_COMMITS: usize = 50; /// Default and ceiling for `limit` on search. The ceiling exists because the /// index holds whole note bodies, so a large page is a large response. const SEARCH_LIMIT_DEFAULT: i64 = 50; const SEARCH_LIMIT_MAX: i64 = 200; // --- Response shapes --- /// One notes namespace as the repository holds it. #[derive(Serialize, ToSchema)] pub(crate) struct NamespaceEntry { /// Namespace as a person says it: `commits`, `review/security`. pub name: String, /// The ref it lives on, for a caller assembling a fetch refspec. pub git_ref: String, /// Object id the ref points at. pub tip: String, /// Notes in the namespace. pub notes: i64, } #[derive(Serialize, ToSchema)] pub(crate) struct NamespacesResponse { pub data: Vec, } /// Who wrote the note and when, from the notes ref's own history. #[derive(Serialize, ToSchema)] pub(crate) struct NoteAttribution { /// The notes commit that set the note to what it says now. pub commit: String, pub name: String, pub email: String, pub at: DateTime, /// False when the bounded walk ran out before finding the change, so the /// commit named is as far back as it looked rather than the one responsible. pub exact: bool, } #[derive(Serialize, ToSchema)] pub(crate) struct NoteResponse { pub namespace: String, /// The annotated object. Need not be a commit: notes on blobs and trees are /// legal and this returns them. pub target: String, /// The blob holding the content. pub blob: String, /// Note content. Note bodies are bytes, not text; anything that is not UTF-8 /// is replaced rather than rejected, since a note git accepted has to be /// readable here. pub content: String, /// Present only when the request asked for it. pub attribution: Option, } /// What a write did. #[derive(Serialize, ToSchema)] pub(crate) struct WriteResponse { pub namespace: String, pub target: String, /// `written` when the ref moved, `unchanged` when the namespace already said /// exactly this. Re-putting an identical note is not an error and costs no /// commit. pub status: &'static str, /// Somebody else annotated the same target while this write was in flight /// and the two were merged, so the stored note is not byte-for-byte what was /// sent. Re-read it if that matters. pub merged: bool, /// Where the namespace points now, absent when nothing was written. pub tip: Option, } /// One search hit, out of the index. #[derive(Serialize, ToSchema)] pub(crate) struct SearchHit { pub namespace: String, pub target: String, pub blob: String, pub content: String, /// Whether the annotated object is a commit. False for a note on a blob or /// a tree, where `summary` and `time` are empty. pub target_is_commit: bool, pub summary: String, pub time: Option>, pub updated_at: DateTime, pub updated_by: String, } #[derive(Serialize, ToSchema)] pub(crate) struct SearchResponse { pub data: Vec, /// False when the index has never seen this repository, which makes an /// empty `data` mean "not searchable yet" rather than "no matches". The /// repository still holds its notes and every other endpoint here returns /// them; only search needs the index. pub indexed: bool, } // --- Request shapes --- #[derive(Deserialize, ToSchema)] pub(crate) struct NamespaceQuery { /// Defaults to `commits`, git's own default namespace. pub namespace: Option, } #[derive(Deserialize, ToSchema)] pub(crate) struct GetNoteQuery { pub namespace: Option, /// Ask for the attribution walk. Off by default because it costs a walk of /// the notes ref per note. #[serde(default)] pub attribution: bool, } #[derive(Deserialize, ToSchema)] pub(crate) struct PutNoteRequest { pub namespace: Option, /// The note body. Trailing whitespace is trimmed and a newline appended, the /// same shape git's own notes carry. pub content: String, } #[derive(Deserialize, ToSchema)] pub(crate) struct SearchQuery { /// The query, in `websearch_to_tsquery` syntax: bare words, `"quoted /// phrases"`, `or`, and `-excluded`. pub q: String, pub namespace: Option, /// Restrict to notes on commits, dropping notes on blobs and trees. #[serde(default)] pub commits_only: bool, pub limit: Option, } // --- Handlers --- /// `GET /api/git/{owner}/{repo}/notes`: the namespaces this repository carries. #[utoipa::path( get, path = "/api/git/{owner}/{repo}/notes", tag = "Git Notes", params( ("owner" = String, Path, description = "Repository owner's username"), ("repo" = String, Path, description = "Repository name"), ), responses( (status = 200, description = "Namespaces, with a note count each", body = NamespacesResponse), (status = 404, description = "No such repository, or not visible to the caller"), ), )] #[tracing::instrument(skip_all, name = "api::git_notes::list_namespaces")] pub(crate) async fn list_namespaces( State(db): State, State(config): State, MaybeUserVerified(maybe_user): MaybeUserVerified, Path((owner, repo_name)): Path<(String, String)>, headers: HeaderMap, ) -> Result { let resolved = read_repo(&db, &config, &owner, &repo_name, &headers, maybe_user).await?; let data = resolved .with_repo(|gix_repo| { let engine = GixEngine::new(gix_repo); let namespaces = notes::list_namespaces(&engine).map_err(crate::git::GitError::from)?; let mut out = Vec::with_capacity(namespaces.len()); for ns in namespaces { // Counted from the tree rather than from the index: the index // may be cold or behind, and a count that disagrees with what // the next request returns is worse than a slightly dearer one. let count = notes::count_notes(&engine, ns.tip).map_err(crate::git::GitError::from)?; out.push(NamespaceEntry { name: ns.name, git_ref: ns.full_ref, tip: ns.tip.to_hex(), notes: count as i64, }); } Ok(out) }) .await?; Ok(Json(NamespacesResponse { data })) } /// `GET /api/git/{owner}/{repo}/notes/{target}`: one note. #[utoipa::path( get, path = "/api/git/{owner}/{repo}/notes/{target}", tag = "Git Notes", params( ("owner" = String, Path, description = "Repository owner's username"), ("repo" = String, Path, description = "Repository name"), ("target" = String, Path, description = "Full object id of the annotated object"), ("namespace" = Option, Query, description = "Notes namespace, default `commits`"), ("attribution" = Option, Query, description = "Include who wrote the note; costs a bounded walk of the notes ref"), ), responses( (status = 200, description = "The note", body = NoteResponse), (status = 404, description = "No such repository, namespace, or note"), ), )] #[tracing::instrument(skip_all, name = "api::git_notes::get_note")] pub(crate) async fn get_note( State(db): State, State(config): State, MaybeUserVerified(maybe_user): MaybeUserVerified, Path((owner, repo_name, target_hex)): Path<(String, String, String)>, Query(query): Query, headers: HeaderMap, ) -> Result { let namespace = namespace_or_default(query.namespace.as_deref()); let target = parse_target(&target_hex)?; let resolved = read_repo(&db, &config, &owner, &repo_name, &headers, maybe_user).await?; let want_attribution = query.attribution; let ns_for_repo = namespace.clone(); let note = resolved .with_repo(move |gix_repo| { let engine = GixEngine::new(gix_repo); // A namespace that does not exist and a namespace with no note on // this target are the same 404: both mean the repository holds // nothing here, and distinguishing them would tell an anonymous // caller which namespaces a repository carries by another route. let Some(ns) = notes::resolve_namespace(&engine, &ns_for_repo) .map_err(crate::git::GitError::from)? else { return Err(AppError::NotFound); }; let Some(note) = notes::note_for(&engine, ns.tip, target).map_err(crate::git::GitError::from)? else { return Err(AppError::NotFound); }; let attribution = if want_attribution { notes::attribution(&engine, ns.tip, target, ATTRIBUTION_MAX_COMMITS) .map_err(crate::git::GitError::from)? .map(|a| NoteAttribution { commit: a.note_commit.to_hex(), name: a.by.name, email: a.by.email, at: a.by.time, exact: a.exact, }) } else { None }; Ok(NoteResponse { namespace: ns.name, target: note.target.to_hex(), blob: note.blob.to_hex(), content: note.content_lossy().into_owned(), attribution, }) }) .await?; Ok(Json(note)) } /// `PUT /api/git/{owner}/{repo}/notes/{target}`: add or replace a note. #[utoipa::path( put, path = "/api/git/{owner}/{repo}/notes/{target}", tag = "Git Notes", params( ("owner" = String, Path, description = "Repository owner's username"), ("repo" = String, Path, description = "Repository name"), ("target" = String, Path, description = "Full object id of the commit to annotate"), ), request_body = PutNoteRequest, responses( (status = 200, description = "What the write did", body = WriteResponse), (status = 401, description = "No credential; writes need a push-scoped personal access token"), (status = 403, description = "A session cookie, a read-only token, or an account that cannot push here"), (status = 404, description = "No such repository, or no such commit in it"), (status = 422, description = "Reserved namespace, empty or oversized content, or sustained write contention"), ), )] #[tracing::instrument(skip_all, name = "api::git_notes::put_note")] pub(crate) async fn put_note( State(db): State, State(config): State, Path((owner, repo_name, target_hex)): Path<(String, String, String)>, headers: HeaderMap, Json(request): Json, ) -> Result { let namespace = namespace_or_default(request.namespace.as_deref()); validation::validate_note_namespace(&namespace)?; validation::validate_note_content(&request.content)?; let mut content = request.content.trim_end().to_string(); content.push('\n'); write( &db, &config, &owner, &repo_name, &target_hex, &headers, namespace, Some(content), ) .await .map(Json) } /// `DELETE /api/git/{owner}/{repo}/notes/{target}`: remove a note. #[utoipa::path( delete, path = "/api/git/{owner}/{repo}/notes/{target}", tag = "Git Notes", params( ("owner" = String, Path, description = "Repository owner's username"), ("repo" = String, Path, description = "Repository name"), ("target" = String, Path, description = "Full object id of the annotated commit"), ("namespace" = Option, Query, description = "Notes namespace, default `commits`"), ), responses( (status = 204, description = "The note is gone, whether or not it was there"), (status = 401, description = "No credential; writes need a push-scoped personal access token"), (status = 403, description = "A session cookie, a read-only token, or an account that cannot push here"), (status = 404, description = "No such repository, or no such commit in it"), ), )] #[tracing::instrument(skip_all, name = "api::git_notes::delete_note")] pub(crate) async fn delete_note( State(db): State, State(config): State, Path((owner, repo_name, target_hex)): Path<(String, String, String)>, Query(query): Query, headers: HeaderMap, ) -> Result { let namespace = namespace_or_default(query.namespace.as_deref()); validation::validate_note_namespace(&namespace)?; write( &db, &config, &owner, &repo_name, &target_hex, &headers, namespace, None, ) .await?; // Deleting a note that was not there is a no-op rather than a 404: the // caller asked for a state, and the state holds. Ok(StatusCode::NO_CONTENT) } /// `GET /api/git/{owner}/{repo}/notes/search`: full-text search over the index. /// /// `search` is a static segment and an object id is 40 or 64 hex characters, so /// it can never be shadowed by, or shadow, a real target on `/notes/{target}`. #[utoipa::path( get, path = "/api/git/{owner}/{repo}/notes/search", tag = "Git Notes", params( ("owner" = String, Path, description = "Repository owner's username"), ("repo" = String, Path, description = "Repository name"), ("q" = String, Query, description = "Query: bare words, quoted phrases, `or`, `-excluded`"), ("namespace" = Option, Query, description = "Restrict to one namespace"), ("commits_only" = Option, Query, description = "Drop notes on blobs and trees"), ("limit" = Option, Query, description = "Maximum hits, default 50, capped at 200"), ), responses( (status = 200, description = "Matching notes, and whether the index has seen this repository", body = SearchResponse), (status = 404, description = "No such repository, or not visible to the caller"), ), )] #[tracing::instrument(skip_all, name = "api::git_notes::search_notes")] pub(crate) async fn search_notes( State(db): State, State(config): State, MaybeUserVerified(maybe_user): MaybeUserVerified, Path((owner, repo_name)): Path<(String, String)>, Query(query): Query, headers: HeaderMap, ) -> Result { let resolved = read_repo(&db, &config, &owner, &repo_name, &headers, maybe_user).await?; let repo_id: GitRepoId = resolved.db_repo.id; let indexed = db::git_notes::is_indexed(&db, repo_id).await?; let limit = query .limit .unwrap_or(SEARCH_LIMIT_DEFAULT) .clamp(1, SEARCH_LIMIT_MAX); let term = query.q.trim(); // An empty query matches everything in `websearch_to_tsquery`, which is not // what an empty search box means. Answer it as no hits rather than as the // whole index. let rows = if term.is_empty() { Vec::new() } else { db::git_notes::search( &db, repo_id, term, query.namespace.as_deref(), query.commits_only, limit, ) .await? }; let data = rows .into_iter() .map(|n| SearchHit { namespace: n.namespace, target: n.target_oid, blob: n.blob_oid, content: n.content, target_is_commit: n.target_is_commit, summary: n.target_summary, time: n.target_time, updated_at: n.updated_at, updated_by: n.updated_by, }) .collect(); Ok(Json(SearchResponse { data, indexed })) } // --- Shared halves --- /// Resolve a repository for a read, honouring either credential. /// /// Visibility is decided by `resolve_repo` on every request rather than /// remembered anywhere: a note inherits the visibility of the repository holding /// it, and that can change between two calls. async fn read_repo( db: &PgPool, config: &Config, owner: &str, repo_name: &str, headers: &HeaderMap, maybe_user: Option, ) -> Result { let principal = crate::routes::git::resolve_git_http_principal(db, headers, maybe_user.map(|u| u.id)).await; resolve_repo(db, config, owner, repo_name, principal.map(|p| p.user_id)).await } /// The write half of PUT and DELETE: authorize, write, reindex. #[allow(clippy::too_many_arguments)] async fn write( db: &PgPool, config: &Config, owner: &str, repo_name: &str, target_hex: &str, headers: &HeaderMap, namespace: String, content: Option, ) -> Result { // No session is consulted: a write is token-only, so `None` here is the // whole session half of `resolve_git_http_principal`. let principal = crate::routes::git::resolve_git_http_principal(db, headers, None).await; let principal = require_push_token(principal.as_ref())?; // Account standing is already settled: `resolve_git_http_principal` refuses // a suspended or deactivated user. This load is for the note identity. let user = db::users::get_user_by_id(db, principal.user_id) .await? .ok_or(AppError::Unauthorized)?; let resolved = resolve_repo(db, config, owner, repo_name, Some(principal.user_id)).await?; if !notes_write::can_write_notes(db, &resolved, principal.user_id).await? { return Err(AppError::Forbidden); } let target = parse_target(target_hex)?; let gix_target = gix::ObjectId::from_hex(target_hex.as_bytes()).map_err(|_| AppError::NotFound)?; let who = notes_write::identity(user.display_name.as_deref(), user.username.as_str()); let written_namespace = namespace.clone(); let written = resolved .with_repo(move |gix_repo| { // The same check the browser write makes: a note on an id this // repository does not hold is invisible in the one place it would // have been read. gix_repo .find_commit(gix_target) .map_err(|_| AppError::NotFound)?; let engine = GixEngine::new(gix_repo); notes::write_note( &engine, &namespace, target, content.as_deref().map(str::as_bytes), &who, ) .map_err(|e| match e { notes::NotesError::Raced => AppError::validation( "Another writer holds this namespace right now. Try again.".to_string(), ), other => crate::git::GitError::from(other).into(), }) }) .await?; // The fourth path that moves `refs/notes/*` server-side, and so the fourth // that owes the index a reindex. A server-side ref write fires no hook. notes_index::reindex_after_write( db, config, resolved.db_repo.id, owner, repo_name, &written_namespace, ) .await; Ok(match written { notes::Written::Unchanged => WriteResponse { namespace: written_namespace, target: target.to_hex(), status: "unchanged", merged: false, tip: None, }, notes::Written::Committed { tip, merged } => WriteResponse { namespace: written_namespace, target: target.to_hex(), status: "written", merged, tip: Some(tip.to_hex()), }, }) } /// Only a push-scoped personal access token may write. /// /// The same gate `receive-pack` applies (`routes::git::raw::require_push_token`), /// and for the same reason: these routes carry no CSRF token, so a cookie would /// be an ambient credential on an unsealed mutation. A read-only token is a 403 /// rather than a 401 because the credential was understood and refused. fn require_push_token(principal: Option<&GitHttpPrincipal>) -> Result<&GitHttpPrincipal> { let principal = principal.ok_or(AppError::Unauthorized)?; if principal.token_push != Some(true) { return Err(AppError::Forbidden); } Ok(principal) } /// `commits` is git's default namespace and the one a caller who says nothing /// means. fn namespace_or_default(namespace: Option<&str>) -> String { namespace .map(str::trim) .filter(|n| !n.is_empty()) .unwrap_or(notes::DEFAULT_NAMESPACE) .to_string() } /// Parse a target object id. A malformed id is a 404 rather than a 422: it names /// nothing, which is indistinguishable from naming something absent, and saying /// which would tell an anonymous caller whether an object exists. fn parse_target(hex: &str) -> Result { Oid::from_hex(hex.as_bytes()).map_err(|_| AppError::NotFound) } #[cfg(test)] mod tests { use super::*; #[test] fn a_missing_namespace_is_gits_own_default() { assert_eq!(namespace_or_default(None), notes::DEFAULT_NAMESPACE); assert_eq!(namespace_or_default(Some(" ")), notes::DEFAULT_NAMESPACE); assert_eq!( namespace_or_default(Some(" review/security ")), "review/security" ); } #[test] fn only_a_push_scoped_token_may_write() { let user_id = crate::db::UserId::from(uuid::Uuid::nil()); let cookie = GitHttpPrincipal { user_id, token_push: None, }; let read_only = GitHttpPrincipal { user_id, token_push: Some(false), }; let push = GitHttpPrincipal { user_id, token_push: Some(true), }; // A session cookie is not a write credential here, the same call // `receive-pack` makes. Without this the CSRF-skipped route would take // an ambient credential. assert!(matches!( require_push_token(Some(&cookie)), Err(AppError::Forbidden) )); assert!(matches!( require_push_token(Some(&read_only)), Err(AppError::Forbidden) )); assert!(matches!( require_push_token(None), Err(AppError::Unauthorized) )); assert!(require_push_token(Some(&push)).is_ok()); } #[test] fn a_target_that_is_not_a_full_object_id_is_not_found() { assert!(parse_target("not-hex").is_err()); // A prefix is refused rather than resolved: note tree paths are always // full ids, and accepting a short one would mean guessing. assert!(parse_target("0123abc").is_err()); assert!(parse_target(&"a".repeat(40)).is_ok()); assert!(parse_target(&"a".repeat(64)).is_ok()); } }