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-crypto",
|
||||||
"radicle-dag",
|
"radicle-dag",
|
||||||
"radicle-fetch",
|
"radicle-fetch",
|
||||||
"radicle-httpd",
|
|
||||||
"radicle-node",
|
"radicle-node",
|
||||||
"radicle-remote-helper",
|
"radicle-remote-helper",
|
||||||
"radicle-ssh",
|
"radicle-ssh",
|
||||||
|
|
|
||||||
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-crdt`: Conflict-free replicated datatypes (CRDTs) used for things like discussions and patches.
|
||||||
* `radicle-crypto`: A wrapper around Ed25519 cryptographic signing primitives.
|
* `radicle-crypto`: A wrapper around Ed25519 cryptographic signing primitives.
|
||||||
* `radicle-dag`: A simple directed acyclic graph implementation used by `radicle-cob`.
|
* `radicle-dag`: A simple directed acyclic graph implementation used by `radicle-cob`.
|
||||||
* `radicle-httpd`: The radicle HTTP daemon that serves API clients and Git fetch requests.
|
|
||||||
* `radicle-node`: The radicle peer-to-peer daemon that enables users to connect to the network and share code.
|
* `radicle-node`: The radicle peer-to-peer daemon that enables users to connect to the network and share code.
|
||||||
* `radicle-remote-helper`: A Git remote helper for `rad://` remotes.
|
* `radicle-remote-helper`: A Git remote helper for `rad://` remotes.
|
||||||
* `radicle-ssh`: OpenSSH functionality, including a library used to interface with `ssh-agent`.
|
* `radicle-ssh`: OpenSSH functionality, including a library used to interface with `ssh-agent`.
|
||||||
|
|
@ -41,11 +40,6 @@ For example, the equivalent of `rad auth` in debug mode would be:
|
||||||
|
|
||||||
Arguments after the `--` are passed directly to the `rad` executable.
|
Arguments after the `--` are passed directly to the `rad` executable.
|
||||||
|
|
||||||
When running the radicle node, you may specify an alternate port for the `git-daemon`
|
|
||||||
like so:
|
|
||||||
|
|
||||||
$ cargo run -p radicle-node -- --git-daemon 127.0.0.1:9876
|
|
||||||
|
|
||||||
This is useful if you are running multiple nodes on the same machine. You can also
|
This is useful if you are running multiple nodes on the same machine. You can also
|
||||||
specify different listen addresses for the peer-to-peer protocol using `--listen`.
|
specify different listen addresses for the peer-to-peer protocol using `--listen`.
|
||||||
To view all options, run `cargo run -p radicle-node -- --help`.
|
To view all options, run `cargo run -p radicle-node -- --help`.
|
||||||
|
|
@ -82,8 +76,8 @@ avoid storing development keys with `ssh-agent`.
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
Logging for `radicle-node` and `radicle-httpd` is turned on by default. Check
|
Logging for `radicle-node` is turned on by default. Check the respective
|
||||||
the respective `--help` output to set the log level.
|
`--help` output to set the log level.
|
||||||
|
|
||||||
## Writing tests
|
## Writing tests
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,9 +51,8 @@ Or directly from our seed node:
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
*Systemd* unit files are provided for the node and HTTP daemon under the
|
*Systemd* unit files are provided for the node under the `/systemd` folder.
|
||||||
`/systemd` folder. They can be used as a starting point for further
|
They can be used as a starting point for further customization.
|
||||||
customization.
|
|
||||||
|
|
||||||
For running in debug mode, see [HACKING.md](HACKING.md).
|
For running in debug mode, see [HACKING.md](HACKING.md).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,6 @@ RUN cargo zigbuild --locked --release \
|
||||||
--target=aarch64-unknown-linux-musl \
|
--target=aarch64-unknown-linux-musl \
|
||||||
--target=x86_64-unknown-linux-musl \
|
--target=x86_64-unknown-linux-musl \
|
||||||
-p radicle-node \
|
-p radicle-node \
|
||||||
-p radicle-httpd \
|
|
||||||
-p radicle-remote-helper \
|
-p radicle-remote-helper \
|
||||||
-p radicle-cli
|
-p radicle-cli
|
||||||
|
|
||||||
|
|
@ -57,28 +56,24 @@ COPY --from=builder \
|
||||||
/src/target/x86_64-unknown-linux-musl/release/rad-web \
|
/src/target/x86_64-unknown-linux-musl/release/rad-web \
|
||||||
/src/target/x86_64-unknown-linux-musl/release/git-remote-rad \
|
/src/target/x86_64-unknown-linux-musl/release/git-remote-rad \
|
||||||
/src/target/x86_64-unknown-linux-musl/release/radicle-node \
|
/src/target/x86_64-unknown-linux-musl/release/radicle-node \
|
||||||
/src/target/x86_64-unknown-linux-musl/release/radicle-httpd \
|
|
||||||
/builds/x86_64-unknown-linux-musl/bin/
|
/builds/x86_64-unknown-linux-musl/bin/
|
||||||
COPY --from=builder \
|
COPY --from=builder \
|
||||||
/src/target/aarch64-unknown-linux-musl/release/rad \
|
/src/target/aarch64-unknown-linux-musl/release/rad \
|
||||||
/src/target/aarch64-unknown-linux-musl/release/rad-web \
|
/src/target/aarch64-unknown-linux-musl/release/rad-web \
|
||||||
/src/target/aarch64-unknown-linux-musl/release/git-remote-rad \
|
/src/target/aarch64-unknown-linux-musl/release/git-remote-rad \
|
||||||
/src/target/aarch64-unknown-linux-musl/release/radicle-node \
|
/src/target/aarch64-unknown-linux-musl/release/radicle-node \
|
||||||
/src/target/aarch64-unknown-linux-musl/release/radicle-httpd \
|
|
||||||
/builds/aarch64-unknown-linux-musl/bin/
|
/builds/aarch64-unknown-linux-musl/bin/
|
||||||
COPY --from=builder \
|
COPY --from=builder \
|
||||||
/src/target/aarch64-apple-darwin/release/rad \
|
/src/target/aarch64-apple-darwin/release/rad \
|
||||||
/src/target/aarch64-apple-darwin/release/rad-web \
|
/src/target/aarch64-apple-darwin/release/rad-web \
|
||||||
/src/target/aarch64-apple-darwin/release/git-remote-rad \
|
/src/target/aarch64-apple-darwin/release/git-remote-rad \
|
||||||
/src/target/aarch64-apple-darwin/release/radicle-node \
|
/src/target/aarch64-apple-darwin/release/radicle-node \
|
||||||
/src/target/aarch64-apple-darwin/release/radicle-httpd \
|
|
||||||
/builds/aarch64-apple-darwin/bin/
|
/builds/aarch64-apple-darwin/bin/
|
||||||
COPY --from=builder \
|
COPY --from=builder \
|
||||||
/src/target/x86_64-apple-darwin/release/rad \
|
/src/target/x86_64-apple-darwin/release/rad \
|
||||||
/src/target/x86_64-apple-darwin/release/rad-web \
|
/src/target/x86_64-apple-darwin/release/rad-web \
|
||||||
/src/target/x86_64-apple-darwin/release/git-remote-rad \
|
/src/target/x86_64-apple-darwin/release/git-remote-rad \
|
||||||
/src/target/x86_64-apple-darwin/release/radicle-node \
|
/src/target/x86_64-apple-darwin/release/radicle-node \
|
||||||
/src/target/x86_64-apple-darwin/release/radicle-httpd \
|
|
||||||
/builds/x86_64-apple-darwin/bin/
|
/builds/x86_64-apple-darwin/bin/
|
||||||
COPY --from=builder /src/*.1 /builds/x86_64-unknown-linux-musl/man/man1/
|
COPY --from=builder /src/*.1 /builds/x86_64-unknown-linux-musl/man/man1/
|
||||||
COPY --from=builder /src/*.1 /builds/aarch64-unknown-linux-musl/man/man1/
|
COPY --from=builder /src/*.1 /builds/aarch64-unknown-linux-musl/man/man1/
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ override_dh_auto_install:
|
||||||
cargo install --locked --path=radicle-cli --root=debian/radicle
|
cargo install --locked --path=radicle-cli --root=debian/radicle
|
||||||
cargo install --locked --path=radicle-node --root=debian/radicle
|
cargo install --locked --path=radicle-node --root=debian/radicle
|
||||||
cargo install --locked --path=radicle-remote-helper --root=debian/radicle
|
cargo install --locked --path=radicle-remote-helper --root=debian/radicle
|
||||||
cargo install --locked --path=radicle-httpd --root=debian/radicle
|
|
||||||
rm -f debian/*/.crates*.*
|
rm -f debian/*/.crates*.*
|
||||||
|
|
||||||
override_dh_auto_test:
|
override_dh_auto_test:
|
||||||
|
|
|
||||||
|
|
@ -170,10 +170,6 @@
|
||||||
crates = builtins.listToAttrs (map
|
crates = builtins.listToAttrs (map
|
||||||
({name, ...} @ package: lib.nameValuePair name (crate package))
|
({name, ...} @ package: lib.nameValuePair name (crate package))
|
||||||
[
|
[
|
||||||
{
|
|
||||||
name = "radicle-httpd";
|
|
||||||
pages = ["radicle-httpd.1.adoc"];
|
|
||||||
}
|
|
||||||
{
|
{
|
||||||
name = "radicle-cli";
|
name = "radicle-cli";
|
||||||
pages = [
|
pages = [
|
||||||
|
|
@ -233,11 +229,6 @@
|
||||||
drv = self.packages.${system}.radicle-node;
|
drv = self.packages.${system}.radicle-node;
|
||||||
};
|
};
|
||||||
|
|
||||||
apps.radicle-httpd = flake-utils.lib.mkApp {
|
|
||||||
name = "radicle-httpd";
|
|
||||||
drv = self.packages.${system}.radicle-httpd;
|
|
||||||
};
|
|
||||||
|
|
||||||
devShells.default = craneLib.devShell {
|
devShells.default = craneLib.devShell {
|
||||||
# Extra inputs can be added here; cargo and rustc are provided by default.
|
# Extra inputs can be added here; cargo and rustc are provided by default.
|
||||||
packages = with pkgs; [
|
packages = with pkgs; [
|
||||||
|
|
|
||||||
|
|
@ -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