From 0610701252d1d0a7cf202e20da0c35384d1f7120 Mon Sep 17 00:00:00 2001 From: alyssa Date: Wed, 28 May 2025 23:00:39 +0000 Subject: [PATCH] feat(api): allow unauthed requests to /systems/:id/settings --- Cargo.lock | 6 +- Cargo.toml | 3 +- crates/api/src/auth.rs | 26 ++++++ crates/api/src/endpoints/mod.rs | 1 + crates/api/src/endpoints/private.rs | 10 +-- crates/api/src/endpoints/system.rs | 68 +++++++++++++++ crates/api/src/main.rs | 10 ++- crates/api/src/middleware/mod.rs | 16 ++-- crates/api/src/middleware/params.rs | 123 ++++++++++++++++++++++++++++ crates/macros/src/model.rs | 65 +++++++++++---- crates/models/src/_util.rs | 14 ++++ crates/models/src/lib.rs | 22 +++++ crates/models/src/system.rs | 33 ++------ crates/models/src/system_config.rs | 7 +- 14 files changed, 334 insertions(+), 70 deletions(-) create mode 100644 crates/api/src/endpoints/system.rs create mode 100644 crates/api/src/middleware/params.rs diff --git a/Cargo.lock b/Cargo.lock index 6b44de61..341ba381 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -276,8 +276,7 @@ dependencies = [ [[package]] name = "axum" version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" +source = "git+https://github.com/pluralkit/axum?branch=v0.8.4-pluralkit#3b45806719f27e69aed912bac724056b910f4aa6" dependencies = [ "axum-core 0.5.2", "bytes", @@ -327,8 +326,7 @@ dependencies = [ [[package]] name = "axum-core" version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" +source = "git+https://github.com/pluralkit/axum?branch=v0.8.4-pluralkit#3b45806719f27e69aed912bac724056b910f4aa6" dependencies = [ "bytes", "futures-core", diff --git a/Cargo.toml b/Cargo.toml index 5f1272bc..48c73439 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,6 @@ resolver = "2" [workspace.dependencies] anyhow = "1" -axum = "0.8.4" axum-macros = "0.4.1" bytes = "1.6.0" chrono = "0.4" @@ -24,6 +23,8 @@ tracing = "0.1" tracing-subscriber = { version = "0.3.16", features = ["env-filter", "json"] } uuid = { version = "1.7.0", features = ["serde"] } +axum = { git = "https://github.com/pluralkit/axum", branch = "v0.8.4-pluralkit" } + twilight-gateway = { git = "https://github.com/pluralkit/twilight", branch = "pluralkit-fb4f590" } twilight-cache-inmemory = { git = "https://github.com/pluralkit/twilight", branch = "pluralkit-fb4f590", features = ["permission-calculator"] } twilight-util = { git = "https://github.com/pluralkit/twilight", branch = "pluralkit-fb4f590", features = ["permission-calculator"] } diff --git a/crates/api/src/auth.rs b/crates/api/src/auth.rs index 49752a4b..c084eafe 100644 --- a/crates/api/src/auth.rs +++ b/crates/api/src/auth.rs @@ -1,3 +1,5 @@ +use pluralkit_models::{PKSystem, PrivacyLevel, SystemId}; + pub const INTERNAL_SYSTEMID_HEADER: &'static str = "x-pluralkit-systemid"; pub const INTERNAL_APPID_HEADER: &'static str = "x-pluralkit-appid"; @@ -19,4 +21,28 @@ impl AuthState { pub fn app_id(&self) -> Option { self.app_id } + + pub fn access_level_for(&self, a: &impl Authable) -> PrivacyLevel { + if self + .system_id + .map(|id| id == a.authable_system_id()) + .unwrap_or(false) + { + PrivacyLevel::Private + } else { + PrivacyLevel::Public + } + } +} + +// authable trait/impls + +pub trait Authable { + fn authable_system_id(&self) -> SystemId; +} + +impl Authable for PKSystem { + fn authable_system_id(&self) -> SystemId { + self.id + } } diff --git a/crates/api/src/endpoints/mod.rs b/crates/api/src/endpoints/mod.rs index f19f44d8..c311367c 100644 --- a/crates/api/src/endpoints/mod.rs +++ b/crates/api/src/endpoints/mod.rs @@ -1 +1,2 @@ pub mod private; +pub mod system; diff --git a/crates/api/src/endpoints/private.rs b/crates/api/src/endpoints/private.rs index ad76e275..df67421c 100644 --- a/crates/api/src/endpoints/private.rs +++ b/crates/api/src/endpoints/private.rs @@ -52,7 +52,7 @@ use axum::{ }; use hyper::StatusCode; use libpk::config; -use pluralkit_models::{PKSystem, PKSystemConfig}; +use pluralkit_models::{PKSystem, PKSystemConfig, PrivacyLevel}; use reqwest::ClientBuilder; #[derive(serde::Deserialize, Debug)] @@ -151,14 +151,12 @@ pub async fn discord_callback( .await .expect("failed to query"); - if system.is_none() { + let Some(system) = system else { return json_err( StatusCode::BAD_REQUEST, "user does not have a system registered".to_string(), ); - } - - let system = system.unwrap(); + }; let system_config: Option = sqlx::query_as( r#" @@ -179,7 +177,7 @@ pub async fn discord_callback( ( StatusCode::OK, serde_json::to_string(&serde_json::json!({ - "system": system.to_json(), + "system": system.to_json(PrivacyLevel::Private), "config": system_config.to_json(), "user": user, "token": token, diff --git a/crates/api/src/endpoints/system.rs b/crates/api/src/endpoints/system.rs new file mode 100644 index 00000000..21672db2 --- /dev/null +++ b/crates/api/src/endpoints/system.rs @@ -0,0 +1,68 @@ +use axum::{ + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, + Extension, +}; +use serde_json::json; +use sqlx::Postgres; +use tracing::error; + +use pluralkit_models::{PKSystem, PKSystemConfig, PrivacyLevel}; + +use crate::{auth::AuthState, util::json_err, ApiContext}; + +pub async fn get_system_settings( + Extension(auth): Extension, + Extension(system): Extension, + State(ctx): State, +) -> Response { + let access_level = auth.access_level_for(&system); + + let config = match sqlx::query_as::( + "select * from system_config where system = $1", + ) + .bind(system.id) + .fetch_optional(&ctx.db) + .await + { + Ok(Some(config)) => config, + Ok(None) => { + error!( + system = system.id, + "failed to find system config for existing system" + ); + return json_err( + StatusCode::INTERNAL_SERVER_ERROR, + r#"{"message": "500: Internal Server Error", "code": 0}"#.to_string(), + ); + } + Err(err) => { + error!(?err, "failed to query system config"); + return json_err( + StatusCode::INTERNAL_SERVER_ERROR, + r#"{"message": "500: Internal Server Error", "code": 0}"#.to_string(), + ); + } + }; + + ( + StatusCode::OK, + serde_json::to_string(&match access_level { + PrivacyLevel::Private => config.to_json(), + PrivacyLevel::Public => json!({ + "pings_enabled": config.pings_enabled, + "latch_timeout": config.latch_timeout, + "case_sensitive_proxy_tags": config.case_sensitive_proxy_tags, + "proxy_error_message_enabled": config.proxy_error_message_enabled, + "hid_display_split": config.hid_display_split, + "hid_display_caps": config.hid_display_caps, + "hid_list_padding": config.hid_list_padding, + "proxy_switch": config.proxy_switch, + "name_format": config.name_format, + }), + }) + .unwrap(), + ) + .into_response() +} diff --git a/crates/api/src/main.rs b/crates/api/src/main.rs index 48666d6f..57ecbb0a 100644 --- a/crates/api/src/main.rs +++ b/crates/api/src/main.rs @@ -77,7 +77,7 @@ fn router(ctx: ApiContext) -> Router { Router::new() .route("/v2/systems/{system_id}", get(rproxy)) .route("/v2/systems/{system_id}", patch(rproxy)) - .route("/v2/systems/{system_id}/settings", get(rproxy)) + .route("/v2/systems/{system_id}/settings", get(endpoints::system::get_system_settings)) .route("/v2/systems/{system_id}/settings", patch(rproxy)) .route("/v2/systems/{system_id}/members", get(rproxy)) @@ -134,10 +134,12 @@ fn router(ctx: ApiContext) -> Router { .route("/v2/groups/{group_id}/oembed.json", get(rproxy)) .layer(middleware::ratelimit::ratelimiter(middleware::ratelimit::do_request_ratelimited)) // this sucks - .layer(axum::middleware::from_fn(middleware::ignore_invalid_routes)) - .layer(axum::middleware::from_fn(middleware::cors)) - .layer(axum::middleware::from_fn(middleware::logger)) + .layer(axum::middleware::from_fn(middleware::ignore_invalid_routes::ignore_invalid_routes)) + .layer(axum::middleware::from_fn(middleware::cors::cors)) + .layer(axum::middleware::from_fn(middleware::logger::logger)) + + .layer(axum::middleware::from_fn_with_state(ctx.clone(), middleware::params::params)) .layer(axum::middleware::from_fn_with_state(ctx.clone(), middleware::auth::auth)) .layer(tower_http::catch_panic::CatchPanicLayer::custom(util::handle_panic)) diff --git a/crates/api/src/middleware/mod.rs b/crates/api/src/middleware/mod.rs index ad67be11..5c2b04dc 100644 --- a/crates/api/src/middleware/mod.rs +++ b/crates/api/src/middleware/mod.rs @@ -1,12 +1,6 @@ -mod cors; -pub use cors::cors; - -mod logger; -pub use logger::logger; - -mod ignore_invalid_routes; -pub use ignore_invalid_routes::ignore_invalid_routes; - -pub mod ratelimit; - pub mod auth; +pub mod cors; +pub mod ignore_invalid_routes; +pub mod logger; +pub mod params; +pub mod ratelimit; diff --git a/crates/api/src/middleware/params.rs b/crates/api/src/middleware/params.rs new file mode 100644 index 00000000..f1219614 --- /dev/null +++ b/crates/api/src/middleware/params.rs @@ -0,0 +1,123 @@ +use axum::{ + extract::{Request, State}, + http::StatusCode, + middleware::Next, + response::Response, + routing::url_params::UrlParams, +}; + +use sqlx::{types::Uuid, Postgres}; +use tracing::error; + +use crate::auth::AuthState; +use crate::{util::json_err, ApiContext}; +use pluralkit_models::PKSystem; + +pub async fn params(State(ctx): State, mut req: Request, next: Next) -> Response { + let pms = match req.extensions().get::() { + None => Vec::new(), + Some(UrlParams::Params(pms)) => pms.clone(), + _ => { + return json_err( + StatusCode::BAD_REQUEST, + r#"{"error": "400: Bad Request", "code": 0}"#.to_string(), + ) + .into() + } + }; + + for (key, value) in pms { + match key.as_ref() { + "system_id" => match value.as_str() { + "@me" => { + let Some(system_id) = req + .extensions() + .get::() + .expect("missing auth state") + .system_id() + else { + return json_err( + StatusCode::UNAUTHORIZED, + r#"{"error": "401: Missing or invalid Authorization header", "code": 0}"#.to_string(), + ) + .into(); + }; + + match sqlx::query_as::( + "select * from systems where id = $1", + ) + .bind(system_id) + .fetch_optional(&ctx.db) + .await + { + Ok(Some(system)) => { + req.extensions_mut().insert(system); + } + Ok(None) => { + error!( + ?system_id, + "could not find previously authenticated system in db" + ); + return json_err( + StatusCode::INTERNAL_SERVER_ERROR, + r#"{"message": "500: Internal Server Error", "code": 0}"# + .to_string(), + ); + } + Err(err) => { + error!( + ?err, + "failed to query previously authenticated system in db" + ); + return json_err( + StatusCode::INTERNAL_SERVER_ERROR, + r#"{"message": "500: Internal Server Error", "code": 0}"# + .to_string(), + ); + } + } + } + id => { + match match Uuid::parse_str(id) { + Ok(uuid) => sqlx::query_as::( + "select * from systems where uuid = $1", + ) + .bind(uuid), + Err(_) => sqlx::query_as::( + "select * from systems where hid = $1", + ) + .bind(id), + } + .fetch_optional(&ctx.db) + .await + { + Ok(Some(system)) => { + req.extensions_mut().insert(system); + } + Ok(None) => { + return json_err( + StatusCode::NOT_FOUND, + r#"{"message":"System not found.","code":20001}"#.to_string(), + ) + } + Err(err) => { + error!(?err, ?id, "failed to query system from path in db"); + return json_err( + StatusCode::INTERNAL_SERVER_ERROR, + r#"{"message": "500: Internal Server Error", "code": 0}"# + .to_string(), + ); + } + } + } + }, + "member_id" => {} + "group_id" => {} + "switch_id" => {} + "guild_id" => {} + _ => {} + } + } + + next.run(req).await +} diff --git a/crates/macros/src/model.rs b/crates/macros/src/model.rs index bfd219a7..924b5bcd 100644 --- a/crates/macros/src/model.rs +++ b/crates/macros/src/model.rs @@ -16,6 +16,7 @@ struct ModelField { patch: ElemPatchability, json: Option, is_privacy: bool, + privacy: Option, default: Option, } @@ -26,6 +27,7 @@ fn parse_field(field: syn::Field) -> ModelField { patch: ElemPatchability::None, json: None, is_privacy: false, + privacy: None, default: None, }; @@ -61,6 +63,12 @@ fn parse_field(field: syn::Field) -> ModelField { } f.json = Some(nv.value.clone()); } + "privacy" => { + if f.privacy.is_some() { + panic!("cannot set privacy multiple times for same field"); + } + f.privacy = Some(nv.value.clone()); + } "default" => { if f.default.is_some() { panic!("cannot set default multiple times for same field"); @@ -107,8 +115,6 @@ pub fn macro_impl( panic!("fields of a struct must be named"); }; - // println!("{}: {:#?}", tname, fields); - let tfields = mk_tfields(fields.clone()); let from_json = mk_tfrom_json(fields.clone()); let _from_sql = mk_tfrom_sql(fields.clone()); @@ -137,9 +143,7 @@ pub fn macro_impl( #from_json } - pub fn to_json(self) -> serde_json::Value { - #to_json - } + #to_json } #[derive(Debug, Clone)] @@ -188,19 +192,28 @@ fn mk_tfrom_sql(_fields: Vec) -> TokenStream { quote! { unimplemented!(); } } fn mk_tto_json(fields: Vec) -> TokenStream { - // todo: check privacy access + let has_privacy = fields.iter().any(|f| f.privacy.is_some()); let fielddefs: TokenStream = fields .iter() .filter_map(|f| { f.json.as_ref().map(|v| { let tname = f.name.clone(); - if let Some(default) = f.default.as_ref() { + let maybepriv = if let Some(privacy) = f.privacy.as_ref() { quote! { - #v: self.#tname.unwrap_or(#default), + #v: crate::_util::privacy_lookup!(self.#tname, self.#privacy, lookup_level) } } else { quote! { - #v: self.#tname, + #v: self.#tname + } + }; + if let Some(default) = f.default.as_ref() { + quote! { + #maybepriv.unwrap_or(#default), + } + } else { + quote! { + #maybepriv, } } }) @@ -222,13 +235,35 @@ fn mk_tto_json(fields: Vec) -> TokenStream { }) .collect(); - quote! { - serde_json::json!({ - #fielddefs - "privacy": { - #privacyfielddefs + let privdef = if has_privacy { + quote! { + , lookup_level: crate::PrivacyLevel + } + } else { + quote! {} + }; + + let privacy_fielddefs = if has_privacy { + quote! { + "privacy": if matches!(lookup_level, crate::PrivacyLevel::Private) { + Some(serde_json::json!({ + #privacyfielddefs + })) + } else { + None } - }) + } + } else { + quote! {} + }; + + quote! { + pub fn to_json(self #privdef) -> serde_json::Value { + serde_json::json!({ + #fielddefs + #privacy_fielddefs + }) + } } } diff --git a/crates/models/src/_util.rs b/crates/models/src/_util.rs index c13a0bf6..2d4921ad 100644 --- a/crates/models/src/_util.rs +++ b/crates/models/src/_util.rs @@ -33,3 +33,17 @@ macro_rules! fake_enum_impls { } pub(crate) use fake_enum_impls; + +macro_rules! privacy_lookup { + ($v:expr, $vprivacy:expr, $lookup_level:expr) => { + if matches!($vprivacy, crate::PrivacyLevel::Public) + || matches!($lookup_level, crate::PrivacyLevel::Private) + { + Some($v) + } else { + None + } + }; +} + +pub(crate) use privacy_lookup; diff --git a/crates/models/src/lib.rs b/crates/models/src/lib.rs index bb7bb08d..0bd1f92b 100644 --- a/crates/models/src/lib.rs +++ b/crates/models/src/lib.rs @@ -9,3 +9,25 @@ macro_rules! model { model!(system); model!(system_config); + +#[derive(serde::Serialize, Debug, Clone)] +#[serde(rename_all = "snake_case")] +pub enum PrivacyLevel { + Public, + Private, +} + +// this sucks, put it somewhere else +use sqlx::{postgres::PgTypeInfo, Database, Decode, Postgres, Type}; +use std::error::Error; +_util::fake_enum_impls!(PrivacyLevel); + +impl From for PrivacyLevel { + fn from(value: i32) -> Self { + match value { + 1 => PrivacyLevel::Public, + 2 => PrivacyLevel::Private, + _ => unreachable!(), + } + } +} diff --git a/crates/models/src/system.rs b/crates/models/src/system.rs index 42b61fe4..ddaf1980 100644 --- a/crates/models/src/system.rs +++ b/crates/models/src/system.rs @@ -1,36 +1,13 @@ -use std::error::Error; - use pk_macros::pk_model; use chrono::NaiveDateTime; -use sqlx::{postgres::PgTypeInfo, Database, Decode, Postgres, Type}; use uuid::Uuid; -use crate::_util::fake_enum_impls; +use crate::PrivacyLevel; // todo: fix this pub type SystemId = i32; -// todo: move this -#[derive(serde::Serialize, Debug, Clone)] -#[serde(rename_all = "snake_case")] -pub enum PrivacyLevel { - Public, - Private, -} - -fake_enum_impls!(PrivacyLevel); - -impl From for PrivacyLevel { - fn from(value: i32) -> Self { - match value { - 1 => PrivacyLevel::Public, - 2 => PrivacyLevel::Private, - _ => unreachable!(), - } - } -} - #[pk_model] struct System { id: SystemId, @@ -40,21 +17,25 @@ struct System { #[json = "uuid"] uuid: Uuid, #[json = "name"] + #[privacy = name_privacy] name: Option, #[json = "description"] + #[privacy = description_privacy] description: Option, #[json = "tag"] tag: Option, #[json = "pronouns"] + #[privacy = pronoun_privacy] pronouns: Option, #[json = "avatar_url"] + #[privacy = avatar_privacy] avatar_url: Option, - #[json = "banner_image"] + #[json = "banner"] + #[privacy = banner_privacy] banner_image: Option, #[json = "color"] color: Option, token: Option, - #[json = "webhook_url"] webhook_url: Option, webhook_token: Option, #[json = "created"] diff --git a/crates/models/src/system_config.rs b/crates/models/src/system_config.rs index 04b1995b..772d231d 100644 --- a/crates/models/src/system_config.rs +++ b/crates/models/src/system_config.rs @@ -10,7 +10,7 @@ pub const DEFAULT_GROUP_LIMIT: i32 = 250; #[derive(serde::Serialize, Debug, Clone)] #[serde(rename_all = "snake_case")] -enum HidPadFormat { +pub enum HidPadFormat { #[serde(rename = "off")] None, Left, @@ -31,7 +31,7 @@ impl From for HidPadFormat { #[derive(serde::Serialize, Debug, Clone)] #[serde(rename_all = "snake_case")] -enum ProxySwitchAction { +pub enum ProxySwitchAction { Off, New, Add, @@ -83,7 +83,8 @@ struct SystemConfig { #[json = "proxy_switch"] proxy_switch: ProxySwitchAction, #[json = "name_format"] - name_format: String, + #[default = "{name} {tag}".to_string()] + name_format: Option, #[json = "description_templates"] description_templates: Vec, }