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:
parent
ab532be033
commit
a4989b7ce7
File diff suppressed because it is too large
Load Diff
|
|
@ -9,7 +9,6 @@ members = [
|
|||
"radicle-crypto",
|
||||
"radicle-dag",
|
||||
"radicle-fetch",
|
||||
"radicle-httpd",
|
||||
"radicle-node",
|
||||
"radicle-remote-helper",
|
||||
"radicle-ssh",
|
||||
|
|
|
|||
10
HACKING.md
10
HACKING.md
|
|
@ -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-crypto`: A wrapper around Ed25519 cryptographic signing primitives.
|
||||
* `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-remote-helper`: A Git remote helper for `rad://` remotes.
|
||||
* `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.
|
||||
|
||||
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
|
||||
specify different listen addresses for the peer-to-peer protocol using `--listen`.
|
||||
To view all options, run `cargo run -p radicle-node -- --help`.
|
||||
|
|
@ -82,8 +76,8 @@ avoid storing development keys with `ssh-agent`.
|
|||
|
||||
## Logging
|
||||
|
||||
Logging for `radicle-node` and `radicle-httpd` is turned on by default. Check
|
||||
the respective `--help` output to set the log level.
|
||||
Logging for `radicle-node` is turned on by default. Check the respective
|
||||
`--help` output to set the log level.
|
||||
|
||||
## Writing tests
|
||||
|
||||
|
|
|
|||
|
|
@ -51,9 +51,8 @@ Or directly from our seed node:
|
|||
|
||||
## Running
|
||||
|
||||
*Systemd* unit files are provided for the node and HTTP daemon under the
|
||||
`/systemd` folder. They can be used as a starting point for further
|
||||
customization.
|
||||
*Systemd* unit files are provided for the node under the `/systemd` folder.
|
||||
They can be used as a starting point for further customization.
|
||||
|
||||
For running in debug mode, see [HACKING.md](HACKING.md).
|
||||
|
||||
|
|
|
|||
|
|
@ -45,7 +45,6 @@ RUN cargo zigbuild --locked --release \
|
|||
--target=aarch64-unknown-linux-musl \
|
||||
--target=x86_64-unknown-linux-musl \
|
||||
-p radicle-node \
|
||||
-p radicle-httpd \
|
||||
-p radicle-remote-helper \
|
||||
-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/git-remote-rad \
|
||||
/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/
|
||||
COPY --from=builder \
|
||||
/src/target/aarch64-unknown-linux-musl/release/rad \
|
||||
/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/radicle-node \
|
||||
/src/target/aarch64-unknown-linux-musl/release/radicle-httpd \
|
||||
/builds/aarch64-unknown-linux-musl/bin/
|
||||
COPY --from=builder \
|
||||
/src/target/aarch64-apple-darwin/release/rad \
|
||||
/src/target/aarch64-apple-darwin/release/rad-web \
|
||||
/src/target/aarch64-apple-darwin/release/git-remote-rad \
|
||||
/src/target/aarch64-apple-darwin/release/radicle-node \
|
||||
/src/target/aarch64-apple-darwin/release/radicle-httpd \
|
||||
/builds/aarch64-apple-darwin/bin/
|
||||
COPY --from=builder \
|
||||
/src/target/x86_64-apple-darwin/release/rad \
|
||||
/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/radicle-node \
|
||||
/src/target/x86_64-apple-darwin/release/radicle-httpd \
|
||||
/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/aarch64-unknown-linux-musl/man/man1/
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ override_dh_auto_install:
|
|||
cargo install --locked --path=radicle-cli --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-httpd --root=debian/radicle
|
||||
rm -f debian/*/.crates*.*
|
||||
|
||||
override_dh_auto_test:
|
||||
|
|
|
|||
|
|
@ -170,10 +170,6 @@
|
|||
crates = builtins.listToAttrs (map
|
||||
({name, ...} @ package: lib.nameValuePair name (crate package))
|
||||
[
|
||||
{
|
||||
name = "radicle-httpd";
|
||||
pages = ["radicle-httpd.1.adoc"];
|
||||
}
|
||||
{
|
||||
name = "radicle-cli";
|
||||
pages = [
|
||||
|
|
@ -233,11 +229,6 @@
|
|||
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 {
|
||||
# Extra inputs can be added here; cargo and rustc are provided by default.
|
||||
packages = with pkgs; [
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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"] }
|
||||
|
|
@ -1 +0,0 @@
|
|||
../build.rs
|
||||
|
|
@ -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()),
|
||||
}
|
||||
}
|
||||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }))))
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 } }));
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
@ -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))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
//! Extra CLI commands relating to HTTPd.
|
||||
pub mod web;
|
||||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue