Remove `radicle-httpd` crate

The HTTP daemon is moved to the `radicle-explorer` repository. Hence,
all traces of it are removed from this repository.
This commit is contained in:
cloudhead 2024-06-06 11:14:00 +02:00
parent ab532be033
commit a4989b7ce7
No known key found for this signature in database
34 changed files with 32 additions and 7909 deletions

1009
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,6 @@ members = [
"radicle-crypto", "radicle-crypto",
"radicle-dag", "radicle-dag",
"radicle-fetch", "radicle-fetch",
"radicle-httpd",
"radicle-node", "radicle-node",
"radicle-remote-helper", "radicle-remote-helper",
"radicle-ssh", "radicle-ssh",

View File

@ -24,7 +24,6 @@ The repository is structured in *crates*, as follows:
* `radicle-crdt`: Conflict-free replicated datatypes (CRDTs) used for things like discussions and patches. * `radicle-crdt`: Conflict-free replicated datatypes (CRDTs) used for things like discussions and patches.
* `radicle-crypto`: A wrapper around Ed25519 cryptographic signing primitives. * `radicle-crypto`: A wrapper around Ed25519 cryptographic signing primitives.
* `radicle-dag`: A simple directed acyclic graph implementation used by `radicle-cob`. * `radicle-dag`: A simple directed acyclic graph implementation used by `radicle-cob`.
* `radicle-httpd`: The radicle HTTP daemon that serves API clients and Git fetch requests.
* `radicle-node`: The radicle peer-to-peer daemon that enables users to connect to the network and share code. * `radicle-node`: The radicle peer-to-peer daemon that enables users to connect to the network and share code.
* `radicle-remote-helper`: A Git remote helper for `rad://` remotes. * `radicle-remote-helper`: A Git remote helper for `rad://` remotes.
* `radicle-ssh`: OpenSSH functionality, including a library used to interface with `ssh-agent`. * `radicle-ssh`: OpenSSH functionality, including a library used to interface with `ssh-agent`.
@ -41,11 +40,6 @@ For example, the equivalent of `rad auth` in debug mode would be:
Arguments after the `--` are passed directly to the `rad` executable. Arguments after the `--` are passed directly to the `rad` executable.
When running the radicle node, you may specify an alternate port for the `git-daemon`
like so:
$ cargo run -p radicle-node -- --git-daemon 127.0.0.1:9876
This is useful if you are running multiple nodes on the same machine. You can also This is useful if you are running multiple nodes on the same machine. You can also
specify different listen addresses for the peer-to-peer protocol using `--listen`. specify different listen addresses for the peer-to-peer protocol using `--listen`.
To view all options, run `cargo run -p radicle-node -- --help`. To view all options, run `cargo run -p radicle-node -- --help`.
@ -82,8 +76,8 @@ avoid storing development keys with `ssh-agent`.
## Logging ## Logging
Logging for `radicle-node` and `radicle-httpd` is turned on by default. Check Logging for `radicle-node` is turned on by default. Check the respective
the respective `--help` output to set the log level. `--help` output to set the log level.
## Writing tests ## Writing tests

View File

@ -51,9 +51,8 @@ Or directly from our seed node:
## Running ## Running
*Systemd* unit files are provided for the node and HTTP daemon under the *Systemd* unit files are provided for the node under the `/systemd` folder.
`/systemd` folder. They can be used as a starting point for further They can be used as a starting point for further customization.
customization.
For running in debug mode, see [HACKING.md](HACKING.md). For running in debug mode, see [HACKING.md](HACKING.md).

View File

@ -45,7 +45,6 @@ RUN cargo zigbuild --locked --release \
--target=aarch64-unknown-linux-musl \ --target=aarch64-unknown-linux-musl \
--target=x86_64-unknown-linux-musl \ --target=x86_64-unknown-linux-musl \
-p radicle-node \ -p radicle-node \
-p radicle-httpd \
-p radicle-remote-helper \ -p radicle-remote-helper \
-p radicle-cli -p radicle-cli
@ -57,28 +56,24 @@ COPY --from=builder \
/src/target/x86_64-unknown-linux-musl/release/rad-web \ /src/target/x86_64-unknown-linux-musl/release/rad-web \
/src/target/x86_64-unknown-linux-musl/release/git-remote-rad \ /src/target/x86_64-unknown-linux-musl/release/git-remote-rad \
/src/target/x86_64-unknown-linux-musl/release/radicle-node \ /src/target/x86_64-unknown-linux-musl/release/radicle-node \
/src/target/x86_64-unknown-linux-musl/release/radicle-httpd \
/builds/x86_64-unknown-linux-musl/bin/ /builds/x86_64-unknown-linux-musl/bin/
COPY --from=builder \ COPY --from=builder \
/src/target/aarch64-unknown-linux-musl/release/rad \ /src/target/aarch64-unknown-linux-musl/release/rad \
/src/target/aarch64-unknown-linux-musl/release/rad-web \ /src/target/aarch64-unknown-linux-musl/release/rad-web \
/src/target/aarch64-unknown-linux-musl/release/git-remote-rad \ /src/target/aarch64-unknown-linux-musl/release/git-remote-rad \
/src/target/aarch64-unknown-linux-musl/release/radicle-node \ /src/target/aarch64-unknown-linux-musl/release/radicle-node \
/src/target/aarch64-unknown-linux-musl/release/radicle-httpd \
/builds/aarch64-unknown-linux-musl/bin/ /builds/aarch64-unknown-linux-musl/bin/
COPY --from=builder \ COPY --from=builder \
/src/target/aarch64-apple-darwin/release/rad \ /src/target/aarch64-apple-darwin/release/rad \
/src/target/aarch64-apple-darwin/release/rad-web \ /src/target/aarch64-apple-darwin/release/rad-web \
/src/target/aarch64-apple-darwin/release/git-remote-rad \ /src/target/aarch64-apple-darwin/release/git-remote-rad \
/src/target/aarch64-apple-darwin/release/radicle-node \ /src/target/aarch64-apple-darwin/release/radicle-node \
/src/target/aarch64-apple-darwin/release/radicle-httpd \
/builds/aarch64-apple-darwin/bin/ /builds/aarch64-apple-darwin/bin/
COPY --from=builder \ COPY --from=builder \
/src/target/x86_64-apple-darwin/release/rad \ /src/target/x86_64-apple-darwin/release/rad \
/src/target/x86_64-apple-darwin/release/rad-web \ /src/target/x86_64-apple-darwin/release/rad-web \
/src/target/x86_64-apple-darwin/release/git-remote-rad \ /src/target/x86_64-apple-darwin/release/git-remote-rad \
/src/target/x86_64-apple-darwin/release/radicle-node \ /src/target/x86_64-apple-darwin/release/radicle-node \
/src/target/x86_64-apple-darwin/release/radicle-httpd \
/builds/x86_64-apple-darwin/bin/ /builds/x86_64-apple-darwin/bin/
COPY --from=builder /src/*.1 /builds/x86_64-unknown-linux-musl/man/man1/ COPY --from=builder /src/*.1 /builds/x86_64-unknown-linux-musl/man/man1/
COPY --from=builder /src/*.1 /builds/aarch64-unknown-linux-musl/man/man1/ COPY --from=builder /src/*.1 /builds/aarch64-unknown-linux-musl/man/man1/

1
debian/rules vendored
View File

@ -12,7 +12,6 @@ override_dh_auto_install:
cargo install --locked --path=radicle-cli --root=debian/radicle cargo install --locked --path=radicle-cli --root=debian/radicle
cargo install --locked --path=radicle-node --root=debian/radicle cargo install --locked --path=radicle-node --root=debian/radicle
cargo install --locked --path=radicle-remote-helper --root=debian/radicle cargo install --locked --path=radicle-remote-helper --root=debian/radicle
cargo install --locked --path=radicle-httpd --root=debian/radicle
rm -f debian/*/.crates*.* rm -f debian/*/.crates*.*
override_dh_auto_test: override_dh_auto_test:

View File

@ -170,10 +170,6 @@
crates = builtins.listToAttrs (map crates = builtins.listToAttrs (map
({name, ...} @ package: lib.nameValuePair name (crate package)) ({name, ...} @ package: lib.nameValuePair name (crate package))
[ [
{
name = "radicle-httpd";
pages = ["radicle-httpd.1.adoc"];
}
{ {
name = "radicle-cli"; name = "radicle-cli";
pages = [ pages = [
@ -233,11 +229,6 @@
drv = self.packages.${system}.radicle-node; drv = self.packages.${system}.radicle-node;
}; };
apps.radicle-httpd = flake-utils.lib.mkApp {
name = "radicle-httpd";
drv = self.packages.${system}.radicle-httpd;
};
devShells.default = craneLib.devShell { devShells.default = craneLib.devShell {
# Extra inputs can be added here; cargo and rustc are provided by default. # Extra inputs can be added here; cargo and rustc are provided by default.
packages = with pkgs; [ packages = with pkgs; [

View File

@ -1,25 +0,0 @@
= radicle-httpd(1)
The Radicle Team <team@radicle.xyz>
:doctype: manpage
:revnumber: 1.0.0
:revdate: 2024-04-22
:mansource: rad {revnumber}
:manmanual: Radicle CLI Manual
== Name
radicle-httpd - Radicle HTTP daemon
== Synopsis
*radicle-httpd* --help
== Description
A Radicle HTTP daemon exposing a JSON HTTP API that allows someone to browse local
repositories on a Radicle node via their web browser. This manual page is a
placeholder to point you at the *--help* option.
== SEE ALSO ==
*rad*(1)

View File

@ -1,67 +0,0 @@
[package]
name = "radicle-httpd"
description = "Radicle HTTP daemon"
homepage = "https://radicle.xyz"
license = "MIT OR Apache-2.0"
version = "0.10.0"
authors = ["cloudhead <cloudhead@radicle.xyz>"]
edition = "2021"
default-run = "radicle-httpd"
build = "build.rs"
[features]
default = []
logfmt = [
"tracing-logfmt",
"tracing-subscriber/env-filter"
]
[[bin]]
name = "radicle-httpd"
path = "src/main.rs"
[[bin]]
name = "rad-web"
path = "src/bin/rad-web.rs"
[dependencies]
anyhow = { version = "1" }
axum = { version = "0.7.2", default-features = false, features = ["json", "query", "tokio", "http1"] }
axum-auth = { version= "0.7.0", default-features = false, features = ["auth-bearer"] }
axum-server = { version = "0.6.0", default-features = false }
base64 = "0.21.3"
chrono = { version = "0.4.22", default-features = false, features = ["clock"] }
fastrand = { version = "2.0.0" }
flate2 = { version = "1" }
hyper = { version = "1.0.1", default-features = false }
lexopt = { version = "0.3.0" }
lru = { version = "0.12.0" }
nonempty = { version = "0.9.0", features = ["serialize"] }
radicle-surf = { version = "0.21.0", default-features = false, features = ["serde"] }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
thiserror = { version = "1" }
time = { version = "0.3.17", features = ["parsing", "serde"] }
tokio = { version = "1.21", default-features = false, features = ["macros", "rt-multi-thread"] }
tower-http = { version = "0.5", default-features = false, features = ["trace", "cors", "set-header"] }
tracing = { version = "0.1.37", default-features = false, features = ["std", "log"] }
tracing-logfmt = { version = "0.3", optional = true }
tracing-subscriber = { version = "0.3", default-features = false, features = ["std", "ansi", "fmt"] }
ureq = { version = "2.9", default-features = false, features = ["json"] }
url = { version = "2.5.0" }
[dependencies.radicle]
version = "0.11.0"
[dependencies.radicle-term]
version = "0.10.0"
[dependencies.radicle-cli]
version = "0.10.0"
[dev-dependencies]
hyper = { version = "1.0.1", default-features = false, features = ["client"] }
pretty_assertions = { version = "1.3.0" }
radicle-crypto = { version = "0.10.0", features = ["test"] }
tempfile = { version = "3.3.0" }
tower = { version = "0.4", features = ["util"] }

View File

@ -1 +0,0 @@
../build.rs

View File

@ -1,261 +0,0 @@
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 radicle::issue::cache::Issues as _;
use radicle::patch::cache::Patches as _;
use radicle::storage::git::Repository;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tokio::sync::RwLock;
use tower_http::cors::{self, CorsLayer};
use radicle::cob::{issue, patch, Author};
use radicle::identity::{DocAt, RepoId};
use radicle::node::policy::Scope;
use radicle::node::routing::Store;
use radicle::node::AliasStore;
use radicle::node::{Handle, NodeId};
use radicle::storage::{ReadRepository, ReadStorage};
use radicle::{Node, Profile};
mod error;
mod json;
mod v1;
use crate::api::error::Error;
use crate::cache::Cache;
use crate::Options;
pub const RADICLE_VERSION: &str = env!("RADICLE_VERSION");
// This version has to be updated on every breaking change to the radicle-httpd API.
pub const API_VERSION: &str = "0.1.0";
/// Identifier for sessions
type SessionId = String;
#[derive(Clone)]
pub struct Context {
profile: Arc<Profile>,
sessions: Arc<RwLock<HashMap<SessionId, auth::Session>>>,
cache: Option<Cache>,
}
impl Context {
pub fn new(profile: Arc<Profile>, options: &Options) -> Self {
Self {
profile,
sessions: Default::default(),
cache: options.cache.map(Cache::new),
}
}
pub fn project_info<R: ReadRepository + radicle::cob::Store>(
&self,
repo: &R,
doc: DocAt,
) -> Result<project::Info, error::Error> {
let (_, head) = repo.head()?;
let DocAt { doc, .. } = doc;
let id = repo.id();
let payload = doc.project()?;
let aliases = self.profile.aliases();
let delegates = doc
.delegates
.into_iter()
.map(|did| json::author(&Author::new(did), aliases.alias(did.as_key())))
.collect::<Vec<_>>();
let issues = self.profile.issues(repo)?.counts()?;
let patches = self.profile.patches(repo)?.counts()?;
let db = &self.profile.database()?;
let seeding = db.count(&id).unwrap_or_default();
Ok(project::Info {
payload,
delegates,
threshold: doc.threshold,
visibility: doc.visibility,
head,
issues,
patches,
id,
seeding,
})
}
/// Get a repository by RID, checking to make sure we're allowed to view it.
pub fn repo(&self, rid: RepoId) -> Result<(Repository, DocAt), error::Error> {
let repo = self.profile.storage.repository(rid)?;
let doc = repo.identity_doc()?;
// Don't allow accessing private repos.
if doc.visibility.is_private() {
return Err(Error::NotFound);
}
Ok((repo, doc))
}
#[cfg(test)]
pub fn profile(&self) -> &Arc<Profile> {
&self.profile
}
#[cfg(test)]
pub fn sessions(&self) -> &Arc<RwLock<HashMap<SessionId, auth::Session>>> {
&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 {
#[serde(default)]
pub show: ProjectQuery,
pub page: Option<usize>,
pub per_page: Option<usize>,
}
#[derive(Serialize, Deserialize, Clone, Default)]
#[serde(rename_all = "camelCase")]
pub enum ProjectQuery {
All,
#[default]
Pinned,
}
#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct RawQuery {
pub mime: Option<String>,
}
#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct CobsQuery<T> {
pub page: Option<usize>,
pub per_page: Option<usize>,
pub state: Option<T>,
}
#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct PoliciesQuery {
/// The NID from which to fetch from after tracking a repo.
pub from: Option<NodeId>,
pub scope: Option<Scope>,
}
#[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 serde::Serialize;
use serde_json::Value;
use radicle::cob;
use radicle::git::Oid;
use radicle::identity::project::Project;
use radicle::identity::{RepoId, Visibility};
/// Project info.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Info {
/// Project metadata.
#[serde(flatten)]
pub payload: Project,
pub delegates: Vec<Value>,
pub threshold: usize,
pub visibility: Visibility,
pub head: Oid,
pub patches: cob::patch::PatchCounts,
pub issues: cob::issue::IssueCounts,
pub id: RepoId,
pub seeding: usize,
}
}
/// Announce refs to the network for the given RID.
pub fn announce_refs(mut node: Node, rid: RepoId) -> Result<(), Error> {
match node.announce_refs(rid) {
Ok(_) => Ok(()),
Err(e) if e.is_connection_err() => Ok(()),
Err(e) => Err(e.into()),
}
}

View File

@ -1,44 +0,0 @@
use serde::{Deserialize, Serialize};
use time::serde::timestamp;
use time::{Duration, OffsetDateTime};
use radicle::crypto::PublicKey;
use radicle::node::Alias;
use crate::api::error::Error;
use crate::api::Context;
pub const UNAUTHORIZED_SESSIONS_EXPIRATION: Duration = Duration::seconds(60);
pub const AUTHORIZED_SESSIONS_EXPIRATION: Duration = Duration::weeks(1);
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum AuthState {
Authorized,
Unauthorized,
}
#[derive(Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Session {
pub status: AuthState,
pub public_key: PublicKey,
pub alias: Alias,
#[serde(with = "timestamp")]
pub issued_at: OffsetDateTime,
#[serde(with = "timestamp")]
pub expires_at: OffsetDateTime,
}
pub async fn validate(ctx: &Context, token: &str) -> Result<(), Error> {
let sessions_store = ctx.sessions.read().await;
let session = sessions_store
.get(token)
.ok_or(Error::Auth("Unauthorized"))?;
if session.status != AuthState::Authorized || session.expires_at <= OffsetDateTime::now_utc() {
return Err(Error::Auth("Unauthorized"));
}
Ok(())
}

View File

@ -1,158 +0,0 @@
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
/// Errors relating to the API backend.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// The entity was not found.
#[error("entity not found")]
NotFound,
/// An error occurred during an authentication process.
#[error("could not authenticate: {0}")]
Auth(&'static str),
/// An error occurred with env variables.
#[error(transparent)]
Env(#[from] std::env::VarError),
/// Profile error.
#[error(transparent)]
Profile(#[from] radicle::profile::Error),
/// Crypto error.
#[error(transparent)]
Crypto(#[from] radicle::crypto::Error),
/// Storage error.
#[error(transparent)]
Storage(#[from] radicle::storage::Error),
/// Cob cache error.
#[error(transparent)]
CobCache(#[from] radicle::cob::cache::Error),
/// Cob issue cache error.
#[error(transparent)]
CacheIssue(#[from] radicle::cob::issue::cache::Error),
/// Cob issue error.
#[error(transparent)]
CobIssue(#[from] radicle::cob::issue::Error),
/// Cob patch error.
#[error(transparent)]
CobPatch(#[from] radicle::cob::patch::Error),
/// Cob patch cache error.
#[error(transparent)]
CachePatch(#[from] radicle::cob::patch::cache::Error),
/// Cob store error.
#[error(transparent)]
CobStore(#[from] radicle::cob::store::Error),
/// Repository error.
#[error(transparent)]
Repository(#[from] radicle::storage::RepositoryError),
/// Routing error.
#[error(transparent)]
Routing(#[from] radicle::node::routing::Error),
/// Project doc error.
#[error(transparent)]
ProjectDoc(#[from] radicle::identity::doc::PayloadError),
/// Surf directory error.
#[error(transparent)]
SurfDir(#[from] radicle_surf::fs::error::Directory),
/// Surf error.
#[error(transparent)]
Surf(#[from] radicle_surf::Error),
/// Git2 error.
#[error(transparent)]
Git2(#[from] radicle::git::raw::Error),
/// Storage refs error.
#[error(transparent)]
StorageRef(#[from] radicle::storage::refs::Error),
/// Identity doc error.
#[error(transparent)]
IdentityDoc(#[from] radicle::identity::doc::DocError),
/// Tracking store error.
#[error(transparent)]
TrackingStore(#[from] radicle::node::policy::store::Error),
/// Node database error.
#[error(transparent)]
Database(#[from] radicle::node::db::Error),
/// Node error.
#[error(transparent)]
Node(#[from] radicle::node::Error),
/// Invalid update to issue or patch.
#[error("{0}")]
BadRequest(String),
}
impl IntoResponse for Error {
fn into_response(self) -> Response {
let message = self.to_string();
let (status, msg) = match self {
Error::NotFound => (StatusCode::NOT_FOUND, None),
Error::CobStore(e @ radicle::cob::store::Error::NotFound(_, _)) => {
(StatusCode::NOT_FOUND, Some(e.to_string()))
}
Error::Auth(msg) => (StatusCode::UNAUTHORIZED, Some(msg.to_string())),
Error::Crypto(msg) => (StatusCode::BAD_REQUEST, Some(msg.to_string())),
Error::Surf(radicle_surf::Error::Git(e)) if radicle::git::is_not_found_err(&e) => {
(StatusCode::NOT_FOUND, Some(e.message().to_owned()))
}
Error::Surf(radicle_surf::Error::Directory(
e @ radicle_surf::fs::error::Directory::PathNotFound(_),
)) => (StatusCode::NOT_FOUND, Some(e.to_string())),
Error::Git2(e) if radicle::git::is_not_found_err(&e) => {
(StatusCode::NOT_FOUND, Some(e.message().to_owned()))
}
Error::Git2(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Some(e.message().to_owned()),
),
Error::Storage(err) if err.is_not_found() => {
(StatusCode::NOT_FOUND, Some(err.to_string()))
}
Error::Repository(err) if err.is_not_found() => {
(StatusCode::NOT_FOUND, Some(err.to_string()))
}
Error::StorageRef(err) if err.is_not_found() => {
(StatusCode::NOT_FOUND, Some(err.to_string()))
}
Error::BadRequest(msg) => (StatusCode::BAD_REQUEST, Some(msg)),
other => {
tracing::error!("Error: {message}");
tracing::debug!("Error Debug: {:?}", other);
if cfg!(debug_assertions) {
(StatusCode::INTERNAL_SERVER_ERROR, Some(other.to_string()))
} else {
(StatusCode::INTERNAL_SERVER_ERROR, None)
}
}
};
let body = Json(json!({
"error": msg.or_else(|| status.canonical_reason().map(|r| r.to_string())),
"code": status.as_u16()
}));
(status, body).into_response()
}
}

View File

@ -1,312 +0,0 @@
//! Utilities for building JSON responses of our API.
use std::collections::BTreeMap;
use std::path::Path;
use std::str;
use base64::prelude::{Engine, BASE64_STANDARD};
use radicle::cob::{CodeLocation, Reaction};
use radicle::patch::ReviewId;
use serde_json::{json, Value};
use radicle::cob::issue::{Issue, IssueId};
use radicle::cob::patch::{Merge, Patch, PatchId, Review};
use radicle::cob::thread::{Comment, CommentId, Edit};
use radicle::cob::{ActorId, Author};
use radicle::git::RefString;
use radicle::node::{Alias, AliasStore};
use radicle::prelude::NodeId;
use radicle::storage::{git, refs, RemoteRepository};
use radicle_surf::blob::Blob;
use radicle_surf::tree::{EntryKind, Tree};
use radicle_surf::{Commit, Oid};
use crate::api::auth::Session;
/// Returns JSON of a commit.
pub(crate) fn commit(commit: &Commit) -> Value {
json!({
"id": commit.id,
"author": {
"name": commit.author.name,
"email": commit.author.email
},
"summary": commit.summary,
"description": commit.description(),
"parents": commit.parents,
"committer": {
"name": commit.committer.name,
"email": commit.committer.email,
"time": commit.committer.time.seconds()
}
})
}
/// Returns JSON of a session.
pub(crate) fn session(session_id: String, session: &Session) -> Value {
json!({
"sessionId": session_id,
"status": session.status,
"publicKey": session.public_key,
"alias": session.alias,
"issuedAt": session.issued_at.unix_timestamp(),
"expiresAt": session.expires_at.unix_timestamp()
})
}
/// Returns JSON for a blob with a given `path`.
pub(crate) fn blob<T: AsRef<[u8]>>(blob: &Blob<T>, path: &str) -> Value {
json!({
"binary": blob.is_binary(),
"name": name_in_path(path),
"content": blob_content(blob),
"path": path,
"lastCommit": commit(blob.commit())
})
}
/// Returns a string for the blob content, encoded in base64 if binary.
pub fn blob_content<T: AsRef<[u8]>>(blob: &Blob<T>) -> String {
match str::from_utf8(blob.content()) {
Ok(s) => s.to_owned(),
Err(_) => BASE64_STANDARD.encode(blob.content()),
}
}
/// Returns JSON for a tree with a given `path` and `stats`.
pub(crate) fn tree(tree: &Tree, path: &str) -> Value {
let prefix = Path::new(path);
let entries = tree
.entries()
.iter()
.map(|entry| {
json!({
"path": prefix.join(entry.name()),
"oid": entry.object_id(),
"name": entry.name(),
"kind": match entry.entry() {
EntryKind::Tree(_) => "tree",
EntryKind::Blob(_) => "blob",
EntryKind::Submodule { .. } => "submodule"
},
})
})
.collect::<Vec<_>>();
json!({
"entries": &entries,
"lastCommit": commit(tree.commit()),
"name": name_in_path(path),
"path": path,
})
}
/// Returns JSON for an `issue`.
pub(crate) fn issue(id: IssueId, issue: Issue, aliases: &impl AliasStore) -> Value {
json!({
"id": id.to_string(),
"author": author(&issue.author(), aliases.alias(issue.author().id())),
"title": issue.title(),
"state": issue.state(),
"assignees": issue.assignees().map(|assignee|
author(&Author::from(*assignee.as_key()), aliases.alias(assignee))
).collect::<Vec<_>>(),
"discussion": issue.comments().map(|(id, c)| issue_comment(id, c, aliases)).collect::<Vec<_>>(),
"labels": issue.labels().collect::<Vec<_>>(),
})
}
/// Returns JSON for a `patch`.
pub(crate) fn patch(
id: PatchId,
patch: Patch,
repo: &git::Repository,
aliases: &impl AliasStore,
) -> Value {
json!({
"id": id.to_string(),
"author": author(patch.author(), aliases.alias(patch.author().id())),
"title": patch.title(),
"state": patch.state(),
"target": patch.target(),
"labels": patch.labels().collect::<Vec<_>>(),
"merges": patch.merges().map(|(nid, m)| merge(nid, m, aliases)).collect::<Vec<_>>(),
"assignees": patch.assignees().map(|assignee|
author(&Author::from(*assignee), aliases.alias(&assignee))
).collect::<Vec<_>>(),
"revisions": patch.revisions().map(|(id, rev)| {
json!({
"id": id,
"author": author(rev.author(), aliases.alias(rev.author().id())),
"description": rev.description(),
"edits": rev.edits().map(|e| edit(e, aliases)).collect::<Vec<_>>(),
"reactions": rev.reactions().iter().flat_map(|(location, reaction)| {
reactions(reaction.iter().fold(BTreeMap::new(), |mut acc: BTreeMap<&Reaction, Vec<_>>, (author, emoji)| {
acc.entry(emoji).or_default().push(author);
acc
}), location.as_ref(), aliases)
}).collect::<Vec<_>>(),
"base": rev.base(),
"oid": rev.head(),
"refs": get_refs(repo, patch.author().id(), &rev.head()).unwrap_or_default(),
"discussions": rev.discussion().comments().map(|(id, c)| {
patch_comment(id, c, aliases)
}).collect::<Vec<_>>(),
"timestamp": rev.timestamp().as_secs(),
"reviews": patch.reviews_of(id).map(move |(id, r)| {
review(id, r, aliases)
}).collect::<Vec<_>>(),
})
}).collect::<Vec<_>>(),
})
}
/// Returns JSON for a `reaction`.
fn reactions(
reactions: BTreeMap<&Reaction, Vec<&ActorId>>,
location: Option<&CodeLocation>,
aliases: &impl AliasStore,
) -> Vec<Value> {
reactions
.into_iter()
.map(|(emoji, authors)| {
if let Some(l) = location {
json!({ "location": l, "emoji": emoji, "authors": authors.into_iter().map(|a|
author(&Author::from(*a), aliases.alias(a))
).collect::<Vec<_>>()})
} else {
json!({ "emoji": emoji, "authors": authors.into_iter().map(|a|
author(&Author::from(*a), aliases.alias(a))
).collect::<Vec<_>>()})
}
})
.collect::<Vec<_>>()
}
/// Returns JSON for an `author` and fills in `alias` when present.
pub(crate) fn author(author: &Author, alias: Option<Alias>) -> Value {
match alias {
Some(alias) => json!({
"id": author.id,
"alias": alias,
}),
None => json!(author),
}
}
/// Returns JSON for a patch `Merge` and fills in `alias` when present.
fn merge(nid: &NodeId, merge: &Merge, aliases: &impl AliasStore) -> Value {
json!({
"author": author(&Author::from(*nid), aliases.alias(nid)),
"commit": merge.commit,
"timestamp": merge.timestamp.as_secs(),
"revision": merge.revision,
})
}
/// Returns JSON for a patch `Review` and fills in `alias` when present.
fn review(id: &ReviewId, review: &Review, aliases: &impl AliasStore) -> Value {
let a = review.author();
json!({
"id": id,
"author": author(a, aliases.alias(a.id())),
"verdict": review.verdict(),
"summary": review.summary(),
"comments": review.comments().map(|(id, c)| review_comment(id, c, aliases)).collect::<Vec<_>>(),
"timestamp": review.timestamp().as_secs(),
})
}
/// Returns JSON for an `Edit`.
fn edit(edit: &Edit, aliases: &impl AliasStore) -> Value {
json!({
"author": author(&Author::from(edit.author), aliases.alias(&edit.author)),
"body": edit.body,
"timestamp": edit.timestamp.as_secs(),
"embeds": edit.embeds,
})
}
/// Returns JSON for a Issue `Comment`.
fn issue_comment(id: &CommentId, comment: &Comment, aliases: &impl AliasStore) -> Value {
json!({
"id": *id,
"author": author(&Author::from(comment.author()), aliases.alias(&comment.author())),
"body": comment.body(),
"edits": comment.edits().map(|e| edit(e, aliases)).collect::<Vec<_>>(),
"embeds": comment.embeds().to_vec(),
"reactions": reactions(comment.reactions(), None, aliases),
"timestamp": comment.timestamp().as_secs(),
"replyTo": comment.reply_to(),
"resolved": comment.is_resolved(),
})
}
/// Returns JSON for a Patch `Comment`.
fn patch_comment(
id: &CommentId,
comment: &Comment<CodeLocation>,
aliases: &impl AliasStore,
) -> Value {
json!({
"id": *id,
"author": author(&Author::from(comment.author()), aliases.alias(&comment.author())),
"body": comment.body(),
"edits": comment.edits().map(|e| edit(e, aliases)).collect::<Vec<_>>(),
"embeds": comment.embeds().to_vec(),
"reactions": reactions(comment.reactions(), None, aliases),
"timestamp": comment.timestamp().as_secs(),
"replyTo": comment.reply_to(),
"location": comment.location(),
"resolved": comment.is_resolved(),
})
}
/// Returns JSON for a `Review`.
fn review_comment(
id: &CommentId,
comment: &Comment<CodeLocation>,
aliases: &impl AliasStore,
) -> Value {
json!({
"id": *id,
"author": author(&Author::from(comment.author()), aliases.alias(&comment.author())),
"body": comment.body(),
"edits": comment.edits().map(|e| edit(e, aliases)).collect::<Vec<_>>(),
"embeds": comment.embeds().to_vec(),
"reactions": reactions(comment.reactions(), None, aliases),
"timestamp": comment.timestamp().as_secs(),
"replyTo": comment.reply_to(),
"location": comment.location(),
"resolved": comment.is_resolved(),
})
}
/// Returns the name part of a path string.
fn name_in_path(path: &str) -> &str {
match path.rsplit('/').next() {
Some(name) => name,
None => path,
}
}
fn get_refs(
repo: &git::Repository,
id: &ActorId,
head: &Oid,
) -> Result<Vec<RefString>, refs::Error> {
let remote = repo.remote(id)?;
let refs = remote
.refs
.iter()
.filter_map(|(name, o)| {
if o == head {
Some(name.to_owned())
} else {
None
}
})
.collect::<Vec<_>>();
Ok(refs)
}

View File

@ -1,71 +0,0 @@
mod delegates;
mod node;
mod profile;
mod projects;
mod sessions;
mod stats;
use axum::extract::State;
use axum::response::{IntoResponse, Json};
use axum::routing::get;
use axum::Router;
use serde_json::json;
use crate::api::{Context, API_VERSION, RADICLE_VERSION};
pub fn router(ctx: Context) -> Router {
let root_router = Router::new()
.route("/", get(root_handler))
.with_state(ctx.clone());
let routes = Router::new()
.merge(root_router)
.merge(node::router(ctx.clone()))
.merge(profile::router(ctx.clone()))
.merge(sessions::router(ctx.clone()))
.merge(delegates::router(ctx.clone()))
.merge(projects::router(ctx.clone()))
.merge(stats::router(ctx));
Router::new().nest("/v1", routes)
}
async fn root_handler(State(ctx): State<Context>) -> impl IntoResponse {
let response = json!({
"message": "Welcome!",
"service": "radicle-httpd",
"version": format!("{}-{}", RADICLE_VERSION, env!("GIT_HEAD")),
"apiVersion": API_VERSION,
"nid": ctx.profile.public_key,
"path": "/api/v1",
"links": [
{
"href": "/projects",
"rel": "projects",
"type": "GET"
},
{
"href": "/node",
"rel": "node",
"type": "GET"
},
{
"href": "/delegates/:did/projects",
"rel": "projects",
"type": "GET"
},
{
"href": "/profile",
"rel": "profile",
"type": "GET"
},
{
"href": "/stats",
"rel": "stats",
"type": "GET"
}
]
});
Json(response)
}

View File

@ -1,225 +0,0 @@
use axum::extract::State;
use axum::response::IntoResponse;
use axum::routing::get;
use axum::{Json, Router};
use radicle::cob::Author;
use radicle::identity::Did;
use radicle::issue::cache::Issues as _;
use radicle::node::routing::Store;
use radicle::node::AliasStore;
use radicle::patch::cache::Patches as _;
use radicle::storage::{ReadRepository, ReadStorage};
use crate::api::error::Error;
use crate::api::json;
use crate::api::project::Info;
use crate::api::Context;
use crate::api::{PaginationQuery, ProjectQuery};
use crate::axum_extra::{Path, Query};
pub fn router(ctx: Context) -> Router {
Router::new()
.route(
"/delegates/:delegate/projects",
get(delegates_projects_handler),
)
.with_state(ctx)
}
/// List all projects which delegate is a part of.
/// `GET /delegates/:delegate/projects`
async fn delegates_projects_handler(
State(ctx): State<Context>,
Path(delegate): Path<Did>,
Query(qs): Query<PaginationQuery>,
) -> impl IntoResponse {
let PaginationQuery {
show,
page,
per_page,
} = qs;
let page = page.unwrap_or(0);
let per_page = per_page.unwrap_or(10);
let storage = &ctx.profile.storage;
let db = &ctx.profile.database()?;
let pinned = &ctx.profile.config.web.pinned;
let mut projects = match show {
ProjectQuery::All => storage
.repositories()?
.into_iter()
.filter(|repo| repo.doc.visibility.is_public())
.collect::<Vec<_>>(),
ProjectQuery::Pinned => storage.repositories_by_id(pinned.repositories.iter())?,
};
projects.sort_by_key(|p| p.rid);
let infos = projects
.into_iter()
.filter_map(|id| {
if !id.doc.delegates.iter().any(|d| *d == delegate) {
return None;
}
let Ok(repo) = storage.repository(id.rid) else {
return None;
};
let Ok((_, head)) = repo.head() else {
return None;
};
let Ok(payload) = id.doc.project() else {
return None;
};
let Ok(issues) = ctx.profile.issues(&repo) else {
return None;
};
let Ok(issues) = issues.counts() else {
return None;
};
let Ok(patches) = ctx.profile.patches(&repo) else {
return None;
};
let Ok(patches) = patches.counts() else {
return None;
};
let aliases = ctx.profile.aliases();
let delegates = id
.doc
.delegates
.into_iter()
.map(|did| json::author(&Author::new(did), aliases.alias(did.as_key())))
.collect::<Vec<_>>();
let seeding = db.count(&id.rid).unwrap_or_default();
Some(Info {
payload,
delegates,
threshold: id.doc.threshold,
visibility: id.doc.visibility,
head,
issues,
patches,
id: id.rid,
seeding,
})
})
.skip(page * per_page)
.take(per_page)
.collect::<Vec<_>>();
Ok::<_, Error>(Json(infos))
}
#[cfg(test)]
mod routes {
use std::net::SocketAddr;
use axum::extract::connect_info::MockConnectInfo;
use axum::http::StatusCode;
use serde_json::json;
use crate::test::{self, get, HEAD, RID};
#[tokio::test]
async fn test_delegates_projects() {
let tmp = tempfile::tempdir().unwrap();
let seed = test::seed(tmp.path());
let app = super::router(seed.clone())
.layer(MockConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080))));
let response = get(
&app,
"/delegates/did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/projects?show=all",
)
.await;
assert_eq!(
response.status(),
StatusCode::OK,
"failed response: {:?}",
response.json().await
);
assert_eq!(
response.json().await,
json!([
{
"name": "hello-world",
"description": "Rad repository for tests",
"defaultBranch": "master",
"delegates": [
{
"id": "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
"alias": "seed"
}
],
"threshold": 1,
"visibility": {
"type": "public"
},
"head": HEAD,
"patches": {
"open": 1,
"draft": 0,
"archived": 0,
"merged": 0,
},
"issues": {
"open": 1,
"closed": 0,
},
"id": RID,
"seeding": 0,
},
])
);
let app = super::router(seed).layer(MockConnectInfo(SocketAddr::from((
[192, 168, 13, 37],
8080,
))));
let response = get(
&app,
"/delegates/did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/projects?show=all",
)
.await;
assert_eq!(
response.status(),
StatusCode::OK,
"failed response: {:?}",
response.json().await
);
assert_eq!(
response.json().await,
json!([
{
"name": "hello-world",
"description": "Rad repository for tests",
"defaultBranch": "master",
"delegates": [
{
"id": "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
"alias": "seed"
}
],
"threshold": 1,
"visibility": {
"type": "public"
},
"head": HEAD,
"patches": {
"open": 1,
"draft": 0,
"archived": 0,
"merged": 0,
},
"issues": {
"open": 1,
"closed": 0,
},
"id": RID,
"seeding": 0,
}
])
);
}
}

View File

@ -1,135 +0,0 @@
use axum::extract::State;
use axum::response::IntoResponse;
use axum::routing::{get, put};
use axum::{Json, Router};
use axum_auth::AuthBearer;
use hyper::StatusCode;
use serde_json::json;
use radicle::identity::RepoId;
use radicle::node::routing::Store;
use radicle::node::{
policy::{Policy, SeedPolicy},
AliasStore, Handle, NodeId, DEFAULT_TIMEOUT,
};
use radicle::Node;
use crate::api::error::Error;
use crate::api::{self, Context, PoliciesQuery, RADICLE_VERSION};
use crate::axum_extra::{Path, Query};
pub fn router(ctx: Context) -> Router {
Router::new()
.route("/node", get(node_handler))
.route("/node/policies/repos", get(node_policies_repos_handler))
.route(
"/node/policies/repos/:rid",
put(node_policies_seed_handler).delete(node_policies_unseed_handler),
)
.route("/nodes/:nid", get(nodes_handler))
.route("/nodes/:nid/inventory", get(nodes_inventory_handler))
.with_state(ctx)
}
/// Return local node information.
/// `GET /node`
async fn node_handler(State(ctx): State<Context>) -> impl IntoResponse {
let node = Node::new(ctx.profile.socket());
let node_id = ctx.profile.public_key;
let node_state = if node.is_running() {
"running"
} else {
"stopped"
};
let config = match node.config() {
Ok(config) => Some(config),
Err(err) => {
tracing::error!("Error getting node config: {:#}", err);
None
}
};
let response = json!({
"id": node_id.to_string(),
"version": format!("{}-{}", RADICLE_VERSION, env!("GIT_HEAD")),
"config": config,
"state": node_state,
});
Ok::<_, Error>(Json(response))
}
/// Return stored information about other nodes.
/// `GET /nodes/:nid`
async fn nodes_handler(State(ctx): State<Context>, Path(nid): Path<NodeId>) -> impl IntoResponse {
let aliases = ctx.profile.aliases();
let response = json!({
"alias": aliases.alias(&nid),
});
Ok::<_, Error>(Json(response))
}
/// Return stored information about other nodes.
/// `GET /nodes/:nid/inventory`
async fn nodes_inventory_handler(
State(ctx): State<Context>,
Path(nid): Path<NodeId>,
) -> impl IntoResponse {
let db = &ctx.profile.database()?;
let resources = db.get_resources(&nid)?;
Ok::<_, Error>(Json(resources))
}
/// Return local repo policies information.
/// `GET /node/policies/repos`
async fn node_policies_repos_handler(State(ctx): State<Context>) -> impl IntoResponse {
let policies = ctx.profile.policies()?;
let mut repos = Vec::new();
for SeedPolicy { rid: id, policy } in policies.seed_policies()? {
repos.push(json!({
"id": id,
"scope": policy.scope().unwrap_or_default(),
"policy": Policy::from(policy),
}));
}
Ok::<_, Error>(Json(repos))
}
/// Seed a new repo.
/// `PUT /node/policies/repos/:rid`
async fn node_policies_seed_handler(
State(ctx): State<Context>,
AuthBearer(token): AuthBearer,
Path(project): Path<RepoId>,
Query(qs): Query<PoliciesQuery>,
) -> impl IntoResponse {
api::auth::validate(&ctx, &token).await?;
let mut node = Node::new(ctx.profile.socket());
node.seed(project, qs.scope.unwrap_or_default())?;
if let Some(from) = qs.from {
let results = node.fetch(project, from, DEFAULT_TIMEOUT)?;
return Ok::<_, Error>((
StatusCode::OK,
Json(json!({ "success": true, "results": results })),
));
}
Ok::<_, Error>((StatusCode::OK, Json(json!({ "success": true }))))
}
/// Unseed a repo.
/// `DELETE /node/policies/repos/:rid`
async fn node_policies_unseed_handler(
State(ctx): State<Context>,
AuthBearer(token): AuthBearer,
Path(project): Path<RepoId>,
) -> impl IntoResponse {
api::auth::validate(&ctx, &token).await?;
let mut node = Node::new(ctx.profile.socket());
node.unseed(project)?;
Ok::<_, Error>((StatusCode::OK, Json(json!({ "success": true }))))
}

View File

@ -1,123 +0,0 @@
use std::net::SocketAddr;
use axum::extract::{ConnectInfo, State};
use axum::response::IntoResponse;
use axum::routing::get;
use axum::{Json, Router};
use serde_json::json;
use crate::api::error::Error;
use crate::api::Context;
pub fn router(ctx: Context) -> Router {
Router::new()
.route("/profile", get(profile_handler))
.with_state(ctx)
}
/// Return local profile information.
/// `GET /profile`
async fn profile_handler(
State(ctx): State<Context>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
) -> impl IntoResponse {
if !addr.ip().is_loopback() {
return Err(Error::Auth("Profile data is only shown for localhost"));
}
Ok::<_, Error>(Json(
json!({ "config": ctx.profile.config, "home": ctx.profile.home.path() }),
))
}
#[cfg(test)]
mod routes {
use std::net::SocketAddr;
use axum::extract::connect_info::MockConnectInfo;
use axum::http::StatusCode;
use serde_json::json;
use crate::test::{self, get};
#[tokio::test]
async fn test_remote_profile() {
let tmp = tempfile::tempdir().unwrap();
let seed = test::seed(tmp.path());
let app = super::router(seed.clone())
.layer(MockConnectInfo(SocketAddr::from(([192, 168, 1, 1], 8080))));
let response = get(&app, "/profile").await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(
response.json().await,
json!({
"error": "Profile data is only shown for localhost",
"code": 401
})
)
}
#[tokio::test]
async fn test_profile() {
let tmp = tempfile::tempdir().unwrap();
let seed = test::seed(tmp.path());
let app = super::router(seed.clone())
.layer(MockConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080))));
let response = get(&app, "/profile").await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.json().await,
json!({
"config": {
"publicExplorer": "https://app.radicle.xyz/nodes/$host/$rid$path",
"preferredSeeds": [
"z6MkrLMMsiPWUcNPHcRajuMi9mDfYckSoJyPwwnknocNYPm7@seed.radicle.garden:8776",
"z6Mkmqogy2qEM2ummccUthFEaaHvyYmYBYh3dbe9W4ebScxo@ash.radicle.garden:8776"
],
"web": { "pinned": { "repositories": [] } },
"cli": {
"hints": true
},
"node": {
"alias": "seed",
"listen": [],
"peers": { "type": "dynamic" },
"connect": [],
"externalAddresses": [],
"network": "main",
"log": "INFO",
"relay": "auto",
"limits": {
"routingMaxSize": 1000,
"routingMaxAge": 604800,
"gossipMaxAge": 1209600,
"fetchConcurrency": 1,
"maxOpenFiles": 4096,
"rate": {
"inbound": {
"fillRate": 5.0,
"capacity": 1024
},
"outbound": {
"fillRate": 10.0,
"capacity": 2048
}
},
"connection": {
"inbound": 128,
"outbound": 16
}
},
"workers": 8,
"seedingPolicy": {
"default": "block",
}
}
},
"home": seed.profile.path()
})
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,183 +0,0 @@
use std::iter::repeat_with;
use axum::extract::State;
use axum::response::IntoResponse;
use axum::routing::{post, put};
use axum::{Json, Router};
use axum_auth::AuthBearer;
use hyper::StatusCode;
use radicle::crypto::{PublicKey, Signature};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use crate::api::auth::{self, AuthState, Session};
use crate::api::error::Error;
use crate::api::json;
use crate::api::Context;
use crate::axum_extra::Path;
pub fn router(ctx: Context) -> Router {
Router::new()
.route("/sessions", post(session_create_handler))
.route(
"/sessions/:id",
put(session_signin_handler)
.get(session_handler)
.delete(session_delete_handler),
)
.with_state(ctx)
}
#[derive(Debug, Deserialize, Serialize)]
struct AuthChallenge {
sig: Signature,
pk: PublicKey,
}
/// Create session.
/// `POST /sessions`
async fn session_create_handler(State(ctx): State<Context>) -> impl IntoResponse {
let mut rng = fastrand::Rng::new();
let session_id = repeat_with(|| rng.alphanumeric())
.take(32)
.collect::<String>();
let signer = ctx.profile.signer().map_err(Error::from)?;
let session = Session {
status: AuthState::Unauthorized,
public_key: *signer.public_key(),
alias: ctx.profile.config.node.alias.clone(),
issued_at: OffsetDateTime::now_utc(),
expires_at: OffsetDateTime::now_utc()
.checked_add(auth::UNAUTHORIZED_SESSIONS_EXPIRATION)
.unwrap(),
};
let mut sessions = ctx.sessions.write().await;
sessions.insert(session_id.clone(), session.clone());
Ok::<_, Error>((
StatusCode::CREATED,
Json(json::session(session_id, &session)),
))
}
/// Get a session.
/// `GET /sessions/:id`
async fn session_handler(
State(ctx): State<Context>,
Path(session_id): Path<String>,
) -> impl IntoResponse {
let sessions = ctx.sessions.read().await;
let session = sessions.get(&session_id).ok_or(Error::NotFound)?;
Ok::<_, Error>(Json(json::session(session_id, session)))
}
/// Update session.
/// `PUT /sessions/:id`
async fn session_signin_handler(
State(ctx): State<Context>,
Path(session_id): Path<String>,
Json(request): Json<AuthChallenge>,
) -> impl IntoResponse {
let mut sessions = ctx.sessions.write().await;
let session = sessions.get_mut(&session_id).ok_or(Error::NotFound)?;
if session.status == AuthState::Unauthorized {
if session.public_key != request.pk {
return Err(Error::Auth("Invalid public key"));
}
if session.expires_at <= OffsetDateTime::now_utc() {
return Err(Error::Auth("Session expired"));
}
let payload = format!("{}:{}", session_id, request.pk);
request
.pk
.verify(payload.as_bytes(), &request.sig)
.map_err(Error::from)?;
session.status = AuthState::Authorized;
session.expires_at = OffsetDateTime::now_utc()
.checked_add(auth::AUTHORIZED_SESSIONS_EXPIRATION)
.unwrap();
return Ok::<_, Error>(Json(json!({ "success": true })));
}
Err(Error::Auth("Session already authorized"))
}
/// Delete session.
/// `DELETE /sessions/:id`
async fn session_delete_handler(
State(ctx): State<Context>,
AuthBearer(token): AuthBearer,
Path(session_id): Path<String>,
) -> impl IntoResponse {
if token != session_id {
return Err(Error::Auth("Not authorized to delete this session"));
}
let mut sessions = ctx.sessions.write().await;
sessions.remove_entry(&token).ok_or(Error::NotFound)?;
Ok::<_, Error>(Json(json!({ "success": true })))
}
#[cfg(test)]
mod routes {
use crate::commands::web::{sign, SessionInfo};
use axum::body::Body;
use axum::http::StatusCode;
use crate::api::auth::{AuthState, Session};
use crate::test::{self, get, post, put};
#[tokio::test]
async fn test_session() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test::seed(tmp.path());
let app = super::router(ctx.to_owned());
// Create session.
let response = post(&app, "/sessions", None, None).await;
let status = response.status();
let json = response.json().await;
let session_info: SessionInfo = serde_json::from_value(json).unwrap();
assert_eq!(status, StatusCode::CREATED);
// Check that an unauthorized session has been created.
let response = get(&app, format!("/sessions/{}", session_info.session_id)).await;
let status = response.status();
let json = response.json().await;
let body: Session = serde_json::from_value(json).unwrap();
assert_eq!(status, StatusCode::OK);
assert_eq!(body.status, AuthState::Unauthorized);
// Create request body
let signer = ctx.profile.signer().unwrap();
let signature = sign(signer, &session_info).unwrap();
let body = serde_json::to_vec(&super::AuthChallenge {
sig: signature,
pk: session_info.public_key,
})
.unwrap();
let response = put(
&app,
format!("/sessions/{}", session_info.session_id),
Some(Body::from(body)),
None,
)
.await;
assert_eq!(response.status(), StatusCode::OK);
// Check that session has been authorized.
let response = get(&app, format!("/sessions/{}", session_info.session_id)).await;
let status = response.status();
let json = response.json().await;
let body: Session = serde_json::from_value(json).unwrap();
assert_eq!(status, StatusCode::OK);
assert_eq!(body.status, AuthState::Authorized);
}
}

View File

@ -1,42 +0,0 @@
use axum::extract::State;
use axum::response::IntoResponse;
use axum::routing::get;
use axum::{Json, Router};
use serde_json::json;
use radicle::storage::ReadStorage;
use crate::api::error::Error;
use crate::api::Context;
pub fn router(ctx: Context) -> Router {
Router::new()
.route("/stats", get(stats_handler))
.with_state(ctx)
}
/// Return the stats for the node.
/// `GET /stats`
async fn stats_handler(State(ctx): State<Context>) -> impl IntoResponse {
let total = ctx.profile.storage.repositories()?.len();
Ok::<_, Error>(Json(json!({ "repos": { "total": total } })))
}
#[cfg(test)]
mod routes {
use axum::http::StatusCode;
use serde_json::json;
use crate::test::{self, get};
#[tokio::test]
async fn test_stats() {
let tmp = tempfile::tempdir().unwrap();
let app = super::router(test::seed(tmp.path()));
let response = get(&app, "/stats").await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.json().await, json!({ "repos": { "total": 2 } }));
}
}

View File

@ -1,99 +0,0 @@
use axum::extract::path::ErrorKind;
use axum::extract::rejection::{PathRejection, QueryRejection};
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use axum::http::{header, StatusCode};
use axum::response::IntoResponse;
use axum::{async_trait, Json};
use serde::de::DeserializeOwned;
use serde::Serialize;
pub struct Path<T>(pub T);
#[async_trait]
impl<S, T> FromRequestParts<S> for Path<T>
where
T: DeserializeOwned + Send,
S: Send + Sync,
{
type Rejection = (StatusCode, axum::Json<Error>);
async fn from_request_parts(req: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
match axum::extract::Path::<T>::from_request_parts(req, state).await {
Ok(value) => Ok(Self(value.0)),
Err(rejection) => {
let status = StatusCode::BAD_REQUEST;
let body = match rejection {
PathRejection::FailedToDeserializePathParams(inner) => {
let kind = inner.into_kind();
match &kind {
ErrorKind::Message(msg) => Json(Error {
success: false,
error: msg.to_string(),
}),
_ => Json(Error {
success: false,
error: kind.to_string(),
}),
}
}
_ => Json(Error {
success: false,
error: format!("{rejection}"),
}),
};
Err((status, body))
}
}
}
}
#[derive(Default)]
pub struct Query<T>(pub T);
#[async_trait]
impl<S, T> FromRequestParts<S> for Query<T>
where
T: DeserializeOwned + Send,
S: Send + Sync,
{
type Rejection = (StatusCode, axum::Json<Error>);
async fn from_request_parts(req: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
match axum::extract::Query::<T>::from_request_parts(req, state).await {
Ok(value) => Ok(Self(value.0)),
Err(rejection) => {
let status = StatusCode::BAD_REQUEST;
let body = match rejection {
QueryRejection::FailedToDeserializeQueryString(inner) => Json(Error {
success: false,
error: inner.to_string(),
}),
_ => Json(Error {
success: false,
error: format!("{rejection}"),
}),
};
Err((status, body))
}
}
}
}
#[derive(Serialize)]
pub struct Error {
success: bool,
error: String,
}
/// Add a Cache-Control header that marks the response as immutable and
/// instructs clients to cache the response for 7 days.
pub fn immutable_response(data: impl serde::Serialize) -> impl IntoResponse {
(
[(header::CACHE_CONTROL, "public, max-age=604800, immutable")],
Json(data),
)
}

View File

@ -1,10 +0,0 @@
use radicle_cli::terminal as term;
use radicle_httpd::commands::web as rad_web;
fn main() {
term::run_command_args::<rad_web::Options, _>(
rad_web::HELP,
rad_web::run,
std::env::args_os().skip(1).collect(),
)
}

View File

@ -1,22 +0,0 @@
use std::num::NonZeroUsize;
use std::sync::Arc;
use lru::LruCache;
use tokio::sync::Mutex;
use radicle::prelude::RepoId;
use radicle_surf::Oid;
#[derive(Clone)]
pub struct Cache {
pub tree: Arc<Mutex<LruCache<(RepoId, Oid, String), serde_json::Value>>>,
}
impl Cache {
/// Creates a new cache of the given size.
pub fn new(size: NonZeroUsize) -> Self {
Cache {
tree: Arc::new(Mutex::new(LruCache::new(size))),
}
}
}

View File

@ -1,2 +0,0 @@
//! Extra CLI commands relating to HTTPd.
pub mod web;

View File

@ -1,233 +0,0 @@
use std::ffi::OsString;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::process::Command;
use std::thread::sleep;
use std::time::Duration;
use anyhow::{anyhow, Context};
use serde::{Deserialize, Serialize};
use url::{Position, Url};
use radicle::crypto::{PublicKey, Signature, Signer};
use radicle_cli::terminal as term;
use radicle_cli::terminal::args::{Args, Error, Help};
pub const HELP: Help = Help {
name: "web",
description: "Run the HTTP daemon and connect the web explorer to it",
version: env!("RADICLE_VERSION"),
usage: r#"
Usage
rad web [<option>...] [<explorer-url>]
Runs the Radicle HTTP Daemon and opens a Radicle web explorer to authenticate with it.
Options
--listen, -l <addr> Address to bind the HTTP daemon to (default: 127.0.0.1:8080)
--connect, -c [<addr>] Connect the explorer to an already running daemon (default: 127.0.0.1:8080)
--path, -p <path> Path to be opened in the explorer after authentication
--[no-]open Open the authentication URL automatically (default: open)
--help Print help
"#,
};
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionInfo {
pub session_id: String,
pub public_key: PublicKey,
}
#[derive(Debug)]
pub struct Options {
pub app_url: Url,
pub listen: SocketAddr,
pub path: Option<String>,
pub connect: Option<SocketAddr>,
pub open: bool,
}
impl Args for Options {
fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
use lexopt::prelude::*;
let mut parser = lexopt::Parser::from_args(args);
let mut listen = None;
let mut connect = None;
let mut path = None;
// SAFETY: This is a valid URL.
#[allow(clippy::unwrap_used)]
let mut app_url = Url::parse("https://app.radicle.xyz").unwrap();
let mut open = true;
while let Some(arg) = parser.next()? {
match arg {
Long("listen") | Short('l') if listen.is_none() => {
let val = parser.value()?;
listen = Some(term::args::socket_addr(&val)?);
}
Long("path") | Short('p') if path.is_none() => {
let val = parser.value()?;
path = Some(term::args::string(&val));
}
Long("connect") | Short('c') if connect.is_none() => {
if let Ok(val) = parser.value() {
connect = Some(term::args::socket_addr(&val)?);
} else {
connect = Some(SocketAddr::new(
IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)),
8080,
));
}
}
Long("open") => open = true,
Long("no-open") => open = false,
Long("help") | Short('h') => {
return Err(Error::Help.into());
}
Value(val) => {
let val = val.to_string_lossy();
app_url = Url::parse(val.as_ref()).context("invalid explorer URL supplied")?;
}
_ => {
return Err(anyhow!(arg.unexpected()));
}
}
}
Ok((
Options {
open,
app_url,
listen: listen.unwrap_or(SocketAddr::new(
IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)),
8080,
)),
path,
connect,
},
vec![],
))
}
}
pub fn sign(signer: Box<dyn Signer>, session: &SessionInfo) -> Result<Signature, anyhow::Error> {
signer
.try_sign(format!("{}:{}", session.session_id, session.public_key).as_bytes())
.map_err(anyhow::Error::from)
}
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
let profile = ctx.profile()?;
let runtime_and_handle = if options.connect.is_none() {
tracing_subscriber::fmt::init();
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("failed to create threaded runtime");
let httpd_handle = runtime.spawn(crate::run(crate::Options {
aliases: Default::default(),
listen: options.listen,
cache: None,
}));
Some((runtime, httpd_handle))
} else {
None
};
let mut retries = 30;
let connect = options.connect.unwrap_or(options.listen);
let response = loop {
retries -= 1;
sleep(Duration::from_millis(100));
match ureq::post(&format!("http://{connect}/api/v1/sessions")).call() {
Ok(response) => {
break response;
}
Err(err) => {
if err.kind() == ureq::ErrorKind::ConnectionFailed && retries > 0 {
continue;
} else {
anyhow::bail!(err);
}
}
}
};
let session = response.into_json::<SessionInfo>()?;
let signer = profile.signer()?;
let signature = sign(signer, &session)?;
let mut auth_url = options.app_url.clone();
auth_url
.path_segments_mut()
.map_err(|_| anyhow!("URL not supported"))?
.push("session")
.push(&session.session_id);
auth_url
.query_pairs_mut()
.append_pair("pk", &session.public_key.to_string())
.append_pair("sig", &signature.to_string())
.append_pair("addr", &connect.to_string());
let pathname = radicle::rad::cwd().ok().and_then(|(_, rid)| {
Url::parse(
&profile
.config
.public_explorer
.url(options.listen, rid)
.to_string(),
)
.map(|x| x[Position::BeforePath..].to_string())
.ok()
});
if let Some(path) = options.path.or(pathname) {
auth_url.query_pairs_mut().append_pair("path", &path);
}
if options.open {
#[cfg(any(target_os = "freebsd", target_os = "windows"))]
let cmd_name = "echo";
#[cfg(target_os = "macos")]
let cmd_name = "open";
#[cfg(target_os = "linux")]
let cmd_name = "xdg-open";
let mut cmd = Command::new(cmd_name);
match cmd.arg(auth_url.as_str()).spawn() {
Ok(mut child) => match child.wait() {
Ok(exit_status) => {
if exit_status.success() {
term::success!("Opened {auth_url}");
} else {
term::info!("Visit {auth_url} to connect");
}
}
Err(_) => {
term::info!("Visit {auth_url} to connect");
}
},
Err(_) => {
term::error(format!("Could not open web browser via `{cmd_name}`"));
term::hint("Use `rad web --no-open` if this continues");
term::info!("Visit {auth_url} to connect");
}
}
} else {
term::info!("Visit {auth_url} to connect");
}
if let Some((runtime, httpd_handle)) = runtime_and_handle {
runtime
.block_on(httpd_handle)?
.context("httpd server error")?;
}
Ok(())
}

View File

@ -1,116 +0,0 @@
use std::process::ExitStatus;
use axum::http;
use axum::response::{IntoResponse, Response};
/// Errors relating to the Git backend.
#[derive(Debug, thiserror::Error)]
pub enum GitError {
/// The entity was not found.
#[error("not found")]
NotFound,
/// I/O error.
#[error("i/o error: {0}")]
Io(#[from] std::io::Error),
/// The service is not available.
#[error("service '{0}' not available")]
ServiceUnavailable(&'static str),
/// Invalid identifier.
#[error("invalid radicle identifier: {0}")]
Id(#[from] radicle::identity::IdError),
/// Storage error.
#[error("storage: {0}")]
Storage(#[from] radicle::storage::Error),
/// Repository error.
#[error("repository: {0}")]
Repository(#[from] radicle::storage::RepositoryError),
/// Git backend error.
#[error("git-http-backend: exited with code {0}")]
BackendExited(ExitStatus),
/// Git backend error.
#[error("git-http-backend: invalid header returned: {0:?}")]
BackendHeader(String),
/// HeaderName error.
#[error(transparent)]
InvalidHeaderName(#[from] axum::http::header::InvalidHeaderName),
/// HeaderValue error.
#[error(transparent)]
InvalidHeaderValue(#[from] axum::http::header::InvalidHeaderValue),
}
impl GitError {
pub fn status(&self) -> http::StatusCode {
match self {
GitError::ServiceUnavailable(_) => http::StatusCode::SERVICE_UNAVAILABLE,
GitError::Id(_) => http::StatusCode::NOT_FOUND,
GitError::NotFound => http::StatusCode::NOT_FOUND,
_ => http::StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
impl IntoResponse for GitError {
fn into_response(self) -> Response {
tracing::error!("{}", self);
self.status().into_response()
}
}
/// Errors relating to the `/raw` route.
#[derive(Debug, thiserror::Error)]
pub enum RawError {
/// Surf error.
#[error(transparent)]
Surf(#[from] radicle_surf::Error),
/// Git error.
#[error(transparent)]
Git(#[from] radicle::git::ext::Error),
/// Radicle Storage error.
#[error(transparent)]
Storage(#[from] radicle::storage::Error),
/// Repository error.
#[error(transparent)]
Repository(#[from] radicle::storage::RepositoryError),
/// Http Headers error.
#[error(transparent)]
Headers(#[from] http::header::InvalidHeaderValue),
/// Surf file error.
#[error(transparent)]
SurfFile(#[from] radicle_surf::fs::error::File),
/// The entity was not found.
#[error("not found")]
NotFound,
}
impl RawError {
pub fn status(&self) -> http::StatusCode {
match self {
RawError::SurfFile(_) | RawError::NotFound => http::StatusCode::NOT_FOUND,
_ => http::StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
impl IntoResponse for RawError {
fn into_response(self) -> Response {
tracing::error!("{}", self);
self.status().into_response()
}
}

View File

@ -1,252 +0,0 @@
use std::collections::HashMap;
use std::io::prelude::*;
use std::net::SocketAddr;
use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::{io, net, str};
use axum::body::Bytes;
use axum::extract::{ConnectInfo, Path as AxumPath, RawQuery, State};
use axum::http::header::HeaderName;
use axum::http::{HeaderMap, Method, StatusCode};
use axum::response::IntoResponse;
use axum::routing::any;
use axum::Router;
use flate2::write::GzDecoder;
use hyper::body::Buf as _;
use radicle::identity::RepoId;
use radicle::profile::Profile;
use radicle::storage::{ReadRepository, ReadStorage};
use crate::error::GitError as Error;
pub fn router(profile: Arc<Profile>, aliases: HashMap<String, RepoId>) -> Router {
Router::new()
.route("/:project/*request", any(git_handler))
.with_state((profile, aliases))
}
async fn git_handler(
State((profile, aliases)): State<(Arc<Profile>, HashMap<String, RepoId>)>,
AxumPath((project, request)): AxumPath<(String, String)>,
method: Method,
headers: HeaderMap,
ConnectInfo(remote): ConnectInfo<SocketAddr>,
query: RawQuery,
body: Bytes,
) -> impl IntoResponse {
let query = query.0.unwrap_or_default();
let name = project.strip_suffix(".git").unwrap_or(&project);
let rid: RepoId = match name.parse() {
Ok(rid) => rid,
Err(_) => {
let Some(rid) = aliases.get(name) else {
return Err(Error::NotFound);
};
*rid
}
};
let (status, headers, body) = git_http_backend(
&profile, method, headers, body, remote, rid, &request, query,
)
.await?;
let mut response_headers = HeaderMap::new();
for (name, vec) in headers.iter() {
for value in vec {
let header: HeaderName = name.try_into()?;
response_headers.insert(header, value.parse()?);
}
}
Ok::<_, Error>((status, response_headers, body))
}
async fn git_http_backend(
profile: &Profile,
method: Method,
headers: HeaderMap,
mut body: Bytes,
remote: net::SocketAddr,
id: RepoId,
path: &str,
query: String,
) -> Result<(StatusCode, HashMap<String, Vec<String>>, Vec<u8>), Error> {
let git_dir = radicle::storage::git::paths::repository(&profile.storage, &id);
let content_type =
if let Some(Ok(content_type)) = headers.get("Content-Type").map(|h| h.to_str()) {
content_type
} else {
""
};
// Don't allow cloning of private repositories.
let doc = profile.storage.repository(id)?.identity_doc()?;
if doc.visibility.is_private() {
return Err(Error::NotFound);
}
// Reject push requests.
match (path, query.as_str()) {
("git-receive-pack", _) | (_, "service=git-receive-pack") => {
return Err(Error::ServiceUnavailable("git-receive-pack"));
}
_ => {}
};
tracing::debug!("id: {:?}", id);
tracing::debug!("headers: {:?}", headers);
tracing::debug!("path: {:?}", path);
tracing::debug!("method: {:?}", method.as_str());
tracing::debug!("remote: {:?}", remote.to_string());
let mut cmd = Command::new("git");
let mut child = cmd
.arg("http-backend")
.env("REQUEST_METHOD", method.as_str())
.env("GIT_PROJECT_ROOT", git_dir)
// "The GIT_HTTP_EXPORT_ALL environmental variable may be passed to git-http-backend to bypass
// the check for the "git-daemon-export-ok" file in each repository before allowing export of
// that repository."
.env("GIT_HTTP_EXPORT_ALL", String::default())
.env("PATH_INFO", Path::new("/").join(path))
.env("CONTENT_TYPE", content_type)
.env("QUERY_STRING", query)
.stderr(Stdio::piped())
.stdout(Stdio::piped())
.stdin(Stdio::piped())
.spawn()?;
// Whether the request body is compressed.
let gzip = matches!(
headers.get("Content-Encoding").map(|h| h.to_str()),
Some(Ok("gzip"))
);
{
// This is safe because we captured the child's stdin.
let mut stdin = child.stdin.take().unwrap();
// Copy the request body to git-http-backend's stdin.
if gzip {
let mut decoder = GzDecoder::new(&mut stdin);
let mut reader = body.reader();
io::copy(&mut reader, &mut decoder)?;
decoder.finish()?;
} else {
while body.has_remaining() {
let mut chunk = body.chunk();
let count = chunk.len();
io::copy(&mut chunk, &mut stdin)?;
body.advance(count);
}
}
}
match child.wait_with_output() {
Ok(output) if output.status.success() => {
tracing::info!("git-http-backend: exited successfully for {}", id);
let mut reader = std::io::Cursor::new(output.stdout);
let mut headers = HashMap::new();
// Parse headers returned by git so that we can use them in the client response.
for line in io::Read::by_ref(&mut reader).lines() {
let line = line?;
if line.is_empty() || line == "\r" {
break;
}
let mut parts = line.splitn(2, ':');
let key = parts.next();
let value = parts.next();
if let (Some(key), Some(value)) = (key, value) {
let value = &value[1..];
headers
.entry(key.to_string())
.or_insert_with(Vec::new)
.push(value.to_string());
} else {
return Err(Error::BackendHeader(line));
}
}
let status = {
tracing::debug!("git-http-backend: {:?}", &headers);
let line = headers.remove("Status").unwrap_or_default();
let line = line.into_iter().next().unwrap_or_default();
let mut parts = line.split(' ');
parts
.next()
.and_then(|p| p.parse().ok())
.unwrap_or(StatusCode::OK)
};
let position = reader.position() as usize;
let body = reader.into_inner().split_off(position);
Ok((status, headers, body))
}
Ok(output) => {
if let Ok(output) = std::str::from_utf8(&output.stderr) {
tracing::error!("git-http-backend: stderr: {}", output.trim_end());
}
Err(Error::BackendExited(output.status))
}
Err(err) => {
panic!("failed to wait for git-http-backend: {err}");
}
}
}
#[cfg(test)]
mod routes {
use std::collections::HashMap;
use std::net::SocketAddr;
use std::str::FromStr;
use axum::extract::connect_info::MockConnectInfo;
use axum::http::StatusCode;
use radicle::identity::RepoId;
use crate::test::{self, get, RID};
#[tokio::test]
async fn test_info_request() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test::seed(tmp.path());
let app = super::router(ctx.profile().to_owned(), HashMap::new())
.layer(MockConnectInfo(SocketAddr::from(([0, 0, 0, 0], 8080))));
let response = get(&app, format!("/{RID}.git/info/refs")).await;
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_aliases() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test::seed(tmp.path());
let app = super::router(
ctx.profile().to_owned(),
HashMap::from_iter([(String::from("heartwood"), RepoId::from_str(RID).unwrap())]),
)
.layer(MockConnectInfo(SocketAddr::from(([0, 0, 0, 0], 8080))));
let response = get(&app, "/woodheart.git/info/refs").await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let response = get(&app, "/heartwood.git/info/refs").await;
assert_eq!(response.status(), StatusCode::OK);
}
}

View File

@ -1,180 +0,0 @@
#![allow(clippy::type_complexity)]
#![allow(clippy::too_many_arguments)]
#![recursion_limit = "256"]
pub mod commands;
pub mod error;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::num::NonZeroUsize;
use std::process::Command;
use std::str;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Context as _;
use axum::body::{Body, HttpBody};
use axum::http::{Request, Response};
use axum::middleware;
use axum::Router;
use tokio::net::TcpListener;
use tower_http::trace::TraceLayer;
use tracing::Span;
use radicle::identity::RepoId;
use radicle::Profile;
use tracing_extra::{tracing_middleware, ColoredStatus, Paint, RequestId, TracingInfo};
mod api;
mod axum_extra;
mod cache;
mod git;
mod raw;
#[cfg(test)]
mod test;
mod tracing_extra;
/// Default cache HTTP size.
pub const DEFAULT_CACHE_SIZE: NonZeroUsize = unsafe { NonZeroUsize::new_unchecked(100) };
#[derive(Debug, Clone)]
pub struct Options {
pub aliases: HashMap<String, RepoId>,
pub listen: SocketAddr,
pub cache: Option<NonZeroUsize>,
}
/// Run the Server.
pub async fn run(options: Options) -> anyhow::Result<()> {
let git_version = Command::new("git")
.arg("version")
.output()
.context("'git' command must be available")?
.stdout;
tracing::info!("{}", str::from_utf8(&git_version)?.trim());
let listener = TcpListener::bind(options.listen).await?;
tracing::info!("listening on http://{}", options.listen);
let profile = Profile::load()?;
let request_id = RequestId::new();
tracing::info!("using radicle home at {}", profile.home().path().display());
let app =
router(options, profile)?
.layer(middleware::from_fn(tracing_middleware))
.layer(
TraceLayer::new_for_http()
.make_span_with(move |_request: &Request<Body>| {
tracing::info_span!("request", id = %request_id.clone().next())
})
.on_response(
|response: &Response<Body>, latency: Duration, _span: &Span| {
if let Some(info) = response.extensions().get::<TracingInfo>() {
tracing::info!(
"{} \"{} {} {:?}\" {} {:?} {}",
info.connect_info.0,
info.method,
info.uri,
info.version,
ColoredStatus(response.status()),
latency,
Paint::dim(
response
.body()
.size_hint()
.exact()
.map(|n| n.to_string())
.unwrap_or("0".to_string())
.into()
),
);
} else {
tracing::info!("Processed");
}
},
),
)
.into_make_service_with_connect_info::<SocketAddr>();
axum::serve(listener, app)
.await
.map_err(anyhow::Error::from)
}
/// Create a router consisting of other sub-routers.
fn router(options: Options, profile: Profile) -> anyhow::Result<Router> {
let profile = Arc::new(profile);
let ctx = api::Context::new(profile.clone(), &options);
let api_router = api::router(ctx);
let git_router = git::router(profile.clone(), options.aliases);
let raw_router = raw::router(profile);
let app = Router::new()
.merge(git_router)
.nest("/api", api_router)
.nest("/raw", raw_router);
Ok(app)
}
pub mod logger {
use tracing::dispatcher::Dispatch;
pub fn init() -> Result<(), tracing::subscriber::SetGlobalDefaultError> {
tracing::dispatcher::set_global_default(Dispatch::new(subscriber()))
}
#[cfg(feature = "logfmt")]
pub fn subscriber() -> impl tracing::Subscriber {
use tracing_subscriber::layer::SubscriberExt as _;
use tracing_subscriber::EnvFilter;
tracing_subscriber::Registry::default()
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.with(tracing_logfmt::layer())
}
#[cfg(not(feature = "logfmt"))]
pub fn subscriber() -> impl tracing::Subscriber {
tracing_subscriber::FmtSubscriber::builder()
.with_target(false)
.with_max_level(tracing::Level::DEBUG)
.finish()
}
}
#[cfg(test)]
mod routes {
use std::collections::HashMap;
use std::net::SocketAddr;
use axum::extract::connect_info::MockConnectInfo;
use axum::http::StatusCode;
use crate::test::{self, get};
#[tokio::test]
async fn test_invalid_route_returns_404() {
let tmp = tempfile::tempdir().unwrap();
let app = super::router(
super::Options {
aliases: HashMap::new(),
listen: SocketAddr::from(([0, 0, 0, 0], 8080)),
cache: None,
},
test::profile(tmp.path(), [0xff; 32]),
)
.unwrap()
.layer(MockConnectInfo(SocketAddr::from(([0, 0, 0, 0], 8080))));
let response = get(&app, "/aa/a").await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
}

View File

@ -1,62 +0,0 @@
use std::num::NonZeroUsize;
use std::{collections::HashMap, process};
use radicle::prelude::RepoId;
use radicle_httpd as httpd;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let options = parse_options()?;
// SAFETY: The logger is only initialized once.
httpd::logger::init().unwrap();
tracing::info!("version {}-{}", env!("RADICLE_VERSION"), env!("GIT_HEAD"));
match httpd::run(options).await {
Ok(()) => {}
Err(err) => {
tracing::error!("Fatal: {:#}", err);
process::exit(1);
}
}
Ok(())
}
/// Parse command-line arguments into HTTP options.
fn parse_options() -> Result<httpd::Options, lexopt::Error> {
use lexopt::prelude::*;
let mut parser = lexopt::Parser::from_env();
let mut listen = None;
let mut aliases = HashMap::new();
let mut cache = Some(httpd::DEFAULT_CACHE_SIZE);
while let Some(arg) = parser.next()? {
match arg {
Long("listen") => {
let addr = parser.value()?.parse()?;
listen = Some(addr);
}
Long("alias") | Short('a') => {
let alias: String = parser.value()?.parse()?;
let id: RepoId = parser.value()?.parse()?;
aliases.insert(alias, id);
}
Long("cache") => {
let size = parser.value()?.parse()?;
cache = NonZeroUsize::new(size);
}
Long("help") | Short('h') => {
println!("usage: radicle-httpd [--listen <addr>] [--alias <name> <rid>] [--cache <size>]..");
process::exit(0);
}
_ => return Err(arg.unexpected()),
}
}
Ok(httpd::Options {
aliases,
listen: listen.unwrap_or_else(|| ([0, 0, 0, 0], 8080).into()),
cache,
})
}

View File

@ -1,225 +0,0 @@
use std::sync::Arc;
use std::time::Duration;
use axum::extract::{Query, State};
use axum::http::{header, HeaderValue, Method, StatusCode};
use axum::response::IntoResponse;
use axum::routing::get;
use axum::Router;
use hyper::HeaderMap;
use radicle_surf::blob::{Blob, BlobRef};
use tower_http::cors;
use radicle::prelude::RepoId;
use radicle::profile::Profile;
use radicle::storage::{ReadRepository, ReadStorage};
use radicle_surf::{Oid, Repository};
use crate::api::RawQuery;
use crate::axum_extra::Path;
use crate::error::RawError as Error;
const MAX_BLOB_SIZE: usize = 4_194_304;
static MIMES: &[(&str, &str)] = &[
("3gp", "video/3gpp"),
("7z", "application/x-7z-compressed"),
("aac", "audio/aac"),
("avi", "video/x-msvideo"),
("bin", "application/octet-stream"),
("bmp", "image/bmp"),
("bz", "application/x-bzip"),
("bz2", "application/x-bzip2"),
("csh", "application/x-csh"),
("css", "text/css"),
("csv", "text/csv"),
("doc", "application/msword"),
(
"docx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
),
("epub", "application/epub+zip"),
("gz", "application/gzip"),
("gif", "image/gif"),
("htm", "text/html"),
("html", "text/html"),
("ico", "image/vnd.microsoft.icon"),
("jar", "application/java-archive"),
("jpeg", "image/jpeg"),
("jpg", "image/jpeg"),
("js", "text/javascript"),
("json", "application/json"),
("mjs", "text/javascript"),
("mp3", "audio/mpeg"),
("mp4", "video/mp4"),
("mpeg", "video/mpeg"),
("odp", "application/vnd.oasis.opendocument.presentation"),
("ods", "application/vnd.oasis.opendocument.spreadsheet"),
("odt", "application/vnd.oasis.opendocument.text"),
("oga", "audio/ogg"),
("ogv", "video/ogg"),
("ogx", "application/ogg"),
("otf", "font/otf"),
("png", "image/png"),
("pdf", "application/pdf"),
("php", "application/x-httpd-php"),
("ppt", "application/vnd.ms-powerpoint"),
(
"pptx",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
),
("rar", "application/vnd.rar"),
("rtf", "application/rtf"),
("sh", "application/x-sh"),
("svg", "image/svg+xml"),
("tar", "application/x-tar"),
("tif", "image/tiff"),
("tiff", "image/tiff"),
("ttf", "font/ttf"),
("txt", "text/plain"),
("wav", "audio/wav"),
("weba", "audio/webm"),
("webm", "video/webm"),
("webp", "image/webp"),
("woff", "font/woff"),
("woff2", "font/woff2"),
("xhtml", "application/xhtml+xml"),
("xls", "application/vnd.ms-excel"),
(
"xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
),
("xml", "application/xml"),
("zip", "application/zip"),
];
pub fn router(profile: Arc<Profile>) -> Router {
Router::new()
.route("/:rid/:sha/*path", get(file_by_commit_handler))
.route("/:rid/head/*path", get(file_by_canonical_head_handler))
.route("/:rid/blobs/:oid", get(file_by_oid_handler))
.with_state(profile)
.layer(
cors::CorsLayer::new()
.max_age(Duration::from_secs(86400))
.allow_origin(cors::Any)
.allow_methods([Method::GET])
.allow_headers([header::CONTENT_TYPE]),
)
}
async fn file_by_commit_handler(
Path((rid, sha, path)): Path<(RepoId, Oid, String)>,
State(profile): State<Arc<Profile>>,
) -> impl IntoResponse {
let storage = &profile.storage;
let repo = storage.repository(rid)?;
// Don't allow downloading raw files for private repos.
if repo.identity_doc()?.visibility.is_private() {
return Err(Error::NotFound);
}
let repo: Repository = repo.backend.into();
let blob = repo.blob(sha, &path)?;
blob_response(blob, path)
}
async fn file_by_canonical_head_handler(
Path((rid, path)): Path<(RepoId, String)>,
State(profile): State<Arc<Profile>>,
) -> impl IntoResponse {
let storage = &profile.storage;
let repo = storage.repository(rid)?;
// Don't allow downloading raw files for private repos.
if repo.identity_doc()?.visibility.is_private() {
return Err(Error::NotFound);
}
let (_, sha) = repo.head()?;
let repo: Repository = repo.backend.into();
let blob = repo.blob(sha, &path)?;
blob_response(blob, path)
}
fn blob_response(
blob: Blob<BlobRef>,
path: String,
) -> Result<(StatusCode, HeaderMap, Vec<u8>), Error> {
let mut response_headers = HeaderMap::new();
if blob.size() > MAX_BLOB_SIZE {
return Ok::<_, Error>((StatusCode::PAYLOAD_TOO_LARGE, response_headers, vec![]));
}
let mime = if let Some(ext) = path.split('.').last() {
MIMES
.binary_search_by(|(k, _)| k.cmp(&ext))
.map(|k| MIMES[k].1)
.unwrap_or("text; charset=utf-8")
} else {
"application/octet-stream"
};
response_headers.insert(header::CONTENT_TYPE, HeaderValue::from_str(mime)?);
Ok::<_, Error>((StatusCode::OK, response_headers, blob.content().to_owned()))
}
async fn file_by_oid_handler(
Path((rid, oid)): Path<(RepoId, Oid)>,
State(profile): State<Arc<Profile>>,
Query(qs): Query<RawQuery>,
) -> impl IntoResponse {
let storage = &profile.storage;
let repo = storage.repository(rid)?;
// Don't allow downloading raw files for private repos.
if repo.identity_doc()?.visibility.is_private() {
return Err(Error::NotFound);
}
let blob = repo.blob(oid)?;
let mut response_headers = HeaderMap::new();
if blob.size() > MAX_BLOB_SIZE {
return Ok::<_, Error>((StatusCode::PAYLOAD_TOO_LARGE, response_headers, vec![]));
}
response_headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_str(&qs.mime.unwrap_or("application/octet-stream".to_string()))?,
);
Ok::<_, Error>((StatusCode::OK, response_headers, blob.content().to_vec()))
}
#[cfg(test)]
mod routes {
use axum::http::StatusCode;
use crate::test::{self, get, RID, RID_PRIVATE};
use radicle::storage::ReadStorage;
#[tokio::test]
async fn test_file_handler() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test::seed(tmp.path());
let app = super::router(ctx.profile().to_owned());
let response = get(&app, format!("/{RID}/head/dir1/README")).await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.body().await, "Hello World from dir1!\n");
// Make sure the repo exists in storage.
ctx.profile()
.storage
.repository(RID_PRIVATE.parse().unwrap())
.unwrap();
let response = get(&app, format!("/{RID_PRIVATE}/head/README")).await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
}

View File

@ -1,395 +0,0 @@
use std::collections::BTreeSet;
use std::fs;
use std::path::Path;
use std::str::FromStr;
use std::sync::Arc;
use axum::body::{Body, Bytes};
use axum::http::{Method, Request};
use axum::Router;
use serde_json::Value;
use time::OffsetDateTime;
use tower::ServiceExt;
use radicle::cob::patch::MergeTarget;
use radicle::crypto::ssh::keystore::MemorySigner;
use radicle::crypto::ssh::Keystore;
use radicle::crypto::{KeyPair, Seed, Signer};
use radicle::git::{raw as git2, RefString};
use radicle::identity::Visibility;
use radicle::profile::{env, Home};
use radicle::storage::ReadStorage;
use radicle::Storage;
use radicle::{node, profile};
use radicle_crypto::test::signer::MockSigner;
use crate::api::{auth, Context};
pub const RID: &str = "rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp";
pub const RID_PRIVATE: &str = "rad:zLuTzcmoWMcdK37xqArS8eckp9vK";
pub const HEAD: &str = "e8c676b9e3b42308dc9d218b70faa5408f8e58ca";
pub const PARENT: &str = "ee8d6a29304623a78ebfa5eeed5af674d0e58f83";
pub const INITIAL_COMMIT: &str = "f604ce9fd5b7cc77b7609beda45ea8760bee78f7";
pub const DID: &str = "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi";
pub const ISSUE_ID: &str = "ca67d195c0b308b51810dedd93157a20764d5db5";
pub const ISSUE_DISCUSSION_ID: &str = "41e2823caa54f1d53e375035ed4aabd0a89fa855";
pub const ISSUE_COMMENT_ID: &str = "e9f963fab82ad875e46b29a327c5d3d51f825cdc";
pub const SESSION_ID: &str = "u9MGAkkfkMOv0uDDB2WeUHBT7HbsO2Dy";
pub const TIMESTAMP: u64 = 1671125284;
pub const CONTRIBUTOR_RID: &str = "rad:z4XaCmN3jLSeiMvW15YTDpNbDHFhG";
pub const CONTRIBUTOR_DID: &str = "did:key:z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8";
pub const CONTRIBUTOR_ALIAS: &str = "seed";
pub const CONTRIBUTOR_PATCH_ID: &str = "3e3f0dc34b3eeb64cfbc7218fbd52b97246e0564";
/// Create a new profile.
pub fn profile(home: &Path, seed: [u8; 32]) -> radicle::Profile {
let home = Home::new(home).unwrap();
let keystore = Keystore::new(&home.keys());
let keypair = KeyPair::from_seed(Seed::from(seed));
let alias = node::Alias::new("seed");
let storage = Storage::open(
home.storage(),
radicle::git::UserInfo {
alias: alias.clone(),
key: keypair.pk.into(),
},
)
.unwrap();
let mut db = home.policies_mut().unwrap();
db.follow(&keypair.pk.into(), Some(&alias)).unwrap();
radicle::storage::git::transport::local::register(storage.clone());
keystore.store(keypair.clone(), "radicle", None).unwrap();
radicle::Profile {
home,
storage,
keystore,
public_key: keypair.pk.into(),
config: profile::Config::new(alias),
}
}
pub fn seed(dir: &Path) -> Context {
let home = dir.join("radicle");
let profile = profile(home.as_path(), [0xff; 32]);
let signer = Box::new(MockSigner::from_seed([0xff; 32]));
crate::logger::init().ok();
seed_with_signer(dir, profile, &signer)
}
pub fn contributor(dir: &Path) -> Context {
let mut seed = [0xff; 32];
*seed.last_mut().unwrap() = 0xee;
let home = dir.join("radicle");
let profile = profile(home.as_path(), seed);
let signer = MemorySigner::load(&profile.keystore, None).unwrap();
seed_with_signer(dir, profile, &signer)
}
fn seed_with_signer<G: Signer>(dir: &Path, profile: radicle::Profile, signer: &G) -> Context {
const DEFAULT_BRANCH: &str = "master";
crate::logger::init().ok();
profile.policies_mut().unwrap();
profile.database_mut().unwrap(); // Create the database.
let mut policies = profile.policies_mut().unwrap();
let workdir = dir.join("hello-world-private");
fs::create_dir_all(&workdir).unwrap();
// add commits to workdir (repo)
let mut opts = git2::RepositoryInitOptions::new();
opts.initial_head(DEFAULT_BRANCH);
let repo = git2::Repository::init_opts(&workdir, &opts).unwrap();
let tree = radicle::git::write_tree(
Path::new("README"),
"Hello Private World!\n".as_bytes(),
&repo,
)
.unwrap();
let sig_time = git2::Time::new(1673001014, 0);
let sig = git2::Signature::new("Alice Liddell", "alice@radicle.xyz", &sig_time).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "Initial commit\n", &tree, &[])
.unwrap();
// rad init
let repo = git2::Repository::open(&workdir).unwrap();
let name = "hello-world-private".to_string();
let description = "Private Rad repository for tests".to_string();
let branch = RefString::try_from(DEFAULT_BRANCH).unwrap();
let visibility = Visibility::Private {
allow: BTreeSet::default(),
};
let (rid, _, _) = radicle::rad::init(
&repo,
&name,
&description,
branch,
visibility,
signer,
&profile.storage,
)
.unwrap();
policies.seed(&rid, node::policy::Scope::All).unwrap();
let workdir = dir.join("hello-world");
env::set_var(env::GIT_COMMITTER_DATE, TIMESTAMP.to_string());
fs::create_dir_all(&workdir).unwrap();
// add commits to workdir (repo)
let mut opts = git2::RepositoryInitOptions::new();
opts.initial_head(DEFAULT_BRANCH);
let repo = git2::Repository::init_opts(&workdir, &opts).unwrap();
let tree =
radicle::git::write_tree(Path::new("README"), "Hello World!\n".as_bytes(), &repo).unwrap();
let sig_time = git2::Time::new(1673001014, 0);
let sig = git2::Signature::new("Alice Liddell", "alice@radicle.xyz", &sig_time).unwrap();
let oid = repo
.commit(Some("HEAD"), &sig, &sig, "Initial commit\n", &tree, &[])
.unwrap();
let commit = repo.find_commit(oid).unwrap();
repo.checkout_tree(tree.as_object(), None).unwrap();
let tree = radicle::git::write_tree(
Path::new("CONTRIBUTING"),
"Thank you very much!\n".as_bytes(),
&repo,
)
.unwrap();
let sig_time = git2::Time::new(1673002014, 0);
let sig = git2::Signature::new("Alice Liddell", "alice@radicle.xyz", &sig_time).unwrap();
let oid2 = repo
.commit(
Some("HEAD"),
&sig,
&sig,
"Add contributing file\n",
&tree,
&[&commit],
)
.unwrap();
let commit2 = repo.find_commit(oid2).unwrap();
repo.checkout_tree(tree.as_object(), None).unwrap();
fs::create_dir(workdir.join("dir1")).unwrap();
fs::write(
workdir.join("dir1").join("README"),
"Hello World from dir1!\n",
)
.unwrap();
let mut index = repo.index().unwrap();
index
.add_all(["."], git2::IndexAddOption::DEFAULT, None)
.unwrap();
index.write().unwrap();
let oid = index.write_tree().unwrap();
let tree = repo.find_tree(oid).unwrap();
let sig_time = git2::Time::new(1673003014, 0);
let sig = git2::Signature::new("Alice Liddell", "alice@radicle.xyz", &sig_time).unwrap();
repo.commit(
Some("HEAD"),
&sig,
&sig,
"Add another folder\n",
&tree,
&[&commit2],
)
.unwrap();
// rad init
let repo = git2::Repository::open(&workdir).unwrap();
let name = "hello-world".to_string();
let description = "Rad repository for tests".to_string();
let branch = RefString::try_from(DEFAULT_BRANCH).unwrap();
let visibility = Visibility::default();
let (rid, _, _) = radicle::rad::init(
&repo,
&name,
&description,
branch,
visibility,
signer,
&profile.storage,
)
.unwrap();
policies.seed(&rid, node::policy::Scope::All).unwrap();
let storage = &profile.storage;
let repo = storage.repository(rid).unwrap();
let mut issues = profile.issues_mut(&repo).unwrap();
let issue = issues
.create(
"Issue #1".to_string(),
"Change 'hello world' to 'hello everyone'".to_string(),
&[],
&[],
[],
signer,
)
.unwrap();
tracing::debug!(target: "test", "Contributor issue: {}", issue.id());
// eq. rad patch open
let mut patches = profile.patches_mut(&repo).unwrap();
let oid = radicle::git::Oid::from_str(HEAD).unwrap();
let base = radicle::git::Oid::from_str(PARENT).unwrap();
let patch = patches
.create(
"A new `hello world`",
"change `hello world` in README to something else",
MergeTarget::Delegates,
base,
oid,
&[],
signer,
)
.unwrap();
tracing::debug!(target: "test", "Contributor patch: {}", patch.id());
let options = crate::Options {
aliases: std::collections::HashMap::new(),
listen: std::net::SocketAddr::from(([0, 0, 0, 0], 8080)),
cache: Some(crate::DEFAULT_CACHE_SIZE),
};
Context::new(Arc::new(profile), &options)
}
/// Adds an authorized session to the Context::sessions HashMap.
pub async fn create_session(ctx: Context) {
let issued_at = OffsetDateTime::now_utc();
let mut sessions = ctx.sessions().write().await;
sessions.insert(
String::from(SESSION_ID),
auth::Session {
status: auth::AuthState::Authorized,
public_key: ctx.profile().public_key,
alias: ctx.profile().config.node.alias.clone(),
issued_at,
expires_at: issued_at
.checked_add(auth::AUTHORIZED_SESSIONS_EXPIRATION)
.unwrap(),
},
);
}
pub async fn get(app: &Router, path: impl ToString) -> Response {
Response(
app.clone()
.oneshot(request(path, Method::GET, None, None))
.await
.unwrap(),
)
}
pub async fn post(
app: &Router,
path: impl ToString,
body: Option<Body>,
auth: Option<String>,
) -> Response {
Response(
app.clone()
.oneshot(request(path, Method::POST, body, auth))
.await
.unwrap(),
)
}
pub async fn patch(
app: &Router,
path: impl ToString,
body: Option<Body>,
auth: Option<String>,
) -> Response {
Response(
app.clone()
.oneshot(request(path, Method::PATCH, body, auth))
.await
.unwrap(),
)
}
pub async fn put(
app: &Router,
path: impl ToString,
body: Option<Body>,
auth: Option<String>,
) -> Response {
Response(
app.clone()
.oneshot(request(path, Method::PUT, body, auth))
.await
.unwrap(),
)
}
fn request(
path: impl ToString,
method: Method,
body: Option<Body>,
auth: Option<String>,
) -> Request<Body> {
let mut request = Request::builder()
.method(method)
.uri(path.to_string())
.header("Content-Type", "application/json");
if let Some(token) = auth {
request = request.header("Authorization", format!("Bearer {token}"));
}
request.body(body.unwrap_or_else(Body::empty)).unwrap()
}
#[derive(Debug)]
pub struct Response(axum::response::Response);
impl Response {
pub async fn json(self) -> Value {
let body = self.body().await;
serde_json::from_slice(&body).unwrap()
}
pub async fn id(self) -> radicle::git::Oid {
let json = self.json().await;
let string = json["id"].as_str().unwrap();
radicle::git::Oid::from_str(string).unwrap()
}
pub async fn success(self) -> bool {
let json = self.json().await;
let success = json["success"].as_bool();
success.unwrap_or(false)
}
pub fn status(&self) -> axum::http::StatusCode {
self.0.status()
}
pub async fn body(self) -> Bytes {
axum::body::to_bytes(self.0.into_body(), usize::MAX)
.await
.unwrap()
}
}

View File

@ -1,70 +0,0 @@
use std::fmt;
use std::net::SocketAddr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use axum::body::Body;
use axum::extract::ConnectInfo;
use axum::http::Request;
use axum::middleware::Next;
use axum::response::IntoResponse;
use axum::Extension;
use hyper::{Method, StatusCode, Uri, Version};
pub use radicle_term::ansi::Paint;
#[derive(Clone)]
pub struct RequestId(Arc<AtomicU64>);
impl RequestId {
pub fn new() -> RequestId {
RequestId(Arc::new(0.into()))
}
pub fn next(&mut self) -> u64 {
self.0.fetch_add(1, Ordering::SeqCst)
}
}
#[derive(Clone)]
pub struct TracingInfo {
pub connect_info: ConnectInfo<SocketAddr>,
pub method: Method,
pub version: Version,
pub uri: Uri,
}
pub struct ColoredStatus(pub StatusCode);
impl fmt::Display for ColoredStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.0.as_u16() {
200..=299 => write!(f, "{}", Paint::green(self.0)),
300..=399 => write!(f, "{}", Paint::blue(self.0)),
400..=499 => write!(f, "{}", Paint::red(self.0)),
_ => write!(f, "{}", Paint::yellow(self.0)),
}
}
}
pub async fn tracing_middleware(request: Request<Body>, next: Next) -> impl IntoResponse {
let connect_info = *request
.extensions()
.get::<ConnectInfo<std::net::SocketAddr>>()
.unwrap();
let method = request.method().clone();
let version = request.version();
let uri = request.uri().clone();
let tracing_info = TracingInfo {
connect_info,
method,
version,
uri,
};
let response = next.run(request).await;
(Extension(tracing_info), response)
}

View File

@ -1,23 +0,0 @@
# Example systemd unit file for `radicle-httpd`.
#
# When running radicle-httpd on a server, it should be run as a separate user.
#
# Copy this file into /etc/systemd/system and set the User/Group parameters
# under [Service] appropriately, as well as the `RAD_HOME` environment variable.
#
[Unit]
Description=Radicle HTTP Daemon
After=network.target network-online.target
Requires=network-online.target
[Service]
User=seed
Group=seed
ExecStart=/usr/local/bin/radicle-httpd --listen 127.0.0.1:8080
Environment=RAD_HOME=/home/seed/.radicle RUST_BACKTRACE=1 RUST_LOG=info
KillMode=process
Restart=always
RestartSec=1
[Install]
WantedBy=multi-user.target