From ae2a35abb91585e1e989ea5e53298f4e00db8137 Mon Sep 17 00:00:00 2001 From: xphoniex Date: Tue, 22 Nov 2022 10:54:57 +0000 Subject: [PATCH] Migrate `/v1/`, `/v1/node` routes to heartwood Signed-off-by: xphoniex --- radicle-httpd/Cargo.toml | 23 +++--- radicle-httpd/src/api.rs | 119 +++++++++++++++++++++++++++++++ radicle-httpd/src/api/auth.rs | 90 +++++++++++++++++++++++ radicle-httpd/src/api/v1.rs | 11 +++ radicle-httpd/src/api/v1/node.rs | 26 +++++++ radicle-httpd/src/lib.rs | 21 ++++-- 6 files changed, 275 insertions(+), 15 deletions(-) create mode 100644 radicle-httpd/src/api.rs create mode 100644 radicle-httpd/src/api/auth.rs create mode 100644 radicle-httpd/src/api/v1.rs create mode 100644 radicle-httpd/src/api/v1/node.rs diff --git a/radicle-httpd/Cargo.toml b/radicle-httpd/Cargo.toml index 36f41aafa..b0f93c736 100644 --- a/radicle-httpd/Cargo.toml +++ b/radicle-httpd/Cargo.toml @@ -16,17 +16,22 @@ logfmt = [ [dependencies] anyhow = { version = "1" } -flate2 = { version = "1" } -lexopt = { version = "0.2.1" } -thiserror = { version = "1" } -tokio = { version = "1.21", default-features = false, features = ["macros", "rt-multi-thread"] } -tracing = { version = "0.1.37", default-features = false, features = ["std", "log"] } -tracing-subscriber = { version = "0.3", default-features = false, features = ["std", "ansi", "fmt"] } -tracing-logfmt = { version = "0.2", optional = true } -axum = { version = "0.5.16", default-features = false } +axum = { version = "0.5.16", default-features = false, features = ["json", "headers", "query"] } axum-server = { version = "0.4.2", default-features = false } +ethers-core = { version = "1.0" } +flate2 = { version = "1" } hyper = { version = "0.14.17", default-features = false } -tower-http = { version = "0.3.4", default-features = false, features = ["trace"] } +lexopt = { version = "0.2.1" } +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", features = ["preserve_order"] } +siwe = { version = "0.5" } +thiserror = { version = "1" } +time = { version = "0.3.17" } +tokio = { version = "1.21", default-features = false, features = ["macros", "rt-multi-thread"] } +tower-http = { version = "0.3.4", default-features = false, features = ["trace", "cors"] } +tracing = { version = "0.1.37", default-features = false, features = ["std", "log"] } +tracing-logfmt = { version = "0.2", optional = true } +tracing-subscriber = { version = "0.3", default-features = false, features = ["std", "ansi", "fmt"] } [dependencies.radicle] path = "../radicle" diff --git a/radicle-httpd/src/api.rs b/radicle-httpd/src/api.rs new file mode 100644 index 000000000..feda09c92 --- /dev/null +++ b/radicle-httpd/src/api.rs @@ -0,0 +1,119 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use axum::body::{Body, BoxBody}; +use axum::http::header::{AUTHORIZATION, CONTENT_TYPE}; +use axum::http::Method; +use axum::response::{IntoResponse, Json}; +use axum::routing::get; +use axum::{Extension, Router}; +use hyper::http::{Request, Response}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tokio::sync::RwLock; +use tower_http::cors::{self, CorsLayer}; +use tower_http::trace::TraceLayer; +use tracing::Span; + +use radicle::{Profile, Storage}; + +mod auth; +mod v1; + +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Identifier for sessions +type SessionId = String; + +#[derive(Clone)] +pub struct Context { + profile: Arc, + sessions: Arc>>, +} + +impl Context { + pub fn new(profile: Arc) -> Self { + Self { + profile, + sessions: Default::default(), + } + } + + fn storage(&self) -> &Storage { + &self.profile.storage + } +} + +pub fn router(ctx: Context) -> Router { + let root_router = Router::new() + .route("/", get(root_handler)) + .layer(Extension(ctx.clone())); + + Router::new() + .merge(root_router) + .merge(v1::router(ctx)) + .layer( + CorsLayer::new() + .max_age(Duration::from_secs(86400)) + .allow_origin(cors::Any) + .allow_methods([Method::GET, Method::POST, Method::PUT]) + .allow_headers([CONTENT_TYPE, AUTHORIZATION]), + ) + .layer( + TraceLayer::new_for_http() + .make_span_with(|request: &Request| { + tracing::info_span!( + "request", + method = %request.method(), + uri = %request.uri(), + status = tracing::field::Empty, + latency = tracing::field::Empty, + ) + }) + .on_response( + |response: &Response, latency: Duration, span: &Span| { + span.record("status", &tracing::field::debug(response.status())); + span.record("latency", &tracing::field::debug(latency)); + + tracing::info!("Processed"); + }, + ), + ) +} + +async fn root_handler(Extension(ctx): Extension) -> impl IntoResponse { + let response = json!({ + "message": "Welcome!", + "service": "radicle-httpd", + "version": format!("{}-{}", VERSION, env!("GIT_HEAD")), + "node": { "id": ctx.profile.public_key }, + "path": "/", + "links": [ + { + "href": "/v1/projects", + "rel": "projects", + "type": "GET" + }, + { + "href": "/v1/node", + "rel": "node", + "type": "GET" + }, + { + "href": "/v1/delegates/:urn/projects", + "rel": "projects", + "type": "GET" + } + ] + }); + + Json(response) +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "kebab-case")] +pub struct PaginationQuery { + pub page: Option, + pub per_page: Option, +} diff --git a/radicle-httpd/src/api/auth.rs b/radicle-httpd/src/api/auth.rs new file mode 100644 index 000000000..bdb9b95d7 --- /dev/null +++ b/radicle-httpd/src/api/auth.rs @@ -0,0 +1,90 @@ +use std::convert::TryFrom; +use std::str::FromStr; + +use ethers_core::types::{Signature, H160}; +use serde::{Deserialize, Serialize, Serializer}; +use time::OffsetDateTime; + +use crate::error::Error; + +#[derive(Clone)] +pub struct DateTime(OffsetDateTime); + +impl Serialize for DateTime { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&format!("{}", self.0)) + } +} + +#[derive(Deserialize, Serialize)] +pub struct AuthRequest { + pub message: String, + #[serde(deserialize_with = "deserialize_signature")] + pub signature: Signature, +} + +fn deserialize_signature<'de, D>(deserializer: D) -> Result +where + D: serde::de::Deserializer<'de>, +{ + let buf = String::deserialize(deserializer)?; + Signature::from_str(&buf).map_err(serde::de::Error::custom) +} + +pub enum AuthState { + Authorized(Session), + Unauthorized { + nonce: String, + expiration_time: DateTime, + }, +} + +// We copy the implementation of siwe::Message here to derive Serialization and Debug +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Session { + pub domain: String, + pub address: H160, + pub statement: Option, + pub uri: String, + pub version: u64, + pub chain_id: u64, + pub nonce: String, + pub issued_at: DateTime, + pub expiration_time: Option, + pub resources: Vec, +} + +impl TryFrom for Session { + type Error = Error; + + fn try_from(message: siwe::Message) -> Result { + Ok(Session { + domain: message.domain.host().to_string(), + address: H160(message.address), + statement: None, + uri: message.uri.to_string(), + version: message.version as u64, + chain_id: message.chain_id, + nonce: message.nonce, + issued_at: DateTime(message.issued_at.as_ref().to_owned()), + expiration_time: message + .expiration_time + .map(|x| DateTime(x.as_ref().to_owned())), + resources: message.resources.iter().map(|r| r.to_string()).collect(), + }) + } +} + +#[cfg(test)] +mod test { + #[test] + fn test_auth_request_de() { + let json = serde_json::json!({ + "message": "Hello World!", + "signature": "20096c6ed2bcccb88c9cafbbbbda7a5a3cff6d0ca318c07faa58464083ca40a92f899fbeb26a4c763a7004b13fd0f1ba6c321d4e3a023e30f63c40d4154b99a41c" + }); + + let _req: super::AuthRequest = serde_json::from_value(json).unwrap(); + } +} diff --git a/radicle-httpd/src/api/v1.rs b/radicle-httpd/src/api/v1.rs new file mode 100644 index 000000000..66e2c6c09 --- /dev/null +++ b/radicle-httpd/src/api/v1.rs @@ -0,0 +1,11 @@ +mod node; + +use axum::Router; + +use crate::api::Context; + +pub fn router(ctx: Context) -> Router { + let routes = Router::new().merge(node::router(ctx)); + + Router::new().nest("/v1", routes) +} diff --git a/radicle-httpd/src/api/v1/node.rs b/radicle-httpd/src/api/v1/node.rs new file mode 100644 index 000000000..529c6598d --- /dev/null +++ b/radicle-httpd/src/api/v1/node.rs @@ -0,0 +1,26 @@ +use axum::response::IntoResponse; +use axum::routing::get; +use axum::{Extension, Json, Router}; +use serde_json::json; + +use radicle::node::NodeId; + +use crate::api::Context; + +pub fn router(ctx: Context) -> Router { + let node_id = ctx.profile.public_key; + + Router::new() + .route("/node", get(node_handler)) + .layer(Extension(node_id)) +} + +/// Return the node id for the node identity. +/// `GET /node` +async fn node_handler(Extension(node_id): Extension) -> impl IntoResponse { + let response = json!({ + "id": node_id.to_string(), + }); + + Json(response) +} diff --git a/radicle-httpd/src/lib.rs b/radicle-httpd/src/lib.rs index 554cbf15b..065e7f459 100644 --- a/radicle-httpd/src/lib.rs +++ b/radicle-httpd/src/lib.rs @@ -32,6 +32,8 @@ use radicle::profile::Profile; use error::Error; +mod api; + pub const VERSION: &str = env!("CARGO_PKG_VERSION"); #[derive(Debug, Clone)] @@ -39,7 +41,7 @@ pub struct Options { pub listen: net::SocketAddr, } -/// Run the Git Server. +/// Run the Server. pub async fn run(options: Options) -> anyhow::Result<()> { let git_version = Command::new("git") .arg("version") @@ -48,12 +50,12 @@ pub async fn run(options: Options) -> anyhow::Result<()> { .stdout; tracing::info!("{}", str::from_utf8(&git_version)?.trim()); - let profile = radicle::Profile::load()?; + let profile = Arc::new(radicle::Profile::load()?); tracing::info!("using radicle home at {}", profile.home.display()); - let app = Router::new() + let git_router = Router::new() .route("/:project/*request", any(git_handler)) - .layer(Extension(Arc::new(profile))) + .layer(Extension(profile.clone())) .layer( TraceLayer::new_for_http() .make_span_with(|request: &Request| { @@ -73,11 +75,18 @@ pub async fn run(options: Options) -> anyhow::Result<()> { tracing::info!("Processed"); }, ), - ) - .into_make_service_with_connect_info::(); + ); + + let ctx = api::Context::new(profile); + let api_router = api::router(ctx); tracing::info!("listening on http://{}", options.listen); + let app = Router::new() + .merge(git_router) + .nest("/api", api_router) + .into_make_service_with_connect_info::(); + axum::Server::bind(&options.listen) .serve(app) .await