diff --git a/.sqlx/query-8bfb350d4f539a110b05f42812ea2593a1556ef214f3bed519de6b6e21c7d477.json b/.sqlx/query-467df7989a1f4f5f3bc3409a4060e89dcfdd5a5ab9c7a96dc4008e74e06be914.json similarity index 65% rename from .sqlx/query-8bfb350d4f539a110b05f42812ea2593a1556ef214f3bed519de6b6e21c7d477.json rename to .sqlx/query-467df7989a1f4f5f3bc3409a4060e89dcfdd5a5ab9c7a96dc4008e74e06be914.json index 9be628e8..b3ecb9f5 100644 --- a/.sqlx/query-8bfb350d4f539a110b05f42812ea2593a1556ef214f3bed519de6b6e21c7d477.json +++ b/.sqlx/query-467df7989a1f4f5f3bc3409a4060e89dcfdd5a5ab9c7a96dc4008e74e06be914.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n clients.id as \"id!\",\n clients.name as \"name!\",\n clients.icon_url as \"icon_url?\",\n clients.max_scopes as \"max_scopes!\",\n clients.secret_hash as \"secret_hash!\",\n clients.created as \"created!\",\n clients.created_by as \"created_by!\",\n uris.uri_ids as \"uri_ids?\",\n uris.uri_vals as \"uri_vals?\"\n FROM oauth_clients clients\n LEFT JOIN (\n SELECT client_id, array_agg(id) as uri_ids, array_agg(uri) as uri_vals\n FROM oauth_client_redirect_uris\n GROUP BY client_id\n ) uris ON clients.id = uris.client_id\n WHERE created_by = $1", + "query": "\n SELECT\n clients.id as \"id!\",\n clients.name as \"name!\",\n clients.icon_url as \"icon_url?\",\n clients.max_scopes as \"max_scopes!\",\n clients.secret_hash as \"secret_hash!\",\n clients.created as \"created!\",\n clients.created_by as \"created_by!\",\n clients.url as \"url?\",\n clients.description as \"description?\",\n uris.uri_ids as \"uri_ids?\",\n uris.uri_vals as \"uri_vals?\"\n FROM oauth_clients clients\n LEFT JOIN (\n SELECT client_id, array_agg(id) as uri_ids, array_agg(uri) as uri_vals\n FROM oauth_client_redirect_uris\n GROUP BY client_id\n ) uris ON clients.id = uris.client_id\n WHERE created_by = $1", "describe": { "columns": [ { @@ -40,11 +40,21 @@ }, { "ordinal": 7, + "name": "url?", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "description?", + "type_info": "Text" + }, + { + "ordinal": 9, "name": "uri_ids?", "type_info": "Int8Array" }, { - "ordinal": 8, + "ordinal": 10, "name": "uri_vals?", "type_info": "TextArray" } @@ -62,9 +72,11 @@ false, false, false, + true, + true, null, null ] }, - "hash": "8bfb350d4f539a110b05f42812ea2593a1556ef214f3bed519de6b6e21c7d477" + "hash": "467df7989a1f4f5f3bc3409a4060e89dcfdd5a5ab9c7a96dc4008e74e06be914" } diff --git a/.sqlx/query-e8cc8895ebc8b1904a43e00f1e123f75ffdaebc76d67a5d35218fa9273d46d53.json b/.sqlx/query-781f54f36b8713bd36f5f89c98f8c89e94cc7c54e0b7302c877586186bb128a2.json similarity index 54% rename from .sqlx/query-e8cc8895ebc8b1904a43e00f1e123f75ffdaebc76d67a5d35218fa9273d46d53.json rename to .sqlx/query-781f54f36b8713bd36f5f89c98f8c89e94cc7c54e0b7302c877586186bb128a2.json index d01d5876..1eaac2f1 100644 --- a/.sqlx/query-e8cc8895ebc8b1904a43e00f1e123f75ffdaebc76d67a5d35218fa9273d46d53.json +++ b/.sqlx/query-781f54f36b8713bd36f5f89c98f8c89e94cc7c54e0b7302c877586186bb128a2.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE oauth_clients\n SET name = $1, icon_url = $2, max_scopes = $3\n WHERE (id = $4)\n ", + "query": "\n UPDATE oauth_clients\n SET name = $1, icon_url = $2, max_scopes = $3, url = $4, description = $5\n WHERE (id = $6)\n ", "describe": { "columns": [], "parameters": { @@ -8,10 +8,12 @@ "Text", "Text", "Int8", + "Text", + "Text", "Int8" ] }, "nullable": [] }, - "hash": "e8cc8895ebc8b1904a43e00f1e123f75ffdaebc76d67a5d35218fa9273d46d53" + "hash": "781f54f36b8713bd36f5f89c98f8c89e94cc7c54e0b7302c877586186bb128a2" } diff --git a/.sqlx/query-fdfb2433a8e407d42cec1791d67549ab5c23306758168af38f955c06d251b0b7.json b/.sqlx/query-93190af0fcfe2e795ed6b90eece7c567b8724509d1fdbd5cc75f516c1811c9d3.json similarity index 64% rename from .sqlx/query-fdfb2433a8e407d42cec1791d67549ab5c23306758168af38f955c06d251b0b7.json rename to .sqlx/query-93190af0fcfe2e795ed6b90eece7c567b8724509d1fdbd5cc75f516c1811c9d3.json index 08fa78f0..4179fde2 100644 --- a/.sqlx/query-fdfb2433a8e407d42cec1791d67549ab5c23306758168af38f955c06d251b0b7.json +++ b/.sqlx/query-93190af0fcfe2e795ed6b90eece7c567b8724509d1fdbd5cc75f516c1811c9d3.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n clients.id as \"id!\",\n clients.name as \"name!\",\n clients.icon_url as \"icon_url?\",\n clients.max_scopes as \"max_scopes!\",\n clients.secret_hash as \"secret_hash!\",\n clients.created as \"created!\",\n clients.created_by as \"created_by!\",\n uris.uri_ids as \"uri_ids?\",\n uris.uri_vals as \"uri_vals?\"\n FROM oauth_clients clients\n LEFT JOIN (\n SELECT client_id, array_agg(id) as uri_ids, array_agg(uri) as uri_vals\n FROM oauth_client_redirect_uris\n GROUP BY client_id\n ) uris ON clients.id = uris.client_id\n WHERE clients.id = ANY($1::bigint[])", + "query": "\n SELECT\n clients.id as \"id!\",\n clients.name as \"name!\",\n clients.icon_url as \"icon_url?\",\n clients.max_scopes as \"max_scopes!\",\n clients.secret_hash as \"secret_hash!\",\n clients.created as \"created!\",\n clients.created_by as \"created_by!\",\n clients.url as \"url?\",\n clients.description as \"description?\",\n uris.uri_ids as \"uri_ids?\",\n uris.uri_vals as \"uri_vals?\"\n FROM oauth_clients clients\n LEFT JOIN (\n SELECT client_id, array_agg(id) as uri_ids, array_agg(uri) as uri_vals\n FROM oauth_client_redirect_uris\n GROUP BY client_id\n ) uris ON clients.id = uris.client_id\n WHERE clients.id = ANY($1::bigint[])", "describe": { "columns": [ { @@ -40,11 +40,21 @@ }, { "ordinal": 7, + "name": "url?", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "description?", + "type_info": "Text" + }, + { + "ordinal": 9, "name": "uri_ids?", "type_info": "Int8Array" }, { - "ordinal": 8, + "ordinal": 10, "name": "uri_vals?", "type_info": "TextArray" } @@ -62,9 +72,11 @@ true, true, true, + true, + true, null, null ] }, - "hash": "fdfb2433a8e407d42cec1791d67549ab5c23306758168af38f955c06d251b0b7" + "hash": "93190af0fcfe2e795ed6b90eece7c567b8724509d1fdbd5cc75f516c1811c9d3" } diff --git a/migrations/20231122230639_oauth_client_metadata.sql b/migrations/20231122230639_oauth_client_metadata.sql new file mode 100644 index 00000000..dd05dbba --- /dev/null +++ b/migrations/20231122230639_oauth_client_metadata.sql @@ -0,0 +1,7 @@ +-- Add migration script here +ALTER TABLE + oauth_clients +ADD + COLUMN url text NULL, +ADD + COLUMN description text NULL; \ No newline at end of file diff --git a/src/database/models/oauth_client_item.rs b/src/database/models/oauth_client_item.rs index 48870c67..c2abbea7 100644 --- a/src/database/models/oauth_client_item.rs +++ b/src/database/models/oauth_client_item.rs @@ -23,6 +23,8 @@ pub struct OAuthClient { pub redirect_uris: Vec, pub created: DateTime, pub created_by: UserId, + pub url: Option, + pub description: Option, } struct ClientQueryResult { @@ -33,6 +35,8 @@ struct ClientQueryResult { secret_hash: String, created: DateTime, created_by: i64, + url: Option, + description: Option, uri_ids: Option>, uri_vals: Option>, } @@ -53,6 +57,8 @@ macro_rules! select_clients_with_predicate { clients.secret_hash as "secret_hash!", clients.created as "created!", clients.created_by as "created_by!", + clients.url as "url?", + clients.description as "description?", uris.uri_ids as "uri_ids?", uris.uri_vals as "uri_vals?" FROM oauth_clients clients @@ -155,12 +161,14 @@ impl OAuthClient { sqlx::query!( " UPDATE oauth_clients - SET name = $1, icon_url = $2, max_scopes = $3 - WHERE (id = $4) + SET name = $1, icon_url = $2, max_scopes = $3, url = $4, description = $5 + WHERE (id = $6) ", self.name, self.icon_url, self.max_scopes.to_postgres(), + self.url, + self.description, self.id.0, ) .execute(exec) @@ -240,6 +248,8 @@ impl From for OAuthClient { redirect_uris: redirects, created: r.created, created_by: UserId(r.created_by), + url: r.url, + description: r.description, } } } diff --git a/src/models/v3/oauth_clients.rs b/src/models/v3/oauth_clients.rs index f13eb97b..e3979f68 100644 --- a/src/models/v3/oauth_clients.rs +++ b/src/models/v3/oauth_clients.rs @@ -54,6 +54,13 @@ pub struct OAuthClient { // The user that created (and thus controls) this client pub created_by: UserId, + + // When this client was created + pub created: DateTime, + + // (optional) Metadata about the client + pub url: Option, + pub description: Option, } #[derive(Deserialize, Serialize)] @@ -88,6 +95,9 @@ impl From for OAuthClient { max_scopes: value.max_scopes, redirect_uris: value.redirect_uris.into_iter().map(|r| r.into()).collect(), created_by: value.created_by.into(), + created: value.created, + url: value.url, + description: value.description, } } } diff --git a/src/routes/v3/oauth_clients.rs b/src/routes/v3/oauth_clients.rs index 277ff912..881cd040 100644 --- a/src/routes/v3/oauth_clients.rs +++ b/src/routes/v3/oauth_clients.rs @@ -1,4 +1,4 @@ -use std::{collections::HashSet, fmt::Display}; +use std::{collections::HashSet, fmt::Display, sync::Arc}; use actix_web::{ delete, get, patch, post, @@ -16,7 +16,9 @@ use validator::Validate; use super::ApiError; use crate::{ auth::checks::ValidateAllAuthorized, + file_hosting::FileHost, models::{ids::base62_impl::parse_base62, oauth_clients::DeleteOAuthClientQueryParam}, + util::routes::read_from_payload, }; use crate::{ auth::{checks::ValidateAuthorized, get_user_from_headers}, @@ -50,6 +52,8 @@ pub fn config(cfg: &mut web::ServiceConfig) { .service(oauth_client_create) .service(oauth_client_edit) .service(oauth_client_delete) + .service(oauth_client_icon_edit) + .service(oauth_client_icon_delete) .service(get_client) .service(get_clients) .service(get_user_oauth_authorizations), @@ -145,6 +149,15 @@ pub struct NewOAuthApp { pub max_scopes: Scopes, pub redirect_uris: Vec, + + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 255) + )] + pub url: Option, + + #[validate(length(max = 255))] + pub description: Option, } #[post("app")] @@ -187,6 +200,8 @@ pub async fn oauth_client_create<'a>( redirect_uris, created: Utc::now(), created_by: current_user.id.into(), + url: new_oauth_app.url.clone(), + description: new_oauth_app.description.clone(), secret_hash: client_secret_hash, }; client.clone().insert(&mut transaction).await?; @@ -248,6 +263,15 @@ pub struct OAuthClientEdit { #[validate(length(min = 1))] pub redirect_uris: Option>, + + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 255) + )] + pub url: Option>, + + #[validate(length(max = 255))] + pub description: Option>, } #[patch("app/{id}")] @@ -289,6 +313,8 @@ pub async fn oauth_client_edit( icon_url, max_scopes, redirect_uris, + url, + description, } = client_updates.into_inner(); if let Some(name) = name { updated_client.name = name; @@ -302,6 +328,14 @@ pub async fn oauth_client_edit( updated_client.max_scopes = max_scopes; } + if let Some(url) = url { + updated_client.url = url; + } + + if let Some(description) = description { + updated_client.description = description; + } + let mut transaction = pool.begin().await?; updated_client .update_editable_fields(&mut *transaction) @@ -319,6 +353,130 @@ pub async fn oauth_client_edit( } } +#[derive(Serialize, Deserialize)] +pub struct Extension { + pub ext: String, +} + +#[patch("app/{id}/icon")] +#[allow(clippy::too_many_arguments)] +pub async fn oauth_client_icon_edit( + web::Query(ext): web::Query, + req: HttpRequest, + client_id: web::Path, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + mut payload: web::Payload, + session_queue: web::Data, +) -> Result { + if let Some(content_type) = crate::util::ext::get_image_content_type(&ext.ext) { + let cdn_url = dotenvy::var("CDN_URL")?; + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::SESSION_ACCESS]), + ) + .await? + .1; + + let client = OAuthClient::get((*client_id).into(), &**pool) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The specified client does not exist!".to_string()) + })?; + + client.validate_authorized(Some(&user))?; + + if let Some(ref icon) = client.icon_url { + let name = icon.split(&format!("{cdn_url}/")).nth(1); + + if let Some(icon_path) = name { + file_host.delete_file_version("", icon_path).await?; + } + } + + let bytes = + read_from_payload(&mut payload, 262144, "Icons must be smaller than 256KiB").await?; + let hash = sha1::Sha1::from(&bytes).hexdigest(); + let upload_data = file_host + .upload_file( + content_type, + &format!("data/{}/{}.{}", client_id, hash, ext.ext), + bytes.freeze(), + ) + .await?; + + let mut transaction = pool.begin().await?; + + let mut editable_client = client.clone(); + editable_client.icon_url = Some(format!("{}/{}", cdn_url, upload_data.file_name)); + + editable_client + .update_editable_fields(&mut *transaction) + .await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::InvalidInput(format!( + "Invalid format for project icon: {}", + ext.ext + ))) + } +} + +#[delete("app/{id}/icon")] +pub async fn oauth_client_icon_delete( + req: HttpRequest, + client_id: web::Path, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + session_queue: web::Data, +) -> Result { + let cdn_url = dotenvy::var("CDN_URL")?; + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::SESSION_ACCESS]), + ) + .await? + .1; + + let client = OAuthClient::get((*client_id).into(), &**pool) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The specified client does not exist!".to_string()) + })?; + client.validate_authorized(Some(&user))?; + + if let Some(ref icon) = client.icon_url { + let name = icon.split(&format!("{cdn_url}/")).nth(1); + + if let Some(icon_path) = name { + file_host.delete_file_version("", icon_path).await?; + } + } + + let mut transaction = pool.begin().await?; + + let mut editable_client = client.clone(); + editable_client.icon_url = None; + + editable_client + .update_editable_fields(&mut *transaction) + .await?; + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) +} + #[get("authorizations")] pub async fn get_user_oauth_authorizations( req: HttpRequest, diff --git a/tests/oauth_clients.rs b/tests/oauth_clients.rs index 6f42c433..e601b6c0 100644 --- a/tests/oauth_clients.rs +++ b/tests/oauth_clients.rs @@ -41,6 +41,8 @@ async fn can_create_edit_get_oauth_client() { let client_id = get_json_val_str(creation_result.client.id); let icon_url = Some("https://modrinth.com/icon".to_string()); + let url = Some("https://modrinth.com".to_string()); + let description = Some("test description".to_string()); let edited_redirect_uris = vec![ redirect_uris[0].clone(), "https://modrinth.com/b".to_string(), @@ -50,6 +52,8 @@ async fn can_create_edit_get_oauth_client() { icon_url: Some(icon_url.clone()), max_scopes: None, redirect_uris: Some(edited_redirect_uris.clone()), + url: Some(url.clone()), + description: Some(description.clone()), }; let resp = env .api @@ -63,6 +67,8 @@ async fn can_create_edit_get_oauth_client() { .await; assert_eq!(1, clients.len()); assert_eq!(icon_url, clients[0].icon_url); + assert_eq!(url, clients[0].url); + assert_eq!(description, clients[0].description); assert_eq!(client_name, clients[0].name); assert_eq!(2, clients[0].redirect_uris.len()); assert_eq!(edited_redirect_uris[0], clients[0].redirect_uris[0].uri);