From 07019257caad5f9822b053b649f84616c6f02f64 Mon Sep 17 00:00:00 2001 From: Jordan Doyle Date: Sat, 18 Sep 2021 00:35:14 +0100 Subject: [PATCH] Implement Web UI management of existing member permissions --- Cargo.lock | 26 ++++++++++++++++++++++++++ chartered-db/Cargo.toml | 1 + chartered-db/src/crates.rs | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ chartered-db/src/users.rs | 15 ++++++++++++--- chartered-frontend/src/index.tsx | 4 ++-- chartered-git/src/main.rs | 2 +- chartered-web/src/main.rs | 17 +++++++++++++++-- chartered-frontend/src/pages/SingleCrate.tsx | 252 -------------------------------------------------------------------------------- chartered-web/src/endpoints/mod.rs | 4 ++-- chartered-frontend/src/pages/crate/CrateView.tsx | 177 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ chartered-frontend/src/pages/crate/Members.tsx | 282 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ chartered-web/src/endpoints/web_api/crate_info.rs | 73 ------------------------------------------------------------------------- chartered-web/src/endpoints/web_api/mod.rs | 3 +-- chartered-web/src/endpoints/web_api/ssh_key.rs | 20 ++++++-------------- chartered-web/src/endpoints/web_api/crates/info.rs | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ chartered-web/src/endpoints/web_api/crates/members.rs | 129 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ chartered-web/src/endpoints/web_api/crates/mod.rs | 7 +++++++ 17 files changed, 804 insertions(+), 355 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bbf6099..ba708b9 100644 --- a/Cargo.lock +++ a/Cargo.lock @@ -203,6 +203,7 @@ "dotenv", "hex", "itertools", + "option_set", "rand", "serde", "serde_json", @@ -660,6 +661,15 @@ ] [[package]] +name = "heck" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" +dependencies = [ + "unicode-segmentation", +] + +[[package]] name = "hermit-abi" version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -956,6 +966,16 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" + +[[package]] +name = "option_set" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83b2d96bdcfa87852ffdc8e77e5fb544ffe2f85ed60568f5b62c98a05ec9a9b" +dependencies = [ + "heck", + "serde", +] [[package]] name = "parking_lot" @@ -1592,6 +1612,12 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f6906492a7cd215bfa4cf595b600146ccfac0c79bcbd1f3000162af5e8b06" + +[[package]] +name = "unicode-segmentation" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8895849a949e7845e06bd6dc1aa51731a103c42707010a5b591c0038fb73385b" [[package]] name = "unicode-xid" diff --git a/chartered-db/Cargo.toml b/chartered-db/Cargo.toml index 3f25c0e..04422f0 100644 --- a/chartered-db/Cargo.toml +++ a/chartered-db/Cargo.toml @@ -16,6 +16,7 @@ displaydoc = "0.2" hex = "0.4" itertools = "0.10" +option_set = "0.1" rand = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/chartered-db/src/crates.rs b/chartered-db/src/crates.rs index 8c5f079..0a98e2b 100644 --- a/chartered-db/src/crates.rs +++ a/chartered-db/src/crates.rs @@ -1,3 +1,5 @@ +use crate::users::UserCratePermission; + use super::{ schema::{crate_versions, crates}, BitwiseExpressionMethods, ConnectionPool, Result, @@ -161,13 +163,11 @@ pub async fn owners(self: Arc, conn: ConnectionPool) -> Result> { tokio::task::spawn_blocking(move || { - use crate::schema::user_crate_permissions::{ - dsl::permissions, dsl::user_crate_permissions, - }; + use crate::schema::user_crate_permissions::dsl::permissions; let conn = conn.get()?; - Ok(user_crate_permissions + Ok(UserCratePermission::belonging_to(&*self) .filter( permissions .bitwise_and(crate::users::UserCratePermissionValue::MANAGE_USERS.bits()) @@ -176,6 +176,72 @@ .inner_join(crate::schema::users::dsl::users) .select(crate::schema::users::all_columns) .load::(&conn)?) + }) + .await? + } + + pub async fn members( + self: Arc, + conn: ConnectionPool, + ) -> Result> { + tokio::task::spawn_blocking(move || { + let conn = conn.get()?; + + Ok(UserCratePermission::belonging_to(&*self) + .inner_join(crate::schema::users::dsl::users) + .select(( + crate::schema::users::all_columns, + crate::schema::user_crate_permissions::permissions, + )) + .load(&conn)?) + }) + .await? + } + + pub async fn update_permissions( + self: Arc, + conn: ConnectionPool, + given_user_id: i32, + given_permissions: crate::users::UserCratePermissionValue, + ) -> Result { + tokio::task::spawn_blocking(move || { + use crate::schema::user_crate_permissions::dsl::{ + crate_id, permissions, user_crate_permissions, user_id, + }; + + let conn = conn.get()?; + + Ok(diesel::update( + user_crate_permissions + .filter(user_id.eq(given_user_id)) + .filter(crate_id.eq(self.id)), + ) + .set(permissions.eq(given_permissions.bits())) + .execute(&conn)?) + }) + .await? + } + + pub async fn delete_member( + self: Arc, + conn: ConnectionPool, + given_user_id: i32, + ) -> Result<()> { + tokio::task::spawn_blocking(move || { + use crate::schema::user_crate_permissions::dsl::{ + crate_id, user_crate_permissions, user_id, + }; + + let conn = conn.get()?; + + diesel::delete( + user_crate_permissions + .filter(user_id.eq(given_user_id)) + .filter(crate_id.eq(self.id)) + ) + .execute(&conn)?; + + Ok(()) }) .await? } diff --git a/chartered-db/src/users.rs b/chartered-db/src/users.rs index 4bffdaa..e7f9ba7 100644 --- a/chartered-db/src/users.rs +++ a/chartered-db/src/users.rs @@ -1,8 +1,10 @@ use super::{ schema::{user_crate_permissions, user_sessions, user_ssh_keys, users}, ConnectionPool, Result, }; +use bitflags::bitflags; use diesel::{insert_into, prelude::*, Associations, Identifiable, Queryable}; +use option_set::{option_set, OptionSet}; use rand::{thread_rng, Rng}; use std::sync::Arc; use thrussh_keys::PublicKeyBase64; @@ -232,9 +234,9 @@ } } -bitflags::bitflags! { - #[derive(FromSqlRow, AsExpression, Default)] - pub struct UserCratePermissionValue: i32 { +option_set! { + #[derive(FromSqlRow, AsExpression)] + pub struct UserCratePermissionValue: Identity + i32 { const VISIBLE = 0b0000_0000_0000_0000_0000_0000_0000_0001; const PUBLISH_VERSION = 0b0000_0000_0000_0000_0000_0000_0000_0010; const YANK_VERSION = 0b0000_0000_0000_0000_0000_0000_0000_0100; @@ -242,6 +244,12 @@ } } +impl UserCratePermissionValue { + pub fn names() -> &'static [&'static str] { + Self::NAMES + } +} + impl diesel::deserialize::FromSql for UserCratePermissionValue where @@ -258,6 +266,7 @@ #[derive(Identifiable, Queryable, Associations, Default, PartialEq, Eq, Hash, Debug)] #[belongs_to(User)] +#[belongs_to(super::crates::Crate)] pub struct UserCratePermission { pub id: i32, pub user_id: i32, diff --git a/chartered-frontend/src/index.tsx b/chartered-frontend/src/index.tsx index b8aaad5..c1d1caf 100644 --- a/chartered-frontend/src/index.tsx +++ a/chartered-frontend/src/index.tsx @@ -16,7 +16,7 @@ import Login from "./pages/Login"; import Dashboard from "./pages/Dashboard"; -import SingleCrate from "./pages/SingleCrate"; +import CrateView from "./pages/crate/CrateView"; import ListSshKeys from "./pages/ssh-keys/ListSshKeys"; import AddSshKeys from "./pages/ssh-keys/AddSshKeys"; @@ -29,7 +29,7 @@ } /> } /> - } /> + } /> } /> } /> diff --git a/chartered-git/src/main.rs b/chartered-git/src/main.rs index f4ff6bf..7e44478 100644 --- a/chartered-git/src/main.rs +++ a/chartered-git/src/main.rs @@ -11,6 +11,7 @@ use bytes::BytesMut; use chrono::TimeZone; use futures::future::Future; +use log::warn; use std::collections::BTreeMap; use std::{fmt::Write, pin::Pin, sync::Arc}; use thrussh::{ @@ -19,7 +20,6 @@ }; use thrussh_keys::{key, PublicKeyBase64}; use tokio_util::codec::{Decoder, Encoder as TokioEncoder}; -use log::warn; #[tokio::main] #[allow(clippy::semicolon_if_nothing_returned)] // broken clippy lint diff --git a/chartered-web/src/main.rs b/chartered-web/src/main.rs index 75f6620..5ba3a94 100644 --- a/chartered-web/src/main.rs +++ a/chartered-web/src/main.rs @@ -5,7 +5,7 @@ mod middleware; use axum::{ - handler::{delete, get, post, put}, + handler::{delete, get, patch, post, put}, http::Method, AddExtensionLayer, Router, }; @@ -69,7 +69,19 @@ axum_box_after_every_route!(Router::new().route("/login", post(endpoints::web_api::login))); let web_authenticated = axum_box_after_every_route!(Router::new() - .route("/crates/:crate", get(endpoints::web_api::crate_info)) + .route("/crates/:crate", get(endpoints::web_api::crates::info)) + .route( + "/crates/:crate/members", + get(endpoints::web_api::crates::get_members) + ) + .route( + "/crates/:crate/members", + patch(endpoints::web_api::crates::update_members) + ) + .route( + "/crates/:crate/members", + delete(endpoints::web_api::crates::delete_member) + ) .route("/ssh-key", get(endpoints::web_api::get_ssh_keys)) .route("/ssh-key", put(endpoints::web_api::add_ssh_key)) .route("/ssh-key/:id", delete(endpoints::web_api::delete_ssh_key))) @@ -95,6 +107,7 @@ .allow_methods(vec![ Method::GET, Method::POST, + Method::PATCH, Method::DELETE, Method::PUT, Method::OPTIONS, diff --git a/chartered-frontend/src/pages/SingleCrate.tsx b/chartered-frontend/src/pages/SingleCrate.tsx deleted file mode 100644 index 81b3078..0000000 100644 --- a/chartered-frontend/src/pages/SingleCrate.tsx +++ /dev/null @@ -1,252 +1,0 @@ -import React = require('react'); - -import { useState, useEffect } from 'react'; - -import { useAuth } from '../useAuth'; -import Nav from "../sections/Nav"; -import Loading from './Loading'; -import ErrorPage from './ErrorPage'; -import { Box, HouseDoor, Book, Building, PersonPlus } from 'react-bootstrap-icons'; -import { useParams } from "react-router-dom"; -import { authenticatedEndpoint, useAuthenticatedRequest } from '../util'; - -import Prism from 'react-syntax-highlighter/dist/cjs/prism'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; - -type Tab = 'readme' | 'versions' | 'members'; - -interface CrateInfo { - versions: CrateInfoVersion[], -} - -interface CrateInfoVersion { - vers: string, - homepage: string | null, - description: string | null, - documentation: string | null, - repository: string | null, - deps: CrateInfoVersionDependency[], -} - -interface CrateInfoVersionDependency { - name: string, - version_req: string, -} - -export default function SingleCrate() { - const auth = useAuth(); - const { crate } = useParams(); - - const [currentTab, setCurrentTab] = useState('readme'); - - const { response: crateInfo, error } = useAuthenticatedRequest({ - auth, - endpoint: `crates/${crate}`, - }); - - if (error) { - return ; - } else if (!crateInfo) { - return ; - } - - const crateVersion = crateInfo.versions[crateInfo.versions.length - 1]; - - return ( -
-
- ); -} - -function ReadMe(props: { crateInfo: any }) { - return ( - - ) : ( - - {children} - - ) - } - }} /> - ); -} - -function Members(props: { crateInfo: CrateInfoVersion }) { - const x = ["John Paul", "David Davidson", "Andrew Smith"]; - - return
-
- - - {x.map(v => - - - - - - - - )} - - - - - - - - - -
- - - {v}
- (that's you!) -
-
-
-
- - -
- -
- - -
-
- -
-
- - -
- -
- - -
-
-
-
-
- -
-
-
-
; -}diff --git a/chartered-web/src/endpoints/mod.rs b/chartered-web/src/endpoints/mod.rs index e8b3b14..08a6267 100644 --- a/chartered-web/src/endpoints/mod.rs +++ a/chartered-web/src/endpoints/mod.rs @@ -12,7 +12,7 @@ #[derive(serde::Serialize)] pub struct ErrorResponse { - error: String, + error: Option, } macro_rules! define_error_response { @@ -25,7 +25,7 @@ fn into_response(self) -> axum::http::Response { let body = serde_json::to_vec(&crate::endpoints::ErrorResponse { - error: self.to_string(), + error: Some(self.to_string()), }) .unwrap(); diff --git a/chartered-frontend/src/pages/crate/CrateView.tsx b/chartered-frontend/src/pages/crate/CrateView.tsx new file mode 100644 index 0000000..b54305b 100644 --- /dev/null +++ a/chartered-frontend/src/pages/crate/CrateView.tsx @@ -1,0 +1,177 @@ +import React = require('react'); + +import { useState, useEffect } from 'react'; + +import { useAuth } from '../../useAuth'; +import Nav from "../../sections/Nav"; +import Loading from '../Loading'; +import ErrorPage from '../ErrorPage'; +import { Box, HouseDoor, Book, Building } from 'react-bootstrap-icons'; +import { useParams } from "react-router-dom"; +import { useAuthenticatedRequest } from '../../util'; + +import Prism from 'react-syntax-highlighter/dist/cjs/prism'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import Members from './Members'; + +type Tab = 'readme' | 'versions' | 'members'; + +export interface CrateInfo { + versions: CrateInfoVersion[], +} + +export interface CrateInfoVersion { + vers: string, + homepage: string | null, + description: string | null, + documentation: string | null, + repository: string | null, + deps: CrateInfoVersionDependency[], +} + +export interface CrateInfoVersionDependency { + name: string, + version_req: string, +} + +export default function SingleCrate() { + const auth = useAuth(); + const { crate } = useParams(); + + const [currentTab, setCurrentTab] = useState('readme'); + + const { response: crateInfo, error } = useAuthenticatedRequest({ + auth, + endpoint: `crates/${crate}`, + }); + + if (error) { + return ; + } else if (!crateInfo) { + return ; + } + + const crateVersion = crateInfo.versions[crateInfo.versions.length - 1]; + + return ( +
+
+ ); +} + +function ReadMe(props: { crateInfo: any }) { + return ( + + ) : ( + + {children} + + ) + } + }} /> + ); +} diff --git a/chartered-frontend/src/pages/crate/Members.tsx b/chartered-frontend/src/pages/crate/Members.tsx new file mode 100644 index 0000000..9412f95 100644 --- /dev/null +++ a/chartered-frontend/src/pages/crate/Members.tsx @@ -1,0 +1,282 @@ +import React = require("react"); +import { useState } from "react"; +import { PersonPlus, Trash, CheckLg, Save, PlusLg } from 'react-bootstrap-icons'; +import { authenticatedEndpoint, useAuthenticatedRequest } from "../../util"; +import { useAuth } from "../../useAuth"; +import { Button, Modal } from "react-bootstrap"; + +interface CratesMembersResponse { + allowed_permissions: string[], + members: Member[], +} + +interface Member { + id: number, + username: string, + permissions: string[], +} + +export default function Members({ crate }: { crate: string }) { + const auth = useAuth(); + const [reload, setReload] = useState(0); + const { response, error } = useAuthenticatedRequest({ + auth, + endpoint: `crates/${crate}/members`, + }, [reload]); + + if (error) { + return <>{error}; + } else if (!response) { + return
+
+ Loading... +
+
; + } + + const allowedPermissions = response.allowed_permissions; + + return
+
+ + + {response.members.map((member, index) => + setReload(reload + 1)} + /> + )} + + + + + + + + + + + +
+
+ +
+
+ + + + + +
+
+
; +} + +function MemberListItem({ crate, member, allowedPermissions, onUpdateComplete }: { crate: string, member: Member, allowedPermissions: string[], onUpdateComplete: () => any }) { + const auth = useAuth(); + const [selectedPermissions, setSelectedPermissions] = useState(member.permissions); + const [deleting, setDeleting] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + let itemAction = <>; + + const saveUserPermissions = async () => { + setSaving(true); + + try { + let res = await fetch(authenticatedEndpoint(auth, `crates/${crate}/members`), { + method: 'PATCH', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + user_id: member.id, + permissions: selectedPermissions, + }), + }); + let json = await res.json(); + + if (json.error) { + throw new Error(json.error); + } + + onUpdateComplete(); + } catch (e) { + setError(error); + } finally { + setSaving(false); + } + }; + + const doDelete = async () => { + setSaving(true); + + try { + let res = await fetch(authenticatedEndpoint(auth, `crates/${crate}/members`), { + method: 'DELETE', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + user_id: member.id, + }), + }); + let json = await res.json(); + + if (json.error) { + throw new Error(json.error); + } + + onUpdateComplete(); + } catch (e) { + setError(error); + } finally { + setSaving(false); + } + }; + + if (saving) { + itemAction = ; + } else if (selectedPermissions.indexOf("VISIBLE") === -1) { + itemAction = ; + } else if (selectedPermissions.sort().join(',') != member.permissions.sort().join(',')) { + itemAction = ; + } + + return <> + setDeleting(false)} + onConfirm={() => doDelete()} + username={member.username} /> + + setError(null)} /> + + + + + + + + {member.username}
+ (that's you!) + + + + + + + + {itemAction} + + + ; +} + +function RenderPermissions({ allowedPermissions, selectedPermissions, userId, onChange }: { allowedPermissions: string[], selectedPermissions: string[], userId: number, onChange: (permissions) => any }) { + return ( +
+ {allowedPermissions.map((permission) => ( +
+ -1} + onChange={(e) => { + let newUserPermissions = new Set(selectedPermissions); + + if (e.target.checked) { + newUserPermissions.add(permission); + } else { + newUserPermissions.delete(permission); + } + + onChange(Array.from(newUserPermissions)); + }} + /> + +
+ ))} +
+ ); +} + +function DeleteModal(props: { show: boolean, onCancel: () => void, onConfirm: () => void, username: string }) { + return ( + + + + Are you sure you wish to remove this member from the crate? + + + +

+ Are you sure you wish to remove {props.username} from the crate? +

+
+ + + + +
+ ); +} + +function ErrorModal(props: { error?: string, onClose: () => void }) { + return ( + + + + Error + + + +

+ {props.error} +

+
+ + + +
+ ); +} diff --git a/chartered-web/src/endpoints/web_api/crate_info.rs b/chartered-web/src/endpoints/web_api/crate_info.rs deleted file mode 100644 index 0359c85..0000000 100644 --- a/chartered-web/src/endpoints/web_api/crate_info.rs +++ /dev/null @@ -1,73 +1,0 @@ -use axum::{extract, Json}; -use chartered_db::{ - crates::Crate, - users::{User, UserCratePermissionValue as Permission}, - ConnectionPool, -}; -use chartered_types::cargo::{CrateVersion, CrateVersionMetadata}; -use serde::Serialize; -use std::sync::Arc; -use thiserror::Error; - -#[derive(Error, Debug)] -pub enum Error { - #[error("Failed to query database")] - Database(#[from] chartered_db::Error), - #[error("Failed to fetch crate file")] - File(#[from] std::io::Error), - #[error("The requested crate does not exist")] - NoCrate, -} - -impl Error { - pub fn status_code(&self) -> axum::http::StatusCode { - use axum::http::StatusCode; - - match self { - Self::Database(_) | Self::File(_) => StatusCode::INTERNAL_SERVER_ERROR, - Self::NoCrate => StatusCode::NOT_FOUND, - } - } -} - -define_error_response!(Error); - -pub async fn handle( - extract::Path((_session_key, name)): extract::Path<(String, String)>, - extract::Extension(db): extract::Extension, - extract::Extension(user): extract::Extension>, -) -> Result, Error> { - let crate_ = Crate::find_by_name(db.clone(), name) - .await? - .ok_or(Error::NoCrate) - .map(std::sync::Arc::new)?; - ensure_has_crate_perm!(db, user, crate_, Permission::VISIBLE | -> Error::NoCrate); - - let versions = crate_.clone().versions(db).await?; - - Ok(Json(Response { - versions: versions - .into_iter() - .map(|v| { - let (inner, meta) = v.into_cargo_format(&crate_); - ResponseVersion { - inner: inner.into_owned(), - meta, - } - }) - .collect(), - })) -} - -#[derive(Serialize)] -pub struct ResponseVersion { - #[serde(flatten)] - meta: CrateVersionMetadata, - #[serde(flatten)] - inner: CrateVersion<'static>, -} - -#[derive(Serialize)] -pub struct Response { - versions: Vec, -} diff --git a/chartered-web/src/endpoints/web_api/mod.rs b/chartered-web/src/endpoints/web_api/mod.rs index 127f1eb..8920f76 100644 --- a/chartered-web/src/endpoints/web_api/mod.rs +++ a/chartered-web/src/endpoints/web_api/mod.rs @@ -1,8 +1,7 @@ -mod crate_info; +pub mod crates; mod login; mod ssh_key; -pub use crate_info::handle as crate_info; pub use login::handle as login; pub use ssh_key::{ handle_delete as delete_ssh_key, handle_get as get_ssh_keys, handle_put as add_ssh_key, diff --git a/chartered-web/src/endpoints/web_api/ssh_key.rs b/chartered-web/src/endpoints/web_api/ssh_key.rs index c5cba09..272f170 100644 --- a/chartered-web/src/endpoints/web_api/ssh_key.rs +++ a/chartered-web/src/endpoints/web_api/ssh_key.rs @@ -7,6 +7,8 @@ use std::sync::Arc; use thiserror::Error; +use crate::endpoints::ErrorResponse; + #[derive(Serialize)] pub struct GetResponse { keys: Vec, @@ -47,39 +49,29 @@ #[derive(Deserialize)] pub struct PutRequest { key: String, -} - -#[derive(Serialize)] -pub struct PutResponse { - error: bool, } pub async fn handle_put( extract::Extension(db): extract::Extension, extract::Extension(user): extract::Extension>, extract::Json(req): extract::Json, -) -> Result, Error> { +) -> Result, Error> { match user.insert_ssh_key(db, &req.key).await { - Ok(()) => Ok(Json(PutResponse { error: false })), + Ok(()) => Ok(Json(ErrorResponse { error: None })), Err(e @ chartered_db::Error::KeyParse(_)) => Err(Error::KeyParse(e)), Err(e) => Err(Error::Database(e)), } -} - -#[derive(Serialize)] -pub struct DeleteResponse { - error: bool, } pub async fn handle_delete( extract::Extension(db): extract::Extension, extract::Extension(user): extract::Extension>, extract::Path((_session_key, ssh_key_id)): extract::Path<(String, i32)>, -) -> Result, Error> { +) -> Result, Error> { let deleted = user.delete_user_ssh_key_by_id(db, ssh_key_id).await?; if deleted { - Ok(Json(DeleteResponse { error: false })) + Ok(Json(ErrorResponse { error: None })) } else { Err(Error::NonExistentKey) } diff --git a/chartered-web/src/endpoints/web_api/crates/info.rs b/chartered-web/src/endpoints/web_api/crates/info.rs new file mode 100644 index 0000000..0359c85 100644 --- /dev/null +++ a/chartered-web/src/endpoints/web_api/crates/info.rs @@ -1,0 +1,73 @@ +use axum::{extract, Json}; +use chartered_db::{ + crates::Crate, + users::{User, UserCratePermissionValue as Permission}, + ConnectionPool, +}; +use chartered_types::cargo::{CrateVersion, CrateVersionMetadata}; +use serde::Serialize; +use std::sync::Arc; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum Error { + #[error("Failed to query database")] + Database(#[from] chartered_db::Error), + #[error("Failed to fetch crate file")] + File(#[from] std::io::Error), + #[error("The requested crate does not exist")] + NoCrate, +} + +impl Error { + pub fn status_code(&self) -> axum::http::StatusCode { + use axum::http::StatusCode; + + match self { + Self::Database(_) | Self::File(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::NoCrate => StatusCode::NOT_FOUND, + } + } +} + +define_error_response!(Error); + +pub async fn handle( + extract::Path((_session_key, name)): extract::Path<(String, String)>, + extract::Extension(db): extract::Extension, + extract::Extension(user): extract::Extension>, +) -> Result, Error> { + let crate_ = Crate::find_by_name(db.clone(), name) + .await? + .ok_or(Error::NoCrate) + .map(std::sync::Arc::new)?; + ensure_has_crate_perm!(db, user, crate_, Permission::VISIBLE | -> Error::NoCrate); + + let versions = crate_.clone().versions(db).await?; + + Ok(Json(Response { + versions: versions + .into_iter() + .map(|v| { + let (inner, meta) = v.into_cargo_format(&crate_); + ResponseVersion { + inner: inner.into_owned(), + meta, + } + }) + .collect(), + })) +} + +#[derive(Serialize)] +pub struct ResponseVersion { + #[serde(flatten)] + meta: CrateVersionMetadata, + #[serde(flatten)] + inner: CrateVersion<'static>, +} + +#[derive(Serialize)] +pub struct Response { + versions: Vec, +} diff --git a/chartered-web/src/endpoints/web_api/crates/members.rs b/chartered-web/src/endpoints/web_api/crates/members.rs new file mode 100644 index 0000000..de2942b 100644 --- /dev/null +++ a/chartered-web/src/endpoints/web_api/crates/members.rs @@ -1,0 +1,129 @@ +use axum::{extract, Json}; +use chartered_db::{ + crates::Crate, + users::{User, UserCratePermissionValue as Permission}, + ConnectionPool, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use thiserror::Error; + +use crate::endpoints::ErrorResponse; + +#[derive(Serialize)] +pub struct GetResponse { + allowed_permissions: &'static [&'static str], + members: Vec, +} + +#[derive(Deserialize, Serialize)] +pub struct GetResponseMember { + id: i32, + username: String, + permissions: Permission, +} + +pub async fn handle_get( + extract::Path((_session_key, name)): extract::Path<(String, String)>, + extract::Extension(db): extract::Extension, + extract::Extension(user): extract::Extension>, +) -> Result, Error> { + let crate_ = Crate::find_by_name(db.clone(), name) + .await? + .ok_or(Error::NoCrate) + .map(std::sync::Arc::new)?; + ensure_has_crate_perm!(db, user, crate_, Permission::VISIBLE | -> Error::NoCrate, Permission::MANAGE_USERS | -> Error::NoPermission); + + let members = crate_ + .members(db) + .await? + .into_iter() + .map(|(user, permissions)| GetResponseMember { + id: user.id, + username: user.username, + permissions, + }) + .collect(); + + Ok(Json(GetResponse { + allowed_permissions: Permission::names(), + members, + })) +} + +#[derive(Deserialize)] +pub struct PatchRequest { + user_id: i32, + permissions: Permission, +} + +pub async fn handle_patch( + extract::Path((_session_key, name)): extract::Path<(String, String)>, + extract::Extension(db): extract::Extension, + extract::Extension(user): extract::Extension>, + extract::Json(req): extract::Json, +) -> Result, Error> { + let crate_ = Crate::find_by_name(db.clone(), name) + .await? + .ok_or(Error::NoCrate) + .map(std::sync::Arc::new)?; + ensure_has_crate_perm!(db, user, crate_, Permission::VISIBLE | -> Error::NoCrate, Permission::MANAGE_USERS | -> Error::NoPermission); + + let affected_rows = crate_ + .update_permissions(db, req.user_id, req.permissions) + .await?; + if affected_rows == 0 { + return Err(Error::UpdateConflictRemoved); + } + + Ok(Json(ErrorResponse { error: None })) +} + +#[derive(Deserialize)] +pub struct DeleteRequest { + user_id: i32, +} + +pub async fn handle_delete( + extract::Path((_session_key, name)): extract::Path<(String, String)>, + extract::Extension(db): extract::Extension, + extract::Extension(user): extract::Extension>, + extract::Json(req): extract::Json, +) -> Result, Error> { + let crate_ = Crate::find_by_name(db.clone(), name) + .await? + .ok_or(Error::NoCrate) + .map(std::sync::Arc::new)?; + ensure_has_crate_perm!(db, user, crate_, Permission::VISIBLE | -> Error::NoCrate, Permission::MANAGE_USERS | -> Error::NoPermission); + + crate_.delete_member(db, req.user_id).await?; + + Ok(Json(ErrorResponse { error: None })) +} + +#[derive(Error, Debug)] +pub enum Error { + #[error("Failed to query database")] + Database(#[from] chartered_db::Error), + #[error("The requested crate does not exist")] + NoCrate, + #[error("You don't have permission to manage users for this crate")] + NoPermission, + #[error("Permissions update conflict, user was removed as a member of the crate")] + UpdateConflictRemoved, +} + +impl Error { + pub fn status_code(&self) -> axum::http::StatusCode { + use axum::http::StatusCode; + + match self { + Self::Database(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::NoCrate => StatusCode::NOT_FOUND, + Self::NoPermission => StatusCode::FORBIDDEN, + Self::UpdateConflictRemoved => StatusCode::CONFLICT, + } + } +} + +define_error_response!(Error); diff --git a/chartered-web/src/endpoints/web_api/crates/mod.rs b/chartered-web/src/endpoints/web_api/crates/mod.rs new file mode 100644 index 0000000..19bf6bf 100644 --- /dev/null +++ a/chartered-web/src/endpoints/web_api/crates/mod.rs @@ -1,0 +1,7 @@ +mod info; +mod members; + +pub use info::handle as info; +pub use members::{ + handle_delete as delete_member, handle_get as get_members, handle_patch as update_members, +}; -- rgit 0.1.3