diff --git a/Cargo.toml b/Cargo.toml index a2ed7a2..f03b81c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,8 +2,10 @@ resolver = "2" members = [ "crates/cli", + "crates/client", "crates/model", "crates/server", + "crates/shared", ] [workspace.package] @@ -25,6 +27,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0" } shellexpand = { version = "3.1" } sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio", "time", "uuid"] } +test-log = { version = "0.2", features = ["trace"] } thiserror = { version = "2.0" } time = { version = "0.3", features = ["formatting", "macros", "serde"] } time-humanize = { version = "0.1" } @@ -34,7 +37,9 @@ tracing = { version = "0.1", features = ["log"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] } uuid = { version = "1.11", features = ["v4", "serde"] } +parcel-client = { path = "crates/client" } parcel-model = { path = "crates/model" } +parcel-shared = { path = "crates/shared" } [profile.release] opt-level = 3 diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml new file mode 100644 index 0000000..6633b83 --- /dev/null +++ b/crates/client/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "parcel-client" +version.workspace = true +edition.workspace = true +publish = false + +[dependencies] +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +time.workspace = true +uuid.workspace = true + +parcel-shared.workspace = true + +typed-builder = { version = "0.21" } + +[dependencies.reqwest] +version = "0.12" +default-features = false +features = ["json", "rustls-tls-native-roots"] diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs new file mode 100644 index 0000000..63cc83d --- /dev/null +++ b/crates/client/src/client.rs @@ -0,0 +1,220 @@ +use parcel_shared::types::api::{ + ApiMeResponse, ApiTeamResponse, ApiTeamsResponse, ApiUploadOrder, ApiUploadResponse, + ApiUploadSort, ApiUploadsResponse, +}; +use reqwest::{ + header::{AUTHORIZATION, USER_AGENT}, + Method, +}; +use typed_builder::TypedBuilder; +use uuid::Uuid; + +static PARCEL_USER_AGENT: &str = concat!("parcel/", env!("CARGO_PKG_VERSION")); + +pub struct Client { + client: reqwest::Client, + base_url: String, + api_key: String, +} + +impl Client { + pub fn build() -> ClientBuilder { + ClientBuilder::new() + } + + pub fn new(base_url: String, api_key: String) -> Self { + let client = reqwest::Client::new(); + Client { + client, + base_url, + api_key, + } + } + + pub fn new_with_client(base_url: String, api_key: String, client: reqwest::Client) -> Self { + Client { + client, + base_url, + api_key, + } + } + + pub fn get_api_key(&self) -> &str { + &self.api_key + } + + pub fn get_base_url(&self) -> &str { + &self.base_url + } + + pub fn get_client(&self) -> &reqwest::Client { + &self.client + } + + pub fn request(&self, method: Method, path: &str) -> reqwest::RequestBuilder { + let url = format!("{}{}", self.base_url, path); + self.client + .request(method, &url) + .header(USER_AGENT, PARCEL_USER_AGENT) + .header(AUTHORIZATION, format!("Bearer {}", self.api_key)) + } + + pub async fn get_me(&self) -> Result { + let response = self.request(Method::GET, "/api/1.0/me").send().await?; + let response = response.json().await?; + Ok(response) + } + + pub async fn get_teams(&self) -> Result { + let response = self.request(Method::GET, "/api/1.0/teams").send().await?; + let response = response.json().await?; + Ok(response) + } + + pub async fn get_team(&self, team_id: Uuid) -> Result { + let response = self + .request(Method::GET, &format!("/api/1.0/teams/{team_id}")) + .send() + .await?; + let response = response.json().await?; + Ok(response) + } + + pub async fn get_uploads( + &self, + query: UploadsListQuery, + ) -> Result { + let mut builder = self.request(Method::GET, "/api/1.0/uploads"); + let params = query.into_params(); + + if !params.is_empty() { + builder = builder.query(¶ms); + } + + let response = builder.send().await?; + let response = response.json().await?; + + Ok(response) + } + + pub async fn get_team_uploads( + &self, + team_id: Uuid, + query: UploadsListQuery, + ) -> Result { + let mut builder = self.request(Method::GET, &format!("/api/1.0/teams/{team_id}/uploads")); + let params = query.into_params(); + + if !params.is_empty() { + builder = builder.query(¶ms); + } + + let response = builder.send().await?; + let response = response.json().await?; + + Ok(response) + } + + pub async fn get_upload(&self, upload_id: Uuid) -> Result { + let response = self + .request(Method::GET, &format!("/api/1.0/uploads/{upload_id}")) + .send() + .await?; + let response = response.json().await?; + Ok(response) + } +} + +#[derive(Debug, Default, Clone, TypedBuilder)] +pub struct UploadsListQuery { + filename: Option, + sort: Option, + order: Option, + limit: Option, + offset: Option, +} + +impl UploadsListQuery { + fn into_params(self) -> Vec<(&'static str, String)> { + let mut params = Vec::new(); + + if let Some(filename) = self.filename { + params.push(("filename", filename)); + } + if let Some(sort) = self.sort { + params.push(("sort", sort.to_string())); + } + if let Some(order) = self.order { + params.push(("order", order.to_string())); + } + if let Some(limit) = self.limit { + params.push(("limit", limit.to_string())); + } + if let Some(offset) = self.offset { + params.push(("offset", offset.to_string())); + } + + params + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ClientError { + #[error("Request failed: {0}")] + RequestFailed(#[from] reqwest::Error), + #[error("JSON deserialization error: {0}")] + JsonError(#[from] serde_json::Error), + #[error("API error: {0}")] + ApiError(String), +} + +#[derive(Debug, Default)] +pub struct ClientBuilder { + client: Option, + base_url: Option, + api_key: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum ClientBuilderError { + #[error("Missing API key")] + MissingApiKey, + #[error("Missing base URL")] + MissingBaseUrl, +} + +impl ClientBuilder { + pub fn new() -> Self { + ClientBuilder { + client: None, + base_url: None, + api_key: None, + } + } + + pub fn with_client(mut self, client: reqwest::Client) -> Self { + self.client = Some(client); + self + } + + pub fn with_base_url(mut self, base_url: String) -> Self { + self.base_url = Some(base_url); + self + } + + pub fn with_api_key(mut self, api_key: String) -> Self { + self.api_key = Some(api_key); + self + } + + pub fn build(self) -> Result { + let client = self.client.unwrap_or_default(); + let base_url = self.base_url.ok_or(ClientBuilderError::MissingBaseUrl)?; + let api_key = self.api_key.ok_or(ClientBuilderError::MissingApiKey)?; + Ok(Client { + client, + base_url, + api_key, + }) + } +} diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs new file mode 100644 index 0000000..30b6203 --- /dev/null +++ b/crates/client/src/lib.rs @@ -0,0 +1,3 @@ +pub mod client; + +pub use client::{Client, ClientBuilder}; diff --git a/crates/model/migrations/014_api_keys.sql b/crates/model/migrations/014_api_keys.sql new file mode 100644 index 0000000..a22c4ba --- /dev/null +++ b/crates/model/migrations/014_api_keys.sql @@ -0,0 +1,15 @@ +-- Add API keys table and aassociations to users and teams. + +CREATE TABLE api_keys ( + id TEXT NOT NULL PRIMARY KEY, + owner TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + code TEXT NOT NULL, + name TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL, + created_by TEXT REFERENCES users(id) ON DELETE SET NULL, + last_used TIMESTAMP +); + +-- Make sure that the code for an API key is unique. +CREATE UNIQUE INDEX api_keys_code_uindex ON api_keys (code); diff --git a/crates/model/src/api_key.rs b/crates/model/src/api_key.rs new file mode 100644 index 0000000..7f91539 --- /dev/null +++ b/crates/model/src/api_key.rs @@ -0,0 +1,83 @@ +use serde::Serialize; +use sqlx::{FromRow, SqlitePool}; +use time::OffsetDateTime; + +use crate::{types::Key, user::User}; + +#[derive(Debug, FromRow, Serialize)] +pub struct ApiKey { + pub id: Key, + pub owner: Key, + #[serde(skip)] + pub code: String, + pub name: String, + pub enabled: bool, + pub created_at: OffsetDateTime, + pub created_by: Option>, + pub last_used: Option, +} + +impl ApiKey { + pub fn new(owner: Key, code: String, name: String, created_by: Key) -> Self { + let id = Key::new(); + Self { + id, + owner, + code, + name, + enabled: true, + created_at: OffsetDateTime::now_utc(), + created_by: Some(created_by), + last_used: None, + } + } + + pub async fn create(&self, pool: &SqlitePool) -> sqlx::Result<()> { + sqlx::query( + "INSERT INTO api_keys \ + (id, owner, code, name, enabled, created_at, created_by) \ + VALUES ($1, $2, $3, $4, $5, $6, $7)", + ) + .bind(self.id) + .bind(self.owner) + .bind(&self.code) + .bind(&self.name) + .bind(self.enabled) + .bind(self.created_at) + .bind(self.created_by) + .execute(pool) + .await?; + Ok(()) + } + + pub async fn get(pool: &SqlitePool, id: Key) -> sqlx::Result> { + sqlx::query_as("SELECT * FROM api_keys WHERE id = $1") + .bind(id) + .fetch_optional(pool) + .await + } + + pub async fn get_by_code(pool: &SqlitePool, code: &str) -> sqlx::Result> { + sqlx::query_as("SELECT * FROM api_keys WHERE code = $1") + .bind(code) + .fetch_optional(pool) + .await + } + + pub async fn record_last_use(&mut self, pool: &SqlitePool) -> sqlx::Result<()> { + let now = OffsetDateTime::now_utc(); + + let result = sqlx::query("UPDATE api_keys SET last_used = $1 WHERE id = $2") + .bind(now) + .bind(self.id) + .execute(pool) + .await?; + + if result.rows_affected() == 0 { + return Err(sqlx::Error::RowNotFound); + } + + self.last_used = Some(now); + Ok(()) + } +} diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 5a71c61..5d6ee78 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -1,3 +1,4 @@ +pub mod api_key; pub mod migration; pub mod password; pub mod team; diff --git a/crates/model/src/team.rs b/crates/model/src/team.rs index 5d74382..c9b7584 100644 --- a/crates/model/src/team.rs +++ b/crates/model/src/team.rs @@ -85,10 +85,14 @@ impl Team { } pub async fn get_for_user(pool: &SqlitePool, user: Key) -> sqlx::Result> { - sqlx::query_as("SELECT teams.* FROM teams LEFT JOIN team_members ON team_members.team = teams.id WHERE team_members.user = $1") - .bind(user) - .fetch_all(pool) - .await + sqlx::query_as( + "SELECT teams.* FROM teams \ + LEFT JOIN team_members ON team_members.team = teams.id \ + WHERE team_members.user = $1", + ) + .bind(user) + .fetch_all(pool) + .await } pub async fn delete(&self, pool: &SqlitePool) -> sqlx::Result<()> { diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 69a1c7d..d12bfaa 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -34,13 +34,12 @@ tracing-subscriber.workspace = true uuid.workspace = true parcel-model.workspace = true +parcel-shared.workspace = true fast_qr = { version = "0.13", features = ["svg"] } mime = { version = "0.3" } -minijinja = { version = "2.0", features = ["unicode", "loader", "json", "urlencode", "speedups"] } nanoid = { version = "0.4" } notify = { version = "8.0" } -poem = { version = "3.1", features = ["anyhow", "cookie", "csrf", "multipart", "session", "static-files"] } rust-embed = { version = "8.0", features = ["debug-embed", "interpolate-folder-path"] } serde_html_form = { version = "0.2" } totp-lite = { version = "2.0" } @@ -50,5 +49,30 @@ validator = { version = "0.20", features = ["derive"] } esbuild-bundle = { git = "https://github.com/BlakeRain/esbuild-bundle", tag = "v0.3.0" } poem-route-macro = { git = "https://github.com/BlakeRain/poem-route-macro" } +[dependencies.minijinja] +version = "2.0" +features = [ + "unicode", + "loader", + "json", + "urlencode", + "speedups", +] + +[dependencies.poem] +version = "3.1" +features = [ + "anyhow", + "cookie", + "csrf", + "multipart", + "session", + "static-files", + "test", +] + +[dev-dependencies] +test-log.workspace = true + [build-dependencies] build-data = { version = "0.3" } diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index 30529d2..756c7fb 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -11,6 +11,7 @@ use crate::{env::Env, workers::previews::PreviewWorker}; mod extractors { pub mod admin; + pub mod api_key; pub mod user; } @@ -19,6 +20,7 @@ pub mod templates; mod handlers { pub mod admin; + pub mod api; pub mod index; pub mod teams; pub mod uploads; @@ -77,53 +79,60 @@ pub fn create_app( let routes = add_debug_routes(define_routes!({ *"/static" { static_ep } - "/" handlers::index::index GET - "/tab" handlers::index::tab GET - "/uploads/delete" handlers::uploads::delete POST - "/uploads/list" handlers::uploads::list GET - "/uploads/list/:page" handlers::uploads::page GET - "/uploads/new" handlers::uploads::new GET POST - "/uploads/:id" handlers::uploads::upload GET DELETE - "/uploads/:id/download" handlers::uploads::download GET POST - "/uploads/:id/edit" handlers::uploads::edit GET POST - "/uploads/:id/edit/slug" handlers::uploads::check_slug POST - "/uploads/:id/preview" handlers::uploads::preview GET - "/uploads/:id/preview/error" handlers::uploads::preview_error DELETE - "/uploads/:id/public" handlers::uploads::public POST - "/uploads/:id/reset" handlers::uploads::reset POST - "/uploads/:id/share" handlers::uploads::share GET - "/uploads/:id/transfer" handlers::uploads::transfer GET POST - "/uploads/:owner/:slug" handlers::uploads::custom_upload GET - "/teams/:id" handlers::teams::team GET - "/teams/:id/settings" handlers::teams::settings::settings GET POST - "/teams/:id/settings/slug" handlers::teams::settings::check_slug POST - "/teams/:id/tab" handlers::teams::tab GET - "/teams/:id/uploads/list" handlers::teams::uploads::list GET - "/teams/:id/uploads/list/:page" handlers::teams::uploads::page GET - "/user/signin" handlers::users::signin GET POST - "/user/signin/totp" handlers::users::signin_totp GET POST - "/user/signout" handlers::users::signout GET - "/user/settings" handlers::users::settings GET POST - "/user/settings/password" handlers::users::password POST - "/user/settings/totp" handlers::users::setup_totp GET POST - "/user/settings/totp/remove" handlers::users::remove_totp GET POST - "/admin" handlers::admin::admin GET - "/admin/setup" handlers::admin::setup::setup GET POST - "/admin/uploads" handlers::admin::uploads::uploads GET - "/admin/uploads/cache" handlers::admin::uploads::cache GET POST DELETE - "/admin/users" handlers::admin::users::users GET - "/admin/users/new" handlers::admin::users::new GET POST - "/admin/users/new/username" handlers::admin::users::new_username POST - "/admin/users/:id" handlers::admin::users::user GET POST DELETE - "/admin/users/:id/disable" handlers::admin::users::disable_user POST - "/admin/users/:id/enable" handlers::admin::users::enable_user POST - "/admin/users/:id/masquerade" handlers::admin::users::masquerade GET - "/admin/users/:id/username" handlers::admin::users::check_username POST - "/admin/teams" handlers::admin::teams::teams GET - "/admin/teams/new" handlers::admin::teams::new GET POST - "/admin/teams/new/slug" handlers::admin::teams::check_new_slug POST - "/admin/teams/:id" handlers::admin::teams::team GET POST DELETE - "/admin/teams/:id/slug" handlers::admin::teams::check_slug POST + "/" handlers::index::index GET + "/admin" handlers::admin::admin GET + "/admin/setup" handlers::admin::setup::setup GET POST + "/admin/teams" handlers::admin::teams::teams GET + "/admin/teams/:id" handlers::admin::teams::team GET POST DELETE + "/admin/teams/:id/slug" handlers::admin::teams::check_slug POST + "/admin/teams/new" handlers::admin::teams::new GET POST + "/admin/teams/new/slug" handlers::admin::teams::check_new_slug POST + "/admin/uploads" handlers::admin::uploads::uploads GET + "/admin/uploads/cache" handlers::admin::uploads::cache GET POST DELETE + "/admin/users" handlers::admin::users::users GET + "/admin/users/:id" handlers::admin::users::user GET POST DELETE + "/admin/users/:id/disable" handlers::admin::users::disable_user POST + "/admin/users/:id/enable" handlers::admin::users::enable_user POST + "/admin/users/:id/masquerade" handlers::admin::users::masquerade GET + "/admin/users/:id/username" handlers::admin::users::check_username POST + "/admin/users/new" handlers::admin::users::new GET POST + "/admin/users/new/username" handlers::admin::users::new_username POST + "/tab" handlers::index::tab GET + "/teams/:id" handlers::teams::team GET + "/teams/:id/settings" handlers::teams::settings::settings GET POST + "/teams/:id/settings/slug" handlers::teams::settings::check_slug POST + "/teams/:id/tab" handlers::teams::tab GET + "/teams/:id/uploads/list" handlers::teams::uploads::list GET + "/teams/:id/uploads/list/:page" handlers::teams::uploads::page GET + "/uploads/:id" handlers::uploads::upload GET DELETE + "/uploads/:id/download" handlers::uploads::download GET POST + "/uploads/:id/edit" handlers::uploads::edit GET POST + "/uploads/:id/edit/slug" handlers::uploads::check_slug POST + "/uploads/:id/preview" handlers::uploads::preview GET + "/uploads/:id/preview/error" handlers::uploads::preview_error DELETE + "/uploads/:id/public" handlers::uploads::public POST + "/uploads/:id/reset" handlers::uploads::reset POST + "/uploads/:id/share" handlers::uploads::share GET + "/uploads/:id/transfer" handlers::uploads::transfer GET POST + "/uploads/:owner/:slug" handlers::uploads::custom_upload GET + "/uploads/delete" handlers::uploads::delete POST + "/uploads/list" handlers::uploads::list GET + "/uploads/list/:page" handlers::uploads::page GET + "/uploads/new" handlers::uploads::new GET POST + "/user/settings" handlers::users::settings GET POST + "/user/settings/password" handlers::users::password POST + "/user/settings/totp" handlers::users::setup_totp GET POST + "/user/settings/totp/remove" handlers::users::remove_totp GET POST + "/user/signin" handlers::users::signin GET POST + "/user/signin/totp" handlers::users::signin_totp GET POST + "/user/signout" handlers::users::signout GET + + "/api/1.0/me" handlers::api::me GET + "/api/1.0/teams" handlers::api::teams GET + "/api/1.0/teams/:team_id" handlers::api::team GET + "/api/1.0/teams/:team_id/uploads" handlers::api::team_uploads GET + "/api/1.0/uploads" handlers::api::uploads GET + "/api/1.0/uploads/:upload_id" handlers::api::upload GET PUT POST DELETE })); let routes = add_tailwind_rebuilder(routes)?.into_endpoint(); diff --git a/crates/server/src/app/extractors/api_key.rs b/crates/server/src/app/extractors/api_key.rs new file mode 100644 index 0000000..79da7ae --- /dev/null +++ b/crates/server/src/app/extractors/api_key.rs @@ -0,0 +1,83 @@ +use parcel_model::{api_key::ApiKey, user::User}; +use poem::{ + http::StatusCode, + web::headers::{authorization::Bearer, Authorization, HeaderMapExt}, + FromRequest, Request, RequestBody, +}; + +pub struct BearerApiKey { + pub key: ApiKey, + pub user: User, +} + +impl std::ops::Deref for BearerApiKey { + type Target = ApiKey; + + fn deref(&self) -> &Self::Target { + &self.key + } +} + +impl<'r> FromRequest<'r> for BearerApiKey { + async fn from_request(request: &'r Request, _: &mut RequestBody) -> poem::Result { + let env = request + .data::() + .expect("Env to be provided"); + + let Some(authorization) = request.headers().typed_get::>() else { + tracing::error!("No Authorization header found"); + return Err(poem::Error::from_string( + "Authorization header not found", + StatusCode::UNAUTHORIZED, + )); + }; + + let Some(mut key) = ApiKey::get_by_code(&env.pool, authorization.token()) + .await + .map_err(|err| { + tracing::error!(?err, "Failed to get API key by code"); + poem::Error::from_string("Invalid API key", StatusCode::FORBIDDEN) + })? + else { + tracing::error!("Invalid API key in Authorization header"); + return Err(poem::Error::from_string( + "Invalid API key", + StatusCode::UNAUTHORIZED, + )); + }; + + if !key.enabled { + tracing::error!(key = ?key.name, "API key is disabled"); + return Err(poem::Error::from_string( + "Invalid API key", + StatusCode::FORBIDDEN, + )); + } + + let user = User::get(&env.pool, key.owner) + .await + .map_err(|err| { + tracing::error!(key = ?key.name, ?err, "Failed to get user by API key owner"); + poem::Error::from_status(StatusCode::INTERNAL_SERVER_ERROR) + })? + .ok_or_else(|| { + tracing::error!(key = ?key.name, "User not found for API key owner"); + poem::Error::from_string("User not found", StatusCode::NOT_FOUND) + })?; + + if !user.enabled { + tracing::error!(key = ?key.name, user = %user.id, "User is disabled"); + return Err(poem::Error::from_string( + "User is disabled", + StatusCode::FORBIDDEN, + )); + } + + key.record_last_use(&env.pool).await.map_err(|err| { + tracing::error!(key = ?key.name, ?err, "Failed to record last use of API key"); + poem::Error::from_status(StatusCode::INTERNAL_SERVER_ERROR) + })?; + + Ok(Self { key, user }) + } +} diff --git a/crates/server/src/app/handlers/api.rs b/crates/server/src/app/handlers/api.rs new file mode 100644 index 0000000..b1dc066 --- /dev/null +++ b/crates/server/src/app/handlers/api.rs @@ -0,0 +1,39 @@ +use parcel_model::user::User; +use parcel_shared::types::api::ApiMeResponse; +use poem::web::{Data, Json}; + +use crate::{app::extractors::api_key::BearerApiKey, env::Env}; + +mod teams; +mod uploads; + +pub use teams::{get_team, get_teams}; +pub use uploads::{ + delete_upload, get_team_uploads, get_upload, get_uploads, post_upload, put_upload, +}; + +#[poem::handler] +pub async fn get_me(env: Data<&Env>, api_key: BearerApiKey) -> poem::Result> { + tracing::info!(key = ?api_key.name, "API key used to get user info"); + + let Some(user) = User::get(&env.pool, api_key.owner).await.map_err(|err| { + tracing::error!(api_key = ?api_key.name, ?err, "Failed to get user by API key owner"); + poem::Error::from_status(poem::http::StatusCode::INTERNAL_SERVER_ERROR) + })? + else { + tracing::error!(api_key = ?api_key.name, "User not found for API key owner"); + return Err(poem::Error::from_string( + "User not found", + poem::http::StatusCode::NOT_FOUND, + )); + }; + + let response = ApiMeResponse { + id: user.id.into(), + username: user.username, + name: user.name, + last_access: user.last_access, + }; + + Ok(Json(response)) +} diff --git a/crates/server/src/app/handlers/api/teams.rs b/crates/server/src/app/handlers/api/teams.rs new file mode 100644 index 0000000..9ff178b --- /dev/null +++ b/crates/server/src/app/handlers/api/teams.rs @@ -0,0 +1,80 @@ +use parcel_model::{team::Team, types::Key}; +use parcel_shared::types::api::{ApiTeamInfo, ApiTeamResponse, ApiTeamsResponse}; +use poem::{ + http::StatusCode, + web::{Data, Json, Path}, +}; + +use crate::{app::extractors::api_key::BearerApiKey, env::Env}; + +#[poem::handler] +pub async fn get_teams( + env: Data<&Env>, + api_key: BearerApiKey, +) -> poem::Result> { + tracing::info!(key = ?api_key.name, "API key used to get teams"); + + let teams = Team::get_for_user(&env.pool, api_key.owner) + .await + .map_err(|err| { + tracing::error!(api_key = ?api_key.name, ?err, "Failed to get teams for API key owner"); + poem::Error::from_status(StatusCode::INTERNAL_SERVER_ERROR) + })?; + + let teams = teams + .into_iter() + .map(|team| ApiTeamInfo { + id: team.id.into(), + name: team.name, + slug: team.slug, + }) + .collect::>(); + + Ok(Json(ApiTeamsResponse { teams })) +} + +#[poem::handler] +pub async fn get_team( + env: Data<&Env>, + api_key: BearerApiKey, + Path(team_id): Path>, +) -> poem::Result> { + tracing::info!(key = ?api_key.name, %team_id, "API key used to get team info"); + + let is_member = api_key + .user + .is_member_of(&env.pool, team_id) + .await + .map_err(|err| { + tracing::error!(api_key = ?api_key.name, ?err, "Failed to check team membership"); + poem::Error::from_status(StatusCode::INTERNAL_SERVER_ERROR) + })?; + + if !is_member { + tracing::error!( + api_key = ?api_key.name, + owner = ?api_key.user.username, + %team_id, + "API key owner is not a member of the team" + ); + + return Err(poem::Error::from_status(StatusCode::NOT_FOUND)); + } + + let Some(team) = Team::get(&env.pool, team_id).await.map_err(|err| { + tracing::error!(api_key = ?api_key.name, ?err, "Failed to get team by ID"); + poem::Error::from_status(StatusCode::INTERNAL_SERVER_ERROR) + })? + else { + tracing::error!(api_key = ?api_key.name, %team_id, "Team not found"); + return Err(poem::Error::from_status(StatusCode::NOT_FOUND)); + }; + + let team = ApiTeamInfo { + id: team.id.into(), + name: team.name, + slug: team.slug, + }; + + Ok(Json(ApiTeamResponse { team })) +} diff --git a/crates/server/src/app/handlers/api/uploads.rs b/crates/server/src/app/handlers/api/uploads.rs new file mode 100644 index 0000000..46604ca --- /dev/null +++ b/crates/server/src/app/handlers/api/uploads.rs @@ -0,0 +1,477 @@ +use parcel_model::{ + password::StoredPassword, + team::Team, + types::Key, + upload::{Upload, UploadList, UploadOrder, UploadPermission, UploadStats}, +}; +use parcel_shared::types::api::{ + ApiUpload, ApiUploadListItem, ApiUploadModifyDownloadLimit, ApiUploadModifyExpiry, + ApiUploadModifyPassword, ApiUploadModifyRequest, ApiUploadModifySlug, ApiUploadOrder, + ApiUploadResponse, ApiUploadSort, ApiUploadsResponse, +}; +use poem::{ + http::StatusCode, + web::{Data, Json, Path, Query}, +}; +use serde::Deserialize; + +use crate::{app::extractors::api_key::BearerApiKey, env::Env}; + +#[derive(Debug, Deserialize)] +pub struct UploadsQuery { + #[serde(default)] + limit: Option, + #[serde(default)] + offset: Option, + #[serde(default)] + filename: Option, + #[serde(default)] + sort: Option, + #[serde(default)] + order: Option, +} + +impl UploadsQuery { + fn get_offset(&self) -> u32 { + self.offset.unwrap_or(0) + } + + fn get_limit(&self) -> u32 { + self.limit.unwrap_or(100).clamp(1, 100) + } + + fn get_sort(&self) -> UploadOrder { + self.sort + .map(|sort| match sort { + ApiUploadSort::Filename => UploadOrder::Filename, + ApiUploadSort::Size => UploadOrder::Size, + ApiUploadSort::Downloads => UploadOrder::Downloads, + ApiUploadSort::ExpiryDate => UploadOrder::ExpiryDate, + ApiUploadSort::UploadedAt => UploadOrder::UploadedAt, + }) + .unwrap_or(UploadOrder::UploadedAt) + } + + fn get_order(&self) -> bool { + self.order + .map(|order| match order { + ApiUploadOrder::Asc => true, + ApiUploadOrder::Desc => false, + }) + .unwrap_or(false) + } +} + +fn api_list_item(item: UploadList) -> ApiUploadListItem { + ApiUploadListItem { + id: item.id.into(), + slug: item.slug, + filename: item.filename, + size: item.size, + public: item.public, + has_password: item.has_password, + downloads: item.downloads, + limit: item.limit, + remaining: item.remaining, + expiry_date: item.expiry_date.map(|date| date.midnight().assume_utc()), + custom_slug: item.custom_slug, + uploaded_by_id: item.uploaded_by_id.map(|id| id.into()), + uploaded_by_name: item.uploaded_by_name, + uploaded_at: item.uploaded_at, + } +} + +fn api_upload(upload: Upload) -> ApiUpload { + ApiUpload { + id: upload.id.into(), + slug: upload.slug, + filename: upload.filename, + size: upload.size, + public: upload.public, + has_password: upload.password.is_some(), + downloads: upload.downloads, + limit: upload.limit, + remaining: upload.remaining, + expiry_date: upload.expiry_date.map(|date| date.midnight().assume_utc()), + custom_slug: upload.custom_slug, + uploaded_by: upload.uploaded_by.map(|id| id.into()), + uploaded_at: upload.uploaded_at, + } +} + +#[poem::handler] +pub async fn get_uploads( + env: Data<&Env>, + api_key: BearerApiKey, + Query(query): Query, +) -> poem::Result> { + let offset = query.get_offset(); + let limit = query.get_limit(); + let sort = query.get_sort(); + let order = query.get_order(); + + let stats = UploadStats::get_for_user(&env.pool, api_key.user.id) + .await + .map_err(|err| { + tracing::error!( + api_key = ?api_key.name, + ?err, + "Failed to get upload stats for API key owner" + ); + + poem::Error::from_status(StatusCode::INTERNAL_SERVER_ERROR) + })?; + + let uploads = UploadList::get_for_user( + &env.pool, + api_key.user.id, + query.filename.as_deref(), + sort, + order, + offset, + limit, + ) + .await + .map_err(|err| { + tracing::error!( + api_key = ?api_key.name, + ?err, + "Failed to get uploads for API key owner" + ); + + poem::Error::from_status(StatusCode::INTERNAL_SERVER_ERROR) + })?; + + let uploads = uploads.into_iter().map(api_list_item).collect::>(); + + Ok(Json(ApiUploadsResponse { + offset, + total: stats.total as u32, + total_size: stats.size, + uploads, + })) +} + +#[poem::handler] +pub async fn get_team_uploads( + env: Data<&Env>, + api_key: BearerApiKey, + Path(team_id): Path>, + Query(query): Query, +) -> poem::Result> { + let is_member = api_key + .user + .is_member_of(&env.pool, team_id) + .await + .map_err(|err| { + tracing::error!( + api_key = ?api_key.name, + team = %team_id, + ?err, + "Failed to check team membership" + ); + + poem::Error::from_status(StatusCode::INTERNAL_SERVER_ERROR) + })?; + + if !is_member { + tracing::error!( + api_key = ?api_key.name, + owner = ?api_key.user.username, + team = %team_id, + "API key owner is not a member of the team" + ); + + return Err(poem::Error::from_status(StatusCode::NOT_FOUND)); + } + + let stats = UploadStats::get_for_team(&env.pool, team_id) + .await + .map_err(|err| { + tracing::error!( + api_key = ?api_key.name, + team = %team_id, + ?err, + "Failed to get upload stats for team" + ); + + poem::Error::from_status(StatusCode::INTERNAL_SERVER_ERROR) + })?; + + let offset = query.get_offset(); + let limit = query.get_limit(); + let sort = query.get_sort(); + let order = query.get_order(); + + let uploads = UploadList::get_for_user( + &env.pool, + api_key.user.id, + query.filename.as_deref(), + sort, + order, + offset, + limit, + ) + .await + .map_err(|err| { + tracing::error!( + api_key = ?api_key.name, + ?err, + "Failed to get uploads for API key owner" + ); + + poem::Error::from_status(StatusCode::INTERNAL_SERVER_ERROR) + })?; + + let uploads = uploads.into_iter().map(api_list_item).collect::>(); + + Ok(Json(ApiUploadsResponse { + offset, + total: stats.total as u32, + total_size: stats.size, + uploads, + })) +} + +#[poem::handler] +pub async fn get_upload( + env: Data<&Env>, + api_key: BearerApiKey, + Path(id): Path>, +) -> poem::Result> { + let Some(upload) = Upload::get(&env.pool, id).await.map_err(|err| { + tracing::error!( + api_key = ?api_key.name, + upload = %id, + ?err, + "Failed to get upload by ID" + ); + + poem::Error::from_status(StatusCode::INTERNAL_SERVER_ERROR) + })? + else { + tracing::error!( + api_key = ?api_key.name, + upload = %id, + "Upload not found" + ); + + return Err(poem::Error::from_status(StatusCode::NOT_FOUND)); + }; + + let can_access = upload + .can_access(&env.pool, Some(&api_key.user), UploadPermission::View) + .await + .map_err(|err| { + tracing::error!( + api_key = ?api_key.name, + upload = %id, + ?err, + "Failed to check upload permission" + ); + + poem::Error::from_status(StatusCode::INTERNAL_SERVER_ERROR) + })?; + + if !can_access { + tracing::error!( + api_key = ?api_key.name, + upload = %id, + "API key owner does not have permission to access the upload" + ); + + return Err(poem::Error::from_status(StatusCode::FORBIDDEN)); + } + + let upload = api_upload(upload); + Ok(Json(ApiUploadResponse { upload })) +} + +#[poem::handler] +pub async fn put_upload( + env: Data<&Env>, + api_key: BearerApiKey, + Path(id): Path>, + Json(request): Json, +) -> poem::Result> { + let Some(mut upload) = Upload::get(&env.pool, id).await.map_err(|err| { + tracing::error!( + api_key = ?api_key.name, + upload = %id, + ?err, + "Failed to get upload by ID" + ); + + poem::Error::from_status(StatusCode::INTERNAL_SERVER_ERROR) + })? + else { + tracing::error!( + api_key = ?api_key.name, + upload = %id, + "Upload not found" + ); + + return Err(poem::Error::from_status(StatusCode::NOT_FOUND)); + }; + + let can_access = upload + .can_access(&env.pool, Some(&api_key.user), UploadPermission::View) + .await + .map_err(|err| { + tracing::error!( + api_key = ?api_key.name, + upload = %id, + ?err, + "Failed to check upload permission" + ); + + poem::Error::from_status(StatusCode::INTERNAL_SERVER_ERROR) + })?; + + if !can_access { + tracing::error!( + api_key = ?api_key.name, + upload = %id, + "API key owner does not have permission to access the upload" + ); + + return Err(poem::Error::from_status(StatusCode::FORBIDDEN)); + } + + if let Some(ApiUploadModifySlug::Custom { ref slug }) = request.slug { + if upload.custom_slug.as_ref() != Some(slug) { + let exists = if let Some(owner) = upload.owner_user { + Upload::custom_slug_exists(&env.pool, owner, Some(id), slug).await + } else if let Some(owner) = upload.owner_team { + Upload::custom_team_slug_exists(&env.pool, owner, Some(id), slug).await + } else { + tracing::error!(%id, "Upload has no owner"); + return Err(poem::Error::from_status(StatusCode::INTERNAL_SERVER_ERROR)); + } + .map_err(|err| { + tracing::error!( + api_key = ?api_key.name, + upload = %id, + ?err, + "Unable to check if custom slug exists" + ); + + poem::Error::from_status(StatusCode::INTERNAL_SERVER_ERROR) + })?; + + if exists { + tracing::error!( + api_key = ?api_key.name, + upload = %id, + "Custom slug already exists" + ); + + return Err(poem::Error::from_status(StatusCode::CONFLICT)); + } + } + } + + if let Some(filename) = request.filename { + upload.filename = filename; + } + + if let Some(slug) = request.slug { + upload.custom_slug = match slug { + ApiUploadModifySlug::Auto => None, + ApiUploadModifySlug::Custom { slug } => { + let slug = slug.trim(); + + if slug.is_empty() { + tracing::error!( + api_key = ?api_key.name, + upload = %id, + "Custom slug cannot be empty" + ); + + return Err(poem::Error::from_status(StatusCode::BAD_REQUEST)); + } + + Some(String::from(slug)) + } + }; + } + + if let Some(public) = request.public { + upload.public = public; + } + + if let Some(limit) = request.limit { + let limit = match limit { + ApiUploadModifyDownloadLimit::Unlimited => None, + ApiUploadModifyDownloadLimit::Limited { limit } => Some(limit), + }; + + let remaining = if upload.limit == limit { + upload.remaining.or(limit) + } else { + limit + }; + + upload.limit = limit; + upload.remaining = remaining; + } + + if let Some(expiry) = request.expiry { + upload.expiry_date = match expiry { + ApiUploadModifyExpiry::Never => None, + ApiUploadModifyExpiry::Date { date } => Some(date.date()), + }; + } + + if let Some(password) = request.password { + upload.password = match password { + ApiUploadModifyPassword::None => None, + ApiUploadModifyPassword::Set { password } => { + if password.is_empty() { + tracing::error!( + api_key = ?api_key.name, + upload = %id, + "Password cannot be empty" + ); + + return Err(poem::Error::from_status(StatusCode::BAD_REQUEST)); + } + + Some(StoredPassword::new(&password).map_err(|err| { + tracing::error!( + api_key = ?api_key.name, + upload = %id, + ?err, + "Failed to create stored password" + ); + + poem::Error::from_status(StatusCode::INTERNAL_SERVER_ERROR) + })?) + } + }; + } + + upload.save(&env.pool).await.map_err(|err| { + tracing::error!( + api_key = ?api_key.name, + upload = %id, + ?err, + "Failed to save upload" + ); + + poem::Error::from_status(StatusCode::INTERNAL_SERVER_ERROR) + })?; + + let upload = api_upload(upload); + Ok(Json(ApiUploadResponse { upload })) +} + +#[poem::handler] +pub async fn post_upload() -> poem::Result<()> { + todo!() +} + +#[poem::handler] +pub async fn delete_upload() -> poem::Result<()> { + todo!() +} diff --git a/crates/server/src/app/handlers/teams/settings.rs b/crates/server/src/app/handlers/teams/settings.rs index daa4115..c76a692 100644 --- a/crates/server/src/app/handlers/teams/settings.rs +++ b/crates/server/src/app/handlers/teams/settings.rs @@ -204,12 +204,10 @@ pub async fn post_settings( ); // Don't let team managers accidently add new members by forming POST requests. - let is_member = team.is_member(&env.pool, user_id) - .await - .map_err(|err| { - tracing::error!(?err, %user_id, "Failed to check if user is active"); - InternalServerError(err) - })?; + let is_member = team.is_member(&env.pool, user_id).await.map_err(|err| { + tracing::error!(?err, %user_id, "Failed to check if user is active"); + InternalServerError(err) + })?; if !is_member { tracing::error!( diff --git a/crates/server/src/env.rs b/crates/server/src/env.rs index 9b7310f..ddde9ce 100644 --- a/crates/server/src/env.rs +++ b/crates/server/src/env.rs @@ -47,9 +47,8 @@ pub struct Inner { } impl Env { - pub async fn new( + pub async fn new_with_pool( Args { - db, config_dir, cache_dir, analytics_domain, @@ -58,6 +57,7 @@ impl Env { max_preview_size, .. }: &Args, + pool: SqlitePool, ) -> sqlx::Result { let config_dir = config_dir.clone(); if !config_dir.exists() { @@ -76,18 +76,6 @@ impl Env { std::fs::create_dir_all(&temp_dir)?; } - tracing::info!(?db, "Creating SQLite connection pool"); - let opts = SqliteConnectOptions::from_str(db)? - .create_if_missing(true) - .journal_mode(SqliteJournalMode::Wal) - .pragma("synchronous", "normal") - .pragma("journal_size_limit", "6144000") - .pragma("mmap_size", "268435456"); - let pool = SqlitePool::connect_with(opts).await?; - - tracing::info!("Running database migrations"); - MIGRATOR.run(&pool).await?; - let analytics_domain = analytics_domain.clone(); let plausible_script = plausible_script.clone(); let preview_generation_interval = Duration::from(*preview_generation_interval); @@ -105,4 +93,20 @@ impl Env { Ok(Self { inner }) } + + pub async fn new(args: &Args) -> sqlx::Result { + tracing::info!(db = ?args.db, "Creating SQLite connection pool"); + let opts = SqliteConnectOptions::from_str(&args.db)? + .create_if_missing(true) + .journal_mode(SqliteJournalMode::Wal) + .pragma("synchronous", "normal") + .pragma("journal_size_limit", "6144000") + .pragma("mmap_size", "268435456"); + let pool = SqlitePool::connect_with(opts).await?; + + tracing::info!("Running database migrations"); + MIGRATOR.run(&pool).await?; + + Self::new_with_pool(args, pool).await + } } diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 4151ffa..77ea9b0 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -6,4 +6,3 @@ pub mod utils; pub mod workers { pub mod previews; } - diff --git a/crates/server/src/workers/previews/config.rs b/crates/server/src/workers/previews/config.rs index 8bb23df..0c7a14f 100644 --- a/crates/server/src/workers/previews/config.rs +++ b/crates/server/src/workers/previews/config.rs @@ -243,4 +243,3 @@ impl PreviewerCommand { true } } - diff --git a/crates/server/tests/fixtures/api_tests.sql b/crates/server/tests/fixtures/api_tests.sql new file mode 100644 index 0000000..6bb27c5 --- /dev/null +++ b/crates/server/tests/fixtures/api_tests.sql @@ -0,0 +1,28 @@ +-- Test user 1: password is 'password123' +INSERT INTO users (id, username, name, password, enabled, admin, "limit", created_at, created_by) + VALUES ( + '184f75f5-d345-4aae-92da-83c853125793', 'user1', 'User 1', + '$argon2id$v=19$m=20480,t=2,p=1$J4vIwv/qkliQHossNbUhiQ$Ma+2B8Ydw+fB7dtjL2Y2hUdS41VzhCw9QTB9Sw3EZ50', + 1, 0, NULL, + "2025-01-01T00:00:00Z", NULL + ); + +INSERT INTO api_keys (id, owner, code, name, enabled, created_at, created_by) + VALUES ( + '59d114ef-9de1-409c-8b31-caf7fa8bf851', + '184f75f5-d345-4aae-92da-83c853125793', + 'testapikey1234567890', + 'Test API Key 1', + 1, + "2025-01-01T00:00:00Z", + '184f75f5-d345-4aae-92da-83c853125793' + ); + +INSERT INTO uploads (id, owner_user, slug, filename, size, public, downloads, uploaded_at, has_preview) + VALUES ( + 'c968840a-79d0-4b95-a36e-2cd95e4773ac', '184f75f5-d345-4aae-92da-83c853125793', + 'ywAjcpQi', 'testfile1.txt', 1024, 0, 0, '2025-01-01T00:00:00Z', 0 + ), ( + '76da2990-4d85-48ba-9931-e95581eb8ed2', '184f75f5-d345-4aae-92da-83c853125793', + 'H7rmeHqJ', 'testfile2.txt', 2048, 0, 0, '2025-01-02T00:00:00Z', 0 + ); diff --git a/crates/server/tests/test_api_list_uploads.rs b/crates/server/tests/test_api_list_uploads.rs new file mode 100644 index 0000000..4b22b1b --- /dev/null +++ b/crates/server/tests/test_api_list_uploads.rs @@ -0,0 +1,36 @@ +use anyhow::Context; +use parcel_shared::types::api::ApiUploadsResponse; +use poem::{http::StatusCode, web::headers::Authorization}; +use sqlx::SqlitePool; + +mod utils; + +#[test_log::test(sqlx::test( + migrator = "parcel_model::migration::MIGRATOR", + fixtures("api_tests.sql") +))] +async fn test_api_list_uploads(pool: SqlitePool) -> anyhow::Result<()> { + let (_, client) = utils::create_test_client(&pool) + .await + .context("failed to create test client")?; + + let response = client + .get("/api/1.0/uploads") + .typed_header( + Authorization::bearer(utils::TEST_API_KEY_1) + .context("failed to create 'Authorization' header")?, + ) + .send() + .await; + + response.assert_status(StatusCode::OK); + response.assert_content_type("application/json; charset=utf-8"); + + let response_body = response.json().await.value().deserialize::(); + + assert_eq!(response_body.offset, 0); + assert_eq!(response_body.total, 2); + assert_eq!(response_body.total_size, 2048 + 1024); + + Ok(()) +} diff --git a/crates/server/tests/test_api_me.rs b/crates/server/tests/test_api_me.rs new file mode 100644 index 0000000..ee41e2e --- /dev/null +++ b/crates/server/tests/test_api_me.rs @@ -0,0 +1,37 @@ +use anyhow::Context; +use parcel_shared::types::api::ApiMeResponse; +use poem::{http::StatusCode, web::headers::Authorization}; +use sqlx::SqlitePool; + +mod utils; + + +#[test_log::test(sqlx::test( + migrator = "parcel_model::migration::MIGRATOR", + fixtures("api_tests.sql") +))] +async fn test_api_me(pool: SqlitePool) -> anyhow::Result<()> { + let (_, client) = utils::create_test_client(&pool) + .await + .context("failed to create test client")?; + + let response = client + .get("/api/1.0/me") + .typed_header( + Authorization::bearer(utils::TEST_API_KEY_1) + .context("failed to create 'Authorization' header")?, + ) + .send() + .await; + + response.assert_status(StatusCode::OK); + response.assert_content_type("application/json; charset=utf-8"); + + let response_body = response.json().await.value().deserialize::(); + + assert_eq!(response_body.id.to_string(), "184f75f5-d345-4aae-92da-83c853125793"); + assert_eq!(response_body.username, "user1"); + assert_eq!(response_body.name, "User 1"); + + Ok(()) +} diff --git a/crates/server/tests/utils/constants.rs b/crates/server/tests/utils/constants.rs new file mode 100644 index 0000000..f2cc788 --- /dev/null +++ b/crates/server/tests/utils/constants.rs @@ -0,0 +1,2 @@ +pub const TEST_API_KEY_1: &str = "testapikey1234567890"; + diff --git a/crates/server/tests/utils/mod.rs b/crates/server/tests/utils/mod.rs new file mode 100644 index 0000000..11a0a1f --- /dev/null +++ b/crates/server/tests/utils/mod.rs @@ -0,0 +1,5 @@ +pub mod constants; +pub mod test_client; + +pub use constants::*; +pub use test_client::create_test_client; diff --git a/crates/server/tests/utils/test_client.rs b/crates/server/tests/utils/test_client.rs new file mode 100644 index 0000000..918d58b --- /dev/null +++ b/crates/server/tests/utils/test_client.rs @@ -0,0 +1,20 @@ +use anyhow::Context; +use clap::Parser; +use parcel_server::{app::create_app, args::Args, env::Env}; +use poem::test::TestClient; +use sqlx::SqlitePool; + +pub async fn create_test_client( + pool: &SqlitePool, +) -> anyhow::Result<(Env, TestClient)> { + let args = + Args::try_parse_from::<_, String>([]).context("failed to parse command line arguments")?; + let env = Env::new_with_pool(&args, pool.clone()) + .await + .context("failed to create environment")?; + let (preview, _) = parcel_server::workers::previews::start_worker(env.clone()) + .await + .context("failed to start preview worker")?; + let app = create_app(env.clone(), preview, None).context("failed to create app")?; + Ok((env, TestClient::new(app))) +} diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml new file mode 100644 index 0000000..b7af198 --- /dev/null +++ b/crates/shared/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "parcel-shared" +version.workspace = true +edition.workspace = true +publish = false + +[dependencies] +serde.workspace = true +time.workspace = true +uuid.workspace = true diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs new file mode 100644 index 0000000..af767f3 --- /dev/null +++ b/crates/shared/src/lib.rs @@ -0,0 +1,3 @@ +pub mod types { + pub mod api; +} diff --git a/crates/shared/src/types/api.rs b/crates/shared/src/types/api.rs new file mode 100644 index 0000000..af5cbd9 --- /dev/null +++ b/crates/shared/src/types/api.rs @@ -0,0 +1,176 @@ +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize)] +pub struct ApiMeResponse { + pub id: Uuid, + pub username: String, + pub name: String, + #[serde(rename = "lastAccess", with = "time::serde::rfc3339::option")] + pub last_access: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ApiTeamsResponse { + pub teams: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ApiTeamResponse { + #[serde(flatten)] + pub team: ApiTeamInfo, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ApiTeamInfo { + pub id: Uuid, + pub name: String, + pub slug: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ApiUploadsResponse { + pub offset: u32, + pub total: u32, + #[serde(rename = "totalSize")] + pub total_size: i64, + pub uploads: Vec, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ApiUploadSort { + #[serde(rename = "filename")] + Filename, + #[serde(rename = "size")] + Size, + #[serde(rename = "downloads")] + Downloads, + #[serde(rename = "expiryDate")] + ExpiryDate, + #[serde(rename = "uploadedAt")] + UploadedAt, +} + +impl std::fmt::Display for ApiUploadSort { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ApiUploadSort::Filename => write!(f, "filename"), + ApiUploadSort::Size => write!(f, "size"), + ApiUploadSort::Downloads => write!(f, "downloads"), + ApiUploadSort::ExpiryDate => write!(f, "expiryDate"), + ApiUploadSort::UploadedAt => write!(f, "uploadedAt"), + } + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ApiUploadOrder { + #[serde(rename = "asc")] + Asc, + #[serde(rename = "desc")] + Desc, +} + +impl std::fmt::Display for ApiUploadOrder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ApiUploadOrder::Asc => write!(f, "asc"), + ApiUploadOrder::Desc => write!(f, "desc"), + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ApiUploadListItem { + pub id: Uuid, + pub slug: String, + pub filename: String, + pub size: i64, + pub public: bool, + #[serde(rename = "hasPassword")] + pub has_password: bool, + pub downloads: i64, + pub limit: Option, + pub remaining: Option, + #[serde(rename = "expiryDate", with = "time::serde::rfc3339::option")] + pub expiry_date: Option, + #[serde(rename = "customSlug")] + pub custom_slug: Option, + #[serde(rename = "uploadedById")] + pub uploaded_by_id: Option, + #[serde(rename = "uploadedByName")] + pub uploaded_by_name: Option, + #[serde(rename = "uploadedAt", with = "time::serde::rfc3339")] + pub uploaded_at: OffsetDateTime, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ApiUploadResponse { + pub upload: ApiUpload, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ApiUpload { + pub id: Uuid, + pub slug: String, + pub filename: String, + pub size: i64, + pub public: bool, + pub has_password: bool, + pub downloads: i64, + pub limit: Option, + pub remaining: Option, + #[serde(rename = "expiryDate", with = "time::serde::rfc3339::option")] + pub expiry_date: Option, + #[serde(rename = "customSlug")] + pub custom_slug: Option, + pub uploaded_by: Option, + pub uploaded_at: OffsetDateTime, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ApiUploadModifyRequest { + pub filename: Option, + pub slug: Option, + pub public: Option, + pub limit: Option, + pub expiry: Option, + pub password: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum ApiUploadModifySlug { + #[serde(rename = "custom")] + Custom { slug: String }, + #[serde(rename = "auto")] + Auto, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum ApiUploadModifyDownloadLimit { + #[serde(rename = "unlimited")] + Unlimited, + #[serde(rename = "limited")] + Limited { limit: i64 }, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum ApiUploadModifyExpiry { + #[serde(rename = "never")] + Never, + #[serde(rename = "date")] + Date { date: OffsetDateTime }, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum ApiUploadModifyPassword { + #[serde(rename = "none")] + None, + #[serde(rename = "set")] + Set { password: String }, +}