🏡 index : ~doyle/chartered.git

author Jordan Doyle <jordan@doyle.la> 2021-10-21 2:14:31.0 +01:00:00
committer Jordan Doyle <jordan@doyle.la> 2021-10-21 22:09:00.0 +01:00:00
commit
f559c415698411451ecb5ade58a67bf298b7eb6a [patch]
tree
f39d0a574f54aa453928c10967cb2b3903f9fb45
parent
c65f07f39d02d622cf558a607b76f71d3b9bbae3
download
f559c415698411451ecb5ade58a67bf298b7eb6a.tar.gz

Keep request handlers top of each file



Diff

 chartered-web/src/main.rs                                      |   5 +++++
 chartered-web/src/middleware/auth.rs                           |  13 ++++++++++++-
 chartered-web/src/middleware/logging.rs                        |   2 ++
 chartered-web/src/endpoints/cargo_api/download.rs              |  96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
 chartered-web/src/endpoints/cargo_api/mod.rs                   |   9 +++++++++
 chartered-web/src/endpoints/cargo_api/owners.rs                |  64 +++++++++++++++++++++++++++++++++++++---------------------------
 chartered-web/src/endpoints/cargo_api/publish.rs               | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
 chartered-web/src/endpoints/cargo_api/yank.rs                  |  47 +++++++++++++++++++++++++++--------------------
 chartered-web/src/endpoints/web_api/ssh_key.rs                 |  41 ++++++++++++++++++++++-------------------
 chartered-web/src/endpoints/web_api/auth/logout.rs             |   3 +++
 chartered-web/src/endpoints/web_api/auth/mod.rs                |   2 ++
 chartered-web/src/endpoints/web_api/auth/openid.rs             |  75 +++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
 chartered-web/src/endpoints/web_api/auth/password.rs           |  44 ++++++++++++++++++++++++--------------------
 chartered-web/src/endpoints/web_api/crates/info.rs             |  50 ++++++++++++++++++++++++++++++--------------------
 chartered-web/src/endpoints/web_api/crates/members.rs          |  60 +++++++++++++++++++++++++++++++++++++-----------------------
 chartered-web/src/endpoints/web_api/crates/most_downloaded.rs  |  34 ++++++++++++++++++----------------
 chartered-web/src/endpoints/web_api/crates/recently_created.rs |  35 +++++++++++++++++++----------------
 chartered-web/src/endpoints/web_api/crates/recently_updated.rs |  34 ++++++++++++++++++----------------
 chartered-web/src/endpoints/web_api/crates/search.rs           |  46 ++++++++++++++++++++++++++--------------------
 chartered-web/src/endpoints/web_api/organisations/crud.rs      |  37 ++++++++++++++++++++-----------------
 chartered-web/src/endpoints/web_api/organisations/info.rs      |  40 ++++++++++++++++++++++++----------------
 chartered-web/src/endpoints/web_api/organisations/list.rs      |  35 +++++++++++++++++++----------------
 chartered-web/src/endpoints/web_api/organisations/members.rs   |  28 +++++++++++++++++-----------
 chartered-web/src/endpoints/web_api/users/info.rs              |  23 ++++++++++++++---------
 chartered-web/src/endpoints/web_api/users/search.rs            |  40 +++++++++++++++++++++++-----------------
 25 files changed, 585 insertions(+), 419 deletions(-)

diff --git a/chartered-web/src/main.rs b/chartered-web/src/main.rs
index 7ab6c49..9b35ccf 100644
--- a/chartered-web/src/main.rs
+++ a/chartered-web/src/main.rs
@@ -58,8 +58,11 @@
#[tokio::main]
#[allow(clippy::semicolon_if_nothing_returned)] // lint breaks with tokio::main
async fn main() -> Result<(), InitError> {
    // parse CLI arguments
    let opts: Opts = Opts::parse();

    // overrides the RUST_LOG variable to our own value based on the
    // amount of `-v`s that were passed when calling the service
    std::env::set_var(
        "RUST_LOG",
        match opts.verbose {
@@ -71,11 +74,13 @@

    let config: config::Config = toml::from_slice(&std::fs::read(&opts.config)?)?;

    // initialise logging/tracing
    tracing_subscriber::fmt::init();

    let bind_address = config.bind_address;
    let pool = chartered_db::init(&config.database_uri)?;

    // the base stack of middleware that is applied to _all_ routes
    let middleware_stack = ServiceBuilder::new()
        .layer_fn(middleware::logging::LoggingMiddleware)
        .into_inner();
diff --git a/chartered-web/src/middleware/auth.rs b/chartered-web/src/middleware/auth.rs
index 4c66dbf..5357247 100644
--- a/chartered-web/src/middleware/auth.rs
+++ a/chartered-web/src/middleware/auth.rs
@@ -1,3 +1,6 @@
//! Check the API key embedded in the path is valid otherwise returns a 401 for authenticated

//! endpoints.


use axum::{
    body::{box_body, Body, BoxBody},
    extract::{self, FromRequest, RequestParts},
@@ -41,12 +44,15 @@
        Box::pin(async move {
            let mut req = RequestParts::new(req);

            // extracts all parameters from the path so we can get the API key which should
            // always be named key
            let params = extract::Path::<HashMap<String, String>>::from_request(&mut req)
                .await
                .unwrap();

            let key = params.get("key").map(String::as_str).unwrap_or_default();

            // grab the ConnectionPool from the extensions created when we initialised the
            // server
            let db = req
                .extensions()
                .unwrap()
@@ -54,6 +60,8 @@
                .unwrap()
                .clone();

            // grab the UserSession that's currently being used for this request and the User that
            // owns the key, otherwise return a 401 if the key doesn't exist
            let (session, user) = match User::find_by_session_key(db, String::from(key))
                .await
                .unwrap()
@@ -72,9 +80,12 @@
                }
            };

            // insert both the user and the session into extensions so handlers can
            // get their hands on them
            req.extensions_mut().unwrap().insert(user);
            req.extensions_mut().unwrap().insert(session);

            // calls handlers/other middleware and drives the request to response
            let response: Response<BoxBody> = inner.call(req.try_into_request().unwrap()).await?;

            Ok(response)
diff --git a/chartered-web/src/middleware/logging.rs b/chartered-web/src/middleware/logging.rs
index c92d36d..7ada30d 100644
--- a/chartered-web/src/middleware/logging.rs
+++ a/chartered-web/src/middleware/logging.rs
@@ -1,3 +1,5 @@
//! Logs each and every request out in a format similar to that of Apache's logs.


use axum::{
    extract::{self, FromRequest, RequestParts},
    http::{Request, Response},
diff --git a/chartered-web/src/endpoints/cargo_api/download.rs b/chartered-web/src/endpoints/cargo_api/download.rs
index 858fdf2..1282394 100644
--- a/chartered-web/src/endpoints/cargo_api/download.rs
+++ a/chartered-web/src/endpoints/cargo_api/download.rs
@@ -1,3 +1,7 @@
//! Called by cargo to download a crate, depending on how we're configured we'll either serve

//! the crate directly from the disk - or we'll redirect cargo elsewhere to download the

//! crate. It all really depends on the `FileSystem` in use in `chartered-fs`.


use axum::{
    body::{Full, HttpBody},
    extract,
@@ -6,50 +10,9 @@
};
use bytes::Bytes;
use chartered_db::{crates::Crate, users::User, ConnectionPool};
use chartered_fs::{FilePointer, FileSystem};
use chartered_fs::{FilePointer, FileReference, FileSystem};
use std::{str::FromStr, sync::Arc};
use thiserror::Error;

#[derive(Error, Debug)]
pub enum Error {
    #[error("{0}")]
    Database(#[from] chartered_db::Error),
    #[error("Failed to fetch crate file: {0}")]
    File(#[from] Box<chartered_fs::Error>),
    #[error("The requested version does not exist for the crate")]
    NoVersion,
}

impl Error {
    pub fn status_code(&self) -> axum::http::StatusCode {
        use axum::http::StatusCode;

        match self {
            Self::Database(e) => e.status_code(),
            Self::File(_) => StatusCode::INTERNAL_SERVER_ERROR,
            Self::NoVersion => StatusCode::NOT_FOUND,
        }
    }
}

define_error_response!(Error);

pub enum ResponseOrRedirect {
    Response(Vec<u8>),
    Redirect(Redirect),
}

impl IntoResponse for ResponseOrRedirect {
    type Body = Full<Bytes>;
    type BodyError = <Self::Body as HttpBody>::Error;

    fn into_response(self) -> Response<Self::Body> {
        match self {
            Self::Response(v) => v.into_response(),
            Self::Redirect(v) => v.into_response().map(|_| Full::from(Bytes::new())),
        }
    }
}

pub async fn handle(
    extract::Path((_session_key, organisation, name, version)): extract::Path<(
@@ -73,17 +36,62 @@
            .increment_download_count(db.clone()),
    );

    // grab the requested version of the crate
    let version = crate_with_permissions
        .version(db, version)
        .await?
        .ok_or(Error::NoVersion)?;

    let file_ref = chartered_fs::FileReference::from_str(&version.filesystem_object).unwrap();

    // parses the filesystem_object returned by the database to a `FileReference` that
    // we can use to get a `FilePointer` that is either on the disk and already available
    // to us or is stored elsewhere but we have a link to that we can redirect to.
    let file_ref = FileReference::from_str(&version.filesystem_object).map_err(Box::new)?;
    let res = fs.read(file_ref).await.map_err(Box::new)?;

    match res {
        FilePointer::Redirect(uri) => Ok(ResponseOrRedirect::Redirect(Redirect::to(uri))),
        FilePointer::Content(content) => Ok(ResponseOrRedirect::Response(content)),
    }
}

/// Returns either bytes directly to the client or redirects them elsewhere.

pub enum ResponseOrRedirect {
    Response(Vec<u8>),
    Redirect(Redirect),
}

impl IntoResponse for ResponseOrRedirect {
    type Body = Full<Bytes>;
    type BodyError = <Self::Body as HttpBody>::Error;

    fn into_response(self) -> Response<Self::Body> {
        match self {
            Self::Response(v) => v.into_response(),
            Self::Redirect(v) => v.into_response().map(|_| Full::from(Bytes::new())),
        }
    }
}

#[derive(Error, Debug)]
pub enum Error {
    #[error("{0}")]
    Database(#[from] chartered_db::Error),
    #[error("Failed to fetch crate file: {0}")]
    File(#[from] Box<chartered_fs::Error>),
    #[error("The requested version does not exist for the crate")]
    NoVersion,
}

impl Error {
    pub fn status_code(&self) -> axum::http::StatusCode {
        use axum::http::StatusCode;

        match self {
            Self::Database(e) => e.status_code(),
            Self::File(_) => StatusCode::INTERNAL_SERVER_ERROR,
            Self::NoVersion => StatusCode::NOT_FOUND,
        }
    }
}

define_error_response!(Error);
diff --git a/chartered-web/src/endpoints/cargo_api/mod.rs b/chartered-web/src/endpoints/cargo_api/mod.rs
index 1740db0..b6b7efb 100644
--- a/chartered-web/src/endpoints/cargo_api/mod.rs
+++ a/chartered-web/src/endpoints/cargo_api/mod.rs
@@ -1,3 +1,11 @@
//! This module is used for anything called directly by cargo. The base URL for all the routes

//! listed in this module is `/a/:key/o/:organisation/api/v1`.

//!

//! Generally only endpoints listed in the [Web API section of the Cargo book][cargo-book] should

//! be implemented here.

//!

//! [cargo-book]: https://doc.rust-lang.org/cargo/reference/registries.html#web-api


mod download;
mod owners;
mod publish;
@@ -12,6 +20,7 @@
use futures::future::Future;
use std::convert::Infallible;

// requests are already authenticated before this router
pub fn routes() -> Router<
    impl tower::Service<
            Request<Body>,
diff --git a/chartered-web/src/endpoints/cargo_api/owners.rs b/chartered-web/src/endpoints/cargo_api/owners.rs
index 35a51e4..b94a34c 100644
--- a/chartered-web/src/endpoints/cargo_api/owners.rs
+++ a/chartered-web/src/endpoints/cargo_api/owners.rs
@@ -1,37 +1,13 @@
//! Called by `cargo owners` to get a list of owners of a particular crate. In our case, however,

//! an 'owner' is quite ambiguous as a _person_ isn't directly responsible for a crate, an

//! _organisation_ is. But for the sake of returning some sort of valuable data we'll just return

//! anyone with the `MANAGE_USERS` permission.


use axum::{extract, Json};
use chartered_db::{crates::Crate, users::User, ConnectionPool};
use serde::Serialize;
use std::sync::Arc;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum Error {
    #[error("{0}")]
    Database(#[from] chartered_db::Error),
}

impl Error {
    pub fn status_code(&self) -> axum::http::StatusCode {
        match self {
            Self::Database(e) => e.status_code(),
        }
    }
}

define_error_response!(Error);

#[derive(Serialize)]
pub struct GetResponse {
    users: Vec<GetResponseUser>,
}

#[derive(Serialize)]
pub struct GetResponseUser {
    // cargo spec says this should be an unsigned 32-bit integer
    // uuid: chartered_db::uuid::Uuid,
    login: String,
    name: Option<String>,
}

pub async fn handle_get(
    extract::Path((_session_key, organisation, name)): extract::Path<(String, String, String)>,
@@ -41,6 +17,7 @@
    let crate_with_permissions =
        Arc::new(Crate::find_by_name(db.clone(), user.id, organisation, name).await?);

    // grab all users with the `MANAGE_USERS` permission for the crate
    let users = crate_with_permissions
        .owners(db)
        .await?
@@ -53,4 +30,33 @@
        .collect();

    Ok(Json(GetResponse { users }))
}

#[derive(Serialize)]
pub struct GetResponse {
    users: Vec<GetResponseUser>,
}

#[derive(Serialize)]
pub struct GetResponseUser {
    // cargo spec says this should be an unsigned 32-bit integer
    // uuid: chartered_db::uuid::Uuid,
    login: String,
    name: Option<String>,
}

#[derive(Error, Debug)]
pub enum Error {
    #[error("{0}")]
    Database(#[from] chartered_db::Error),
}

impl Error {
    pub fn status_code(&self) -> axum::http::StatusCode {
        match self {
            Self::Database(e) => e.status_code(),
        }
    }
}

define_error_response!(Error);
diff --git a/chartered-web/src/endpoints/cargo_api/publish.rs b/chartered-web/src/endpoints/cargo_api/publish.rs
index ef56d46..685c9ad 100644
--- a/chartered-web/src/endpoints/cargo_api/publish.rs
+++ a/chartered-web/src/endpoints/cargo_api/publish.rs
@@ -1,3 +1,8 @@
//! Publishes a crate version

//!

//! This can also potentially create the crate under the given organisation if the crate doesn't

//! already exist and the user has the `CREATE_CRATE` permissions.


use axum::extract;
use bytes::Bytes;
use chartered_db::{crates::Crate, users::User, ConnectionPool};
@@ -8,64 +13,6 @@
use sha2::{Digest, Sha256};
use std::{borrow::Cow, convert::TryInto, sync::Arc};
use thiserror::Error;

#[derive(Error, Debug)]
pub enum Error {
    #[error("{0}")]
    Database(#[from] chartered_db::Error),
    #[error("Invalid JSON from client: {0}")]
    JsonParse(#[from] serde_json::Error),
    #[error("Invalid body")]
    MetadataParse,
    #[error("expected a valid crate name to start with a letter, contain only letters, numbers, hyphens, or underscores and have at most 64 characters ")]
    InvalidCrateName,
    #[error("Failed to push crate file to storage: {0}")]
    File(#[from] Box<chartered_fs::Error>),
}

impl Error {
    pub fn status_code(&self) -> axum::http::StatusCode {
        use axum::http::StatusCode;

        match self {
            Self::Database(e) => e.status_code(),
            Self::JsonParse(_) | Self::MetadataParse | Self::InvalidCrateName => {
                StatusCode::BAD_REQUEST
            }
            Self::File(_) => StatusCode::INTERNAL_SERVER_ERROR,
        }
    }
}

define_error_response!(Error);

#[derive(Serialize, Debug, Default)]
pub struct PublishCrateResponse {
    warnings: PublishCrateResponseWarnings,
}

#[derive(Serialize, Debug, Default)]
pub struct PublishCrateResponseWarnings {
    invalid_categories: Vec<String>,
    invalid_badges: Vec<String>,
    other: Vec<String>,
}

fn validate_crate_name(name: &str) -> bool {
    const MAX_NAME_LENGTH: usize = 64;

    let starts_with_alphabetic = name
        .chars()
        .next()
        .map(|c| c.is_ascii_alphabetic())
        .unwrap_or_default();
    let is_alphanumeric = name
        .chars()
        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
    let is_under_max_length = name.len() < MAX_NAME_LENGTH;

    starts_with_alphabetic && is_alphanumeric && is_under_max_length
}

pub async fn handle(
    extract::Path((_session_key, organisation)): extract::Path<(String, String)>,
@@ -74,13 +21,18 @@
    extract::Extension(fs): extract::Extension<Arc<FileSystem>>,
    body: Bytes,
) -> Result<axum::response::Json<PublishCrateResponse>, Error> {
    // cargo sends the crate metadata and the crate itself packed together, we'll parse these
    // two separate bits of data out
    let (_, (metadata_bytes, crate_bytes)) = parse(body).map_err(|_| Error::MetadataParse)?;
    let metadata: Metadata<'_> = serde_json::from_slice(&metadata_bytes)?;

    // validates the crate has a valid name, crates.io imposes some sane restrictions
    // so we'll just use those
    if !validate_crate_name(&metadata.inner.name) {
        return Err(Error::InvalidCrateName);
    }

    // looks up the crate, though we won't error on it just yet
    let crate_with_permissions = Crate::find_by_name(
        db.clone(),
        user.id,
@@ -89,6 +41,8 @@
    )
    .await;

    // if we failed to lookup the crate because it was missing, we'll create a new one fresh
    // if we have the permissions, that is. `Crate::create` will check those for us
    let crate_with_permissions = match crate_with_permissions {
        Ok(v) => Arc::new(v),
        Err(chartered_db::Error::MissingCrate) => {
@@ -104,10 +58,14 @@
        Err(e) => return Err(e.into()),
    };

    // take a checksum of the crate to write to the database to ensure integrity
    let checksum = hex::encode(Sha256::digest(&crate_bytes));

    // writes the file to the filesystem and takes a `FileReference` we can store in the
    // db to.. reference this file when it's needed (ie. on download)
    let file_ref = fs.write(crate_bytes).await.map_err(Box::new)?;

    // and finally, publish the version!
    crate_with_permissions
        .publish_version(
            db,
@@ -123,6 +81,9 @@
    Ok(axum::response::Json(PublishCrateResponse::default()))
}

/// Cargo sends the metadata and crate packed together and prepended with a single `u32`

/// representing how many bytes are in the next section, we'll parse these two byte chunks

/// out and return them `(Metadata, Crate)`.

fn parse(body: impl Into<BytesWrapper>) -> nom::IResult<BytesWrapper, (Bytes, Bytes)> {
    use nom::{bytes::complete::take, combinator::map_res};
    use std::array::TryFromSliceError;
@@ -139,8 +100,29 @@
    let (rest, crate_bytes) = take(crate_length)(rest)?;

    Ok((rest, (metadata_bytes.into(), crate_bytes.into())))
}

/// Most of these limitations are copied from crates.io as they're rather sane defaults,

/// though not all of them are implemented yet, a non-comprehensive list is available [here][list]

///

/// [list]: https://doc.rust-lang.org/cargo/reference/manifest.html#the-name-field

fn validate_crate_name(name: &str) -> bool {
    const MAX_NAME_LENGTH: usize = 64;

    let starts_with_alphabetic = name
        .chars()
        .next()
        .map(|c| c.is_ascii_alphabetic())
        .unwrap_or_default();
    let is_alphanumeric = name
        .chars()
        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
    let is_under_max_length = name.len() < MAX_NAME_LENGTH;

    starts_with_alphabetic && is_alphanumeric && is_under_max_length
}

/// Some metadata about the crate, sent to us by the user's `cargo` CLI

#[allow(dead_code)] // a lot of these need checking/validating
#[derive(Deserialize, Debug)]
pub struct Metadata<'a> {
@@ -162,6 +144,7 @@
    inner: MetadataCrateVersion<'a>,
}

/// Some specific metadata about the crate version currently being pushed to us

#[derive(Deserialize, Debug)]
pub struct MetadataCrateVersion<'a> {
    #[serde(borrow)]
@@ -227,6 +210,48 @@
            kind: Cow::Owned(us.kind.into_owned()),
            registry: us.registry.map(|v| Cow::Owned(v.into_owned())),
            package: package.map(Cow::Owned),
        }
    }
}

#[derive(Serialize, Debug, Default)]
pub struct PublishCrateResponse {
    warnings: PublishCrateResponseWarnings,
}

#[derive(Serialize, Debug, Default)]
pub struct PublishCrateResponseWarnings {
    invalid_categories: Vec<String>,
    invalid_badges: Vec<String>,
    other: Vec<String>,
}

#[derive(Error, Debug)]
pub enum Error {
    #[error("{0}")]
    Database(#[from] chartered_db::Error),
    #[error("Invalid JSON from client: {0}")]
    JsonParse(#[from] serde_json::Error),
    #[error("Invalid body")]
    MetadataParse,
    #[error("expected a valid crate name to start with a letter, contain only letters, numbers, hyphens, or underscores and have at most 64 characters ")]
    InvalidCrateName,
    #[error("Failed to push crate file to storage: {0}")]
    File(#[from] Box<chartered_fs::Error>),
}

impl Error {
    pub fn status_code(&self) -> axum::http::StatusCode {
        use axum::http::StatusCode;

        match self {
            Self::Database(e) => e.status_code(),
            Self::JsonParse(_) | Self::MetadataParse | Self::InvalidCrateName => {
                StatusCode::BAD_REQUEST
            }
            Self::File(_) => StatusCode::INTERNAL_SERVER_ERROR,
        }
    }
}

define_error_response!(Error);
diff --git a/chartered-web/src/endpoints/cargo_api/yank.rs b/chartered-web/src/endpoints/cargo_api/yank.rs
index 869dd08..ba141a2 100644
--- a/chartered-web/src/endpoints/cargo_api/yank.rs
+++ a/chartered-web/src/endpoints/cargo_api/yank.rs
@@ -1,29 +1,13 @@
//! Handles yanking and unyanking a crate version for those with the `YANK_CRATE` and `UNYANK_CRATE`

//! permissions.

//!

//! If a crate is yanked, cargo will refuse to download it.


use axum::{extract, Json};
use chartered_db::{crates::Crate, users::User, ConnectionPool};
use serde::Serialize;
use std::sync::Arc;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum Error {
    #[error("{0}")]
    Database(#[from] chartered_db::Error),
}

impl Error {
    pub fn status_code(&self) -> axum::http::StatusCode {
        match self {
            Self::Database(e) => e.status_code(),
        }
    }
}

define_error_response!(Error);

#[derive(Serialize)]
pub struct Response {
    ok: bool,
}

pub async fn handle_yank(
    extract::Path((_session_key, organisation, name, version)): extract::Path<(
@@ -63,4 +47,25 @@
        .await?;

    Ok(Json(Response { ok: true }))
}

#[derive(Serialize)]
pub struct Response {
    ok: bool,
}

#[derive(Error, Debug)]
pub enum Error {
    #[error("{0}")]
    Database(#[from] chartered_db::Error),
}

impl Error {
    pub fn status_code(&self) -> axum::http::StatusCode {
        match self {
            Self::Database(e) => e.status_code(),
        }
    }
}

define_error_response!(Error);
diff --git a/chartered-web/src/endpoints/web_api/ssh_key.rs b/chartered-web/src/endpoints/web_api/ssh_key.rs
index 5eb3855..e67b9b9 100644
--- a/chartered-web/src/endpoints/web_api/ssh_key.rs
+++ a/chartered-web/src/endpoints/web_api/ssh_key.rs
@@ -1,3 +1,6 @@
//! Handles CRD of SSH keys for the requesting user, these are not updatable as SSH keys are

//! immutable.


use chartered_db::{users::User, ConnectionPool};

use axum::{extract, Json};
@@ -9,20 +12,6 @@
use tracing::warn;

use crate::endpoints::ErrorResponse;

#[derive(Serialize)]
pub struct GetResponse {
    keys: Vec<GetResponseKey>,
}

#[derive(Serialize)]
pub struct GetResponseKey {
    uuid: Uuid,
    name: String,
    fingerprint: String,
    created_at: DateTime<Utc>,
    last_used_at: Option<DateTime<Utc>>,
}

pub async fn handle_get(
    extract::Extension(db): extract::Extension<ConnectionPool>,
@@ -47,11 +36,6 @@
        .collect();

    Ok(Json(GetResponse { keys }))
}

#[derive(Deserialize)]
pub struct PutRequest {
    key: String,
}

pub async fn handle_put(
@@ -78,6 +62,25 @@
    } else {
        Err(Error::NonExistentKey)
    }
}

#[derive(Serialize)]
pub struct GetResponse {
    keys: Vec<GetResponseKey>,
}

#[derive(Serialize)]
pub struct GetResponseKey {
    uuid: Uuid,
    name: String,
    fingerprint: String,
    created_at: DateTime<Utc>,
    last_used_at: Option<DateTime<Utc>>,
}

#[derive(Deserialize)]
pub struct PutRequest {
    key: String,
}

#[derive(Error, Debug)]
diff --git a/chartered-web/src/endpoints/web_api/auth/logout.rs b/chartered-web/src/endpoints/web_api/auth/logout.rs
index 85d0526..5185fe1 100644
--- a/chartered-web/src/endpoints/web_api/auth/logout.rs
+++ a/chartered-web/src/endpoints/web_api/auth/logout.rs
@@ -1,3 +1,6 @@
//! Logs a user out of the session used to send this request and deletes it from the

//! database.


use axum::{extract, Json};
use chartered_db::users::UserSession;
use chartered_db::ConnectionPool;
diff --git a/chartered-web/src/endpoints/web_api/auth/mod.rs b/chartered-web/src/endpoints/web_api/auth/mod.rs
index 094ecbb..0e6063e 100644
--- a/chartered-web/src/endpoints/web_api/auth/mod.rs
+++ a/chartered-web/src/endpoints/web_api/auth/mod.rs
@@ -54,6 +54,8 @@
    picture_url: Option<String>,
}

/// Takes the given `User` and generates a session for it and returns a response containing an API

/// key to the frontend that it can save for further request

pub async fn login(
    db: ConnectionPool,
    user: User,
diff --git a/chartered-web/src/endpoints/web_api/auth/openid.rs b/chartered-web/src/endpoints/web_api/auth/openid.rs
index 47cba46..6d012d1 100644
--- a/chartered-web/src/endpoints/web_api/auth/openid.rs
+++ a/chartered-web/src/endpoints/web_api/auth/openid.rs
@@ -1,3 +1,7 @@
//! Methods for `OpenID` Connect authentication, we allow the frontend to list all the available and

//! enabled providers so they can show them to the frontend and provide methods for actually doing

//! the authentication.


use crate::config::{Config, OidcClients};
use axum::{extract, Json};
use chacha20poly1305::{
@@ -11,12 +15,8 @@
use thiserror::Error;

pub type Nonce = [u8; 16];

#[derive(Serialize)]
pub struct ListProvidersResponse {
    providers: Vec<String>,
}

/// Lists all the available and enabled providers that the user can authenticate with.

#[allow(clippy::unused_async)]
pub async fn list_providers(
    extract::Extension(oidc_clients): extract::Extension<Arc<OidcClients>>,
@@ -28,19 +28,11 @@
            .map(std::string::ToString::to_string)
            .collect(),
    })
}

#[derive(Serialize, Deserialize, Debug)]
pub struct State {
    provider: String,
    nonce: Nonce,
}

#[derive(Serialize)]
pub struct BeginResponse {
    redirect_url: String,
}

/// Starts the authentication process, generating an encrypted state so we can validate the

/// request came from us and returning back the URL the frontend should redirect the user for

/// authenticating with the provider.

#[allow(clippy::unused_async)]
pub async fn begin_oidc(
    extract::Path(provider): extract::Path<String>,
@@ -64,17 +56,10 @@
    Ok(Json(BeginResponse {
        redirect_url: auth_url.to_string(),
    }))
}

#[allow(dead_code)]
#[derive(Deserialize)]
pub struct CompleteOidcParams {
    state: String,
    code: String,
    scope: Option<String>,
    prompt: Option<String>,
}

/// Handles the response back from the OIDC provider, checking the state came from us, validating

/// the token with the provider themselves and then finally logging the user in.

pub async fn complete_oidc(
    extract::Query(params): extract::Query<CompleteOidcParams>,
    extract::Extension(config): extract::Extension<Arc<Config>>,
@@ -83,8 +68,11 @@
    user_agent: Option<extract::TypedHeader<headers::UserAgent>>,
    addr: extract::ConnectInfo<std::net::SocketAddr>,
) -> Result<Json<super::LoginResponse>, Error> {
    // decrypt the state that we created in `begin_oidc` and parse it as json
    let state: State = serde_json::from_slice(&decrypt_url_safe(&params.state, &config)?)?;

    // check the state for the provider so we can get the right OIDC client to
    // verify the code and grab an id_token
    let client = oidc_clients
        .get(&state.provider)
        .ok_or(Error::UnknownOauthProvider)?;
@@ -92,18 +80,26 @@
    let mut token: Token = client.request_token(&params.code).await?.into();

    if let Some(id_token) = token.id_token.as_mut() {
        // ensure the id_token is valid, checking `exp`, etc.
        client.decode_token(id_token)?;

        // ensure the nonce in the returned id_token is the same as the one we sent out encrypted
        // with the original request
        let nonce = base64::encode_config(state.nonce, base64::URL_SAFE_NO_PAD);
        client.validate_token(id_token, Some(nonce.as_str()), None)?;
    } else {
        // the provider didn't send us back a id_token
        return Err(Error::MissingToken);
    }

    // get some basic info from the provider using the claims we requested in `begin_oidc`
    let userinfo = client.request_userinfo(&token).await?;

    let user = User::find_or_create(
        db.clone(),
        // we're using `provider:uid` as the format for OIDC logins, this is fine to create
        // without a password because (1) password auth rejects blank passwords and (2) password
        // auth also rejects any usernames with a `:` in.
        format!("{}:{}", state.provider, userinfo.sub.unwrap()),
        userinfo.name,
        userinfo.nickname,
@@ -113,11 +109,14 @@
    )
    .await?;

    // request looks good, log the user in!
    Ok(Json(super::login(db, user, user_agent, addr).await?))
}

const NONCE_LEN: usize = 12;

// Encrypts the given string using ChaCha20Poly1305 and returns a url safe base64 encoded
// version of it
fn encrypt_url_safe(input: &[u8], config: &Config) -> Result<String, Error> {
    let cipher = ChaCha20Poly1305::new(&config.encryption_key);

@@ -130,6 +129,7 @@
    Ok(base64::encode_config(&ciphertext, base64::URL_SAFE_NO_PAD))
}

// Decrypts the given string assuming it's a url safe base64 encoded ChaCha20Poly1305 cipher.
fn decrypt_url_safe(input: &str, config: &Config) -> Result<Vec<u8>, Error> {
    let cipher = ChaCha20Poly1305::new(&config.encryption_key);

@@ -140,6 +140,31 @@
    cipher
        .decrypt(ciphertext_nonce, ciphertext.as_ref())
        .map_err(Error::from)
}

#[derive(Serialize)]
pub struct ListProvidersResponse {
    providers: Vec<String>,
}

#[derive(Serialize)]
pub struct BeginResponse {
    redirect_url: String,
}

#[allow(dead_code)]
#[derive(Deserialize)]
pub struct CompleteOidcParams {
    state: String,
    code: String,
    scope: Option<String>,
    prompt: Option<String>,
}

#[derive(Serialize, Deserialize, Debug)]
pub struct State {
    provider: String,
    nonce: Nonce,
}

#[derive(Error, Debug)]
diff --git a/chartered-web/src/endpoints/web_api/auth/password.rs b/chartered-web/src/endpoints/web_api/auth/password.rs
index 1a57907..bb7dcf2 100644
--- a/chartered-web/src/endpoints/web_api/auth/password.rs
+++ a/chartered-web/src/endpoints/web_api/auth/password.rs
@@ -1,28 +1,9 @@
//! Password-based authentication, including registration and login.


use axum::{extract, Json};
use chartered_db::{users::User, ConnectionPool};
use serde::Deserialize;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum Error {
    #[error("Failed to query database")]
    Database(#[from] chartered_db::Error),
    #[error("Invalid username/password")]
    UnknownUser,
}

impl Error {
    pub fn status_code(&self) -> axum::http::StatusCode {
        use axum::http::StatusCode;

        match self {
            Self::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
            Self::UnknownUser => StatusCode::FORBIDDEN,
        }
    }
}

define_error_response!(Error);

pub async fn handle(
    extract::Extension(db): extract::Extension<ConnectionPool>,
@@ -48,4 +29,25 @@
pub struct Request {
    username: String,
    password: String,
}

#[derive(Error, Debug)]
pub enum Error {
    #[error("Failed to query database")]
    Database(#[from] chartered_db::Error),
    #[error("Invalid username/password")]
    UnknownUser,
}

impl Error {
    pub fn status_code(&self) -> axum::http::StatusCode {
        use axum::http::StatusCode;

        match self {
            Self::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
            Self::UnknownUser => StatusCode::FORBIDDEN,
        }
    }
}

define_error_response!(Error);
diff --git a/chartered-web/src/endpoints/web_api/crates/info.rs b/chartered-web/src/endpoints/web_api/crates/info.rs
index 8addec3..acbe79a 100644
--- a/chartered-web/src/endpoints/web_api/crates/info.rs
+++ a/chartered-web/src/endpoints/web_api/crates/info.rs
@@ -1,3 +1,10 @@
//! Grabs some info about the crate that can be shown in the web UI, like READMEs, descriptions,

//! versions, etc. We group them all together into a single response as we can fetch them in

//! a single query and the users don't have to make multiple calls out.

//!

//! Unlike crates.io, we're only keeping the _latest_ README pushed to the crate, so there's no

//! need to have version-specific info responses - we'll just send an overview of each one.


use axum::{body::Full, extract, response::IntoResponse, Json};
use bytes::Bytes;
use chartered_db::{crates::Crate, users::User, ConnectionPool};
@@ -6,23 +13,7 @@
use serde::Serialize;
use std::sync::Arc;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum Error {
    #[error("{0}")]
    Database(#[from] chartered_db::Error),
}

impl Error {
    pub fn status_code(&self) -> axum::http::StatusCode {
        match self {
            Self::Database(e) => e.status_code(),
        }
    }
}

define_error_response!(Error);

pub async fn handle(
    extract::Path((_session_key, organisation, name)): extract::Path<(String, String, String)>,
    extract::Extension(db): extract::Extension<ConnectionPool>,
@@ -31,16 +22,12 @@
    let crate_with_permissions =
        Arc::new(Crate::find_by_name(db.clone(), user.id, organisation, name).await?);

    // grab all versions of this crate and the person who uploaded them
    let versions = crate_with_permissions
        .clone()
        .versions_with_uploader(db)
        .await?;

    // returning a Response instead of Json here so we don't have to close
    // every Crate/CrateVersion etc, would be easier if we just had an owned
    // version of each but we're using `spawn_blocking` in chartered-db for
    // diesel which requires `'static' which basically forces us to use Arc
    // if we want to keep a reference to anything ourselves.
    Ok(Json(Response {
        info: (&crate_with_permissions.crate_).into(),
        versions: versions
@@ -57,6 +44,11 @@
            })
            .collect(),
    })
    // returning a Response instead of Json here so we don't have to clone
    // every Crate/CrateVersion etc, would be easier if we just had an owned
    // version of each but we're using `spawn_blocking` in chartered-db for
    // diesel which requires `'static' which basically forces us to use Arc
    // if we want to keep a reference to anything ourselves.
    .into_response())
}

@@ -102,6 +94,22 @@
            repository: crate_.repository.as_deref(),
            homepage: crate_.homepage.as_deref(),
            documentation: crate_.documentation.as_deref(),
        }
    }
}

#[derive(Error, Debug)]
pub enum Error {
    #[error("{0}")]
    Database(#[from] chartered_db::Error),
}

impl Error {
    pub fn status_code(&self) -> axum::http::StatusCode {
        match self {
            Self::Database(e) => e.status_code(),
        }
    }
}

define_error_response!(Error);
diff --git a/chartered-web/src/endpoints/web_api/crates/members.rs b/chartered-web/src/endpoints/web_api/crates/members.rs
index 65a31ea..3f3dc09 100644
--- a/chartered-web/src/endpoints/web_api/crates/members.rs
+++ a/chartered-web/src/endpoints/web_api/crates/members.rs
@@ -1,3 +1,7 @@
//! Handles crate-level member overrides that can add permissions on top of organisations.

//!

//! This is essentially a CRUD controller, nice and easy one.


use axum::{extract, Json};
use chartered_db::{
    crates::Crate, permissions::UserPermission, users::User, uuid::Uuid, ConnectionPool,
@@ -7,21 +11,10 @@
use thiserror::Error;

use crate::endpoints::ErrorResponse;

#[derive(Serialize)]
pub struct GetResponse {
    allowed_permissions: &'static [&'static str],
    members: Vec<GetResponseMember>,
}

#[derive(Deserialize, Serialize)]
pub struct GetResponseMember {
    uuid: Uuid,
    display_name: String,
    picture_url: Option<String>,
    permissions: UserPermission,
}

/// Lists all crate-level members and the permissions they have.

///

/// These members could be specific to the crate or they could be overrides ontop of the org.

pub async fn handle_get(
    extract::Path((_session_key, organisation, name)): extract::Path<(String, String, String)>,
    extract::Extension(db): extract::Extension<ConnectionPool>,
@@ -46,14 +39,9 @@
        allowed_permissions: UserPermission::names(),
        members,
    }))
}

#[derive(Deserialize)]
pub struct PutOrPatchRequest {
    user_uuid: chartered_db::uuid::Uuid,
    permissions: UserPermission,
}

/// Updates a crate member's permissions

pub async fn handle_patch(
    extract::Path((_session_key, organisation, name)): extract::Path<(String, String, String)>,
    extract::Extension(db): extract::Extension<ConnectionPool>,
@@ -77,6 +65,7 @@
    Ok(Json(ErrorResponse { error: None }))
}

/// Inserts an permissions override for this crate for a specific user

pub async fn handle_put(
    extract::Path((_session_key, organisation, name)): extract::Path<(String, String, String)>,
    extract::Extension(db): extract::Extension<ConnectionPool>,
@@ -95,13 +84,9 @@
        .await?;

    Ok(Json(ErrorResponse { error: None }))
}

#[derive(Deserialize)]
pub struct DeleteRequest {
    user_uuid: chartered_db::uuid::Uuid,
}

/// Deletes a member override from this crate

pub async fn handle_delete(
    extract::Path((_session_key, organisation, name)): extract::Path<(String, String, String)>,
    extract::Extension(db): extract::Extension<ConnectionPool>,
@@ -120,6 +105,31 @@
        .await?;

    Ok(Json(ErrorResponse { error: None }))
}

#[derive(Serialize)]
pub struct GetResponse {
    allowed_permissions: &'static [&'static str],
    members: Vec<GetResponseMember>,
}

#[derive(Deserialize, Serialize)]
pub struct GetResponseMember {
    uuid: Uuid,
    display_name: String,
    picture_url: Option<String>,
    permissions: UserPermission,
}

#[derive(Deserialize)]
pub struct PutOrPatchRequest {
    user_uuid: chartered_db::uuid::Uuid,
    permissions: UserPermission,
}

#[derive(Deserialize)]
pub struct DeleteRequest {
    user_uuid: chartered_db::uuid::Uuid,
}

#[derive(Error, Debug)]
diff --git a/chartered-web/src/endpoints/web_api/crates/most_downloaded.rs b/chartered-web/src/endpoints/web_api/crates/most_downloaded.rs
index 623a11d..f19df1f 100644
--- a/chartered-web/src/endpoints/web_api/crates/most_downloaded.rs
+++ a/chartered-web/src/endpoints/web_api/crates/most_downloaded.rs
@@ -1,24 +1,10 @@
//! Returns a list of the top 10 most downloaded crates that the user has access to.


use axum::{extract, Json};
use chartered_db::{crates::Crate, users::User, ConnectionPool};
use serde::Serialize;
use std::sync::Arc;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum Error {
    #[error("{0}")]
    Database(#[from] chartered_db::Error),
}

impl Error {
    pub fn status_code(&self) -> axum::http::StatusCode {
        match self {
            Self::Database(e) => e.status_code(),
        }
    }
}

define_error_response!(Error);

pub async fn handle(
    extract::Extension(db): extract::Extension<ConnectionPool>,
@@ -48,4 +34,20 @@
    name: String,
    downloads: i32,
    organisation: String,
}

#[derive(Error, Debug)]
pub enum Error {
    #[error("{0}")]
    Database(#[from] chartered_db::Error),
}

impl Error {
    pub fn status_code(&self) -> axum::http::StatusCode {
        match self {
            Self::Database(e) => e.status_code(),
        }
    }
}

define_error_response!(Error);
diff --git a/chartered-web/src/endpoints/web_api/crates/recently_created.rs b/chartered-web/src/endpoints/web_api/crates/recently_created.rs
index 45942e9..c2d8520 100644
--- a/chartered-web/src/endpoints/web_api/crates/recently_created.rs
+++ a/chartered-web/src/endpoints/web_api/crates/recently_created.rs
@@ -1,25 +1,12 @@
//! Returns a list to the 10 most recently created crates that the user has access to for their

//! viewing pleasure


use axum::{extract, Json};
use chartered_db::{crates::Crate, users::User, ConnectionPool};
use chrono::{DateTime, TimeZone, Utc};
use serde::Serialize;
use std::sync::Arc;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum Error {
    #[error("{0}")]
    Database(#[from] chartered_db::Error),
}

impl Error {
    pub fn status_code(&self) -> axum::http::StatusCode {
        match self {
            Self::Database(e) => e.status_code(),
        }
    }
}

define_error_response!(Error);

pub async fn handle(
    extract::Extension(db): extract::Extension<ConnectionPool>,
@@ -49,4 +36,20 @@
    name: String,
    created_at: DateTime<Utc>,
    organisation: String,
}

#[derive(Error, Debug)]
pub enum Error {
    #[error("{0}")]
    Database(#[from] chartered_db::Error),
}

impl Error {
    pub fn status_code(&self) -> axum::http::StatusCode {
        match self {
            Self::Database(e) => e.status_code(),
        }
    }
}

define_error_response!(Error);
diff --git a/chartered-web/src/endpoints/web_api/crates/recently_updated.rs b/chartered-web/src/endpoints/web_api/crates/recently_updated.rs
index c2cec7c..beba1c2 100644
--- a/chartered-web/src/endpoints/web_api/crates/recently_updated.rs
+++ a/chartered-web/src/endpoints/web_api/crates/recently_updated.rs
@@ -1,24 +1,10 @@
//! Lists the 10 most recently updated crates that the user has access to.


use axum::{extract, Json};
use chartered_db::{crates::Crate, users::User, ConnectionPool};
use serde::Serialize;
use std::sync::Arc;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum Error {
    #[error("{0}")]
    Database(#[from] chartered_db::Error),
}

impl Error {
    pub fn status_code(&self) -> axum::http::StatusCode {
        match self {
            Self::Database(e) => e.status_code(),
        }
    }
}

define_error_response!(Error);

pub async fn handle(
    extract::Extension(db): extract::Extension<ConnectionPool>,
@@ -48,4 +34,20 @@
    name: String,
    version: String,
    organisation: String,
}

#[derive(Error, Debug)]
pub enum Error {
    #[error("{0}")]
    Database(#[from] chartered_db::Error),
}

impl Error {
    pub fn status_code(&self) -> axum::http::StatusCode {
        match self {
            Self::Database(e) => e.status_code(),
        }
    }
}

define_error_response!(Error);
diff --git a/chartered-web/src/endpoints/web_api/crates/search.rs b/chartered-web/src/endpoints/web_api/crates/search.rs
index 9abb3d1..6bbb5e5 100644
--- a/chartered-web/src/endpoints/web_api/crates/search.rs
+++ a/chartered-web/src/endpoints/web_api/crates/search.rs
@@ -1,29 +1,12 @@
//! Does a simple search over the crates table for a search term, the organisation and crate name

//! are concatenated using a `/` so any substring of `org/crate` will return results. The latest

//! version for each is also fetched so we can show them in the search results.


use axum::{extract, Json};
use chartered_db::{crates::Crate, permissions::UserPermission, users::User, ConnectionPool};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use thiserror::Error;

#[derive(Deserialize)]
pub struct RequestParams {
    q: String,
}

#[derive(Serialize)]
pub struct Response {
    crates: Vec<ResponseCrate>,
}

#[derive(Serialize)]
pub struct ResponseCrate {
    organisation: String,
    name: String,
    description: Option<String>,
    version: String,
    homepage: Option<String>,
    repository: Option<String>,
    permissions: UserPermission,
}

pub async fn handle(
    extract::Extension(db): extract::Extension<ConnectionPool>,
@@ -63,6 +46,27 @@
    .await?;

    Ok(Json(Response { crates }))
}

#[derive(Deserialize)]
pub struct RequestParams {
    q: String,
}

#[derive(Serialize)]
pub struct Response {
    crates: Vec<ResponseCrate>,
}

#[derive(Serialize)]
pub struct ResponseCrate {
    organisation: String,
    name: String,
    description: Option<String>,
    version: String,
    homepage: Option<String>,
    repository: Option<String>,
    permissions: UserPermission,
}

#[derive(Error, Debug)]
diff --git a/chartered-web/src/endpoints/web_api/organisations/crud.rs b/chartered-web/src/endpoints/web_api/organisations/crud.rs
index 7f91de2..e05febe 100644
--- a/chartered-web/src/endpoints/web_api/organisations/crud.rs
+++ a/chartered-web/src/endpoints/web_api/organisations/crud.rs
@@ -1,3 +1,6 @@
//! Allows users to create whole organisations. This endpoint currently isn't limited to any

//! specific users so all users can create an organisation and add people to it.


use axum::{extract, Json};
use chartered_db::{organisations::Organisation, users::User, ConnectionPool};
use serde::Deserialize;
@@ -5,6 +8,23 @@
use thiserror::Error;

use crate::endpoints::ErrorResponse;

pub async fn handle_put(
    extract::Extension(db): extract::Extension<ConnectionPool>,
    extract::Extension(user): extract::Extension<Arc<User>>,
    extract::Json(req): extract::Json<PutRequest>,
) -> Result<Json<ErrorResponse>, Error> {
    Organisation::create(db, req.name, req.description, req.public, user.id).await?;

    Ok(Json(ErrorResponse { error: None }))
}

#[derive(Deserialize)]
pub struct PutRequest {
    name: String,
    description: String,
    public: bool,
}

#[derive(Error, Debug)]
pub enum Error {
@@ -21,20 +41,3 @@
}

define_error_response!(Error);

#[derive(Deserialize)]
pub struct PutRequest {
    name: String,
    description: String,
    public: bool,
}

pub async fn handle_put(
    extract::Extension(db): extract::Extension<ConnectionPool>,
    extract::Extension(user): extract::Extension<Arc<User>>,
    extract::Json(req): extract::Json<PutRequest>,
) -> Result<Json<ErrorResponse>, Error> {
    Organisation::create(db, req.name, req.description, req.public, user.id).await?;

    Ok(Json(ErrorResponse { error: None }))
}
diff --git a/chartered-web/src/endpoints/web_api/organisations/info.rs b/chartered-web/src/endpoints/web_api/organisations/info.rs
index d1015a8..8cc54a6 100644
--- a/chartered-web/src/endpoints/web_api/organisations/info.rs
+++ a/chartered-web/src/endpoints/web_api/organisations/info.rs
@@ -1,3 +1,7 @@
//! Grabs info about a specific organisation including name, description, and a list of members

//! and a list of permissions that are allowed to be applied to others by the requesting user and

//! also all the crates that belong to the organisation.


use axum::{extract, Json};
use chartered_db::{
    organisations::Organisation, permissions::UserPermission, users::User, ConnectionPool,
@@ -5,22 +9,6 @@
use serde::Serialize;
use std::sync::Arc;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum Error {
    #[error("{0}")]
    Database(#[from] chartered_db::Error),
}

impl Error {
    pub fn status_code(&self) -> axum::http::StatusCode {
        match self {
            Self::Database(e) => e.status_code(),
        }
    }
}

define_error_response!(Error);

pub async fn handle_get(
    extract::Path((_session_key, organisation)): extract::Path<(String, String)>,
@@ -30,10 +18,13 @@
    let organisation =
        Arc::new(Organisation::find_by_name(db.clone(), user.id, organisation).await?);

    // checks if the requesting user has the `MANGE_USERS` permission for this
    // organisation
    let can_manage_users = organisation
        .permissions()
        .contains(UserPermission::MANAGE_USERS);

    // fetch both crates and members for the organisation at the same time
    let (crates, users) = tokio::try_join!(
        organisation.clone().crates(db.clone()),
        organisation.clone().members(db),
@@ -41,6 +32,7 @@

    Ok(Json(Response {
        description: organisation.organisation().description.to_string(),
        // all the permissions the requesting user can give out for this organisation
        possible_permissions: can_manage_users.then(UserPermission::all),
        crates: crates
            .into_iter()
@@ -81,4 +73,20 @@
    display_name: String,
    picture_url: Option<String>,
    permissions: Option<UserPermission>,
}

#[derive(Error, Debug)]
pub enum Error {
    #[error("{0}")]
    Database(#[from] chartered_db::Error),
}

impl Error {
    pub fn status_code(&self) -> axum::http::StatusCode {
        match self {
            Self::Database(e) => e.status_code(),
        }
    }
}

define_error_response!(Error);
diff --git a/chartered-web/src/endpoints/web_api/organisations/list.rs b/chartered-web/src/endpoints/web_api/organisations/list.rs
index 480f122..ddc0922 100644
--- a/chartered-web/src/endpoints/web_api/organisations/list.rs
+++ a/chartered-web/src/endpoints/web_api/organisations/list.rs
@@ -1,24 +1,11 @@
//! Lists all the organisations that the requesting user is apart of returning their names and

//! descriptions.


use axum::{extract, Json};
use chartered_db::{organisations::Organisation, users::User, ConnectionPool};
use serde::Serialize;
use std::sync::Arc;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum Error {
    #[error("{0}")]
    Database(#[from] chartered_db::Error),
}

impl Error {
    pub fn status_code(&self) -> axum::http::StatusCode {
        match self {
            Self::Database(e) => e.status_code(),
        }
    }
}

define_error_response!(Error);

pub async fn handle_get(
    extract::Extension(db): extract::Extension<ConnectionPool>,
@@ -46,4 +33,20 @@
pub struct ResponseOrganisation {
    name: String,
    description: String,
}

#[derive(Error, Debug)]
pub enum Error {
    #[error("{0}")]
    Database(#[from] chartered_db::Error),
}

impl Error {
    pub fn status_code(&self) -> axum::http::StatusCode {
        match self {
            Self::Database(e) => e.status_code(),
        }
    }
}

define_error_response!(Error);
diff --git a/chartered-web/src/endpoints/web_api/organisations/members.rs b/chartered-web/src/endpoints/web_api/organisations/members.rs
index 2262a17..60c4b78 100644
--- a/chartered-web/src/endpoints/web_api/organisations/members.rs
+++ a/chartered-web/src/endpoints/web_api/organisations/members.rs
@@ -1,3 +1,6 @@
//! CRUD methods to manage members of an organisation, given the requesting user has the

//! `MANAGE_USERS` permission at the organisation level.


use axum::{extract, Json};
use chartered_db::{
    organisations::Organisation, permissions::UserPermission, users::User, ConnectionPool,
@@ -7,13 +10,8 @@
use thiserror::Error;

use crate::endpoints::ErrorResponse;

#[derive(Deserialize)]
pub struct PutOrPatchRequest {
    user_uuid: chartered_db::uuid::Uuid,
    permissions: UserPermission,
}

/// Updates an organisation member's permissions

pub async fn handle_patch(
    extract::Path((_session_key, organisation)): extract::Path<(String, String)>,
    extract::Extension(db): extract::Extension<ConnectionPool>,
@@ -37,6 +35,7 @@
    Ok(Json(ErrorResponse { error: None }))
}

/// Adds a new member to the organisation with a given set of permissions.

pub async fn handle_put(
    extract::Path((_session_key, organisation)): extract::Path<(String, String)>,
    extract::Extension(db): extract::Extension<ConnectionPool>,
@@ -55,13 +54,9 @@
        .await?;

    Ok(Json(ErrorResponse { error: None }))
}

#[derive(Deserialize)]
pub struct DeleteRequest {
    user_uuid: chartered_db::uuid::Uuid,
}

/// Deletes a member from the organisation entirely

pub async fn handle_delete(
    extract::Path((_session_key, organisation)): extract::Path<(String, String)>,
    extract::Extension(db): extract::Extension<ConnectionPool>,
@@ -78,6 +73,17 @@
    organisation.delete_member(db, action_user.id).await?;

    Ok(Json(ErrorResponse { error: None }))
}

#[derive(Deserialize)]
pub struct PutOrPatchRequest {
    user_uuid: chartered_db::uuid::Uuid,
    permissions: UserPermission,
}

#[derive(Deserialize)]
pub struct DeleteRequest {
    user_uuid: chartered_db::uuid::Uuid,
}

#[derive(Error, Debug)]
diff --git a/chartered-web/src/endpoints/web_api/users/info.rs b/chartered-web/src/endpoints/web_api/users/info.rs
index 3349b5f..9fd6658 100644
--- a/chartered-web/src/endpoints/web_api/users/info.rs
+++ a/chartered-web/src/endpoints/web_api/users/info.rs
@@ -1,7 +1,21 @@
//! Returns a user profile, with some customisable information and some fixed information (such

//! as `alias`) which can be used to uniquely identify a user by another user.

//!

//! Users don't need to be in a common organisation to be able to see each other's profiles.


use axum::{extract, Json};
use chartered_db::{users::User, ConnectionPool};
use serde::Serialize;
use thiserror::Error;

pub async fn handle(
    extract::Path((_session_key, uuid)): extract::Path<(String, chartered_db::uuid::Uuid)>,
    extract::Extension(db): extract::Extension<ConnectionPool>,
) -> Result<Json<Response>, Error> {
    let user = User::find_by_uuid(db, uuid).await?.ok_or(Error::NotFound)?;

    Ok(Json(user.into()))
}

#[derive(Serialize)]
pub struct Response {
@@ -26,15 +40,6 @@
            picture_url: user.picture_url,
        }
    }
}

pub async fn handle(
    extract::Path((_session_key, uuid)): extract::Path<(String, chartered_db::uuid::Uuid)>,
    extract::Extension(db): extract::Extension<ConnectionPool>,
) -> Result<Json<Response>, Error> {
    let user = User::find_by_uuid(db, uuid).await?.ok_or(Error::NotFound)?;

    Ok(Json(user.into()))
}

#[derive(Error, Debug)]
diff --git a/chartered-web/src/endpoints/web_api/users/search.rs b/chartered-web/src/endpoints/web_api/users/search.rs
index a940f38..bfef7f6 100644
--- a/chartered-web/src/endpoints/web_api/users/search.rs
+++ a/chartered-web/src/endpoints/web_api/users/search.rs
@@ -1,24 +1,13 @@
//! Searches through all users for the given search terms, matching on either full name, username

//! or nickname. This is used on the overall search form and also when adding members to either

//! an organisation or a crate.

//!

//! Users are not restricted in what other users they can see.


use axum::{extract, Json};
use chartered_db::{users::User, ConnectionPool};
use serde::{Deserialize, Serialize};
use thiserror::Error;

#[derive(Deserialize)]
pub struct RequestParams {
    q: String,
}

#[derive(Serialize)]
pub struct Response {
    users: Vec<ResponseUser>,
}

#[derive(Serialize)]
pub struct ResponseUser {
    user_uuid: chartered_db::uuid::Uuid,
    display_name: String,
    picture_url: Option<String>,
}

pub async fn handle(
    extract::Extension(db): extract::Extension<ConnectionPool>,
@@ -35,6 +24,23 @@
        .collect();

    Ok(Json(Response { users }))
}

#[derive(Deserialize)]
pub struct RequestParams {
    q: String,
}

#[derive(Serialize)]
pub struct Response {
    users: Vec<ResponseUser>,
}

#[derive(Serialize)]
pub struct ResponseUser {
    user_uuid: chartered_db::uuid::Uuid,
    display_name: String,
    picture_url: Option<String>,
}

#[derive(Error, Debug)]