feat(api): allow unauthed requests to /systems/:id/settings

This commit is contained in:
alyssa 2025-05-28 23:00:39 +00:00
parent 0406c32f6b
commit 0610701252
14 changed files with 334 additions and 70 deletions

View file

@ -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<i32> {
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
}
}

View file

@ -1 +1,2 @@
pub mod private;
pub mod system;

View file

@ -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<PKSystemConfig> = 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,

View file

@ -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<AuthState>,
Extension(system): Extension<PKSystem>,
State(ctx): State<ApiContext>,
) -> Response {
let access_level = auth.access_level_for(&system);
let config = match sqlx::query_as::<Postgres, PKSystemConfig>(
"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()
}

View file

@ -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))

View file

@ -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;

View file

@ -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<ApiContext>, mut req: Request, next: Next) -> Response {
let pms = match req.extensions().get::<UrlParams>() {
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::<AuthState>()
.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::<Postgres, PKSystem>(
"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::<Postgres, PKSystem>(
"select * from systems where uuid = $1",
)
.bind(uuid),
Err(_) => sqlx::query_as::<Postgres, PKSystem>(
"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
}

View file

@ -16,6 +16,7 @@ struct ModelField {
patch: ElemPatchability,
json: Option<Expr>,
is_privacy: bool,
privacy: Option<Expr>,
default: Option<Expr>,
}
@ -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<ModelField>) -> TokenStream {
quote! { unimplemented!(); }
}
fn mk_tto_json(fields: Vec<ModelField>) -> 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<ModelField>) -> 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
})
}
}
}

View file

@ -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;

View file

@ -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<i32> for PrivacyLevel {
fn from(value: i32) -> Self {
match value {
1 => PrivacyLevel::Public,
2 => PrivacyLevel::Private,
_ => unreachable!(),
}
}
}

View file

@ -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<i32> 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<String>,
#[json = "description"]
#[privacy = description_privacy]
description: Option<String>,
#[json = "tag"]
tag: Option<String>,
#[json = "pronouns"]
#[privacy = pronoun_privacy]
pronouns: Option<String>,
#[json = "avatar_url"]
#[privacy = avatar_privacy]
avatar_url: Option<String>,
#[json = "banner_image"]
#[json = "banner"]
#[privacy = banner_privacy]
banner_image: Option<String>,
#[json = "color"]
color: Option<String>,
token: Option<String>,
#[json = "webhook_url"]
webhook_url: Option<String>,
webhook_token: Option<String>,
#[json = "created"]

View file

@ -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<i32> 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<String>,
#[json = "description_templates"]
description_templates: Vec<String>,
}