pub mod auth; use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; use axum::http::header::{AUTHORIZATION, CONTENT_TYPE}; use axum::http::Method; use axum::response::{IntoResponse, Json}; use axum::routing::get; use axum::Router; use base64::prelude::{Engine, BASE64_STANDARD}; use serde::{Deserialize, Serialize}; use serde_json::json; use tokio::sync::RwLock; use tower_http::cors::{self, CorsLayer}; use radicle::cob::{issue, Uri}; use radicle::cob::{patch, Embed}; use radicle::identity::{DocAt, Id}; use radicle::node::routing::Store; use radicle::storage::{Oid, ReadRepository, ReadStorage}; use radicle::Profile; mod error; mod json; mod v1; use crate::cache::Cache; use crate::Options; pub const VERSION: &str = env!("CARGO_PKG_VERSION"); /// Identifier for sessions type SessionId = String; #[derive(Clone)] pub struct Context { profile: Arc, sessions: Arc>>, cache: Option, } impl Context { pub fn new(profile: Arc, options: &Options) -> Self { Self { profile, sessions: Default::default(), cache: options.cache.map(Cache::new), } } pub fn project_info(&self, id: Id) -> Result { let storage = &self.profile.storage; let repo = storage.repository(id)?; let (_, head) = repo.head()?; let DocAt { doc, .. } = repo.identity_doc()?; let payload = doc.project()?; let delegates = doc.delegates; let issues = issue::Issues::open(&repo)?.counts()?; let patches = patch::Patches::open(&repo)?.counts()?; let routing = &self.profile.routing()?; let trackings = routing.count(&id).unwrap_or_default(); Ok(project::Info { payload, delegates, head, issues, patches, id, trackings, }) } #[cfg(test)] pub fn profile(&self) -> &Arc { &self.profile } #[cfg(test)] pub fn sessions(&self) -> &Arc>> { &self.sessions } } pub fn router(ctx: Context) -> Router { Router::new() .route("/", get(root_handler)) .merge(v1::router(ctx)) .layer( CorsLayer::new() .max_age(Duration::from_secs(86400)) .allow_origin(cors::Any) .allow_methods([ Method::GET, Method::POST, Method::PATCH, Method::PUT, Method::DELETE, ]) .allow_headers([CONTENT_TYPE, AUTHORIZATION]), ) } async fn root_handler() -> impl IntoResponse { let response = json!({ "path": "/api", "links": [ { "href": "/v1", "rel": "v1", "type": "GET" } ] }); Json(response) } #[derive(Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct PaginationQuery { pub page: Option, pub per_page: Option, } #[derive(Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct RawQuery { pub mime: Option, } #[derive(Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct CobsQuery { pub page: Option, pub per_page: Option, pub state: Option, } #[derive(Default, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub enum IssueState { Closed, #[default] Open, } impl IssueState { pub fn matches(&self, issue: &issue::State) -> bool { match self { Self::Open => matches!(issue, issue::State::Open), Self::Closed => matches!(issue, issue::State::Closed { .. }), } } } #[derive(Default, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub enum PatchState { #[default] Open, Draft, Archived, Merged, } impl PatchState { pub fn matches(&self, patch: &patch::State) -> bool { match self { Self::Open => matches!(patch, patch::State::Open { .. }), Self::Draft => matches!(patch, patch::State::Draft), Self::Archived => matches!(patch, patch::State::Archived), Self::Merged => matches!(patch, patch::State::Merged { .. }), } } } mod project { use nonempty::NonEmpty; use serde::Serialize; use radicle::cob; use radicle::git::Oid; use radicle::identity::project::Project; use radicle::identity::Id; use radicle::prelude::Did; /// Project info. #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct Info { /// Project metadata. #[serde(flatten)] pub payload: Project, pub delegates: NonEmpty, pub head: Oid, pub patches: cob::patch::PatchCounts, pub issues: cob::issue::IssueCounts, pub id: Id, pub trackings: usize, } } /// A `data:` URI. #[derive(Debug, Clone)] pub struct DataUri(Vec); impl From for Vec { fn from(value: DataUri) -> Self { value.0 } } impl TryFrom<&Uri> for DataUri { type Error = Uri; fn try_from(value: &Uri) -> Result { if let Some(data_uri) = value.as_str().strip_prefix("data:") { let (_, uri_data) = data_uri.split_once(',').ok_or(value.clone())?; let uri_data = BASE64_STANDARD .decode(uri_data) .map_err(|_| value.clone())?; return Ok(DataUri(uri_data)); } Err(value.clone()) } } /// Resolve an embed with a URI to one with actual data. pub fn resolve_embed(repo: &impl ReadRepository, embed: Embed) -> Option>> { DataUri::try_from(&embed.content) .ok() .map(|content| Embed { name: embed.name.clone(), content: content.into(), }) .or_else(|| { Oid::try_from(&embed.content).ok().and_then(|oid| { repo.blob(oid).ok().map(|blob| Embed { name: embed.name, content: blob.content().to_vec(), }) }) }) }