Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Migrate `/v1/`, `/v1/node` routes to heartwood
xphoniex committed 3 years ago
commit ae2a35abb91585e1e989ea5e53298f4e00db8137
parent ef1c331cb6d16f088e1bed724106716cb480b55f
6 files changed +271 -11
modified radicle-httpd/Cargo.toml
@@ -16,17 +16,22 @@ logfmt = [

[dependencies]
anyhow = { version = "1" }
+
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 }
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-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-server = { version = "0.4.2", default-features = false }
-
hyper = { version = "0.14.17", default-features = false }
-
tower-http = { version = "0.3.4", default-features = false, features = ["trace"] }
+
tracing-subscriber = { version = "0.3", default-features = false, features = ["std", "ansi", "fmt"] }

[dependencies.radicle]
path = "../radicle"
added 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<Profile>,
+
    sessions: Arc<RwLock<HashMap<SessionId, auth::AuthState>>>,
+
}
+

+
impl Context {
+
    pub fn new(profile: Arc<Profile>) -> 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<Body>| {
+
                    tracing::info_span!(
+
                        "request",
+
                        method = %request.method(),
+
                        uri = %request.uri(),
+
                        status = tracing::field::Empty,
+
                        latency = tracing::field::Empty,
+
                    )
+
                })
+
                .on_response(
+
                    |response: &Response<BoxBody>, 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<Context>) -> 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<usize>,
+
    pub per_page: Option<usize>,
+
}
added 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<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
+
        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<Signature, D::Error>
+
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<String>,
+
    pub uri: String,
+
    pub version: u64,
+
    pub chain_id: u64,
+
    pub nonce: String,
+
    pub issued_at: DateTime,
+
    pub expiration_time: Option<DateTime>,
+
    pub resources: Vec<String>,
+
}
+

+
impl TryFrom<siwe::Message> for Session {
+
    type Error = Error;
+

+
    fn try_from(message: siwe::Message) -> Result<Session, Error> {
+
        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();
+
    }
+
}
added 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)
+
}
added 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<NodeId>) -> impl IntoResponse {
+
    let response = json!({
+
        "id": node_id.to_string(),
+
    });
+

+
    Json(response)
+
}
modified 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<Body>| {
@@ -73,11 +75,18 @@ pub async fn run(options: Options) -> anyhow::Result<()> {
                        tracing::info!("Processed");
                    },
                ),
-
        )
-
        .into_make_service_with_connect_info::<SocketAddr>();
+
        );
+

+
    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::<SocketAddr>();
+

    axum::Server::bind(&options.listen)
        .serve(app)
        .await