From 3774e59a9f8a779d21ebc23eeef3392988ce5cf5 Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Wed, 27 Dec 2023 15:38:54 -0800 Subject: [PATCH 01/25] initial commit --- migrations/20231226012200_shared_modpacks.sql | 52 ++ src/database/models/ids.rs | 28 ++ src/database/models/minecraft_profile_item.rs | 471 ++++++++++++++++++ src/database/models/mod.rs | 1 + src/models/mod.rs | 1 + src/models/v3/ids.rs | 2 + src/models/v3/minecraft/mod.rs | 1 + src/models/v3/minecraft/profile.rs | 71 +++ src/models/v3/mod.rs | 1 + src/models/v3/pats.rs | 8 + src/routes/v3/minecraft/mod.rs | 1 + src/routes/v3/minecraft/profiles.rs | 326 ++++++++++++ src/routes/v3/mod.rs | 1 + 13 files changed, 964 insertions(+) create mode 100644 migrations/20231226012200_shared_modpacks.sql create mode 100644 src/database/models/minecraft_profile_item.rs create mode 100644 src/models/v3/minecraft/mod.rs create mode 100644 src/models/v3/minecraft/profile.rs create mode 100644 src/routes/v3/minecraft/mod.rs create mode 100644 src/routes/v3/minecraft/profiles.rs diff --git a/migrations/20231226012200_shared_modpacks.sql b/migrations/20231226012200_shared_modpacks.sql new file mode 100644 index 00000000..8aae26d0 --- /dev/null +++ b/migrations/20231226012200_shared_modpacks.sql @@ -0,0 +1,52 @@ +CREATE TABLE shared_profiles ( + id bigint PRIMARY KEY, + name varchar(255) NOT NULL, + owner_id bigint NOT NULL, + icon_url varchar(255), + updated timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + created timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + + game_version_id int NOT NULL REFERENCES loader_field_enum_values(id), + loader_id int NOT NULL REFERENCES loaders(id), + loader_version varchar(255) NOT NULL +); + +CREATE TABLE shared_profiles_mods ( + shared_profile_id bigint NOT NULL REFERENCES shared_profiles(id), + + -- for versions we have hosted + version_id bigint NULL REFERENCES versions(id), -- for versions + + -- for cdn links to files we host directly + file_hash varchar(255) NULL, + install_path varchar(255) NULL, + + CHECK ( + (version_id IS NOT NULL AND file_hash IS NULL AND install_path IS NULL) OR + (version_id IS NULL AND file_hash IS NOT NULL AND install_path IS NOT NULL) + ) +); + +CREATE TABLE shared_profiles_links ( + id bigint PRIMARY KEY, -- id of the shared profile link (ignored in labrinth, for db use only) + link varchar(48) NOT NULL UNIQUE, -- extension of the url that identifies this (ie profiles/afgxxczsewq) + shared_profile_id bigint NOT NULL REFERENCES shared_profiles(id), + created timestamptz NOT NULL DEFAULT now(), + expires timestamptz NOT NULL, + uses_remaining integer NOT NULL DEFAULT 0 -- one less use each time you generate a cdn_auth_token +); + +-- Index off 'link' +CREATE INDEX shared_profiles_links_link_idx ON shared_profiles_links(link); + +-- One generated tokens for downloading files +CREATE TABLE cdn_auth_tokens ( + token varchar(255) PRIMARY KEY, + shared_profiles_links_id bigint NOT NULL REFERENCES shared_profiles_links(id), + user_id bigint NOT NULL REFERENCES users(id), + created timestamptz NOT NULL DEFAULT now(), + expires timestamptz NOT NULL, + + -- unique combinations of shared_profiles_links_id and user_id + CONSTRAINT cdn_auth_tokens_unique UNIQUE (shared_profiles_links_id, user_id) +); \ No newline at end of file diff --git a/src/database/models/ids.rs b/src/database/models/ids.rs index d7e4a97a..9c03afb9 100644 --- a/src/database/models/ids.rs +++ b/src/database/models/ids.rs @@ -192,6 +192,14 @@ generate_ids!( PayoutId ); +generate_ids!( + pub generate_minecraft_profile_id, + MinecraftProfileId, + 8, + "SELECT EXISTS(SELECT 1 FROM shared_profiles WHERE id=$1)", + MinecraftProfileId +); + #[derive(Copy, Clone, Debug, PartialEq, Eq, Type, Hash, Serialize, Deserialize)] #[sqlx(transparent)] pub struct UserId(pub i64); @@ -311,6 +319,14 @@ pub struct OAuthAccessTokenId(pub i64); #[sqlx(transparent)] pub struct PayoutId(pub i64); +#[derive(Copy, Clone, Debug, Type, Serialize, Deserialize, Eq, PartialEq, Hash)] +#[sqlx(transparent)] +pub struct MinecraftProfileId(pub i64); + +#[derive(Copy, Clone, Debug, Type, Serialize, Deserialize, Eq, PartialEq, Hash)] +#[sqlx(transparent)] +pub struct MinecraftProfileLinkId(pub i64); + use crate::models::ids; impl From for ProjectId { @@ -464,3 +480,15 @@ impl From for ids::PayoutId { ids::PayoutId(id.0 as u64) } } + +impl From for MinecraftProfileId { + fn from(id: ids::MinecraftProfileId) -> Self { + MinecraftProfileId(id.0 as i64) + } +} + +impl From for ids::MinecraftProfileId { + fn from(id: MinecraftProfileId) -> Self { + ids::MinecraftProfileId(id.0 as u64) + } +} diff --git a/src/database/models/minecraft_profile_item.rs b/src/database/models/minecraft_profile_item.rs new file mode 100644 index 00000000..bf33bda6 --- /dev/null +++ b/src/database/models/minecraft_profile_item.rs @@ -0,0 +1,471 @@ +use std::path::PathBuf; + +use super::ids::*; +use crate::database::models::DatabaseError; +use crate::database::redis::RedisPool; +use chrono::{DateTime, Utc}; +use dashmap::DashMap; +use futures::TryStreamExt; +use serde::{Deserialize, Serialize}; + +type Override = (String, PathBuf); + +pub const MINECRAFT_PROFILES_NAMESPACE: &str = "minecraft_profiles"; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct MinecraftProfile { + pub id: MinecraftProfileId, + pub name: String, + pub owner_id: UserId, + pub icon_url: Option, + pub created: DateTime, + pub updated: DateTime, + + pub game_version_id: LoaderFieldEnumValueId, + pub loader_id: LoaderId, + pub loader_version: String, + + pub versions: Vec, + pub overrides: Vec, +} + +impl MinecraftProfile { + pub async fn insert( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), DatabaseError> { + sqlx::query!( + " + INSERT INTO shared_profiles ( + id, name, owner_id, icon_url, created, updated, + game_version_id, loader_id, loader_version + ) + VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9 + ) + ", + self.id as MinecraftProfileId, + self.name, + self.owner_id as UserId, + self.icon_url, + self.created, + self.updated, + self.game_version_id as LoaderFieldEnumValueId, + self.loader_id as LoaderId, + self.loader_version, + ) + .execute(&mut **transaction) + .await?; + + Ok(()) + } + + pub async fn remove( + id: MinecraftProfileId, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + redis: &RedisPool, + ) -> Result, DatabaseError> { + // Delete shared_profiles_links_tokens + sqlx::query!( + " + DELETE FROM cdn_auth_tokens + WHERE shared_profiles_links_id IN ( + SELECT id FROM shared_profiles_links + WHERE shared_profile_id = $1 + ) + ", + id as MinecraftProfileId, + ) + .execute(&mut **transaction) + .await?; + + // Delete shared_profiles_links + sqlx::query!( + " + DELETE FROM shared_profiles_links + WHERE shared_profile_id = $1 + ", + id as MinecraftProfileId, + ) + .execute(&mut **transaction) + .await?; + + sqlx::query!( + " + DELETE FROM shared_profiles_mods + WHERE shared_profile_id = $1 + ", + id as MinecraftProfileId, + ) + .execute(&mut **transaction) + .await?; + + sqlx::query!( + " + DELETE FROM shared_profiles_links + WHERE shared_profile_id = $1 + ", + id as MinecraftProfileId, + ) + .execute(&mut **transaction) + .await?; + + MinecraftProfile::clear_cache(id, redis).await?; + + Ok(Some(())) + } + + pub async fn get<'a, 'b, E>( + id: MinecraftProfileId, + executor: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Acquire<'a, Database = sqlx::Postgres>, + { + Self::get_many(&[id], executor, redis) + .await + .map(|x| x.into_iter().next()) + } + + pub async fn get_many<'a, E>( + ids: &[MinecraftProfileId], + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Acquire<'a, Database = sqlx::Postgres>, + { + if ids.is_empty() { + return Ok(Vec::new()); + } + + let mut redis = redis.connect().await?; + let mut exec = exec.acquire().await?; + + let mut found_profiles = Vec::new(); + let mut remaining_ids: Vec = ids.to_vec(); + + if !ids.is_empty() { + let profiles = redis + .multi_get::(MINECRAFT_PROFILES_NAMESPACE, ids.iter().map(|x| x.0)) + .await?; + for profile in profiles { + if let Some(profile) = + profile.and_then(|x| serde_json::from_str::(&x).ok()) + { + remaining_ids.retain(|x| profile.id != *x); + found_profiles.push(profile); + continue; + } + } + } + + if !remaining_ids.is_empty() { + type AttachedProjectsMap = ( + DashMap>, + DashMap>, + ); + let shared_profiles_mods: AttachedProjectsMap = sqlx::query!( + " + SELECT shared_profile_id, version_id, file_hash, install_path + FROM shared_profiles_mods spm + WHERE spm.shared_profile_id = ANY($1) + ", + &remaining_ids.iter().map(|x| x.0).collect::>() + ) + .fetch(&mut *exec) + .try_fold( + (DashMap::new(), DashMap::new()), + |(acc_versions, acc_overrides): AttachedProjectsMap, m| { + let version_id = m.version_id.map(VersionId); + let file_hash = m.file_hash; + let install_path = m.install_path; + if let Some(version_id) = version_id { + acc_versions + .entry(MinecraftProfileId(m.shared_profile_id)) + .or_default() + .push(version_id); + } + + if let (Some(install_path), Some(file_hash)) = (install_path, file_hash) { + acc_overrides + .entry(MinecraftProfileId(m.shared_profile_id)) + .or_default() + .push((file_hash, PathBuf::from(install_path))); + } + + async move { Ok((acc_versions, acc_overrides)) } + }, + ) + .await?; + + let db_profiles: Vec = sqlx::query!( + " + SELECT id, name, owner_id, icon_url, created, updated, game_version_id, loader_id, loader_version + FROM shared_profiles sp + WHERE sp.id = ANY($1) + ", + &remaining_ids.iter().map(|x| x.0).collect::>() + ) + .fetch_many(&mut *exec) + .try_filter_map(|e| async { + Ok(e.right().map(|m| { + let id = MinecraftProfileId(m.id); + let versions = shared_profiles_mods.0.get(&id).map(|x| x.value().clone()).unwrap_or_default(); + let files = shared_profiles_mods.1.get(&id).map(|x| x.value().clone()).unwrap_or_default(); + MinecraftProfile { + id, + name: m.name.clone(), + icon_url: m.icon_url.clone(), + updated: m.updated, + created: m.created, + owner_id: UserId(m.owner_id), + game_version_id: LoaderFieldEnumValueId(m.game_version_id), + loader_id: LoaderId(m.loader_id), + loader_version: m.loader_version.clone(), + versions, + overrides: files + } + })) + }) + .try_collect::>() + .await?; + + for profile in db_profiles { + redis + .set_serialized_to_json( + MINECRAFT_PROFILES_NAMESPACE, + profile.id.0, + &profile, + None, + ) + .await?; + found_profiles.push(profile); + } + } + + Ok(found_profiles) + } + + pub async fn clear_cache( + id: MinecraftProfileId, + redis: &RedisPool, + ) -> Result<(), DatabaseError> { + let mut redis = redis.connect().await?; + + redis + .delete_many([(MINECRAFT_PROFILES_NAMESPACE, Some(id.0.to_string()))]) + .await?; + Ok(()) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct MinecraftProfileLink { + pub id: MinecraftProfileLinkId, + pub link_identifier: String, + pub shared_profile_id: MinecraftProfileId, + pub created: DateTime, + pub expires: DateTime, + pub uses_remaining: i32, +} + +impl MinecraftProfileLink { + pub async fn list<'a, 'b, E>( + shared_profile_id: MinecraftProfileId, + executor: E, + ) -> Result, DatabaseError> + where + E: sqlx::Acquire<'a, Database = sqlx::Postgres>, + { + let mut exec = executor.acquire().await?; + + let links = sqlx::query!( + " + SELECT id, link, shared_profile_id, created, expires, uses_remaining + FROM shared_profiles_links spl + WHERE spl.shared_profile_id = $1 + ", + shared_profile_id.0 + ) + .fetch_many(&mut *exec) + .try_filter_map(|e| async { + Ok(e.right().map(|m| MinecraftProfileLink { + id: MinecraftProfileLinkId(m.id), + link_identifier: m.link, + shared_profile_id: MinecraftProfileId(m.shared_profile_id), + created: m.created, + expires: m.expires, + uses_remaining: m.uses_remaining, + })) + }) + .try_collect::>() + .await?; + + Ok(links) + } + + pub async fn get_url<'a, 'b, E>( + url_identifier: &str, + executor: E, + ) -> Result, DatabaseError> + where + E: sqlx::Acquire<'a, Database = sqlx::Postgres>, + { + let mut exec = executor.acquire().await?; + + let link = sqlx::query!( + " + SELECT id, link, shared_profile_id, created, expires, uses_remaining + FROM shared_profiles_links spl + WHERE spl.link = $1 + ", + url_identifier + ) + .fetch_optional(&mut *exec) + .await? + .map(|m| MinecraftProfileLink { + id: MinecraftProfileLinkId(m.id), + link_identifier: m.link, + shared_profile_id: MinecraftProfileId(m.shared_profile_id), + created: m.created, + expires: m.expires, + uses_remaining: m.uses_remaining, + }); + + Ok(link) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct MinecraftProfileLinkToken { + pub token: String, + pub shared_profiles_links_id: MinecraftProfileId, + pub created: DateTime, + pub expires: DateTime, +} + +impl MinecraftProfileLinkToken { + pub async fn insert( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), DatabaseError> { + sqlx::query!( + " + INSERT INTO cdn_auth_tokens ( + token, shared_profiles_links_id, created, expires + ) + VALUES ( + $1, $2, $3, $4 + ) + ", + self.token, + self.shared_profiles_links_id.0, + self.created, + self.expires, + ) + .execute(&mut **transaction) + .await?; + + Ok(()) + } + + pub async fn get_token<'a, 'b, E>( + token: &str, + executor: E, + ) -> Result, DatabaseError> + where + E: sqlx::Acquire<'a, Database = sqlx::Postgres>, + { + let mut exec = executor.acquire().await?; + + let token = sqlx::query!( + " + SELECT token, shared_profiles_links_id, created, expires + FROM cdn_auth_tokens cat + WHERE cat.token = $1 + ", + token + ) + .fetch_optional(&mut *exec) + .await? + .map(|m| MinecraftProfileLinkToken { + token: m.token, + shared_profiles_links_id: MinecraftProfileId(m.shared_profiles_links_id), + created: m.created, + expires: m.expires, + }); + + Ok(token) + } + + pub async fn get_from_link_user<'a, 'b, E>( + profile_link_id: MinecraftProfileLinkId, + user_id: UserId, + executor: E, + ) -> Result, DatabaseError> + where + E: sqlx::Acquire<'a, Database = sqlx::Postgres>, + { + let mut exec = executor.acquire().await?; + + let token = sqlx::query!( + " + SELECT cat.token, cat.shared_profiles_links_id, cat.created, cat.expires + FROM cdn_auth_tokens cat + INNER JOIN shared_profiles_links spl ON spl.id = cat.shared_profiles_links_id + WHERE spl.id = $1 AND spl.shared_profile_id IN ( + SELECT id FROM shared_profiles sp WHERE sp.owner_id = $2 + ) + ", + profile_link_id.0, + user_id.0 + ) + .fetch_optional(&mut *exec) + .await? + .map(|m| MinecraftProfileLinkToken { + token: m.token, + shared_profiles_links_id: MinecraftProfileId(m.shared_profiles_links_id), + created: m.created, + expires: m.expires, + }); + + Ok(token) + } + + pub async fn delete( + token: &str, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), DatabaseError> { + sqlx::query!( + " + DELETE FROM cdn_auth_tokens + WHERE token = $1 + ", + token + ) + .execute(&mut **transaction) + .await?; + + Ok(()) + } + + pub async fn delete_all( + shared_profile_link_id: MinecraftProfileLinkId, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), DatabaseError> { + sqlx::query!( + " + DELETE FROM cdn_auth_tokens + WHERE shared_profiles_links_id = $1 + ", + shared_profile_link_id.0 + ) + .execute(&mut **transaction) + .await?; + + Ok(()) + } +} diff --git a/src/database/models/mod.rs b/src/database/models/mod.rs index eb931f7d..1893df6b 100644 --- a/src/database/models/mod.rs +++ b/src/database/models/mod.rs @@ -7,6 +7,7 @@ pub mod ids; pub mod image_item; pub mod legacy_loader_fields; pub mod loader_fields; +pub mod minecraft_profile_item; pub mod notification_item; pub mod oauth_client_authorization_item; pub mod oauth_client_item; diff --git a/src/models/mod.rs b/src/models/mod.rs index b1a12c9b..3c420a7b 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -6,6 +6,7 @@ pub use v3::analytics; pub use v3::collections; pub use v3::ids; pub use v3::images; +pub use v3::minecraft; pub use v3::notifications; pub use v3::oauth_clients; pub use v3::organizations; diff --git a/src/models/v3/ids.rs b/src/models/v3/ids.rs index 73d0c32c..d134f331 100644 --- a/src/models/v3/ids.rs +++ b/src/models/v3/ids.rs @@ -2,6 +2,7 @@ use thiserror::Error; pub use super::collections::CollectionId; pub use super::images::ImageId; +pub use super::minecraft::profile::MinecraftProfileId; pub use super::notifications::NotificationId; pub use super::oauth_clients::OAuthClientAuthorizationId; pub use super::oauth_clients::{OAuthClientId, OAuthRedirectUriId}; @@ -129,6 +130,7 @@ base62_id_impl!(OAuthClientId, OAuthClientId); base62_id_impl!(OAuthRedirectUriId, OAuthRedirectUriId); base62_id_impl!(OAuthClientAuthorizationId, OAuthClientAuthorizationId); base62_id_impl!(PayoutId, PayoutId); +base62_id_impl!(MinecraftProfileId, MinecraftProfileId); pub mod base62_impl { use serde::de::{self, Deserializer, Visitor}; diff --git a/src/models/v3/minecraft/mod.rs b/src/models/v3/minecraft/mod.rs new file mode 100644 index 00000000..6b76aba6 --- /dev/null +++ b/src/models/v3/minecraft/mod.rs @@ -0,0 +1 @@ +pub mod profile; diff --git a/src/models/v3/minecraft/profile.rs b/src/models/v3/minecraft/profile.rs new file mode 100644 index 00000000..7d8d0b17 --- /dev/null +++ b/src/models/v3/minecraft/profile.rs @@ -0,0 +1,71 @@ +use std::path::PathBuf; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::{ + database::{ + self, + models::{LoaderFieldEnumValueId, LoaderId}, + }, + models::ids::{Base62Id, UserId, VersionId}, +}; + +/// The ID of a specific profile, encoded as base62 for usage in the API +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug)] +#[serde(from = "Base62Id")] +#[serde(into = "Base62Id")] +pub struct MinecraftProfileId(pub u64); + +/// A project returned from the API +#[derive(Serialize, Deserialize, Clone)] +pub struct MinecraftProfile { + /// The ID of the profile, encoded as a base62 string. + pub id: MinecraftProfileId, + + /// The person that has ownership of this profile. + pub owner_id: UserId, + /// The title or name of the project. + pub name: String, + /// The date at which the project was first created. + pub created: DateTime, + /// The date at which the project was last updated. + pub updated: DateTime, + /// The icon of the project. + pub icon_url: Option, + + /// The loader id + pub loader_id: LoaderId, + /// The loader version + pub loader_version: String, + /// Minecraft game version id + pub game_version_id: LoaderFieldEnumValueId, + + /// Modrinth-associated versions + pub versions: Vec, + /// Overrides for this profile- only install paths are given, + /// hashes are looked up in the CDN by the client + pub override_install_paths: Vec, +} + +impl From for MinecraftProfile { + fn from(profile: database::models::minecraft_profile_item::MinecraftProfile) -> Self { + Self { + id: profile.id.into(), + owner_id: profile.owner_id.into(), + name: profile.name, + created: profile.created, + updated: profile.updated, + icon_url: profile.icon_url, + loader_id: profile.loader_id.into(), + loader_version: profile.loader_version, + game_version_id: profile.game_version_id.into(), + versions: profile.versions.into_iter().map(Into::into).collect(), + override_install_paths: profile + .overrides + .into_iter() + .map(|(_, v)| v.into()) + .collect(), + } + } +} diff --git a/src/models/v3/mod.rs b/src/models/v3/mod.rs index 34f5836b..11f1a64d 100644 --- a/src/models/v3/mod.rs +++ b/src/models/v3/mod.rs @@ -2,6 +2,7 @@ pub mod analytics; pub mod collections; pub mod ids; pub mod images; +pub mod minecraft; pub mod notifications; pub mod oauth_clients; pub mod organizations; diff --git a/src/models/v3/pats.rs b/src/models/v3/pats.rs index d4ef6e28..16bad0db 100644 --- a/src/models/v3/pats.rs +++ b/src/models/v3/pats.rs @@ -106,6 +106,14 @@ bitflags::bitflags! { // only accessible by modrinth-issued sessions const SESSION_ACCESS = 1 << 39; + // create a minecraft profile + const MINECRAFT_PROFILE_CREATE = 1 << 40; + // edit a minecraft profile + const MINEECRAFT_PROFILE_WRITE = 1 << 41; + // download a minecraft profile + const MINECRAFT_PROFILE_DOWNLOAD = 1 << 42; + + const NONE = 0b0; } } diff --git a/src/routes/v3/minecraft/mod.rs b/src/routes/v3/minecraft/mod.rs new file mode 100644 index 00000000..0a9c08b6 --- /dev/null +++ b/src/routes/v3/minecraft/mod.rs @@ -0,0 +1 @@ +pub mod profiles; diff --git a/src/routes/v3/minecraft/profiles.rs b/src/routes/v3/minecraft/profiles.rs new file mode 100644 index 00000000..80567247 --- /dev/null +++ b/src/routes/v3/minecraft/profiles.rs @@ -0,0 +1,326 @@ +use crate::auth::{get_user_from_headers, AuthenticationError}; +use crate::database::models::legacy_loader_fields::MinecraftGameVersion; +use crate::database::models::{ + generate_minecraft_profile_id, minecraft_profile_item, MinecraftProfileId, +}; +use crate::database::redis::RedisPool; +use crate::models::ids::base62_impl::parse_base62; +use crate::models::ids::VersionId; +use crate::models::minecraft::profile::MinecraftProfile; +use crate::models::pats::Scopes; +use crate::queue::session::AuthQueue; +use crate::routes::v3::project_creation::CreateError; +use crate::routes::ApiError; +use crate::util::validate::validation_errors_to_string; +use crate::{database, models}; +use actix_web::web::Data; +use actix_web::{web, HttpRequest, HttpResponse}; +use chrono::Utc; +use rand::distributions::Alphanumeric; +use rand::{Rng, SeedableRng}; +use rand_chacha::ChaCha20Rng; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use std::path::PathBuf; +use validator::Validate; + +pub fn config(cfg: &mut web::ServiceConfig) { + // make sure this is routed in front of /minecraft // TODO + cfg.route("profile", web::post().to(profile_create)); +} + +//TODO: They might require a specific hash so we can compare file to when it was uploaded ?or just date I guess +// todo unwrap() + +#[derive(Serialize, Deserialize, Validate, Clone)] +pub struct ProfileCreateData { + #[validate( + length(min = 3, max = 64), + custom(function = "crate::util::validate::validate_name") + )] + /// The title or name of the profile. + pub name: String, + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 255) + )] + // The icon url of the profile. + // TODO: upload + pub icon_url: Option, + + // The loader string (parsed to a loader) + pub loader: String, + // The loader version + pub loader_version: String, + // The game version string (parsed to a game version) + pub game_version: String, +} + +pub async fn profile_create( + req: HttpRequest, + profile_create_data: web::Json, + client: Data, + redis: Data, + session_queue: Data, +) -> Result { + let profile_create_data = profile_create_data.into_inner(); + + // The currently logged in user + let current_user = get_user_from_headers( + &req, + &**client, + &redis, + &session_queue, + Some(&[Scopes::MINECRAFT_PROFILE_CREATE]), + ) + .await? + .1; + + profile_create_data + .validate() + .map_err(|err| CreateError::InvalidInput(validation_errors_to_string(err, None)))?; + + let game_version_id = MinecraftGameVersion::list(&**client, &redis) + .await? + .into_iter() + .find(|x| x.version == profile_create_data.game_version) + .ok_or_else(|| CreateError::InvalidInput("Invalid Minecraft game version".to_string()))? + .id; + + let loader_id = database::models::loader_fields::Loader::get_id( + &profile_create_data.loader, + &**client, + &redis, + ) + .await? + .ok_or_else(|| CreateError::InvalidInput("Invalid loader".to_string()))?; + + let mut transaction = client.begin().await?; + + let profile_id: MinecraftProfileId = generate_minecraft_profile_id(&mut transaction) + .await? + .into(); + + let profile_builder_actual = minecraft_profile_item::MinecraftProfile { + id: profile_id, + name: profile_create_data.name.clone(), + owner_id: current_user.id.into(), + icon_url: profile_create_data.icon_url.clone(), + created: Utc::now(), + updated: Utc::now(), + game_version_id, + loader_id, + loader_version: profile_create_data.loader_version, + versions: Vec::new(), + overrides: Vec::new(), + }; + let profile_builder = profile_builder_actual.clone(); + profile_builder_actual.insert(&mut transaction).await?; + transaction.commit().await?; + + let profile = models::minecraft::profile::MinecraftProfile::from(profile_builder); + Ok(HttpResponse::Ok().json(profile)) +} + +#[derive(Serialize, Deserialize)] +pub struct MinecraftProfileIds { + pub ids: String, +} +pub async fn profiles_get( + web::Query(ids): web::Query, + pool: web::Data, + redis: web::Data, +) -> Result { + // No user check ,as any user/scope can view profiles. + // In addition, private information (ie: CDN links, tokens, anything outside of the list of hosted versions and install paths) is not returned + let ids = serde_json::from_str::>(&ids.ids)?; + let ids = ids + .into_iter() + .map(|x| parse_base62(x).map(|x| database::models::MinecraftProfileId(x as i64))) + .collect::, _>>()?; + + let profiles_data = + database::models::minecraft_profile_item::MinecraftProfile::get_many(&ids, &**pool, &redis) + .await?; + let profiles = profiles_data + .into_iter() + .map(|data| MinecraftProfile::from(data)) + .collect::>(); + + Ok(HttpResponse::Ok().json(profiles)) +} + +pub async fn profile_get( + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, +) -> Result { + let string = info.into_inner().0; + + // No user check ,as any user/scope can view profiles. + // In addition, private information (ie: CDN links, tokens, anything outside of the list of hosted versions and install paths) is not returned + let id = database::models::MinecraftProfileId(parse_base62(&string)? as i64); + let profile_data = + database::models::minecraft_profile_item::MinecraftProfile::get(id, &**pool, &redis) + .await?; + if let Some(data) = profile_data { + return Ok(HttpResponse::Ok().json(MinecraftProfile::from(data))); + } + Err(ApiError::NotFound) +} + +#[derive(Serialize, Deserialize)] +pub struct ProfileDownload { + // temporary authorization token for the CDN, for downloading the profile files + pub auth_token: String, + + // Version ids for modrinth-hosted versions + pub version_ids: Vec, + + // The override cdns for the profile: + // (cdn url, install path) + pub override_cdns: Vec<(String, PathBuf)>, +} + +pub async fn profile_download( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let url_identifier = info.into_inner().0; + + // Must be logged in to download + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::MINECRAFT_PROFILE_DOWNLOAD]), + ) + .await?; + + // Fetch the profile information of the desired minecraft profile + let Some(profile_link_data) = + database::models::minecraft_profile_item::MinecraftProfileLink::get_url( + &url_identifier, + &**pool, + ) + .await? + else { + return Err(ApiError::NotFound); + }; + + let Some(profile) = database::models::minecraft_profile_item::MinecraftProfile::get( + profile_link_data.shared_profile_id, + &**pool, + &redis, + ) + .await? + else { + return Err(ApiError::NotFound); + }; + + let cdn_downloads_required = profile.overrides.len(); + + let mut transaction = pool.begin().await?; + + // Check no token exists for the username and profile + let existing_token = + database::models::minecraft_profile_item::MinecraftProfileLinkToken::get_from_link_user( + profile_link_data.id, + user_option.1.id.into(), + &mut *transaction, + ) + .await?; + if let Some(token) = existing_token { + // Check if the token is still valid + if token.expires > Utc::now() { + // Simply return the token + transaction.commit().await?; + return Ok(HttpResponse::Ok().json(ProfileDownload { + auth_token: token.token, + version_ids: profile.versions.iter().map(|x| (*x).into()).collect(), + override_cdns: profile.overrides, + })); + } + + // If we're here, the token is invalid, so delete it, and create a new one if we can + database::models::minecraft_profile_item::MinecraftProfileLinkToken::delete( + &token.token, + &mut transaction, + ) + .await?; + } + + // If there's no token, or the token is invalid, create a new one + if profile_link_data.uses_remaining < 1 { + return Err(ApiError::InvalidInput( + "No more downloads remaining".to_string(), + )); + } + + // Reduce the number of downloads remaining + sqlx::query!( + "UPDATE shared_profiles_links SET uses_remaining = uses_remaining - 1 WHERE id = $1", + profile_link_data.id.0 + ) + .execute(&mut *transaction) + .await?; + + // Create a new cdn auth token + let token = database::models::minecraft_profile_item::MinecraftProfileLinkToken { + token: ChaCha20Rng::from_entropy() + .sample_iter(&Alphanumeric) + .take(32) + .map(char::from) + .collect::(), + shared_profiles_links_id: profile_link_data.shared_profile_id, + created: Utc::now(), + expires: Utc::now() + chrono::Duration::minutes(5), + }; + token.insert(&mut transaction).await?; + + // TODO: Create download header to authorize the CDN + // TODO: only if we have enough downloads left on the profile + // (and de increment it) + + // TODO: check user, so same user cant request all of t hem + + // TODO: maybe we should not have a limit number of uses on the token itself? isntead just limit it to like, 5 minutes so it can be retried + + transaction.commit().await?; + + Ok(HttpResponse::Ok().json(ProfileDownload { + auth_token: token.token, + version_ids: profile.versions.iter().map(|x| (*x).into()).collect(), + override_cdns: profile.overrides, + })) +} + +// Used by cloudflare to check headers and permit CDN downloads for a pack +pub async fn profile_token_check( + req: HttpRequest, + pool: web::Data, +) -> Result { + // Extract token from 'authorization' of headers + let token = req + .headers() + .get("authorization") + .and_then(|h| h.to_str().ok()) + .ok_or_else(|| ApiError::Authentication(AuthenticationError::InvalidAuthMethod))?; + + let token = database::models::minecraft_profile_item::MinecraftProfileLinkToken::get_token( + &token, &**pool, + ) + .await?; + + if let Some(token) = token { + if token.expires > Utc::now() { + return Ok(HttpResponse::Ok().finish()); + } + } + + Err(ApiError::NotFound) +} diff --git a/src/routes/v3/mod.rs b/src/routes/v3/mod.rs index a5165fec..4c42e885 100644 --- a/src/routes/v3/mod.rs +++ b/src/routes/v3/mod.rs @@ -6,6 +6,7 @@ use serde_json::json; pub mod analytics_get; pub mod collections; pub mod images; +pub mod minecraft; pub mod moderation; pub mod notifications; pub mod organizations; From af77b1d51376a511754a16a9583a0c965b46e2b5 Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Fri, 29 Dec 2023 04:24:08 -0800 Subject: [PATCH 02/25] working tests --- migrations/20231226012200_shared_modpacks.sql | 1 + src/database/models/ids.rs | 8 + src/database/models/minecraft_profile_item.rs | 100 ++- src/models/v3/minecraft/profile.rs | 46 +- src/models/v3/pats.rs | 2 +- src/routes/v3/minecraft/profiles.rs | 751 +++++++++++++++++- src/routes/v3/mod.rs | 1 + tests/common/api_v3/minecraft_profile.rs | 242 ++++++ tests/common/api_v3/mod.rs | 1 + tests/common/api_v3/project.rs | 2 - tests/common/dummy_data.rs | 6 +- tests/profiles.rs | 393 +++++++++ 12 files changed, 1500 insertions(+), 53 deletions(-) create mode 100644 tests/common/api_v3/minecraft_profile.rs create mode 100644 tests/profiles.rs diff --git a/migrations/20231226012200_shared_modpacks.sql b/migrations/20231226012200_shared_modpacks.sql index 8aae26d0..db8f4221 100644 --- a/migrations/20231226012200_shared_modpacks.sql +++ b/migrations/20231226012200_shared_modpacks.sql @@ -3,6 +3,7 @@ CREATE TABLE shared_profiles ( name varchar(255) NOT NULL, owner_id bigint NOT NULL, icon_url varchar(255), + color integer NULL, updated timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, created timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/src/database/models/ids.rs b/src/database/models/ids.rs index 9c03afb9..953f9405 100644 --- a/src/database/models/ids.rs +++ b/src/database/models/ids.rs @@ -200,6 +200,14 @@ generate_ids!( MinecraftProfileId ); +generate_ids!( + pub generate_minecraft_profile_link_id, + MinecraftProfileLinkId, + 8, + "SELECT EXISTS(SELECT 1 FROM shared_profiles_links WHERE id=$1)", + MinecraftProfileLinkId +); + #[derive(Copy, Clone, Debug, PartialEq, Eq, Type, Hash, Serialize, Deserialize)] #[sqlx(transparent)] pub struct UserId(pub i64); diff --git a/src/database/models/minecraft_profile_item.rs b/src/database/models/minecraft_profile_item.rs index bf33bda6..22d86cce 100644 --- a/src/database/models/minecraft_profile_item.rs +++ b/src/database/models/minecraft_profile_item.rs @@ -8,6 +8,7 @@ use dashmap::DashMap; use futures::TryStreamExt; use serde::{Deserialize, Serialize}; +// Hash and install path type Override = (String, PathBuf); pub const MINECRAFT_PROFILES_NAMESPACE: &str = "minecraft_profiles"; @@ -22,9 +23,12 @@ pub struct MinecraftProfile { pub updated: DateTime, pub game_version_id: LoaderFieldEnumValueId, - pub loader_id: LoaderId, pub loader_version: String, + // These represent the same loader + pub loader_id: LoaderId, + pub loader : String, + pub versions: Vec, pub overrides: Vec, } @@ -201,11 +205,14 @@ impl MinecraftProfile { ) .await?; + // One to many for shared_profiles to loaders, so can safely group by shared_profile_id let db_profiles: Vec = sqlx::query!( " - SELECT id, name, owner_id, icon_url, created, updated, game_version_id, loader_id, loader_version + SELECT sp.id, sp.name, sp.owner_id, sp.icon_url, sp.created, sp.updated, sp.game_version_id, sp.loader_id, l.loader, sp.loader_version FROM shared_profiles sp + LEFT JOIN loaders l ON l.id = sp.loader_id WHERE sp.id = ANY($1) + GROUP BY sp.id, l.id ", &remaining_ids.iter().map(|x| x.0).collect::>() ) @@ -217,14 +224,15 @@ impl MinecraftProfile { let files = shared_profiles_mods.1.get(&id).map(|x| x.value().clone()).unwrap_or_default(); MinecraftProfile { id, - name: m.name.clone(), - icon_url: m.icon_url.clone(), + name: m.name, + icon_url: m.icon_url, updated: m.updated, created: m.created, owner_id: UserId(m.owner_id), game_version_id: LoaderFieldEnumValueId(m.game_version_id), loader_id: LoaderId(m.loader_id), - loader_version: m.loader_version.clone(), + loader_version: m.loader_version, + loader: m.loader, versions, overrides: files } @@ -273,6 +281,32 @@ pub struct MinecraftProfileLink { } impl MinecraftProfileLink { + pub async fn insert( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), DatabaseError> { + sqlx::query!( + " + INSERT INTO shared_profiles_links ( + id, link, shared_profile_id, created, expires, uses_remaining + ) + VALUES ( + $1, $2, $3, $4, $5, $6 + ) + ", + self.id.0, + self.link_identifier, + self.shared_profile_id.0, + self.created, + self.expires, + self.uses_remaining, + ) + .execute(&mut **transaction) + .await?; + + Ok(()) + } + pub async fn list<'a, 'b, E>( shared_profile_id: MinecraftProfileId, executor: E, @@ -307,6 +341,37 @@ impl MinecraftProfileLink { Ok(links) } + pub async fn get<'a, 'b, E>( + id: MinecraftProfileLinkId, + executor: E, + ) -> Result, DatabaseError> + where + E: sqlx::Acquire<'a, Database = sqlx::Postgres>, + { + let mut exec = executor.acquire().await?; + + let link = sqlx::query!( + " + SELECT id, link, shared_profile_id, created, expires, uses_remaining + FROM shared_profiles_links spl + WHERE spl.id = $1 + ", + id.0 + ) + .fetch_optional(&mut *exec) + .await? + .map(|m| MinecraftProfileLink { + id: MinecraftProfileLinkId(m.id), + link_identifier: m.link, + shared_profile_id: MinecraftProfileId(m.shared_profile_id), + created: m.created, + expires: m.expires, + uses_remaining: m.uses_remaining, + }); + + Ok(link) + } + pub async fn get_url<'a, 'b, E>( url_identifier: &str, executor: E, @@ -342,7 +407,8 @@ impl MinecraftProfileLink { #[derive(Clone, Debug, Serialize, Deserialize)] pub struct MinecraftProfileLinkToken { pub token: String, - pub shared_profiles_links_id: MinecraftProfileId, + pub shared_profiles_links_id: MinecraftProfileLinkId, + pub user_id: UserId, pub created: DateTime, pub expires: DateTime, } @@ -355,14 +421,15 @@ impl MinecraftProfileLinkToken { sqlx::query!( " INSERT INTO cdn_auth_tokens ( - token, shared_profiles_links_id, created, expires + token, shared_profiles_links_id, user_id, created, expires ) VALUES ( - $1, $2, $3, $4 + $1, $2, $3, $4, $5 ) ", self.token, self.shared_profiles_links_id.0, + self.user_id.0, self.created, self.expires, ) @@ -383,7 +450,7 @@ impl MinecraftProfileLinkToken { let token = sqlx::query!( " - SELECT token, shared_profiles_links_id, created, expires + SELECT token, user_id, shared_profiles_links_id, created, expires FROM cdn_auth_tokens cat WHERE cat.token = $1 ", @@ -393,7 +460,8 @@ impl MinecraftProfileLinkToken { .await? .map(|m| MinecraftProfileLinkToken { token: m.token, - shared_profiles_links_id: MinecraftProfileId(m.shared_profiles_links_id), + user_id: UserId(m.user_id), + shared_profiles_links_id: MinecraftProfileLinkId(m.shared_profiles_links_id), created: m.created, expires: m.expires, }); @@ -401,6 +469,7 @@ impl MinecraftProfileLinkToken { Ok(token) } + // Get existing token for link and user pub async fn get_from_link_user<'a, 'b, E>( profile_link_id: MinecraftProfileLinkId, user_id: UserId, @@ -413,7 +482,7 @@ impl MinecraftProfileLinkToken { let token = sqlx::query!( " - SELECT cat.token, cat.shared_profiles_links_id, cat.created, cat.expires + SELECT cat.token, cat.user_id, cat.shared_profiles_links_id, cat.created, cat.expires FROM cdn_auth_tokens cat INNER JOIN shared_profiles_links spl ON spl.id = cat.shared_profiles_links_id WHERE spl.id = $1 AND spl.shared_profile_id IN ( @@ -427,7 +496,8 @@ impl MinecraftProfileLinkToken { .await? .map(|m| MinecraftProfileLinkToken { token: m.token, - shared_profiles_links_id: MinecraftProfileId(m.shared_profiles_links_id), + user_id: UserId(m.user_id), + shared_profiles_links_id: MinecraftProfileLinkId(m.shared_profiles_links_id), created: m.created, expires: m.expires, }); @@ -469,3 +539,9 @@ impl MinecraftProfileLinkToken { Ok(()) } } + +pub struct MinecraftProfileOverride { + pub file_hash: String, + pub url: String, + pub install_path: PathBuf, +} \ No newline at end of file diff --git a/src/models/v3/minecraft/profile.rs b/src/models/v3/minecraft/profile.rs index 7d8d0b17..861de21f 100644 --- a/src/models/v3/minecraft/profile.rs +++ b/src/models/v3/minecraft/profile.rs @@ -6,11 +6,14 @@ use serde::{Deserialize, Serialize}; use crate::{ database::{ self, - models::{LoaderFieldEnumValueId, LoaderId}, + models::LoaderFieldEnumValueId, }, models::ids::{Base62Id, UserId, VersionId}, }; +// How many uses should a share link have before it becomes invalid? +pub const DEFAULT_STARTING_LINK_USES: u32 = 5; + /// The ID of a specific profile, encoded as base62 for usage in the API #[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug)] #[serde(from = "Base62Id")] @@ -34,8 +37,8 @@ pub struct MinecraftProfile { /// The icon of the project. pub icon_url: Option, - /// The loader id - pub loader_id: LoaderId, + /// The loader + pub loader: String, /// The loader version pub loader_version: String, /// Minecraft game version id @@ -57,7 +60,7 @@ impl From for Minecr created: profile.created, updated: profile.updated, icon_url: profile.icon_url, - loader_id: profile.loader_id.into(), + loader: profile.loader, loader_version: profile.loader_version, game_version_id: profile.game_version_id.into(), versions: profile.versions.into_iter().map(Into::into).collect(), @@ -69,3 +72,38 @@ impl From for Minecr } } } + +#[derive(Serialize, Deserialize, Clone)] +pub struct MinecraftProfileShareLink { + pub url_identifier: String, + pub url: String, // Includes the url identifier, intentionally redundant + pub profile_id: MinecraftProfileId, + pub uses_remaining: u32, + pub created: DateTime, + pub expires: DateTime, +} + +impl From + for MinecraftProfileShareLink +{ + fn from(link: database::models::minecraft_profile_item::MinecraftProfileLink) -> Self { + + // Generate URL for easy access + let profile_id : MinecraftProfileId = link.shared_profile_id.into(); + let url = format!( + "{}/v3/minecraft/profile/{}/download/{}", + dotenvy::var("SELF_ADDR").unwrap(), + profile_id, + link.link_identifier + ); + + Self { + url_identifier: link.link_identifier, + url, + profile_id, + uses_remaining: link.uses_remaining as u32, + created: link.created, + expires: link.expires, + } + } +} \ No newline at end of file diff --git a/src/models/v3/pats.rs b/src/models/v3/pats.rs index 16bad0db..e9c6a317 100644 --- a/src/models/v3/pats.rs +++ b/src/models/v3/pats.rs @@ -109,7 +109,7 @@ bitflags::bitflags! { // create a minecraft profile const MINECRAFT_PROFILE_CREATE = 1 << 40; // edit a minecraft profile - const MINEECRAFT_PROFILE_WRITE = 1 << 41; + const MINECRAFT_PROFILE_WRITE = 1 << 41; // download a minecraft profile const MINECRAFT_PROFILE_DOWNLOAD = 1 << 42; diff --git a/src/routes/v3/minecraft/profiles.rs b/src/routes/v3/minecraft/profiles.rs index 80567247..c857b5b8 100644 --- a/src/routes/v3/minecraft/profiles.rs +++ b/src/routes/v3/minecraft/profiles.rs @@ -1,36 +1,56 @@ +use crate::auth::checks::filter_visible_version_ids; use crate::auth::{get_user_from_headers, AuthenticationError}; use crate::database::models::legacy_loader_fields::MinecraftGameVersion; use crate::database::models::{ - generate_minecraft_profile_id, minecraft_profile_item, MinecraftProfileId, + generate_minecraft_profile_id, minecraft_profile_item, version_item, generate_minecraft_profile_link_id, }; use crate::database::redis::RedisPool; +use crate::file_hosting::FileHost; use crate::models::ids::base62_impl::parse_base62; use crate::models::ids::VersionId; -use crate::models::minecraft::profile::MinecraftProfile; +use crate::models::minecraft::profile::{MinecraftProfile, MinecraftProfileShareLink, MinecraftProfileId, DEFAULT_STARTING_LINK_USES}; use crate::models::pats::Scopes; use crate::queue::session::AuthQueue; use crate::routes::v3::project_creation::CreateError; use crate::routes::ApiError; +use crate::util::routes::{read_from_payload, read_from_field}; use crate::util::validate::validation_errors_to_string; use crate::{database, models}; +use actix_multipart::{Multipart, Field}; use actix_web::web::Data; use actix_web::{web, HttpRequest, HttpResponse}; use chrono::Utc; +use futures::StreamExt; +use itertools::Itertools; use rand::distributions::Alphanumeric; use rand::{Rng, SeedableRng}; use rand_chacha::ChaCha20Rng; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use std::path::PathBuf; +use std::sync::Arc; use validator::Validate; pub fn config(cfg: &mut web::ServiceConfig) { - // make sure this is routed in front of /minecraft // TODO - cfg.route("profile", web::post().to(profile_create)); + cfg.service( + web::scope("minecraft") + .route("profile", web::post().to(profile_create)) + .route("check_token", web::get().to(profile_token_check)) + .service( + web::scope("profile") + .route("{id}", web::get().to(profile_get)) + .route("{id}", web::patch().to(profile_edit)) + .route("{id}", web::delete().to(profile_delete)) + .route("{id}/override", web::post().to(minecraft_profile_add_override)) + .route("{id}/share", web::get().to(profile_share)) + .route("{id}/share/{url_identifier}", web::get().to(profile_link_get)) + .route("{id}/download", web::get().to(profile_download)) + .route("{id}/icon", web::patch().to(profile_icon_edit)) + .route("{id}/icon", web::delete().to(delete_profile_icon)) + ) + ); } -//TODO: They might require a specific hash so we can compare file to when it was uploaded ?or just date I guess -// todo unwrap() #[derive(Serialize, Deserialize, Validate, Clone)] pub struct ProfileCreateData { @@ -40,22 +60,17 @@ pub struct ProfileCreateData { )] /// The title or name of the profile. pub name: String, - #[validate( - custom(function = "crate::util::validate::validate_url"), - length(max = 255) - )] - // The icon url of the profile. - // TODO: upload - pub icon_url: Option, - // The loader string (parsed to a loader) pub loader: String, // The loader version pub loader_version: String, // The game version string (parsed to a game version) pub game_version: String, + // The list of versions to include in the profile (does not include overrides) + pub versions: Vec, } +// Create a new minecraft profile pub async fn profile_create( req: HttpRequest, profile_create_data: web::Json, @@ -97,21 +112,33 @@ pub async fn profile_create( let mut transaction = client.begin().await?; - let profile_id: MinecraftProfileId = generate_minecraft_profile_id(&mut transaction) + let profile_id: database::models::MinecraftProfileId = generate_minecraft_profile_id(&mut transaction) .await? .into(); + let version_ids = profile_create_data.versions.into_iter().map(|x| x.into()).collect::>(); + let versions = version_item::Version::get_many(&version_ids, &**client, &redis) + .await? + .into_iter() + .map(|x| x.inner) + .collect::>(); + + // Filters versions authorized to see + let versions = filter_visible_version_ids(versions.iter().collect_vec(), &Some(current_user.clone()), &client, &redis).await + .map_err(|_| CreateError::InvalidInput("Could not fetch submitted version ids".to_string()))?; + let profile_builder_actual = minecraft_profile_item::MinecraftProfile { id: profile_id, name: profile_create_data.name.clone(), owner_id: current_user.id.into(), - icon_url: profile_create_data.icon_url.clone(), + icon_url: None, created: Utc::now(), updated: Utc::now(), game_version_id, loader_id, + loader: profile_create_data.loader, loader_version: profile_create_data.loader_version, - versions: Vec::new(), + versions, overrides: Vec::new(), }; let profile_builder = profile_builder_actual.clone(); @@ -126,6 +153,7 @@ pub async fn profile_create( pub struct MinecraftProfileIds { pub ids: String, } +// Get several minecraft profiles by their ids pub async fn profiles_get( web::Query(ids): web::Query, pool: web::Data, @@ -150,6 +178,7 @@ pub async fn profiles_get( Ok(HttpResponse::Ok().json(profiles)) } +// Get a minecraft profile by its id pub async fn profile_get( info: web::Path<(String,)>, pool: web::Data, @@ -169,6 +198,302 @@ pub async fn profile_get( Err(ApiError::NotFound) } +#[derive(Serialize, Deserialize, Validate, Clone)] +pub struct EditMinecraftProfile { + #[validate( + length(min = 3, max = 64), + custom(function = "crate::util::validate::validate_name") + )] + /// The title or name of the profile. + pub name: Option, + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 255) + )] + // The loader string (parsed to a loader) + pub loader: Option, + // The loader version + pub loader_version: Option, + // The game version string (parsed to a game version) + pub game_version: Option, + // The list of versions to include in the profile (does not include overrides) + pub versions: Option>, +} + +// Edit a minecraft profile +pub async fn profile_edit( + req: HttpRequest, + info: web::Path<(String,)>, + edit_data: web::Json, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let string = info.into_inner().0; + let edit_data = edit_data.into_inner(); + // Must be logged in to edit + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::MINECRAFT_PROFILE_WRITE]), + ) + .await?; + + + // Confirm this is our project, then if so, edit + let id = database::models::MinecraftProfileId(parse_base62(&string)? as i64); + let mut transaction = pool.begin().await?; + let profile_data = + database::models::minecraft_profile_item::MinecraftProfile::get(id, &mut *transaction, &redis) + .await?; + + if let Some(data) = profile_data { + if data.owner_id == user_option.1.id.into() { + // Edit the profile + if let Some(name) = edit_data.name { + sqlx::query!( + "UPDATE shared_profiles SET name = $1 WHERE id = $2", + name, + data.id.0 + ) + .execute(&mut *transaction) + .await?; + } + if let Some(loader) = edit_data.loader { + let loader_id = database::models::loader_fields::Loader::get_id( + &loader, + &mut *transaction, + &redis, + ) + .await? + .ok_or_else(|| ApiError::InvalidInput("Invalid loader".to_string()))?; + + sqlx::query!( + "UPDATE shared_profiles SET loader_id = $1 WHERE id = $2", + loader_id.0, + data.id.0 + ) + .execute(&mut *transaction) + .await?; + } + if let Some(loader_version) = edit_data.loader_version { + sqlx::query!( + "UPDATE shared_profiles SET loader_version = $1 WHERE id = $2", + loader_version, + data.id.0 + ) + .execute(&mut *transaction) + .await?; + } + if let Some(game_version) = edit_data.game_version { + let new_game_id = database::models::legacy_loader_fields::MinecraftGameVersion::list(&**pool, &redis) + .await? + .into_iter() + .find(|x| x.version == game_version) + .ok_or_else(|| ApiError::InvalidInput("Invalid Minecraft game version".to_string()))? + .id; + + sqlx::query!( + "UPDATE shared_profiles SET game_version_id = $1 WHERE id = $2", + new_game_id.0, + data.id.0 + ) + .execute(&mut *transaction) + .await?; + } + if let Some(versions) = edit_data.versions { + let version_ids = versions.into_iter().map(|x| x.into()).collect::>(); + let versions = version_item::Version::get_many(&version_ids, &mut *transaction + , &redis) + .await? + .into_iter() + .map(|x| x.inner) + .collect::>(); + + // Filters versions authorized to see + let versions = filter_visible_version_ids(versions.iter().collect_vec(), &Some(user_option.1.clone()), &pool, &redis).await + .map_err(|_| ApiError::InvalidInput("Could not fetch submitted version ids".to_string()))?; + + // Remove all shared profile mods of this profile where version_id is set + sqlx::query!( + "DELETE FROM shared_profiles_mods WHERE shared_profile_id = $1 AND version_id IS NOT NULL", + data.id.0 + ) + .execute(&mut *transaction) + .await?; + + // Insert all new shared profile mods + for v in versions { + sqlx::query!( + "INSERT INTO shared_profiles_mods (shared_profile_id, version_id) VALUES ($1, $2)", + data.id.0, + v.0 + ) + .execute(&mut *transaction) + .await?; + } + + } + transaction.commit().await?; + minecraft_profile_item::MinecraftProfile::clear_cache(data.id, &redis).await?; + return Ok(HttpResponse::Ok().finish()); + } + + else { + return Err(ApiError::CustomAuthentication("You are not the owner of this profile".to_string())); + } + } + Err(ApiError::NotFound) +} + + +// Delete a minecraft profile +pub async fn profile_delete( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let string = info.into_inner().0; + + // Must be logged in to delete + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::MINECRAFT_PROFILE_WRITE]), + ) + .await?; + + // Confirm this is our project, then if so, delete + let id = database::models::MinecraftProfileId(parse_base62(&string)? as i64); + let profile_data = + database::models::minecraft_profile_item::MinecraftProfile::get(id, &**pool, &redis) + .await?; + if let Some(data) = profile_data { + if data.owner_id == user_option.1.id.into() { + let mut transaction = pool.begin().await?; + database::models::minecraft_profile_item::MinecraftProfile::remove(data.id, &mut transaction, &redis) + .await?; + transaction.commit().await?; + minecraft_profile_item::MinecraftProfile::clear_cache(data.id, &redis).await?; + return Ok(HttpResponse::Ok().finish()); + } + } + + Err(ApiError::NotFound) +} + +// Share a minecraft profile with a friend. +// This generates a link struct, including the field 'url' +// that can be shared with friends to generate a token a limited number of times. +// TODO: This link should not be an API link, but a modrinth:// link that is translatable to an API link by the launcher +pub async fn profile_share( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let string = info.into_inner().0; + + // Must be logged in to share + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::MINECRAFT_PROFILE_WRITE]), + ) + .await?; + + // Confirm this is our project, then if so, share + let id = database::models::MinecraftProfileId(parse_base62(&string)? as i64); + let profile_data = + database::models::minecraft_profile_item::MinecraftProfile::get(id, &**pool, &redis) + .await?; + + if let Some(data) = profile_data { + if data.owner_id == user_option.1.id.into() { + + // Generate a share link identifier + let identifier = ChaCha20Rng::from_entropy() + .sample_iter(&Alphanumeric) + .take(8) + .map(char::from) + .collect::(); + + + // Generate a new share link id + let mut transaction = pool.begin().await?; + let profile_link_id = generate_minecraft_profile_link_id(&mut transaction).await?; + + let link = database::models::minecraft_profile_item::MinecraftProfileLink { + id: profile_link_id, + shared_profile_id: data.id, + link_identifier: identifier.clone(), + created: Utc::now(), + expires: Utc::now() + chrono::Duration::days(7), + + uses_remaining: DEFAULT_STARTING_LINK_USES as i32, + }; + link.insert(&mut transaction).await?; + transaction.commit().await?; + minecraft_profile_item::MinecraftProfile::clear_cache(data.id, &redis).await?; + return Ok(HttpResponse::Ok().json(MinecraftProfileShareLink::from(link))); + } + } + Err(ApiError::NotFound) +} + +// See the status of a link to a profile by its id +// This is used by the launcher to check if the link is still valid, expired, or has uses left. +pub async fn profile_link_get( + req: HttpRequest, + info: web::Path<(String,String)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let url_identifier = info.into_inner().1; + // Must be logged in to check + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + None, // No scopes required to read your own links + ) + .await?; + + + // Confirm this is our project, then if so, share + let link_data = database::models::minecraft_profile_item::MinecraftProfileLink::get_url( + &url_identifier, + &**pool, + ) + .await?.ok_or_else(|| ApiError::NotFound)?; + + let data = database::models::minecraft_profile_item::MinecraftProfile::get( + link_data.shared_profile_id, + &**pool, + &redis, + ) + .await?.ok_or_else(|| ApiError::NotFound)?; + + // Only view link meta information if the user is the owner of the profile + if data.owner_id == user_option.1.id.into() { + Ok(HttpResponse::Ok().json(MinecraftProfileShareLink::from(link_data))) + } else { + Err(ApiError::NotFound) + } +} + + #[derive(Serialize, Deserialize)] pub struct ProfileDownload { // temporary authorization token for the CDN, for downloading the profile files @@ -182,6 +507,9 @@ pub struct ProfileDownload { pub override_cdns: Vec<(String, PathBuf)>, } +// Download a minecraft profile +// This converts a share link into a temporary authorization token for the CDN +// TODO: With above change, this is the API link that is translated from a modrinth:// link by the launcher pub async fn profile_download( req: HttpRequest, info: web::Path<(String,)>, @@ -189,6 +517,7 @@ pub async fn profile_download( redis: web::Data, session_queue: web::Data, ) -> Result { + let cdn_url = dotenvy::var("CDN_URL")?; let url_identifier = info.into_inner().0; // Must be logged in to download @@ -222,8 +551,6 @@ pub async fn profile_download( return Err(ApiError::NotFound); }; - let cdn_downloads_required = profile.overrides.len(); - let mut transaction = pool.begin().await?; // Check no token exists for the username and profile @@ -271,39 +598,46 @@ pub async fn profile_download( // Create a new cdn auth token let token = database::models::minecraft_profile_item::MinecraftProfileLinkToken { + user_id: user_option.1.id.into(), // This user is requesting the download token: ChaCha20Rng::from_entropy() .sample_iter(&Alphanumeric) .take(32) .map(char::from) .collect::(), - shared_profiles_links_id: profile_link_data.shared_profile_id, + shared_profiles_links_id: profile_link_data.id, created: Utc::now(), expires: Utc::now() + chrono::Duration::minutes(5), }; token.insert(&mut transaction).await?; - // TODO: Create download header to authorize the CDN - // TODO: only if we have enough downloads left on the profile - // (and de increment it) - - // TODO: check user, so same user cant request all of t hem - - // TODO: maybe we should not have a limit number of uses on the token itself? isntead just limit it to like, 5 minutes so it can be retried - + let override_cdns = profile.overrides.into_iter().map(|x| (format!("{}/custom_files/{}", cdn_url, x.0), x.1)).collect::>(); transaction.commit().await?; + minecraft_profile_item::MinecraftProfile::clear_cache(profile.id, &redis).await?; Ok(HttpResponse::Ok().json(ProfileDownload { auth_token: token.token, version_ids: profile.versions.iter().map(|x| (*x).into()).collect(), - override_cdns: profile.overrides, + override_cdns, })) } +#[derive(Deserialize)] + pub struct TokenUrl { + pub url: String, // TODO: could take a vec instead? + } + // Used by cloudflare to check headers and permit CDN downloads for a pack +// Checks headers for 'authorization: xxyyzz' where xxyyzz is a valid token +// that allows for downloading of url 'url' pub async fn profile_token_check( req: HttpRequest, + file_url : web::Query, pool: web::Data, + redis: web::Data, ) -> Result { + let cdn_url = dotenvy::var("CDN_URL")?; + let file_url = file_url.into_inner().url; + // Extract token from 'authorization' of headers let token = req .headers() @@ -314,13 +648,364 @@ pub async fn profile_token_check( let token = database::models::minecraft_profile_item::MinecraftProfileLinkToken::get_token( &token, &**pool, ) + .await?.ok_or_else(|| ApiError::Authentication(AuthenticationError::InvalidAuthMethod))?; + + if token.expires <= Utc::now() { + return Err(ApiError::Authentication(AuthenticationError::InvalidAuthMethod)); + } + + // Get share link + let share_link = database::models::minecraft_profile_item::MinecraftProfileLink::get( + token.shared_profiles_links_id, + &**pool, + ) + .await?.ok_or_else(|| ApiError::Authentication(AuthenticationError::InvalidAuthMethod))?; + + // Get valid urls for the profile + let profile = database::models::minecraft_profile_item::MinecraftProfile::get( + share_link.shared_profile_id, + &**pool, + &redis, + ) + .await?.ok_or_else(|| ApiError::Authentication(AuthenticationError::InvalidAuthMethod))?; + + // Check the token is valid for the requested file + let file_url_hash = file_url.split(&format!("{cdn_url}/custom_files/")).nth(1).ok_or_else(|| ApiError::Authentication(AuthenticationError::InvalidAuthMethod))?; + + + let valid = profile + .overrides + .iter() + .any(|x| { + x.0 == file_url_hash + }); + + if !valid { + return Err(ApiError::Authentication(AuthenticationError::InvalidAuthMethod)); + } else { + Ok(HttpResponse::Ok().finish()) + } +} + +#[derive(Serialize, Deserialize)] +pub struct Extension { + pub ext: String, +} + +#[allow(clippy::too_many_arguments)] +pub async fn profile_icon_edit( + web::Query(ext): web::Query, + req: HttpRequest, + info: web::Path<(MinecraftProfileId,)>, + 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::MINECRAFT_PROFILE_WRITE]), + ) + .await? + .1; + let id = info.into_inner().0; + + let profile_item = database::models::minecraft_profile_item::MinecraftProfile::get(id.into(), &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The specified profile does not exist!".to_string()) + })?; + + if !user.role.is_mod() && profile_item.owner_id != user.id.into() { + return Err(ApiError::CustomAuthentication( + "You don't have permission to edit this profile's icon.".to_string(), + )); + } + + if let Some(icon) = profile_item.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 color = crate::util::img::get_color_from_img(&bytes)?; + + let hash = sha1::Sha1::from(&bytes).hexdigest(); + let id : MinecraftProfileId = profile_item.id.into(); + let upload_data = file_host + .upload_file( + content_type, + &format!("data/{}/{}.{}", id, hash, ext.ext), + bytes.freeze(), + ) + .await?; + + let mut transaction = pool.begin().await?; + + sqlx::query!( + " + UPDATE shared_profiles + SET icon_url = $1, color = $2 + WHERE (id = $3) + ", + format!("{}/{}", cdn_url, upload_data.file_name), + color.map(|x| x as i32), + profile_item.id as database::models::ids::MinecraftProfileId, + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + database::models::minecraft_profile_item::MinecraftProfile::clear_cache( + profile_item.id, + &redis, + ) + .await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::InvalidInput(format!( + "Invalid format for project icon: {}", + ext.ext + ))) + } +} + +pub async fn delete_profile_icon( + req: HttpRequest, + info: web::Path<(MinecraftProfileId,)>, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::MINECRAFT_PROFILE_WRITE]), + ) + .await? + .1; + let id = info.into_inner().0; + + let profile_item = database::models::minecraft_profile_item::MinecraftProfile::get(id.into(), &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The specified profile does not exist!".to_string()) + })?; + + if !user.role.is_mod() && profile_item.owner_id != user.id.into() { + return Err(ApiError::CustomAuthentication( + "You don't have permission to edit this profile's icon.".to_string(), + )); + } + + let cdn_url = dotenvy::var("CDN_URL")?; + if let Some(icon) = profile_item.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?; + + sqlx::query!( + " + UPDATE shared_profiles + SET icon_url = NULL, color = NULL + WHERE (id = $1) + ", + profile_item.id as database::models::ids::MinecraftProfileId, + ) + .execute(&mut *transaction) .await?; - if let Some(token) = token { - if token.expires > Utc::now() { - return Ok(HttpResponse::Ok().finish()); + transaction.commit().await?; + + database::models::minecraft_profile_item::MinecraftProfile::clear_cache( + profile_item.id, + &redis, + ) + .await?; + + Ok(HttpResponse::NoContent().body("")) +} + + +// Add a new override mod to a minecraft profile, by uploading it to the CDN +// Accepts a multipart field +// the first part is called `data` and contains a json array of objects with the following fields: +// file_name: String +// install_path: String +// The rest of the parts are files, and their install paths are matched to the install paths in the data field +#[derive(Serialize, Deserialize)] +struct MultipartFile { + pub file_name: String, + pub install_path : String, +} + +#[allow(clippy::too_many_arguments)] +pub async fn minecraft_profile_add_override( + req: HttpRequest, + client_id: web::Path, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + mut payload: Multipart, + session_queue: web::Data, +) -> Result { + let client_id = client_id.into_inner(); + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::MINECRAFT_PROFILE_WRITE]), + ) + .await? + .1; + + // Check if this is our profile + let profile_item = database::models::minecraft_profile_item::MinecraftProfile::get(client_id.into(), &**pool, &redis) + .await? + .ok_or_else(|| { + CreateError::InvalidInput("The specified profile does not exist!".to_string()) + })?; + + if !user.role.is_mod() && profile_item.owner_id != user.id.into() { + return Err(CreateError::CustomAuthenticationError( + "You don't have permission to add overrides.".to_string(), + )); + } + + struct UploadedFile { + pub install_path : String, + pub hash: String, + } + + let mut error = None; + let mut uploaded_files = Vec::new(); + + let files : Vec = { + // First, get the data field + let mut field = payload.next().await.ok_or_else(|| { + CreateError::InvalidInput(String::from("Upload must have a data field")) + })??; + + let content_disposition = field.content_disposition().clone(); + // Allow any content type + let name = content_disposition.get_name().ok_or_else(|| { + CreateError::InvalidInput(String::from("Upload must have a name")) + })?; + + if name == "data" { + let mut d: Vec = Vec::new(); + while let Some(chunk) = field.next().await { + d.extend_from_slice(&chunk.map_err(CreateError::MultipartError)?); + } + serde_json::from_slice(&d)? + } else { + return Err(CreateError::InvalidInput(String::from( + "`data` field must come before file fields", + ))); + } + }; + + while let Some(item) = payload.next().await { + let mut field: Field = item?; + if error.is_some() { + continue; + } + let result = async { + let content_disposition = field.content_disposition().clone(); + let content_type = field.content_type().map(|x| x.essence_str()).unwrap_or_else(|| "application/octet-stream").to_string(); + // Allow any content type + let name = content_disposition.get_name().ok_or_else(|| { + CreateError::InvalidInput(String::from("Upload must have a name")) + })?; + + let data = read_from_field(&mut field, 262144, "Icons must be smaller than 256KiB").await?; + let install_path = files.iter().find(|x| x.file_name == name) + .ok_or_else(|| CreateError::InvalidInput(format!("No matching file name in `data` for file '{}'", + name)))?.install_path.clone(); + + let hash = sha1::Sha1::from(&data).hexdigest(); + + file_host + .upload_file( + &content_type, + &format!("custom_files/{hash}"), + data.freeze(), + ) + .await?; + + uploaded_files.push(UploadedFile { + install_path, + hash, + }); + Ok(()) + }.await; + + if result.is_err() { + error = result.err(); } } - Err(ApiError::NotFound) + if let Some(error) = error { + return Err(error); + } + + let mut transaction = pool.begin().await?; + + let (ids, hashes, install_paths): ( + Vec<_>, + Vec<_>, + Vec<_>, + ) = uploaded_files + .into_iter() + .map(|f| { + ( + profile_item.id.0, + f.hash, + f.install_path, + ) + }) + .multiunzip(); + + sqlx::query!( + " + INSERT INTO shared_profiles_mods (shared_profile_id, file_hash, install_path) + SELECT * FROM UNNEST($1::bigint[], $2::text[], $3::text[]) + ", + &ids[..], + &hashes[..], + &install_paths[..], + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + + database::models::minecraft_profile_item::MinecraftProfile::clear_cache( + profile_item.id, + &redis, + ) + .await?; + + Ok(HttpResponse::NoContent().body("")) } diff --git a/src/routes/v3/mod.rs b/src/routes/v3/mod.rs index 4c42e885..29fce5d0 100644 --- a/src/routes/v3/mod.rs +++ b/src/routes/v3/mod.rs @@ -32,6 +32,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { .configure(analytics_get::config) .configure(collections::config) .configure(images::config) + .configure(minecraft::profiles::config) .configure(moderation::config) .configure(notifications::config) .configure(organizations::config) diff --git a/tests/common/api_v3/minecraft_profile.rs b/tests/common/api_v3/minecraft_profile.rs new file mode 100644 index 00000000..c3ee3252 --- /dev/null +++ b/tests/common/api_v3/minecraft_profile.rs @@ -0,0 +1,242 @@ +use actix_web::{ + dev::ServiceResponse, + test::{self, TestRequest}, +}; +use bytes::Bytes; +use itertools::Itertools; +use labrinth::{models::minecraft::profile::{MinecraftProfile, MinecraftProfileShareLink}, util::actix::{MultipartSegment, MultipartSegmentData, AppendsMultipart}, routes::v3::minecraft::profiles::ProfileDownload}; +use serde_json::json; + +use crate::common::{api_common::{request_data::ImageData, Api, AppendsOptionalPat}, dummy_data::TestFile}; + +use super::ApiV3; +pub struct MinecraftProfileOverride { + pub file_name: String, + pub install_path: String, + pub bytes: Vec +} + +impl MinecraftProfileOverride { + pub fn new(test_file : TestFile, install_path : &str) -> Self { + Self { + file_name: test_file.filename(), + install_path: install_path.to_string(), + bytes: test_file.bytes(), + } + } +} + + +impl ApiV3 { + pub async fn create_minecraft_profile( + &self, + name: &str, + loader: &str, + loader_version: &str, + game_version: &str, + versions: Vec<&str>, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::post() + .uri("/v3/minecraft/profile") + .append_pat(pat) + .set_json(json!({ + "name": name, + "loader": loader, + "loader_version": loader_version, + "game_version": game_version, + "versions": versions + })) + .to_request(); + self.call(req).await + } + + pub async fn edit_minecraft_profile( + &self, + id: &str, + name: Option<&str>, + loader: Option<&str>, + loader_version: Option<&str>, + versions: Option>, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::patch() + .uri(&format!("/v3/minecraft/profile/{}", id)) + .append_pat(pat) + .set_json(json!({ + "name": name, + "loader": loader, + "loader_version": loader_version, + "versions": versions + })) + .to_request(); + self.call(req).await + } + + pub async fn get_minecraft_profile(&self, id: &str, pat: Option<&str>) -> ServiceResponse { + let req = TestRequest::get() + .uri(&format!("/v3/minecraft/profile/{}", id)) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + pub async fn get_minecraft_profile_deserialized(&self, id: &str, pat: Option<&str>) -> MinecraftProfile { + let resp = self.get_minecraft_profile(id, pat).await; + assert_eq!(resp.status(), 200); + test::read_body_json(resp).await + } + + pub async fn edit_minecraft_profile_icon( + &self, + id: &str, + icon: Option, + pat: Option<&str>, + ) -> ServiceResponse { + if let Some(icon) = icon { + let req = TestRequest::patch() + .uri(&format!("/v3/minecraft/profile/{}/icon?ext={}", id, icon.extension)) + .append_pat(pat) + .set_payload(Bytes::from(icon.icon)) + .to_request(); + self.call(req).await + } else { + let req = TestRequest::delete() + .uri(&format!("/v3/minecraft/profile/{}/icon", id)) + .append_pat(pat) + .to_request(); + self.call(req).await + } + } + + pub async fn add_minecraft_profile_overrides( + &self, + id: &str, + overrides: Vec, + pat: Option<&str>, + ) -> ServiceResponse { + let mut data = Vec::new(); + let mut multipart_segments : Vec = Vec::new(); + for override_ in overrides { + data.push(serde_json::json!({ + "file_name": override_.file_name, + "install_path": override_.install_path, + })); + multipart_segments.push(MultipartSegment { + name: override_.file_name.clone(), + filename: Some(override_.file_name), + content_type: None, + data: MultipartSegmentData::Binary(override_.bytes.to_vec()), + }); + } + let multipart_segments = std::iter::once(MultipartSegment { + name: "data".to_string(), + filename: None, + content_type: Some("application/json".to_string()), + data: MultipartSegmentData::Text(serde_json::to_string(&data).unwrap()), + }).chain(multipart_segments.into_iter()).collect_vec(); + + let req = TestRequest::post() + .uri(&format!("/v3/minecraft/profile/{}/override", id)) + .append_pat(pat) + .set_multipart(multipart_segments) + .to_request(); + self.call(req).await + } + + pub async fn delete_minecraft_profile_override( + &self, + id: &str, + file_name: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = TestRequest::delete() + .uri(&format!("/v3/minecraft/profile/{}/overrides/{}", id, file_name)) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + pub async fn generate_minecraft_profile_share_link( + &self, + id: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = TestRequest::get() + .uri(&format!("/v3/minecraft/profile/{}/share", id)) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + pub async fn generate_minecraft_profile_share_link_deserialized( + &self, + id: &str, + pat: Option<&str>, + ) -> MinecraftProfileShareLink { + let resp = self.generate_minecraft_profile_share_link(id, pat).await; + assert_eq!(resp.status(), 200); + test::read_body_json(resp).await + } + + pub async fn get_minecraft_profile_share_link( + &self, + profile_id: &str, + url_identifier: &str, + pat: Option<&str> + ) -> ServiceResponse { + let req = TestRequest::get() + .uri(&format!("/v3/minecraft/profile/{}/share/{}", profile_id, url_identifier)) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + pub async fn get_minecraft_profile_share_link_deserialized( + &self, + profile_id: &str, + url_identifier: &str, + pat: Option<&str> + ) -> MinecraftProfileShareLink { + let resp = self.get_minecraft_profile_share_link(profile_id, url_identifier, pat).await; + assert_eq!(resp.status(), 200); + test::read_body_json(resp).await + } + + pub async fn download_minecraft_profile( + &self, + url_identifier: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = TestRequest::get() + .uri(&format!("/v3/minecraft/profile/{}/download", url_identifier)) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + pub async fn download_minecraft_profile_deserialized( + &self, + url_identifier: &str, + pat: Option<&str>, + )-> ProfileDownload { + let resp = self.download_minecraft_profile(url_identifier, pat).await; + assert_eq!(resp.status(), 200); + test::read_body_json(resp).await + } + + pub async fn check_download_minecraft_profile_token( + &self, + token: &str, + url: &str, // Full URL, the route will parse it + ) -> ServiceResponse { + let req = TestRequest::get() + .uri(&format!("/v3/minecraft/check_token?url={url}", url=urlencoding::encode(url))) + .append_header(("Authorization", token)) + .to_request(); + self.call(req).await + } + + +} + diff --git a/tests/common/api_v3/mod.rs b/tests/common/api_v3/mod.rs index caab4ab6..6ffbc5a7 100644 --- a/tests/common/api_v3/mod.rs +++ b/tests/common/api_v3/mod.rs @@ -10,6 +10,7 @@ use labrinth::LabrinthConfig; use std::rc::Rc; pub mod collections; +pub mod minecraft_profile; pub mod oauth; pub mod oauth_clients; pub mod organization; diff --git a/tests/common/api_v3/project.rs b/tests/common/api_v3/project.rs index 8aa09570..ae6992ed 100644 --- a/tests/common/api_v3/project.rs +++ b/tests/common/api_v3/project.rs @@ -384,8 +384,6 @@ impl ApiProject for ApiV3 { .to_request(); let t = self.call(req).await; - println!("Status: {}", t.status()); - println!("respone Body: {:?}", t.response().body()); t } diff --git a/tests/common/dummy_data.rs b/tests/common/dummy_data.rs index 394df369..731a33d9 100644 --- a/tests/common/dummy_data.rs +++ b/tests/common/dummy_data.rs @@ -7,7 +7,7 @@ use labrinth::models::{ oauth_clients::OAuthClient, organizations::Organization, pats::Scopes, - projects::{Project, ProjectId, Version}, + projects::{Project, ProjectId, Version, VersionId}, }; use serde_json::json; use sqlx::Executor; @@ -190,6 +190,7 @@ impl DummyData { project_slug: project_alpha.slug.unwrap(), project_id_parsed: project_alpha.id, version_id: project_alpha_version.id.to_string(), + version_id_parsed: project_alpha_version.id, thread_id: project_alpha.thread_id.to_string(), file_hash: project_alpha_version.files[0].hashes["sha1"].clone(), }, @@ -200,6 +201,7 @@ impl DummyData { project_slug: project_beta.slug.unwrap(), project_id_parsed: project_beta.id, version_id: project_beta_version.id.to_string(), + version_id_parsed: project_beta_version.id, thread_id: project_beta.thread_id.to_string(), file_hash: project_beta_version.files[0].hashes["sha1"].clone(), }, @@ -230,6 +232,7 @@ pub struct DummyProjectAlpha { pub project_slug: String, pub project_id_parsed: ProjectId, pub version_id: String, + pub version_id_parsed: VersionId, pub thread_id: String, pub file_hash: String, pub team_id: String, @@ -241,6 +244,7 @@ pub struct DummyProjectBeta { pub project_slug: String, pub project_id_parsed: ProjectId, pub version_id: String, + pub version_id_parsed: VersionId, pub thread_id: String, pub file_hash: String, pub team_id: String, diff --git a/tests/profiles.rs b/tests/profiles.rs new file mode 100644 index 00000000..1ce118c4 --- /dev/null +++ b/tests/profiles.rs @@ -0,0 +1,393 @@ +use std::path::PathBuf; + +use actix_web::test; +use common::api_v3::ApiV3; +use common::database::*; +use common::environment::with_test_environment; +use common::environment::TestEnvironment; +use labrinth::models::minecraft::profile::MinecraftProfile; + +use crate::common::api_v3::minecraft_profile::MinecraftProfileOverride; +use crate::common::dummy_data::DummyImage; +use crate::common::dummy_data::TestFile; + +mod common; + +#[actix_rt::test] +async fn create_modify_profile() { + // Test setup and dummy data + with_test_environment(None, |test_env: TestEnvironment| async move { + // Create and modifiy a profile with certain properties + // Check that the properties are correct + let api = &test_env.api; + let alpha_version_id = test_env.dummy.project_alpha.version_id.to_string(); + let alpha_version_id_parsed = test_env.dummy.project_alpha.version_id_parsed; + + // Attempt to create a simple profile with invalid data, these should fail. + // - fake loader + // - fake loader version for loader + // - unparseable version (not to be confused with parseable but nonexistent version, which is simply ignored) + // - fake game version + let resp = api + .create_minecraft_profile("test", "fake-loader", "1.0.0", "1.20.1", vec![], USER_USER_PAT) + .await; + assert_eq!(resp.status(), 400); + + // Currently fake version for loader is not checked + // let resp = api + // .create_minecraft_profile("test", "fabric", "fake", "1.20.1", vec![], USER_USER_PAT) + // .await; + // assert_eq!(resp.status(), 400); + + let resp = api + .create_minecraft_profile("test", "fabric", "1.0.0", "1.20.1", vec!["unparseable-version"], USER_USER_PAT) + .await; + assert_eq!(resp.status(), 400); + + let resp = api + .create_minecraft_profile("test", "fabric", "1.0.0", "1.19.1", vec![], USER_USER_PAT) + .await; + assert_eq!(resp.status(), 400); + + // Create a simple profile + // should succeed + let profile = api + .create_minecraft_profile("test", "fabric", "1.0.0", "1.20.1", vec![], USER_USER_PAT) + .await; + println!("{:?}", profile.response().body()); + assert_eq!(profile.status(), 200); + let profile : MinecraftProfile = test::read_body_json(profile).await; + let id = profile.id.to_string(); + + // Get the profile and check the properties are correct + let profile = api + .get_minecraft_profile_deserialized(&id, USER_USER_PAT) + .await; + + assert_eq!(profile.name, "test"); + assert_eq!(profile.loader, "fabric"); + assert_eq!(profile.loader_version, "1.0.0"); + assert_eq!(profile.versions, vec![]); + assert_eq!(profile.icon_url, None); + + println!("Profile id is {}", profile.id.to_string()); + + // Modify the profile illegally in the same ways + let resp = api + .edit_minecraft_profile( + &profile.id.to_string(), + None, + Some("fake-loader"), + None, + None, + USER_USER_PAT, + ) + .await; + println!("{:?}", resp.response().body()); + assert_eq!(resp.status(), 400); + + // Currently fake version for loader is not checked + // let resp = api + // .edit_minecraft_profile( + // &profile.id.to_string(), + // None, + // Some("fabric"), + // Some("fake"), + // None, + // USER_USER_PAT, + // ) + // .await; + + println!("{:?}", resp.response().body()); + assert_eq!(resp.status(), 400); + + let resp = api + .edit_minecraft_profile( + &profile.id.to_string(), + None, + Some("fabric"), + None, + Some(vec!["unparseable-version"]), + USER_USER_PAT, + ) + .await; + println!("{:?}", resp.response().body()); + assert_eq!(resp.status(), 400); + + // Can't modify the profile as another user + let resp = api + .edit_minecraft_profile( + &profile.id.to_string(), + None, + Some("fabric"), + None, + None, + FRIEND_USER_PAT, + ) + .await; + println!("{:?}", resp.response().body()); + assert_eq!(resp.status(), 401); + + // Get and make sure the properties are the same + let profile = api + .get_minecraft_profile_deserialized(&id, USER_USER_PAT) + .await; + + assert_eq!(profile.name, "test"); + assert_eq!(profile.loader, "fabric"); + assert_eq!(profile.loader_version, "1.0.0"); + assert_eq!(profile.versions, vec![]); + assert_eq!(profile.icon_url, None); + + // A successful modification + let resp = api + .edit_minecraft_profile( + &profile.id.to_string(), + Some("test2"), + Some("forge"), + Some("1.0.1"), + Some(vec![&alpha_version_id]), + USER_USER_PAT, + ) + .await; + println!("{:?}", resp.response().body()); + assert_eq!(resp.status(), 200); + + // Get the profile and check the properties + let profile = api + .get_minecraft_profile_deserialized(&id, USER_USER_PAT) + .await; + + println!("{:?}", serde_json::to_string(&profile)); + + assert_eq!(profile.name, "test2"); + assert_eq!(profile.loader, "forge"); + assert_eq!(profile.loader_version, "1.0.1"); + assert_eq!(profile.versions, vec![alpha_version_id_parsed]); + assert_eq!(profile.icon_url, None); + + // Modify the profile again + let resp = api + .edit_minecraft_profile( + &profile.id.to_string(), + Some("test3"), + Some("fabric"), + Some("1.0.0"), + Some(vec![]), + USER_USER_PAT, + ) + .await; + println!("{:?}", resp.response().body()); + assert_eq!(resp.status(), 200); + + // Get the profile and check the properties + let profile = api + .get_minecraft_profile_deserialized(&id, USER_USER_PAT) + .await; + + assert_eq!(profile.name, "test3"); + assert_eq!(profile.loader, "fabric"); + assert_eq!(profile.loader_version, "1.0.0"); + assert_eq!(profile.versions, vec![]); + assert_eq!(profile.icon_url, None); + + }).await; +} + +#[actix_rt::test] +async fn download_profile() { + with_test_environment(None, |test_env: TestEnvironment| async move { + // Get download links for a created profile (including failure), create a share link, and create the correct number of tokens based on that + // They should expire after a time + let api = &test_env.api; + + // Create a simple profile + let profile = api + .create_minecraft_profile("test", "fabric", "1.0.0" ,"1.20.1", vec![], USER_USER_PAT) + .await; + assert_eq!(profile.status(), 200); + let profile : MinecraftProfile = test::read_body_json(profile).await; + let id = profile.id.to_string(); + + // Add an override file to the profile + let resp = api + .add_minecraft_profile_overrides(&id, vec![MinecraftProfileOverride::new(TestFile::BasicMod, "mods/test.jar")], USER_USER_PAT) + .await; + println!("{:?}", resp.response().body()); + assert_eq!(resp.status(), 204); + + println!("Here123123123123213"); + + // As 'user', try to generate a download link for the profile + let share_link = api + .generate_minecraft_profile_share_link_deserialized(&id, USER_USER_PAT) + .await; + // Links should add up + assert_eq!(share_link.uses_remaining, 5); + assert_eq!(share_link.url , format!("{}/v3/minecraft/profile/{}/download/{}", dotenvy::var("SELF_ADDR").unwrap(), id, share_link.url_identifier)); + + // As 'friend', try to get the download links for the profile + // *Anyone* with the link can get + let mut download = api + .download_minecraft_profile_deserialized(&share_link.url_identifier, FRIEND_USER_PAT) + .await; + + // Download url should be: + // - CDN url + // "custom_files" + // - hash + assert_eq!(download.override_cdns.len(), 1); + let override_file_url = download.override_cdns.remove(0).0; + let hash = sha1::Sha1::from(&TestFile::BasicMod.bytes()).hexdigest(); + assert_eq!(override_file_url, format!("{}/custom_files/{}", dotenvy::var("CDN_URL").unwrap(), hash)); + + + // This generates a token, and now the link should have 4 uses remaining + let share_link = api + .get_minecraft_profile_share_link_deserialized(&id, &share_link.url_identifier, USER_USER_PAT) + .await; + println!("\n\n{:?}", serde_json::to_string(&share_link)); + assert_eq!(share_link.uses_remaining, 4); + + // Check cloudflare helper route with a bad token (eg: the profile id), should fail + let resp = api + .check_download_minecraft_profile_token(&share_link.url_identifier, &override_file_url).await; + println!("{:?}", resp.response().body()); + assert_eq!(resp.status(), 401); + let resp = api + .check_download_minecraft_profile_token(&share_link.url, &override_file_url).await; +println!("{:?}", resp.response().body()); +assert_eq!(resp.status(), 401); + + let resp = api + .check_download_minecraft_profile_token(&id, &override_file_url).await; + assert_eq!(resp.status(), 401); + + // Check cloudflare helper route to confirm this is a valid allowable access token + // We attach it as an authorization token and call the route + let download = api + .check_download_minecraft_profile_token(&download.auth_token, &override_file_url).await; + println!("{:?}", download.response().body()); + assert_eq!(download.status(), 200); + + + }).await; +} + +#[actix_rt::test] +async fn add_remove_profile_icon() { + with_test_environment(None, |test_env: TestEnvironment| async move { + // Add and remove an icon from a profile + let api = &test_env.api; + + // Create a simple profile + let profile = api + .create_minecraft_profile("test", "fabric", "1.0.0", "1.20.1", vec![], USER_USER_PAT) + .await; + assert_eq!(profile.status(), 200); + let profile : MinecraftProfile = test::read_body_json(profile).await; + + // Add an icon to the profile + let icon = api + .edit_minecraft_profile_icon(&profile.id.to_string(), Some(DummyImage::SmallIcon.get_icon_data()), USER_USER_PAT) + .await; + println!("{:?}", icon.response().body()); + assert_eq!(icon.status(), 204); + + // Get the profile and check the icon + let profile = api + .get_minecraft_profile_deserialized(&profile.id.to_string(), USER_USER_PAT) + .await; + assert!(profile.icon_url.is_some()); + + // Remove the icon from the profile + let icon = api + .edit_minecraft_profile_icon(&profile.id.to_string(), None, USER_USER_PAT) + .await; + assert_eq!(icon.status(), 204); + + // Get the profile and check the icon + let profile = api + .get_minecraft_profile_deserialized(&profile.id.to_string(), USER_USER_PAT) + .await; + assert!(profile.icon_url.is_none()); + }).await; +} + +#[actix_rt::test] +async fn add_remove_profile_versions() { + with_test_environment(None, |test_env: TestEnvironment| async move { + // Add and remove versions from a profile + let api = &test_env.api; + let alpha_version_id = test_env.dummy.project_alpha.version_id.to_string(); + // Create a simple profile + let profile = api + .create_minecraft_profile("test", "fabric", "1.0.0", "1.20.1", vec![], USER_USER_PAT) + .await; + assert_eq!(profile.status(), 200); + let profile : MinecraftProfile = test::read_body_json(profile).await; + + // Add a hosted version to the profile + let resp = api + .edit_minecraft_profile(&profile.id.to_string(), None, None, None, Some(vec![&alpha_version_id]), USER_USER_PAT) + .await; + println!("{:?}", resp.response().body()); + assert_eq!(resp.status(), 200); + + // Add an override file to the profile + let resp = api + .add_minecraft_profile_overrides(&profile.id.to_string(), vec![MinecraftProfileOverride::new(TestFile::BasicMod, "mods/test.jar")], USER_USER_PAT) + .await; + println!("{:?}", resp.response().body()); + assert_eq!(resp.status(), 204); + + // Get the profile and check the versions + let profile = api + .get_minecraft_profile_deserialized(&profile.id.to_string(), USER_USER_PAT) + .await; + assert_eq!(profile.versions, vec![test_env.dummy.project_alpha.version_id_parsed]); + assert_eq!(profile.override_install_paths, vec![PathBuf::from("mods/test.jar")]); + + // + }).await; +} + +// Cannot add versions you do not have visibility access to +#[actix_rt::test] +async fn hidden_versions_are_forbidden() { + // Test setup and dummy data + with_test_environment(None, |test_env: TestEnvironment| async move { + let api = &test_env.api; + let beta_version_id = test_env.dummy.project_beta.version_id.to_string(); + let alpha_version_id = test_env.dummy.project_alpha.version_id.to_string(); + let alpha_version_id_parsed = test_env.dummy.project_alpha.version_id_parsed; + + // Create a simple profile, as FRIEND, with beta version, which is not visible to FRIEND + // This should not include the beta version + let profile = api + .create_minecraft_profile("test", "fabric", "1.0.0", "1.20.1", vec![&beta_version_id, &alpha_version_id], FRIEND_USER_PAT) + .await; + println!("{:?}", profile.response().body()); + assert_eq!(profile.status(), 200); + let profile : MinecraftProfile = test::read_body_json(profile).await; + assert_eq!(profile.versions, vec![alpha_version_id_parsed]); + + // Edit profile, as FRIEND, with beta version, which is not visible to FRIEND + // This should fail + let resp = api + .edit_minecraft_profile(&profile.id.to_string(), None, None, None, Some(vec![&beta_version_id]), FRIEND_USER_PAT) + .await; + println!("{:?}", resp.response().body()); + assert_eq!(resp.status(), 200); + + // Get the profile and check the versions + // Empty, because alpha is removed, and beta is not visible + let profile = api + .get_minecraft_profile_deserialized(&profile.id.to_string(), FRIEND_USER_PAT) + .await; + assert_eq!(profile.versions, vec![]); + }).await; +} + +// try all file system related thinghs +// go thru all the stuff in the linear issue From 72bb8719995f6e3e309120c131e7ee5762125ed6 Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Fri, 29 Dec 2023 08:56:49 -0800 Subject: [PATCH 03/25] fmt clippy prepare --- ...bb3d1d6917ae1bab7981db40e3cec5a962fee.json | 14 + ...2a8862bad5cff33974d3cfa437299a4f843a5.json | 52 +++ ...158d139e953eb3456c1fe76d9423cfa7ec631.json | 15 + ...fb177b339f6d83e7053102d71edc3c4df72a3.json | 40 ++ ...f8dfc2d10d07e5abc26e6849f2506b8013867.json | 14 + ...222ab04048529502e40bfdd9c4115d1bb424c.json | 15 + ...b94b998b4946191e7e32c474faf426162bd35.json | 16 + ...1ac9fbc4f19ded56907af55ba16b7e8886b66.json | 19 + ...88884acd4d2359eeea55ad2946cd213ed8383.json | 46 +++ ...2f2801d50b2920a16bfd76d806b9f015860c2.json | 22 ++ ...793c82beff50d82259748027bd721317c2a16.json | 15 + ...0398632faebf858ba300194dd2cc83844d48b.json | 22 ++ ...f5a0d3c56c25577ef9e1ce86a018f60742be9.json | 52 +++ ...8aeb766d2d5605c61d2037b9f56499affaf6a.json | 47 +++ ...24d18d178b915145fea6aa479115658978072.json | 14 + ...1fc6f8f295d6b60f55c566dd6be931e67bd46.json | 14 + ...156bee51b528e122864e87cb50226194a3330.json | 15 + ...e740e8b116a83d029889e8a10a232f5abf7b4.json | 14 + ...236cab1d5f571a1d1433e6e1d042410365bf3.json | 18 + ...9fb872714ead7aa28a5ec20d79b00b6cee672.json | 14 + ...06b072a6542bee1cd86a9b3df63ea01ff83e6.json | 14 + ...041ca8f3f4b7209f13b26ce7edfe2d56dc7f1.json | 76 ++++ ...5934c62456e82608166677830a7a54065b7af.json | 16 + ...023bc5f3e36b657d81d99a18d27a336aee8e8.json | 14 + ...f4944eca0d313925195fefe1e12b79330cb1e.json | 52 +++ ...295b89dd680b0fc4866f2e58bd211ea6d7efe.json | 22 ++ ...a1b2c017465c74c08742cfc0d0858a06bb0d0.json | 15 + src/database/models/minecraft_profile_item.rs | 4 +- src/models/v3/minecraft/profile.rs | 14 +- src/routes/v3/minecraft/profiles.rs | 350 ++++++++++-------- tests/common/api_v3/minecraft_profile.rs | 72 ++-- tests/common/api_v3/project.rs | 4 +- tests/common/permissions.rs | 4 +- tests/profiles.rs | 275 +++++++++----- 34 files changed, 1124 insertions(+), 286 deletions(-) create mode 100644 .sqlx/query-027071c4d5206a781f650d40dcbbb3d1d6917ae1bab7981db40e3cec5a962fee.json create mode 100644 .sqlx/query-0f1c4409c8b9c834618e4f5bff22a8862bad5cff33974d3cfa437299a4f843a5.json create mode 100644 .sqlx/query-11b484208e8a2344a20a05a5427158d139e953eb3456c1fe76d9423cfa7ec631.json create mode 100644 .sqlx/query-196bbbebb943163d6c6fad254a4fb177b339f6d83e7053102d71edc3c4df72a3.json create mode 100644 .sqlx/query-1fb64651d324c7df04b28a9dc8ff8dfc2d10d07e5abc26e6849f2506b8013867.json create mode 100644 .sqlx/query-231dc53976b134f645818f66fbb222ab04048529502e40bfdd9c4115d1bb424c.json create mode 100644 .sqlx/query-2b59e48fddcf2b59a1f1f62dce3b94b998b4946191e7e32c474faf426162bd35.json create mode 100644 .sqlx/query-3b7b52a25bcebe13b8f8587f3551ac9fbc4f19ded56907af55ba16b7e8886b66.json create mode 100644 .sqlx/query-44eedb7dbf77302430161c5c71a88884acd4d2359eeea55ad2946cd213ed8383.json create mode 100644 .sqlx/query-4a3f92a0c4ae0bc0f1229e929c12f2801d50b2920a16bfd76d806b9f015860c2.json create mode 100644 .sqlx/query-4b06c3a537a2bf453d0c8f01a76793c82beff50d82259748027bd721317c2a16.json create mode 100644 .sqlx/query-58989968246eeeec7f53e2ac7a40398632faebf858ba300194dd2cc83844d48b.json create mode 100644 .sqlx/query-6d84c45d9a8b88e0ddb827fe547f5a0d3c56c25577ef9e1ce86a018f60742be9.json create mode 100644 .sqlx/query-736e8a7045935d214332fbaef5d8aeb766d2d5605c61d2037b9f56499affaf6a.json create mode 100644 .sqlx/query-78d0c0ba63ff65686b5e32ed14724d18d178b915145fea6aa479115658978072.json create mode 100644 .sqlx/query-84d499c7d691a6996781e5f50a51fc6f8f295d6b60f55c566dd6be931e67bd46.json create mode 100644 .sqlx/query-8a37c029e438415db69f4b6f956156bee51b528e122864e87cb50226194a3330.json create mode 100644 .sqlx/query-99b663374cfb33a34bb3358049fe740e8b116a83d029889e8a10a232f5abf7b4.json create mode 100644 .sqlx/query-a4951e28b9d45554644f14e41cc236cab1d5f571a1d1433e6e1d042410365bf3.json create mode 100644 .sqlx/query-b42deb72b1513f7385f18aff9f19fb872714ead7aa28a5ec20d79b00b6cee672.json create mode 100644 .sqlx/query-b581109d66692cfa90a929dc11a06b072a6542bee1cd86a9b3df63ea01ff83e6.json create mode 100644 .sqlx/query-c11470b8ddaa4f482b6b56c9eb3041ca8f3f4b7209f13b26ce7edfe2d56dc7f1.json create mode 100644 .sqlx/query-c237dd43b7418185b85c96988b35934c62456e82608166677830a7a54065b7af.json create mode 100644 .sqlx/query-c264230159f693aa2fa5d42e5bf023bc5f3e36b657d81d99a18d27a336aee8e8.json create mode 100644 .sqlx/query-dda33960b6c82d05f1a6136f2c3f4944eca0d313925195fefe1e12b79330cb1e.json create mode 100644 .sqlx/query-eb66aaed2aec0466f9b2b11d5bf295b89dd680b0fc4866f2e58bd211ea6d7efe.json create mode 100644 .sqlx/query-ff2c1151935b80022bf5ba7f396a1b2c017465c74c08742cfc0d0858a06bb0d0.json diff --git a/.sqlx/query-027071c4d5206a781f650d40dcbbb3d1d6917ae1bab7981db40e3cec5a962fee.json b/.sqlx/query-027071c4d5206a781f650d40dcbbb3d1d6917ae1bab7981db40e3cec5a962fee.json new file mode 100644 index 00000000..51cb9e48 --- /dev/null +++ b/.sqlx/query-027071c4d5206a781f650d40dcbbb3d1d6917ae1bab7981db40e3cec5a962fee.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM shared_profiles_mods WHERE shared_profile_id = $1 AND version_id IS NOT NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "027071c4d5206a781f650d40dcbbb3d1d6917ae1bab7981db40e3cec5a962fee" +} diff --git a/.sqlx/query-0f1c4409c8b9c834618e4f5bff22a8862bad5cff33974d3cfa437299a4f843a5.json b/.sqlx/query-0f1c4409c8b9c834618e4f5bff22a8862bad5cff33974d3cfa437299a4f843a5.json new file mode 100644 index 00000000..bb571980 --- /dev/null +++ b/.sqlx/query-0f1c4409c8b9c834618e4f5bff22a8862bad5cff33974d3cfa437299a4f843a5.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, link, shared_profile_id, created, expires, uses_remaining\n FROM shared_profiles_links spl\n WHERE spl.shared_profile_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "link", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "shared_profile_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "expires", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "uses_remaining", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "0f1c4409c8b9c834618e4f5bff22a8862bad5cff33974d3cfa437299a4f843a5" +} diff --git a/.sqlx/query-11b484208e8a2344a20a05a5427158d139e953eb3456c1fe76d9423cfa7ec631.json b/.sqlx/query-11b484208e8a2344a20a05a5427158d139e953eb3456c1fe76d9423cfa7ec631.json new file mode 100644 index 00000000..1d8cf3ef --- /dev/null +++ b/.sqlx/query-11b484208e8a2344a20a05a5427158d139e953eb3456c1fe76d9423cfa7ec631.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO shared_profiles_mods (shared_profile_id, version_id) VALUES ($1, $2)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "11b484208e8a2344a20a05a5427158d139e953eb3456c1fe76d9423cfa7ec631" +} diff --git a/.sqlx/query-196bbbebb943163d6c6fad254a4fb177b339f6d83e7053102d71edc3c4df72a3.json b/.sqlx/query-196bbbebb943163d6c6fad254a4fb177b339f6d83e7053102d71edc3c4df72a3.json new file mode 100644 index 00000000..ab0cda5f --- /dev/null +++ b/.sqlx/query-196bbbebb943163d6c6fad254a4fb177b339f6d83e7053102d71edc3c4df72a3.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT shared_profile_id, version_id, file_hash, install_path\n FROM shared_profiles_mods spm\n WHERE spm.shared_profile_id = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "shared_profile_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "version_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "file_hash", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "install_path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + true, + true, + true + ] + }, + "hash": "196bbbebb943163d6c6fad254a4fb177b339f6d83e7053102d71edc3c4df72a3" +} diff --git a/.sqlx/query-1fb64651d324c7df04b28a9dc8ff8dfc2d10d07e5abc26e6849f2506b8013867.json b/.sqlx/query-1fb64651d324c7df04b28a9dc8ff8dfc2d10d07e5abc26e6849f2506b8013867.json new file mode 100644 index 00000000..6b059f87 --- /dev/null +++ b/.sqlx/query-1fb64651d324c7df04b28a9dc8ff8dfc2d10d07e5abc26e6849f2506b8013867.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM shared_profiles_mods\n WHERE shared_profile_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "1fb64651d324c7df04b28a9dc8ff8dfc2d10d07e5abc26e6849f2506b8013867" +} diff --git a/.sqlx/query-231dc53976b134f645818f66fbb222ab04048529502e40bfdd9c4115d1bb424c.json b/.sqlx/query-231dc53976b134f645818f66fbb222ab04048529502e40bfdd9c4115d1bb424c.json new file mode 100644 index 00000000..fe84abeb --- /dev/null +++ b/.sqlx/query-231dc53976b134f645818f66fbb222ab04048529502e40bfdd9c4115d1bb424c.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE shared_profiles SET loader_version = $1 WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "231dc53976b134f645818f66fbb222ab04048529502e40bfdd9c4115d1bb424c" +} diff --git a/.sqlx/query-2b59e48fddcf2b59a1f1f62dce3b94b998b4946191e7e32c474faf426162bd35.json b/.sqlx/query-2b59e48fddcf2b59a1f1f62dce3b94b998b4946191e7e32c474faf426162bd35.json new file mode 100644 index 00000000..8661d6b2 --- /dev/null +++ b/.sqlx/query-2b59e48fddcf2b59a1f1f62dce3b94b998b4946191e7e32c474faf426162bd35.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO shared_profiles_mods (shared_profile_id, file_hash, install_path)\n SELECT * FROM UNNEST($1::bigint[], $2::text[], $3::text[])\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8Array", + "TextArray", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "2b59e48fddcf2b59a1f1f62dce3b94b998b4946191e7e32c474faf426162bd35" +} diff --git a/.sqlx/query-3b7b52a25bcebe13b8f8587f3551ac9fbc4f19ded56907af55ba16b7e8886b66.json b/.sqlx/query-3b7b52a25bcebe13b8f8587f3551ac9fbc4f19ded56907af55ba16b7e8886b66.json new file mode 100644 index 00000000..ab54518c --- /dev/null +++ b/.sqlx/query-3b7b52a25bcebe13b8f8587f3551ac9fbc4f19ded56907af55ba16b7e8886b66.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO shared_profiles_links (\n id, link, shared_profile_id, created, expires, uses_remaining\n )\n VALUES (\n $1, $2, $3, $4, $5, $6\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Varchar", + "Int8", + "Timestamptz", + "Timestamptz", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "3b7b52a25bcebe13b8f8587f3551ac9fbc4f19ded56907af55ba16b7e8886b66" +} diff --git a/.sqlx/query-44eedb7dbf77302430161c5c71a88884acd4d2359eeea55ad2946cd213ed8383.json b/.sqlx/query-44eedb7dbf77302430161c5c71a88884acd4d2359eeea55ad2946cd213ed8383.json new file mode 100644 index 00000000..bbf56901 --- /dev/null +++ b/.sqlx/query-44eedb7dbf77302430161c5c71a88884acd4d2359eeea55ad2946cd213ed8383.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT token, user_id, shared_profiles_links_id, created, expires\n FROM cdn_auth_tokens cat\n WHERE cat.token = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "shared_profiles_links_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "expires", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "44eedb7dbf77302430161c5c71a88884acd4d2359eeea55ad2946cd213ed8383" +} diff --git a/.sqlx/query-4a3f92a0c4ae0bc0f1229e929c12f2801d50b2920a16bfd76d806b9f015860c2.json b/.sqlx/query-4a3f92a0c4ae0bc0f1229e929c12f2801d50b2920a16bfd76d806b9f015860c2.json new file mode 100644 index 00000000..db2fad5c --- /dev/null +++ b/.sqlx/query-4a3f92a0c4ae0bc0f1229e929c12f2801d50b2920a16bfd76d806b9f015860c2.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO shared_profiles (\n id, name, owner_id, icon_url, created, updated, \n game_version_id, loader_id, loader_version\n )\n VALUES (\n $1, $2, $3, $4, $5, $6, \n $7, $8, $9\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Varchar", + "Int8", + "Varchar", + "Timestamptz", + "Timestamptz", + "Int4", + "Int4", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "4a3f92a0c4ae0bc0f1229e929c12f2801d50b2920a16bfd76d806b9f015860c2" +} diff --git a/.sqlx/query-4b06c3a537a2bf453d0c8f01a76793c82beff50d82259748027bd721317c2a16.json b/.sqlx/query-4b06c3a537a2bf453d0c8f01a76793c82beff50d82259748027bd721317c2a16.json new file mode 100644 index 00000000..31aa0a57 --- /dev/null +++ b/.sqlx/query-4b06c3a537a2bf453d0c8f01a76793c82beff50d82259748027bd721317c2a16.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE shared_profiles SET game_version_id = $1 WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "4b06c3a537a2bf453d0c8f01a76793c82beff50d82259748027bd721317c2a16" +} diff --git a/.sqlx/query-58989968246eeeec7f53e2ac7a40398632faebf858ba300194dd2cc83844d48b.json b/.sqlx/query-58989968246eeeec7f53e2ac7a40398632faebf858ba300194dd2cc83844d48b.json new file mode 100644 index 00000000..6da9b468 --- /dev/null +++ b/.sqlx/query-58989968246eeeec7f53e2ac7a40398632faebf858ba300194dd2cc83844d48b.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM shared_profiles_links WHERE id=$1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "58989968246eeeec7f53e2ac7a40398632faebf858ba300194dd2cc83844d48b" +} diff --git a/.sqlx/query-6d84c45d9a8b88e0ddb827fe547f5a0d3c56c25577ef9e1ce86a018f60742be9.json b/.sqlx/query-6d84c45d9a8b88e0ddb827fe547f5a0d3c56c25577ef9e1ce86a018f60742be9.json new file mode 100644 index 00000000..0cdb5ab7 --- /dev/null +++ b/.sqlx/query-6d84c45d9a8b88e0ddb827fe547f5a0d3c56c25577ef9e1ce86a018f60742be9.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, link, shared_profile_id, created, expires, uses_remaining\n FROM shared_profiles_links spl\n WHERE spl.id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "link", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "shared_profile_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "expires", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "uses_remaining", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "6d84c45d9a8b88e0ddb827fe547f5a0d3c56c25577ef9e1ce86a018f60742be9" +} diff --git a/.sqlx/query-736e8a7045935d214332fbaef5d8aeb766d2d5605c61d2037b9f56499affaf6a.json b/.sqlx/query-736e8a7045935d214332fbaef5d8aeb766d2d5605c61d2037b9f56499affaf6a.json new file mode 100644 index 00000000..b515106d --- /dev/null +++ b/.sqlx/query-736e8a7045935d214332fbaef5d8aeb766d2d5605c61d2037b9f56499affaf6a.json @@ -0,0 +1,47 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT cat.token, cat.user_id, cat.shared_profiles_links_id, cat.created, cat.expires\n FROM cdn_auth_tokens cat\n INNER JOIN shared_profiles_links spl ON spl.id = cat.shared_profiles_links_id\n WHERE spl.id = $1 AND spl.shared_profile_id IN (\n SELECT id FROM shared_profiles sp WHERE sp.owner_id = $2\n )\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "shared_profiles_links_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "expires", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "736e8a7045935d214332fbaef5d8aeb766d2d5605c61d2037b9f56499affaf6a" +} diff --git a/.sqlx/query-78d0c0ba63ff65686b5e32ed14724d18d178b915145fea6aa479115658978072.json b/.sqlx/query-78d0c0ba63ff65686b5e32ed14724d18d178b915145fea6aa479115658978072.json new file mode 100644 index 00000000..1b45a675 --- /dev/null +++ b/.sqlx/query-78d0c0ba63ff65686b5e32ed14724d18d178b915145fea6aa479115658978072.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE shared_profiles\n SET icon_url = NULL, color = NULL\n WHERE (id = $1)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "78d0c0ba63ff65686b5e32ed14724d18d178b915145fea6aa479115658978072" +} diff --git a/.sqlx/query-84d499c7d691a6996781e5f50a51fc6f8f295d6b60f55c566dd6be931e67bd46.json b/.sqlx/query-84d499c7d691a6996781e5f50a51fc6f8f295d6b60f55c566dd6be931e67bd46.json new file mode 100644 index 00000000..197033ac --- /dev/null +++ b/.sqlx/query-84d499c7d691a6996781e5f50a51fc6f8f295d6b60f55c566dd6be931e67bd46.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE shared_profiles_links SET uses_remaining = uses_remaining - 1 WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "84d499c7d691a6996781e5f50a51fc6f8f295d6b60f55c566dd6be931e67bd46" +} diff --git a/.sqlx/query-8a37c029e438415db69f4b6f956156bee51b528e122864e87cb50226194a3330.json b/.sqlx/query-8a37c029e438415db69f4b6f956156bee51b528e122864e87cb50226194a3330.json new file mode 100644 index 00000000..ca8f7f14 --- /dev/null +++ b/.sqlx/query-8a37c029e438415db69f4b6f956156bee51b528e122864e87cb50226194a3330.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE shared_profiles SET name = $1 WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "8a37c029e438415db69f4b6f956156bee51b528e122864e87cb50226194a3330" +} diff --git a/.sqlx/query-99b663374cfb33a34bb3358049fe740e8b116a83d029889e8a10a232f5abf7b4.json b/.sqlx/query-99b663374cfb33a34bb3358049fe740e8b116a83d029889e8a10a232f5abf7b4.json new file mode 100644 index 00000000..c04ad998 --- /dev/null +++ b/.sqlx/query-99b663374cfb33a34bb3358049fe740e8b116a83d029889e8a10a232f5abf7b4.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM cdn_auth_tokens\n WHERE token = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "99b663374cfb33a34bb3358049fe740e8b116a83d029889e8a10a232f5abf7b4" +} diff --git a/.sqlx/query-a4951e28b9d45554644f14e41cc236cab1d5f571a1d1433e6e1d042410365bf3.json b/.sqlx/query-a4951e28b9d45554644f14e41cc236cab1d5f571a1d1433e6e1d042410365bf3.json new file mode 100644 index 00000000..52d4b047 --- /dev/null +++ b/.sqlx/query-a4951e28b9d45554644f14e41cc236cab1d5f571a1d1433e6e1d042410365bf3.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO cdn_auth_tokens (\n token, shared_profiles_links_id, user_id, created, expires\n )\n VALUES (\n $1, $2, $3, $4, $5\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8", + "Int8", + "Timestamptz", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "a4951e28b9d45554644f14e41cc236cab1d5f571a1d1433e6e1d042410365bf3" +} diff --git a/.sqlx/query-b42deb72b1513f7385f18aff9f19fb872714ead7aa28a5ec20d79b00b6cee672.json b/.sqlx/query-b42deb72b1513f7385f18aff9f19fb872714ead7aa28a5ec20d79b00b6cee672.json new file mode 100644 index 00000000..00167151 --- /dev/null +++ b/.sqlx/query-b42deb72b1513f7385f18aff9f19fb872714ead7aa28a5ec20d79b00b6cee672.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM cdn_auth_tokens\n WHERE shared_profiles_links_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "b42deb72b1513f7385f18aff9f19fb872714ead7aa28a5ec20d79b00b6cee672" +} diff --git a/.sqlx/query-b581109d66692cfa90a929dc11a06b072a6542bee1cd86a9b3df63ea01ff83e6.json b/.sqlx/query-b581109d66692cfa90a929dc11a06b072a6542bee1cd86a9b3df63ea01ff83e6.json new file mode 100644 index 00000000..7e5c86c1 --- /dev/null +++ b/.sqlx/query-b581109d66692cfa90a929dc11a06b072a6542bee1cd86a9b3df63ea01ff83e6.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM shared_profiles_links\n WHERE shared_profile_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "b581109d66692cfa90a929dc11a06b072a6542bee1cd86a9b3df63ea01ff83e6" +} diff --git a/.sqlx/query-c11470b8ddaa4f482b6b56c9eb3041ca8f3f4b7209f13b26ce7edfe2d56dc7f1.json b/.sqlx/query-c11470b8ddaa4f482b6b56c9eb3041ca8f3f4b7209f13b26ce7edfe2d56dc7f1.json new file mode 100644 index 00000000..53e8d7a2 --- /dev/null +++ b/.sqlx/query-c11470b8ddaa4f482b6b56c9eb3041ca8f3f4b7209f13b26ce7edfe2d56dc7f1.json @@ -0,0 +1,76 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT sp.id, sp.name, sp.owner_id, sp.icon_url, sp.created, sp.updated, sp.game_version_id, sp.loader_id, l.loader, sp.loader_version\n FROM shared_profiles sp \n LEFT JOIN loaders l ON l.id = sp.loader_id\n WHERE sp.id = ANY($1)\n GROUP BY sp.id, l.id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "owner_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "icon_url", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "game_version_id", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "loader_id", + "type_info": "Int4" + }, + { + "ordinal": 8, + "name": "loader", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "loader_version", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "c11470b8ddaa4f482b6b56c9eb3041ca8f3f4b7209f13b26ce7edfe2d56dc7f1" +} diff --git a/.sqlx/query-c237dd43b7418185b85c96988b35934c62456e82608166677830a7a54065b7af.json b/.sqlx/query-c237dd43b7418185b85c96988b35934c62456e82608166677830a7a54065b7af.json new file mode 100644 index 00000000..53c0eac6 --- /dev/null +++ b/.sqlx/query-c237dd43b7418185b85c96988b35934c62456e82608166677830a7a54065b7af.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE shared_profiles\n SET icon_url = $1, color = $2\n WHERE (id = $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int4", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "c237dd43b7418185b85c96988b35934c62456e82608166677830a7a54065b7af" +} diff --git a/.sqlx/query-c264230159f693aa2fa5d42e5bf023bc5f3e36b657d81d99a18d27a336aee8e8.json b/.sqlx/query-c264230159f693aa2fa5d42e5bf023bc5f3e36b657d81d99a18d27a336aee8e8.json new file mode 100644 index 00000000..7f183487 --- /dev/null +++ b/.sqlx/query-c264230159f693aa2fa5d42e5bf023bc5f3e36b657d81d99a18d27a336aee8e8.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM cdn_auth_tokens\n WHERE shared_profiles_links_id IN (\n SELECT id FROM shared_profiles_links\n WHERE shared_profile_id = $1\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "c264230159f693aa2fa5d42e5bf023bc5f3e36b657d81d99a18d27a336aee8e8" +} diff --git a/.sqlx/query-dda33960b6c82d05f1a6136f2c3f4944eca0d313925195fefe1e12b79330cb1e.json b/.sqlx/query-dda33960b6c82d05f1a6136f2c3f4944eca0d313925195fefe1e12b79330cb1e.json new file mode 100644 index 00000000..a8d460a5 --- /dev/null +++ b/.sqlx/query-dda33960b6c82d05f1a6136f2c3f4944eca0d313925195fefe1e12b79330cb1e.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, link, shared_profile_id, created, expires, uses_remaining\n FROM shared_profiles_links spl\n WHERE spl.link = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "link", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "shared_profile_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "expires", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "uses_remaining", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "dda33960b6c82d05f1a6136f2c3f4944eca0d313925195fefe1e12b79330cb1e" +} diff --git a/.sqlx/query-eb66aaed2aec0466f9b2b11d5bf295b89dd680b0fc4866f2e58bd211ea6d7efe.json b/.sqlx/query-eb66aaed2aec0466f9b2b11d5bf295b89dd680b0fc4866f2e58bd211ea6d7efe.json new file mode 100644 index 00000000..f6db1437 --- /dev/null +++ b/.sqlx/query-eb66aaed2aec0466f9b2b11d5bf295b89dd680b0fc4866f2e58bd211ea6d7efe.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM shared_profiles WHERE id=$1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "eb66aaed2aec0466f9b2b11d5bf295b89dd680b0fc4866f2e58bd211ea6d7efe" +} diff --git a/.sqlx/query-ff2c1151935b80022bf5ba7f396a1b2c017465c74c08742cfc0d0858a06bb0d0.json b/.sqlx/query-ff2c1151935b80022bf5ba7f396a1b2c017465c74c08742cfc0d0858a06bb0d0.json new file mode 100644 index 00000000..add17836 --- /dev/null +++ b/.sqlx/query-ff2c1151935b80022bf5ba7f396a1b2c017465c74c08742cfc0d0858a06bb0d0.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE shared_profiles SET loader_id = $1 WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "ff2c1151935b80022bf5ba7f396a1b2c017465c74c08742cfc0d0858a06bb0d0" +} diff --git a/src/database/models/minecraft_profile_item.rs b/src/database/models/minecraft_profile_item.rs index 22d86cce..bc15cdc2 100644 --- a/src/database/models/minecraft_profile_item.rs +++ b/src/database/models/minecraft_profile_item.rs @@ -27,7 +27,7 @@ pub struct MinecraftProfile { // These represent the same loader pub loader_id: LoaderId, - pub loader : String, + pub loader: String, pub versions: Vec, pub overrides: Vec, @@ -544,4 +544,4 @@ pub struct MinecraftProfileOverride { pub file_hash: String, pub url: String, pub install_path: PathBuf, -} \ No newline at end of file +} diff --git a/src/models/v3/minecraft/profile.rs b/src/models/v3/minecraft/profile.rs index 861de21f..59a00712 100644 --- a/src/models/v3/minecraft/profile.rs +++ b/src/models/v3/minecraft/profile.rs @@ -4,10 +4,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use crate::{ - database::{ - self, - models::LoaderFieldEnumValueId, - }, + database::{self, models::LoaderFieldEnumValueId}, models::ids::{Base62Id, UserId, VersionId}, }; @@ -62,12 +59,12 @@ impl From for Minecr icon_url: profile.icon_url, loader: profile.loader, loader_version: profile.loader_version, - game_version_id: profile.game_version_id.into(), + game_version_id: profile.game_version_id, versions: profile.versions.into_iter().map(Into::into).collect(), override_install_paths: profile .overrides .into_iter() - .map(|(_, v)| v.into()) + .map(|(_, v)| v) .collect(), } } @@ -87,9 +84,8 @@ impl From for MinecraftProfileShareLink { fn from(link: database::models::minecraft_profile_item::MinecraftProfileLink) -> Self { - // Generate URL for easy access - let profile_id : MinecraftProfileId = link.shared_profile_id.into(); + let profile_id: MinecraftProfileId = link.shared_profile_id.into(); let url = format!( "{}/v3/minecraft/profile/{}/download/{}", dotenvy::var("SELF_ADDR").unwrap(), @@ -106,4 +102,4 @@ impl From expires: link.expires, } } -} \ No newline at end of file +} diff --git a/src/routes/v3/minecraft/profiles.rs b/src/routes/v3/minecraft/profiles.rs index c857b5b8..e440e784 100644 --- a/src/routes/v3/minecraft/profiles.rs +++ b/src/routes/v3/minecraft/profiles.rs @@ -2,21 +2,24 @@ use crate::auth::checks::filter_visible_version_ids; use crate::auth::{get_user_from_headers, AuthenticationError}; use crate::database::models::legacy_loader_fields::MinecraftGameVersion; use crate::database::models::{ - generate_minecraft_profile_id, minecraft_profile_item, version_item, generate_minecraft_profile_link_id, + generate_minecraft_profile_id, generate_minecraft_profile_link_id, minecraft_profile_item, + version_item, }; use crate::database::redis::RedisPool; use crate::file_hosting::FileHost; use crate::models::ids::base62_impl::parse_base62; use crate::models::ids::VersionId; -use crate::models::minecraft::profile::{MinecraftProfile, MinecraftProfileShareLink, MinecraftProfileId, DEFAULT_STARTING_LINK_USES}; +use crate::models::minecraft::profile::{ + MinecraftProfile, MinecraftProfileId, MinecraftProfileShareLink, DEFAULT_STARTING_LINK_USES, +}; use crate::models::pats::Scopes; use crate::queue::session::AuthQueue; use crate::routes::v3::project_creation::CreateError; use crate::routes::ApiError; -use crate::util::routes::{read_from_payload, read_from_field}; +use crate::util::routes::{read_from_field, read_from_payload}; use crate::util::validate::validation_errors_to_string; use crate::{database, models}; -use actix_multipart::{Multipart, Field}; +use actix_multipart::{Field, Multipart}; use actix_web::web::Data; use actix_web::{web, HttpRequest, HttpResponse}; use chrono::Utc; @@ -34,24 +37,29 @@ use validator::Validate; pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("minecraft") - .route("profile", web::post().to(profile_create)) - .route("check_token", web::get().to(profile_token_check)) - .service( - web::scope("profile") - .route("{id}", web::get().to(profile_get)) - .route("{id}", web::patch().to(profile_edit)) - .route("{id}", web::delete().to(profile_delete)) - .route("{id}/override", web::post().to(minecraft_profile_add_override)) - .route("{id}/share", web::get().to(profile_share)) - .route("{id}/share/{url_identifier}", web::get().to(profile_link_get)) - .route("{id}/download", web::get().to(profile_download)) - .route("{id}/icon", web::patch().to(profile_icon_edit)) - .route("{id}/icon", web::delete().to(delete_profile_icon)) - ) + .route("profile", web::post().to(profile_create)) + .route("check_token", web::get().to(profile_token_check)) + .service( + web::scope("profile") + .route("{id}", web::get().to(profile_get)) + .route("{id}", web::patch().to(profile_edit)) + .route("{id}", web::delete().to(profile_delete)) + .route( + "{id}/override", + web::post().to(minecraft_profile_add_override), + ) + .route("{id}/share", web::get().to(profile_share)) + .route( + "{id}/share/{url_identifier}", + web::get().to(profile_link_get), + ) + .route("{id}/download", web::get().to(profile_download)) + .route("{id}/icon", web::patch().to(profile_icon_edit)) + .route("{id}/icon", web::delete().to(delete_profile_icon)), + ), ); } - #[derive(Serialize, Deserialize, Validate, Clone)] pub struct ProfileCreateData { #[validate( @@ -67,7 +75,7 @@ pub struct ProfileCreateData { // The game version string (parsed to a game version) pub game_version: String, // The list of versions to include in the profile (does not include overrides) - pub versions: Vec, + pub versions: Vec, } // Create a new minecraft profile @@ -112,11 +120,15 @@ pub async fn profile_create( let mut transaction = client.begin().await?; - let profile_id: database::models::MinecraftProfileId = generate_minecraft_profile_id(&mut transaction) - .await? - .into(); + let profile_id: database::models::MinecraftProfileId = + generate_minecraft_profile_id(&mut transaction) + .await?; - let version_ids = profile_create_data.versions.into_iter().map(|x| x.into()).collect::>(); + let version_ids = profile_create_data + .versions + .into_iter() + .map(|x| x.into()) + .collect::>(); let versions = version_item::Version::get_many(&version_ids, &**client, &redis) .await? .into_iter() @@ -124,7 +136,13 @@ pub async fn profile_create( .collect::>(); // Filters versions authorized to see - let versions = filter_visible_version_ids(versions.iter().collect_vec(), &Some(current_user.clone()), &client, &redis).await + let versions = filter_visible_version_ids( + versions.iter().collect_vec(), + &Some(current_user.clone()), + &client, + &redis, + ) + .await .map_err(|_| CreateError::InvalidInput("Could not fetch submitted version ids".to_string()))?; let profile_builder_actual = minecraft_profile_item::MinecraftProfile { @@ -172,7 +190,7 @@ pub async fn profiles_get( .await?; let profiles = profiles_data .into_iter() - .map(|data| MinecraftProfile::from(data)) + .map(MinecraftProfile::from) .collect::>(); Ok(HttpResponse::Ok().json(profiles)) @@ -241,13 +259,15 @@ pub async fn profile_edit( ) .await?; - // Confirm this is our project, then if so, edit let id = database::models::MinecraftProfileId(parse_base62(&string)? as i64); let mut transaction = pool.begin().await?; - let profile_data = - database::models::minecraft_profile_item::MinecraftProfile::get(id, &mut *transaction, &redis) - .await?; + let profile_data = database::models::minecraft_profile_item::MinecraftProfile::get( + id, + &mut *transaction, + &redis, + ) + .await?; if let Some(data) = profile_data { if data.owner_id == user_option.1.id.into() { @@ -288,11 +308,16 @@ pub async fn profile_edit( .await?; } if let Some(game_version) = edit_data.game_version { - let new_game_id = database::models::legacy_loader_fields::MinecraftGameVersion::list(&**pool, &redis) + let new_game_id = + database::models::legacy_loader_fields::MinecraftGameVersion::list( + &**pool, &redis, + ) .await? .into_iter() .find(|x| x.version == game_version) - .ok_or_else(|| ApiError::InvalidInput("Invalid Minecraft game version".to_string()))? + .ok_or_else(|| { + ApiError::InvalidInput("Invalid Minecraft game version".to_string()) + })? .id; sqlx::query!( @@ -305,16 +330,24 @@ pub async fn profile_edit( } if let Some(versions) = edit_data.versions { let version_ids = versions.into_iter().map(|x| x.into()).collect::>(); - let versions = version_item::Version::get_many(&version_ids, &mut *transaction - , &redis) - .await? - .into_iter() - .map(|x| x.inner) - .collect::>(); - + let versions = + version_item::Version::get_many(&version_ids, &mut *transaction, &redis) + .await? + .into_iter() + .map(|x| x.inner) + .collect::>(); + // Filters versions authorized to see - let versions = filter_visible_version_ids(versions.iter().collect_vec(), &Some(user_option.1.clone()), &pool, &redis).await - .map_err(|_| ApiError::InvalidInput("Could not fetch submitted version ids".to_string()))?; + let versions = filter_visible_version_ids( + versions.iter().collect_vec(), + &Some(user_option.1.clone()), + &pool, + &redis, + ) + .await + .map_err(|_| { + ApiError::InvalidInput("Could not fetch submitted version ids".to_string()) + })?; // Remove all shared profile mods of this profile where version_id is set sqlx::query!( @@ -334,21 +367,19 @@ pub async fn profile_edit( .execute(&mut *transaction) .await?; } - } transaction.commit().await?; minecraft_profile_item::MinecraftProfile::clear_cache(data.id, &redis).await?; return Ok(HttpResponse::Ok().finish()); + } else { + return Err(ApiError::CustomAuthentication( + "You are not the owner of this profile".to_string(), + )); } - - else { - return Err(ApiError::CustomAuthentication("You are not the owner of this profile".to_string())); - } } Err(ApiError::NotFound) } - // Delete a minecraft profile pub async fn profile_delete( req: HttpRequest, @@ -377,14 +408,18 @@ pub async fn profile_delete( if let Some(data) = profile_data { if data.owner_id == user_option.1.id.into() { let mut transaction = pool.begin().await?; - database::models::minecraft_profile_item::MinecraftProfile::remove(data.id, &mut transaction, &redis) - .await?; + database::models::minecraft_profile_item::MinecraftProfile::remove( + data.id, + &mut transaction, + &redis, + ) + .await?; transaction.commit().await?; minecraft_profile_item::MinecraftProfile::clear_cache(data.id, &redis).await?; return Ok(HttpResponse::Ok().finish()); } } - + Err(ApiError::NotFound) } @@ -416,10 +451,9 @@ pub async fn profile_share( let profile_data = database::models::minecraft_profile_item::MinecraftProfile::get(id, &**pool, &redis) .await?; - + if let Some(data) = profile_data { if data.owner_id == user_option.1.id.into() { - // Generate a share link identifier let identifier = ChaCha20Rng::from_entropy() .sample_iter(&Alphanumeric) @@ -427,7 +461,6 @@ pub async fn profile_share( .map(char::from) .collect::(); - // Generate a new share link id let mut transaction = pool.begin().await?; let profile_link_id = generate_minecraft_profile_link_id(&mut transaction).await?; @@ -454,7 +487,7 @@ pub async fn profile_share( // This is used by the launcher to check if the link is still valid, expired, or has uses left. pub async fn profile_link_get( req: HttpRequest, - info: web::Path<(String,String)>, + info: web::Path<(String, String)>, pool: web::Data, redis: web::Data, session_queue: web::Data, @@ -470,21 +503,22 @@ pub async fn profile_link_get( ) .await?; - // Confirm this is our project, then if so, share let link_data = database::models::minecraft_profile_item::MinecraftProfileLink::get_url( &url_identifier, &**pool, ) - .await?.ok_or_else(|| ApiError::NotFound)?; + .await? + .ok_or_else(|| ApiError::NotFound)?; let data = database::models::minecraft_profile_item::MinecraftProfile::get( link_data.shared_profile_id, &**pool, &redis, ) - .await?.ok_or_else(|| ApiError::NotFound)?; - + .await? + .ok_or_else(|| ApiError::NotFound)?; + // Only view link meta information if the user is the owner of the profile if data.owner_id == user_option.1.id.into() { Ok(HttpResponse::Ok().json(MinecraftProfileShareLink::from(link_data))) @@ -493,7 +527,6 @@ pub async fn profile_link_get( } } - #[derive(Serialize, Deserialize)] pub struct ProfileDownload { // temporary authorization token for the CDN, for downloading the profile files @@ -610,7 +643,11 @@ pub async fn profile_download( }; token.insert(&mut transaction).await?; - let override_cdns = profile.overrides.into_iter().map(|x| (format!("{}/custom_files/{}", cdn_url, x.0), x.1)).collect::>(); + let override_cdns = profile + .overrides + .into_iter() + .map(|x| (format!("{}/custom_files/{}", cdn_url, x.0), x.1)) + .collect::>(); transaction.commit().await?; minecraft_profile_item::MinecraftProfile::clear_cache(profile.id, &redis).await?; @@ -622,22 +659,22 @@ pub async fn profile_download( } #[derive(Deserialize)] - pub struct TokenUrl { +pub struct TokenUrl { pub url: String, // TODO: could take a vec instead? - } +} // Used by cloudflare to check headers and permit CDN downloads for a pack // Checks headers for 'authorization: xxyyzz' where xxyyzz is a valid token // that allows for downloading of url 'url' pub async fn profile_token_check( req: HttpRequest, - file_url : web::Query, + file_url: web::Query, pool: web::Data, redis: web::Data, ) -> Result { let cdn_url = dotenvy::var("CDN_URL")?; let file_url = file_url.into_inner().url; - + // Extract token from 'authorization' of headers let token = req .headers() @@ -646,12 +683,15 @@ pub async fn profile_token_check( .ok_or_else(|| ApiError::Authentication(AuthenticationError::InvalidAuthMethod))?; let token = database::models::minecraft_profile_item::MinecraftProfileLinkToken::get_token( - &token, &**pool, + token, &**pool, ) - .await?.ok_or_else(|| ApiError::Authentication(AuthenticationError::InvalidAuthMethod))?; + .await? + .ok_or_else(|| ApiError::Authentication(AuthenticationError::InvalidAuthMethod))?; if token.expires <= Utc::now() { - return Err(ApiError::Authentication(AuthenticationError::InvalidAuthMethod)); + return Err(ApiError::Authentication( + AuthenticationError::InvalidAuthMethod, + )); } // Get share link @@ -659,7 +699,8 @@ pub async fn profile_token_check( token.shared_profiles_links_id, &**pool, ) - .await?.ok_or_else(|| ApiError::Authentication(AuthenticationError::InvalidAuthMethod))?; + .await? + .ok_or_else(|| ApiError::Authentication(AuthenticationError::InvalidAuthMethod))?; // Get valid urls for the profile let profile = database::models::minecraft_profile_item::MinecraftProfile::get( @@ -667,21 +708,21 @@ pub async fn profile_token_check( &**pool, &redis, ) - .await?.ok_or_else(|| ApiError::Authentication(AuthenticationError::InvalidAuthMethod))?; + .await? + .ok_or_else(|| ApiError::Authentication(AuthenticationError::InvalidAuthMethod))?; // Check the token is valid for the requested file - let file_url_hash = file_url.split(&format!("{cdn_url}/custom_files/")).nth(1).ok_or_else(|| ApiError::Authentication(AuthenticationError::InvalidAuthMethod))?; - - - let valid = profile - .overrides - .iter() - .any(|x| { - x.0 == file_url_hash - }); - + let file_url_hash = file_url + .split(&format!("{cdn_url}/custom_files/")) + .nth(1) + .ok_or_else(|| ApiError::Authentication(AuthenticationError::InvalidAuthMethod))?; + + let valid = profile.overrides.iter().any(|x| x.0 == file_url_hash); + if !valid { - return Err(ApiError::Authentication(AuthenticationError::InvalidAuthMethod)); + Err(ApiError::Authentication( + AuthenticationError::InvalidAuthMethod, + )) } else { Ok(HttpResponse::Ok().finish()) } @@ -716,11 +757,15 @@ pub async fn profile_icon_edit( .1; let id = info.into_inner().0; - let profile_item = database::models::minecraft_profile_item::MinecraftProfile::get(id.into(), &**pool, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput("The specified profile does not exist!".to_string()) - })?; + let profile_item = database::models::minecraft_profile_item::MinecraftProfile::get( + id.into(), + &**pool, + &redis, + ) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The specified profile does not exist!".to_string()) + })?; if !user.role.is_mod() && profile_item.owner_id != user.id.into() { return Err(ApiError::CustomAuthentication( @@ -742,7 +787,7 @@ pub async fn profile_icon_edit( let color = crate::util::img::get_color_from_img(&bytes)?; let hash = sha1::Sha1::from(&bytes).hexdigest(); - let id : MinecraftProfileId = profile_item.id.into(); + let id: MinecraftProfileId = profile_item.id.into(); let upload_data = file_host .upload_file( content_type, @@ -801,16 +846,17 @@ pub async fn delete_profile_icon( .1; let id = info.into_inner().0; - let profile_item = database::models::minecraft_profile_item::MinecraftProfile::get(id.into(), &**pool, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput("The specified profile does not exist!".to_string()) - })?; + let profile_item = + database::models::minecraft_profile_item::MinecraftProfile::get(id.into(), &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The specified profile does not exist!".to_string()) + })?; if !user.role.is_mod() && profile_item.owner_id != user.id.into() { - return Err(ApiError::CustomAuthentication( - "You don't have permission to edit this profile's icon.".to_string(), - )); + return Err(ApiError::CustomAuthentication( + "You don't have permission to edit this profile's icon.".to_string(), + )); } let cdn_url = dotenvy::var("CDN_URL")?; @@ -846,7 +892,6 @@ pub async fn delete_profile_icon( Ok(HttpResponse::NoContent().body("")) } - // Add a new override mod to a minecraft profile, by uploading it to the CDN // Accepts a multipart field // the first part is called `data` and contains a json array of objects with the following fields: @@ -856,7 +901,7 @@ pub async fn delete_profile_icon( #[derive(Serialize, Deserialize)] struct MultipartFile { pub file_name: String, - pub install_path : String, + pub install_path: String, } #[allow(clippy::too_many_arguments)] @@ -881,11 +926,15 @@ pub async fn minecraft_profile_add_override( .1; // Check if this is our profile - let profile_item = database::models::minecraft_profile_item::MinecraftProfile::get(client_id.into(), &**pool, &redis) - .await? - .ok_or_else(|| { - CreateError::InvalidInput("The specified profile does not exist!".to_string()) - })?; + let profile_item = database::models::minecraft_profile_item::MinecraftProfile::get( + client_id.into(), + &**pool, + &redis, + ) + .await? + .ok_or_else(|| { + CreateError::InvalidInput("The specified profile does not exist!".to_string()) + })?; if !user.role.is_mod() && profile_item.owner_id != user.id.into() { return Err(CreateError::CustomAuthenticationError( @@ -894,14 +943,14 @@ pub async fn minecraft_profile_add_override( } struct UploadedFile { - pub install_path : String, + pub install_path: String, pub hash: String, } let mut error = None; let mut uploaded_files = Vec::new(); - let files : Vec = { + let files: Vec = { // First, get the data field let mut field = payload.next().await.ok_or_else(|| { CreateError::InvalidInput(String::from("Upload must have a data field")) @@ -909,9 +958,9 @@ pub async fn minecraft_profile_add_override( let content_disposition = field.content_disposition().clone(); // Allow any content type - let name = content_disposition.get_name().ok_or_else(|| { - CreateError::InvalidInput(String::from("Upload must have a name")) - })?; + let name = content_disposition + .get_name() + .ok_or_else(|| CreateError::InvalidInput(String::from("Upload must have a name")))?; if name == "data" { let mut d: Vec = Vec::new(); @@ -924,8 +973,8 @@ pub async fn minecraft_profile_add_override( "`data` field must come before file fields", ))); } - }; - + }; + while let Some(item) = payload.next().await { let mut field: Field = item?; if error.is_some() { @@ -933,33 +982,44 @@ pub async fn minecraft_profile_add_override( } let result = async { let content_disposition = field.content_disposition().clone(); - let content_type = field.content_type().map(|x| x.essence_str()).unwrap_or_else(|| "application/octet-stream").to_string(); - // Allow any content type + let content_type = field + .content_type() + .map(|x| x.essence_str()) + .unwrap_or_else(|| "application/octet-stream") + .to_string(); + // Allow any content type let name = content_disposition.get_name().ok_or_else(|| { CreateError::InvalidInput(String::from("Upload must have a name")) })?; - let data = read_from_field(&mut field, 262144, "Icons must be smaller than 256KiB").await?; - let install_path = files.iter().find(|x| x.file_name == name) - .ok_or_else(|| CreateError::InvalidInput(format!("No matching file name in `data` for file '{}'", - name)))?.install_path.clone(); - - let hash = sha1::Sha1::from(&data).hexdigest(); - - file_host - .upload_file( - &content_type, - &format!("custom_files/{hash}"), - data.freeze(), - ) - .await?; - - uploaded_files.push(UploadedFile { - install_path, - hash, - }); - Ok(()) - }.await; + let data = + read_from_field(&mut field, 262144, "Icons must be smaller than 256KiB").await?; + let install_path = files + .iter() + .find(|x| x.file_name == name) + .ok_or_else(|| { + CreateError::InvalidInput(format!( + "No matching file name in `data` for file '{}'", + name + )) + })? + .install_path + .clone(); + + let hash = sha1::Sha1::from(&data).hexdigest(); + + file_host + .upload_file( + &content_type, + &format!("custom_files/{hash}"), + data.freeze(), + ) + .await?; + + uploaded_files.push(UploadedFile { install_path, hash }); + Ok(()) + } + .await; if result.is_err() { error = result.err(); @@ -972,32 +1032,22 @@ pub async fn minecraft_profile_add_override( let mut transaction = pool.begin().await?; - let (ids, hashes, install_paths): ( - Vec<_>, - Vec<_>, - Vec<_>, - ) = uploaded_files + let (ids, hashes, install_paths): (Vec<_>, Vec<_>, Vec<_>) = uploaded_files .into_iter() - .map(|f| { - ( - profile_item.id.0, - f.hash, - f.install_path, - ) - }) + .map(|f| (profile_item.id.0, f.hash, f.install_path)) .multiunzip(); sqlx::query!( - " + " INSERT INTO shared_profiles_mods (shared_profile_id, file_hash, install_path) SELECT * FROM UNNEST($1::bigint[], $2::text[], $3::text[]) ", - &ids[..], - &hashes[..], - &install_paths[..], - ) - .execute(&mut *transaction) - .await?; + &ids[..], + &hashes[..], + &install_paths[..], + ) + .execute(&mut *transaction) + .await?; transaction.commit().await?; diff --git a/tests/common/api_v3/minecraft_profile.rs b/tests/common/api_v3/minecraft_profile.rs index c3ee3252..f1644ab9 100644 --- a/tests/common/api_v3/minecraft_profile.rs +++ b/tests/common/api_v3/minecraft_profile.rs @@ -4,20 +4,27 @@ use actix_web::{ }; use bytes::Bytes; use itertools::Itertools; -use labrinth::{models::minecraft::profile::{MinecraftProfile, MinecraftProfileShareLink}, util::actix::{MultipartSegment, MultipartSegmentData, AppendsMultipart}, routes::v3::minecraft::profiles::ProfileDownload}; +use labrinth::{ + models::minecraft::profile::{MinecraftProfile, MinecraftProfileShareLink}, + routes::v3::minecraft::profiles::ProfileDownload, + util::actix::{AppendsMultipart, MultipartSegment, MultipartSegmentData}, +}; use serde_json::json; -use crate::common::{api_common::{request_data::ImageData, Api, AppendsOptionalPat}, dummy_data::TestFile}; +use crate::common::{ + api_common::{request_data::ImageData, Api, AppendsOptionalPat}, + dummy_data::TestFile, +}; use super::ApiV3; pub struct MinecraftProfileOverride { pub file_name: String, pub install_path: String, - pub bytes: Vec + pub bytes: Vec, } impl MinecraftProfileOverride { - pub fn new(test_file : TestFile, install_path : &str) -> Self { + pub fn new(test_file: TestFile, install_path: &str) -> Self { Self { file_name: test_file.filename(), install_path: install_path.to_string(), @@ -26,7 +33,6 @@ impl MinecraftProfileOverride { } } - impl ApiV3 { pub async fn create_minecraft_profile( &self, @@ -81,7 +87,11 @@ impl ApiV3 { self.call(req).await } - pub async fn get_minecraft_profile_deserialized(&self, id: &str, pat: Option<&str>) -> MinecraftProfile { + pub async fn get_minecraft_profile_deserialized( + &self, + id: &str, + pat: Option<&str>, + ) -> MinecraftProfile { let resp = self.get_minecraft_profile(id, pat).await; assert_eq!(resp.status(), 200); test::read_body_json(resp).await @@ -95,7 +105,10 @@ impl ApiV3 { ) -> ServiceResponse { if let Some(icon) = icon { let req = TestRequest::patch() - .uri(&format!("/v3/minecraft/profile/{}/icon?ext={}", id, icon.extension)) + .uri(&format!( + "/v3/minecraft/profile/{}/icon?ext={}", + id, icon.extension + )) .append_pat(pat) .set_payload(Bytes::from(icon.icon)) .to_request(); @@ -116,7 +129,7 @@ impl ApiV3 { pat: Option<&str>, ) -> ServiceResponse { let mut data = Vec::new(); - let mut multipart_segments : Vec = Vec::new(); + let mut multipart_segments: Vec = Vec::new(); for override_ in overrides { data.push(serde_json::json!({ "file_name": override_.file_name, @@ -134,7 +147,9 @@ impl ApiV3 { filename: None, content_type: Some("application/json".to_string()), data: MultipartSegmentData::Text(serde_json::to_string(&data).unwrap()), - }).chain(multipart_segments.into_iter()).collect_vec(); + }) + .chain(multipart_segments.into_iter()) + .collect_vec(); let req = TestRequest::post() .uri(&format!("/v3/minecraft/profile/{}/override", id)) @@ -151,7 +166,10 @@ impl ApiV3 { pat: Option<&str>, ) -> ServiceResponse { let req = TestRequest::delete() - .uri(&format!("/v3/minecraft/profile/{}/overrides/{}", id, file_name)) + .uri(&format!( + "/v3/minecraft/profile/{}/overrides/{}", + id, file_name + )) .append_pat(pat) .to_request(); self.call(req).await @@ -183,10 +201,13 @@ impl ApiV3 { &self, profile_id: &str, url_identifier: &str, - pat: Option<&str> + pat: Option<&str>, ) -> ServiceResponse { let req = TestRequest::get() - .uri(&format!("/v3/minecraft/profile/{}/share/{}", profile_id, url_identifier)) + .uri(&format!( + "/v3/minecraft/profile/{}/share/{}", + profile_id, url_identifier + )) .append_pat(pat) .to_request(); self.call(req).await @@ -196,12 +217,14 @@ impl ApiV3 { &self, profile_id: &str, url_identifier: &str, - pat: Option<&str> + pat: Option<&str>, ) -> MinecraftProfileShareLink { - let resp = self.get_minecraft_profile_share_link(profile_id, url_identifier, pat).await; + let resp = self + .get_minecraft_profile_share_link(profile_id, url_identifier, pat) + .await; assert_eq!(resp.status(), 200); test::read_body_json(resp).await - } + } pub async fn download_minecraft_profile( &self, @@ -209,21 +232,24 @@ impl ApiV3 { pat: Option<&str>, ) -> ServiceResponse { let req = TestRequest::get() - .uri(&format!("/v3/minecraft/profile/{}/download", url_identifier)) + .uri(&format!( + "/v3/minecraft/profile/{}/download", + url_identifier + )) .append_pat(pat) .to_request(); self.call(req).await } - pub async fn download_minecraft_profile_deserialized( + pub async fn download_minecraft_profile_deserialized( &self, url_identifier: &str, pat: Option<&str>, - )-> ProfileDownload { + ) -> ProfileDownload { let resp = self.download_minecraft_profile(url_identifier, pat).await; assert_eq!(resp.status(), 200); test::read_body_json(resp).await - } + } pub async fn check_download_minecraft_profile_token( &self, @@ -231,12 +257,12 @@ impl ApiV3 { url: &str, // Full URL, the route will parse it ) -> ServiceResponse { let req = TestRequest::get() - .uri(&format!("/v3/minecraft/check_token?url={url}", url=urlencoding::encode(url))) + .uri(&format!( + "/v3/minecraft/check_token?url={url}", + url = urlencoding::encode(url) + )) .append_header(("Authorization", token)) .to_request(); self.call(req).await } - - } - diff --git a/tests/common/api_v3/project.rs b/tests/common/api_v3/project.rs index ae6992ed..20d3e144 100644 --- a/tests/common/api_v3/project.rs +++ b/tests/common/api_v3/project.rs @@ -383,8 +383,8 @@ impl ApiProject for ApiV3 { .append_pat(pat) .to_request(); - let t = self.call(req).await; - t + + self.call(req).await } async fn remove_gallery_item( diff --git a/tests/common/permissions.rs b/tests/common/permissions.rs index 2a645557..52c3eb5c 100644 --- a/tests/common/permissions.rs +++ b/tests/common/permissions.rs @@ -1109,7 +1109,7 @@ async fn get_project_permissions( .await; let permissions = members .iter() - .find(|member| &member.user.id.to_string() == user_id) + .find(|member| member.user.id.to_string() == user_id) .and_then(|member| member.permissions); let organization_members = match organization { @@ -1123,7 +1123,7 @@ async fn get_project_permissions( let organization_default_project_permissions = match organization_members { Some(members) => members .iter() - .find(|member| &member.user.id.to_string() == user_id) + .find(|member| member.user.id.to_string() == user_id) .and_then(|member| member.permissions), None => None, }; diff --git a/tests/profiles.rs b/tests/profiles.rs index 1ce118c4..50ef8d17 100644 --- a/tests/profiles.rs +++ b/tests/profiles.rs @@ -29,7 +29,14 @@ async fn create_modify_profile() { // - unparseable version (not to be confused with parseable but nonexistent version, which is simply ignored) // - fake game version let resp = api - .create_minecraft_profile("test", "fake-loader", "1.0.0", "1.20.1", vec![], USER_USER_PAT) + .create_minecraft_profile( + "test", + "fake-loader", + "1.0.0", + "1.20.1", + vec![], + USER_USER_PAT, + ) .await; assert_eq!(resp.status(), 400); @@ -40,7 +47,14 @@ async fn create_modify_profile() { // assert_eq!(resp.status(), 400); let resp = api - .create_minecraft_profile("test", "fabric", "1.0.0", "1.20.1", vec!["unparseable-version"], USER_USER_PAT) + .create_minecraft_profile( + "test", + "fabric", + "1.0.0", + "1.20.1", + vec!["unparseable-version"], + USER_USER_PAT, + ) .await; assert_eq!(resp.status(), 400); @@ -56,7 +70,7 @@ async fn create_modify_profile() { .await; println!("{:?}", profile.response().body()); assert_eq!(profile.status(), 200); - let profile : MinecraftProfile = test::read_body_json(profile).await; + let profile: MinecraftProfile = test::read_body_json(profile).await; let id = profile.id.to_string(); // Get the profile and check the properties are correct @@ -70,7 +84,7 @@ async fn create_modify_profile() { assert_eq!(profile.versions, vec![]); assert_eq!(profile.icon_url, None); - println!("Profile id is {}", profile.id.to_string()); + println!("Profile id is {}", profile.id); // Modify the profile illegally in the same ways let resp = api @@ -158,7 +172,7 @@ async fn create_modify_profile() { .get_minecraft_profile_deserialized(&id, USER_USER_PAT) .await; - println!("{:?}", serde_json::to_string(&profile)); + println!("{:?}", serde_json::to_string(&profile)); assert_eq!(profile.name, "test2"); assert_eq!(profile.loader, "forge"); @@ -190,88 +204,112 @@ async fn create_modify_profile() { assert_eq!(profile.loader_version, "1.0.0"); assert_eq!(profile.versions, vec![]); assert_eq!(profile.icon_url, None); - - }).await; + }) + .await; } #[actix_rt::test] async fn download_profile() { with_test_environment(None, |test_env: TestEnvironment| async move { - // Get download links for a created profile (including failure), create a share link, and create the correct number of tokens based on that - // They should expire after a time - let api = &test_env.api; - - // Create a simple profile - let profile = api - .create_minecraft_profile("test", "fabric", "1.0.0" ,"1.20.1", vec![], USER_USER_PAT) - .await; - assert_eq!(profile.status(), 200); - let profile : MinecraftProfile = test::read_body_json(profile).await; - let id = profile.id.to_string(); - - // Add an override file to the profile - let resp = api - .add_minecraft_profile_overrides(&id, vec![MinecraftProfileOverride::new(TestFile::BasicMod, "mods/test.jar")], USER_USER_PAT) - .await; - println!("{:?}", resp.response().body()); - assert_eq!(resp.status(), 204); - - println!("Here123123123123213"); - - // As 'user', try to generate a download link for the profile - let share_link = api - .generate_minecraft_profile_share_link_deserialized(&id, USER_USER_PAT) - .await; - // Links should add up - assert_eq!(share_link.uses_remaining, 5); - assert_eq!(share_link.url , format!("{}/v3/minecraft/profile/{}/download/{}", dotenvy::var("SELF_ADDR").unwrap(), id, share_link.url_identifier)); - - // As 'friend', try to get the download links for the profile - // *Anyone* with the link can get - let mut download = api - .download_minecraft_profile_deserialized(&share_link.url_identifier, FRIEND_USER_PAT) - .await; - - // Download url should be: - // - CDN url - // "custom_files" - // - hash - assert_eq!(download.override_cdns.len(), 1); - let override_file_url = download.override_cdns.remove(0).0; - let hash = sha1::Sha1::from(&TestFile::BasicMod.bytes()).hexdigest(); - assert_eq!(override_file_url, format!("{}/custom_files/{}", dotenvy::var("CDN_URL").unwrap(), hash)); - - - // This generates a token, and now the link should have 4 uses remaining - let share_link = api - .get_minecraft_profile_share_link_deserialized(&id, &share_link.url_identifier, USER_USER_PAT) - .await; - println!("\n\n{:?}", serde_json::to_string(&share_link)); - assert_eq!(share_link.uses_remaining, 4); - - // Check cloudflare helper route with a bad token (eg: the profile id), should fail - let resp = api - .check_download_minecraft_profile_token(&share_link.url_identifier, &override_file_url).await; - println!("{:?}", resp.response().body()); - assert_eq!(resp.status(), 401); - let resp = api - .check_download_minecraft_profile_token(&share_link.url, &override_file_url).await; -println!("{:?}", resp.response().body()); -assert_eq!(resp.status(), 401); - - let resp = api - .check_download_minecraft_profile_token(&id, &override_file_url).await; - assert_eq!(resp.status(), 401); - - // Check cloudflare helper route to confirm this is a valid allowable access token - // We attach it as an authorization token and call the route - let download = api - .check_download_minecraft_profile_token(&download.auth_token, &override_file_url).await; - println!("{:?}", download.response().body()); - assert_eq!(download.status(), 200); - - - }).await; + // Get download links for a created profile (including failure), create a share link, and create the correct number of tokens based on that + // They should expire after a time + let api = &test_env.api; + + // Create a simple profile + let profile = api + .create_minecraft_profile("test", "fabric", "1.0.0", "1.20.1", vec![], USER_USER_PAT) + .await; + assert_eq!(profile.status(), 200); + let profile: MinecraftProfile = test::read_body_json(profile).await; + let id = profile.id.to_string(); + + // Add an override file to the profile + let resp = api + .add_minecraft_profile_overrides( + &id, + vec![MinecraftProfileOverride::new( + TestFile::BasicMod, + "mods/test.jar", + )], + USER_USER_PAT, + ) + .await; + println!("{:?}", resp.response().body()); + assert_eq!(resp.status(), 204); + + println!("Here123123123123213"); + + // As 'user', try to generate a download link for the profile + let share_link = api + .generate_minecraft_profile_share_link_deserialized(&id, USER_USER_PAT) + .await; + // Links should add up + assert_eq!(share_link.uses_remaining, 5); + assert_eq!( + share_link.url, + format!( + "{}/v3/minecraft/profile/{}/download/{}", + dotenvy::var("SELF_ADDR").unwrap(), + id, + share_link.url_identifier + ) + ); + + // As 'friend', try to get the download links for the profile + // *Anyone* with the link can get + let mut download = api + .download_minecraft_profile_deserialized(&share_link.url_identifier, FRIEND_USER_PAT) + .await; + + // Download url should be: + // - CDN url + // "custom_files" + // - hash + assert_eq!(download.override_cdns.len(), 1); + let override_file_url = download.override_cdns.remove(0).0; + let hash = sha1::Sha1::from(TestFile::BasicMod.bytes()).hexdigest(); + assert_eq!( + override_file_url, + format!("{}/custom_files/{}", dotenvy::var("CDN_URL").unwrap(), hash) + ); + + // This generates a token, and now the link should have 4 uses remaining + let share_link = api + .get_minecraft_profile_share_link_deserialized( + &id, + &share_link.url_identifier, + USER_USER_PAT, + ) + .await; + println!("\n\n{:?}", serde_json::to_string(&share_link)); + assert_eq!(share_link.uses_remaining, 4); + + // Check cloudflare helper route with a bad token (eg: the profile id), should fail + let resp = api + .check_download_minecraft_profile_token(&share_link.url_identifier, &override_file_url) + .await; + println!("{:?}", resp.response().body()); + assert_eq!(resp.status(), 401); + let resp = api + .check_download_minecraft_profile_token(&share_link.url, &override_file_url) + .await; + println!("{:?}", resp.response().body()); + assert_eq!(resp.status(), 401); + + let resp = api + .check_download_minecraft_profile_token(&id, &override_file_url) + .await; + assert_eq!(resp.status(), 401); + + // Check cloudflare helper route to confirm this is a valid allowable access token + // We attach it as an authorization token and call the route + let download = api + .check_download_minecraft_profile_token(&download.auth_token, &override_file_url) + .await; + println!("{:?}", download.response().body()); + assert_eq!(download.status(), 200); + }) + .await; } #[actix_rt::test] @@ -285,11 +323,15 @@ async fn add_remove_profile_icon() { .create_minecraft_profile("test", "fabric", "1.0.0", "1.20.1", vec![], USER_USER_PAT) .await; assert_eq!(profile.status(), 200); - let profile : MinecraftProfile = test::read_body_json(profile).await; + let profile: MinecraftProfile = test::read_body_json(profile).await; // Add an icon to the profile let icon = api - .edit_minecraft_profile_icon(&profile.id.to_string(), Some(DummyImage::SmallIcon.get_icon_data()), USER_USER_PAT) + .edit_minecraft_profile_icon( + &profile.id.to_string(), + Some(DummyImage::SmallIcon.get_icon_data()), + USER_USER_PAT, + ) .await; println!("{:?}", icon.response().body()); assert_eq!(icon.status(), 204); @@ -311,7 +353,8 @@ async fn add_remove_profile_icon() { .get_minecraft_profile_deserialized(&profile.id.to_string(), USER_USER_PAT) .await; assert!(profile.icon_url.is_none()); - }).await; + }) + .await; } #[actix_rt::test] @@ -325,18 +368,32 @@ async fn add_remove_profile_versions() { .create_minecraft_profile("test", "fabric", "1.0.0", "1.20.1", vec![], USER_USER_PAT) .await; assert_eq!(profile.status(), 200); - let profile : MinecraftProfile = test::read_body_json(profile).await; + let profile: MinecraftProfile = test::read_body_json(profile).await; // Add a hosted version to the profile let resp = api - .edit_minecraft_profile(&profile.id.to_string(), None, None, None, Some(vec![&alpha_version_id]), USER_USER_PAT) + .edit_minecraft_profile( + &profile.id.to_string(), + None, + None, + None, + Some(vec![&alpha_version_id]), + USER_USER_PAT, + ) .await; println!("{:?}", resp.response().body()); assert_eq!(resp.status(), 200); // Add an override file to the profile let resp = api - .add_minecraft_profile_overrides(&profile.id.to_string(), vec![MinecraftProfileOverride::new(TestFile::BasicMod, "mods/test.jar")], USER_USER_PAT) + .add_minecraft_profile_overrides( + &profile.id.to_string(), + vec![MinecraftProfileOverride::new( + TestFile::BasicMod, + "mods/test.jar", + )], + USER_USER_PAT, + ) .await; println!("{:?}", resp.response().body()); assert_eq!(resp.status(), 204); @@ -345,11 +402,18 @@ async fn add_remove_profile_versions() { let profile = api .get_minecraft_profile_deserialized(&profile.id.to_string(), USER_USER_PAT) .await; - assert_eq!(profile.versions, vec![test_env.dummy.project_alpha.version_id_parsed]); - assert_eq!(profile.override_install_paths, vec![PathBuf::from("mods/test.jar")]); - - // - }).await; + assert_eq!( + profile.versions, + vec![test_env.dummy.project_alpha.version_id_parsed] + ); + assert_eq!( + profile.override_install_paths, + vec![PathBuf::from("mods/test.jar")] + ); + + // + }) + .await; } // Cannot add versions you do not have visibility access to @@ -365,17 +429,31 @@ async fn hidden_versions_are_forbidden() { // Create a simple profile, as FRIEND, with beta version, which is not visible to FRIEND // This should not include the beta version let profile = api - .create_minecraft_profile("test", "fabric", "1.0.0", "1.20.1", vec![&beta_version_id, &alpha_version_id], FRIEND_USER_PAT) + .create_minecraft_profile( + "test", + "fabric", + "1.0.0", + "1.20.1", + vec![&beta_version_id, &alpha_version_id], + FRIEND_USER_PAT, + ) .await; println!("{:?}", profile.response().body()); assert_eq!(profile.status(), 200); - let profile : MinecraftProfile = test::read_body_json(profile).await; + let profile: MinecraftProfile = test::read_body_json(profile).await; assert_eq!(profile.versions, vec![alpha_version_id_parsed]); - + // Edit profile, as FRIEND, with beta version, which is not visible to FRIEND // This should fail let resp = api - .edit_minecraft_profile(&profile.id.to_string(), None, None, None, Some(vec![&beta_version_id]), FRIEND_USER_PAT) + .edit_minecraft_profile( + &profile.id.to_string(), + None, + None, + None, + Some(vec![&beta_version_id]), + FRIEND_USER_PAT, + ) .await; println!("{:?}", resp.response().body()); assert_eq!(resp.status(), 200); @@ -386,7 +464,8 @@ async fn hidden_versions_are_forbidden() { .get_minecraft_profile_deserialized(&profile.id.to_string(), FRIEND_USER_PAT) .await; assert_eq!(profile.versions, vec![]); - }).await; + }) + .await; } // try all file system related thinghs From 6eb78018cdc2d56a89d405a84f9139fc9f10511d Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Fri, 29 Dec 2023 09:00:40 -0800 Subject: [PATCH 04/25] removed printlns --- tests/profiles.rs | 28 +--------------------------- 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/tests/profiles.rs b/tests/profiles.rs index 50ef8d17..1b7a6e30 100644 --- a/tests/profiles.rs +++ b/tests/profiles.rs @@ -68,7 +68,6 @@ async fn create_modify_profile() { let profile = api .create_minecraft_profile("test", "fabric", "1.0.0", "1.20.1", vec![], USER_USER_PAT) .await; - println!("{:?}", profile.response().body()); assert_eq!(profile.status(), 200); let profile: MinecraftProfile = test::read_body_json(profile).await; let id = profile.id.to_string(); @@ -84,8 +83,6 @@ async fn create_modify_profile() { assert_eq!(profile.versions, vec![]); assert_eq!(profile.icon_url, None); - println!("Profile id is {}", profile.id); - // Modify the profile illegally in the same ways let resp = api .edit_minecraft_profile( @@ -97,7 +94,6 @@ async fn create_modify_profile() { USER_USER_PAT, ) .await; - println!("{:?}", resp.response().body()); assert_eq!(resp.status(), 400); // Currently fake version for loader is not checked @@ -111,9 +107,7 @@ async fn create_modify_profile() { // USER_USER_PAT, // ) // .await; - - println!("{:?}", resp.response().body()); - assert_eq!(resp.status(), 400); + // assert_eq!(resp.status(), 400); let resp = api .edit_minecraft_profile( @@ -125,7 +119,6 @@ async fn create_modify_profile() { USER_USER_PAT, ) .await; - println!("{:?}", resp.response().body()); assert_eq!(resp.status(), 400); // Can't modify the profile as another user @@ -139,14 +132,12 @@ async fn create_modify_profile() { FRIEND_USER_PAT, ) .await; - println!("{:?}", resp.response().body()); assert_eq!(resp.status(), 401); // Get and make sure the properties are the same let profile = api .get_minecraft_profile_deserialized(&id, USER_USER_PAT) .await; - assert_eq!(profile.name, "test"); assert_eq!(profile.loader, "fabric"); assert_eq!(profile.loader_version, "1.0.0"); @@ -164,16 +155,12 @@ async fn create_modify_profile() { USER_USER_PAT, ) .await; - println!("{:?}", resp.response().body()); assert_eq!(resp.status(), 200); // Get the profile and check the properties let profile = api .get_minecraft_profile_deserialized(&id, USER_USER_PAT) .await; - - println!("{:?}", serde_json::to_string(&profile)); - assert_eq!(profile.name, "test2"); assert_eq!(profile.loader, "forge"); assert_eq!(profile.loader_version, "1.0.1"); @@ -191,7 +178,6 @@ async fn create_modify_profile() { USER_USER_PAT, ) .await; - println!("{:?}", resp.response().body()); assert_eq!(resp.status(), 200); // Get the profile and check the properties @@ -234,11 +220,8 @@ async fn download_profile() { USER_USER_PAT, ) .await; - println!("{:?}", resp.response().body()); assert_eq!(resp.status(), 204); - println!("Here123123123123213"); - // As 'user', try to generate a download link for the profile let share_link = api .generate_minecraft_profile_share_link_deserialized(&id, USER_USER_PAT) @@ -281,19 +264,16 @@ async fn download_profile() { USER_USER_PAT, ) .await; - println!("\n\n{:?}", serde_json::to_string(&share_link)); assert_eq!(share_link.uses_remaining, 4); // Check cloudflare helper route with a bad token (eg: the profile id), should fail let resp = api .check_download_minecraft_profile_token(&share_link.url_identifier, &override_file_url) .await; - println!("{:?}", resp.response().body()); assert_eq!(resp.status(), 401); let resp = api .check_download_minecraft_profile_token(&share_link.url, &override_file_url) .await; - println!("{:?}", resp.response().body()); assert_eq!(resp.status(), 401); let resp = api @@ -306,7 +286,6 @@ async fn download_profile() { let download = api .check_download_minecraft_profile_token(&download.auth_token, &override_file_url) .await; - println!("{:?}", download.response().body()); assert_eq!(download.status(), 200); }) .await; @@ -333,7 +312,6 @@ async fn add_remove_profile_icon() { USER_USER_PAT, ) .await; - println!("{:?}", icon.response().body()); assert_eq!(icon.status(), 204); // Get the profile and check the icon @@ -381,7 +359,6 @@ async fn add_remove_profile_versions() { USER_USER_PAT, ) .await; - println!("{:?}", resp.response().body()); assert_eq!(resp.status(), 200); // Add an override file to the profile @@ -395,7 +372,6 @@ async fn add_remove_profile_versions() { USER_USER_PAT, ) .await; - println!("{:?}", resp.response().body()); assert_eq!(resp.status(), 204); // Get the profile and check the versions @@ -438,7 +414,6 @@ async fn hidden_versions_are_forbidden() { FRIEND_USER_PAT, ) .await; - println!("{:?}", profile.response().body()); assert_eq!(profile.status(), 200); let profile: MinecraftProfile = test::read_body_json(profile).await; assert_eq!(profile.versions, vec![alpha_version_id_parsed]); @@ -455,7 +430,6 @@ async fn hidden_versions_are_forbidden() { FRIEND_USER_PAT, ) .await; - println!("{:?}", resp.response().body()); assert_eq!(resp.status(), 200); // Get the profile and check the versions From 79ffdc146940920610b8b51c471ef4839991b562 Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Fri, 29 Dec 2023 10:42:58 -0800 Subject: [PATCH 05/25] fixes --- src/database/models/minecraft_profile_item.rs | 10 ++- src/models/v3/minecraft/profile.rs | 6 +- src/routes/v3/minecraft/profiles.rs | 15 ++++- tests/common/api_v3/project.rs | 1 - tests/profiles.rs | 66 +++++++++++++++++++ 5 files changed, 86 insertions(+), 12 deletions(-) diff --git a/src/database/models/minecraft_profile_item.rs b/src/database/models/minecraft_profile_item.rs index bc15cdc2..4f7f823f 100644 --- a/src/database/models/minecraft_profile_item.rs +++ b/src/database/models/minecraft_profile_item.rs @@ -372,6 +372,8 @@ impl MinecraftProfileLink { Ok(link) } + // DELETE in here needs to clear all fields as well to prevent orphaned data + pub async fn get_url<'a, 'b, E>( url_identifier: &str, executor: E, @@ -478,6 +480,10 @@ impl MinecraftProfileLinkToken { where E: sqlx::Acquire<'a, Database = sqlx::Postgres>, { + println!( + "Getting for link {} and user {}", + profile_link_id.0, user_id.0 + ); let mut exec = executor.acquire().await?; let token = sqlx::query!( @@ -485,9 +491,7 @@ impl MinecraftProfileLinkToken { SELECT cat.token, cat.user_id, cat.shared_profiles_links_id, cat.created, cat.expires FROM cdn_auth_tokens cat INNER JOIN shared_profiles_links spl ON spl.id = cat.shared_profiles_links_id - WHERE spl.id = $1 AND spl.shared_profile_id IN ( - SELECT id FROM shared_profiles sp WHERE sp.owner_id = $2 - ) + WHERE spl.id = $1 AND cat.user_id = $2 ", profile_link_id.0, user_id.0 diff --git a/src/models/v3/minecraft/profile.rs b/src/models/v3/minecraft/profile.rs index 59a00712..279c7341 100644 --- a/src/models/v3/minecraft/profile.rs +++ b/src/models/v3/minecraft/profile.rs @@ -61,11 +61,7 @@ impl From for Minecr loader_version: profile.loader_version, game_version_id: profile.game_version_id, versions: profile.versions.into_iter().map(Into::into).collect(), - override_install_paths: profile - .overrides - .into_iter() - .map(|(_, v)| v) - .collect(), + override_install_paths: profile.overrides.into_iter().map(|(_, v)| v).collect(), } } } diff --git a/src/routes/v3/minecraft/profiles.rs b/src/routes/v3/minecraft/profiles.rs index e440e784..38ad32ce 100644 --- a/src/routes/v3/minecraft/profiles.rs +++ b/src/routes/v3/minecraft/profiles.rs @@ -121,8 +121,7 @@ pub async fn profile_create( let mut transaction = client.begin().await?; let profile_id: database::models::MinecraftProfileId = - generate_minecraft_profile_id(&mut transaction) - .await?; + generate_minecraft_profile_id(&mut transaction).await?; let version_ids = profile_create_data .versions @@ -594,6 +593,7 @@ pub async fn profile_download( &mut *transaction, ) .await?; + println!("Existing token: {:?}", existing_token); if let Some(token) = existing_token { // Check if the token is still valid if token.expires > Utc::now() { @@ -602,7 +602,11 @@ pub async fn profile_download( return Ok(HttpResponse::Ok().json(ProfileDownload { auth_token: token.token, version_ids: profile.versions.iter().map(|x| (*x).into()).collect(), - override_cdns: profile.overrides, + override_cdns: profile + .overrides + .into_iter() + .map(|x| (format!("{}/custom_files/{}", cdn_url, x.0), x.1)) + .collect::>(), })); } @@ -614,6 +618,8 @@ pub async fn profile_download( .await?; } + println!("Uses remaining: {}", profile_link_data.uses_remaining); + // If there's no token, or the token is invalid, create a new one if profile_link_data.uses_remaining < 1 { return Err(ApiError::InvalidInput( @@ -711,14 +717,17 @@ pub async fn profile_token_check( .await? .ok_or_else(|| ApiError::Authentication(AuthenticationError::InvalidAuthMethod))?; + println!("Profile: {:?}", profile); // Check the token is valid for the requested file let file_url_hash = file_url .split(&format!("{cdn_url}/custom_files/")) .nth(1) .ok_or_else(|| ApiError::Authentication(AuthenticationError::InvalidAuthMethod))?; + println!("File url hash: {}", file_url_hash); let valid = profile.overrides.iter().any(|x| x.0 == file_url_hash); + println!("Valid: {}", valid); if !valid { Err(ApiError::Authentication( AuthenticationError::InvalidAuthMethod, diff --git a/tests/common/api_v3/project.rs b/tests/common/api_v3/project.rs index 20d3e144..d7565fae 100644 --- a/tests/common/api_v3/project.rs +++ b/tests/common/api_v3/project.rs @@ -383,7 +383,6 @@ impl ApiProject for ApiV3 { .append_pat(pat) .to_request(); - self.call(req).await } diff --git a/tests/profiles.rs b/tests/profiles.rs index 1b7a6e30..78557b6e 100644 --- a/tests/profiles.rs +++ b/tests/profiles.rs @@ -5,6 +5,7 @@ use common::api_v3::ApiV3; use common::database::*; use common::environment::with_test_environment; use common::environment::TestEnvironment; +use labrinth::database; use labrinth::models::minecraft::profile::MinecraftProfile; use crate::common::api_v3::minecraft_profile::MinecraftProfileOverride; @@ -240,10 +241,19 @@ async fn download_profile() { // As 'friend', try to get the download links for the profile // *Anyone* with the link can get + let download_old = api + .download_minecraft_profile_deserialized(&share_link.url_identifier, FRIEND_USER_PAT) + .await; + let mut download = api .download_minecraft_profile_deserialized(&share_link.url_identifier, FRIEND_USER_PAT) .await; + // TODO: expiry test + + // Repeated calls should return the same link assuming not expired and same user + assert_eq!(download_old.auth_token, download.auth_token); + // Download url should be: // - CDN url // "custom_files" @@ -266,6 +276,62 @@ async fn download_profile() { .await; assert_eq!(share_link.uses_remaining, 4); + // Generate more tokens until we run ouut + for i in 0..=4 { + let resp = api + .download_minecraft_profile(&share_link.url_identifier, FRIEND_USER_PAT) + .await; + println!("resp: {:?}", resp.response().body()); + assert_eq!(resp.status(), 200); + + let share_link = api + .get_minecraft_profile_share_link_deserialized( + &id, + &share_link.url_identifier, + USER_USER_PAT, + ) + .await; + assert_eq!(share_link.uses_remaining, 4 - i); + + // Manually delete the created token. This forces it the get route regenerate a new one, and allolws us to + // do a decrement test for the uses remaining. + // We use a database call for the sake of the tests which currently only has 5 users + let mut transaction = test_env.db.pool.begin().await.unwrap(); + let id = database::models::minecraft_profile_item::MinecraftProfileLink::get_url( + &share_link.url_identifier, + &mut transaction, + ) + .await + .unwrap() + .unwrap() + .id; + database::models::minecraft_profile_item::MinecraftProfileLinkToken::delete_all( + id, + &mut transaction, + ) + .await + .unwrap(); + transaction.commit().await.unwrap(); + } + + // Now we should be out of tokens + let resp = api + .download_minecraft_profile(&share_link.url_identifier, FRIEND_USER_PAT) + .await; + assert_eq!(resp.status(), 400); + + // Repeat the process to get a new token + // Generate a new token which shoould have 5 uses remaining + let share_link = api + .generate_minecraft_profile_share_link_deserialized(&id, USER_USER_PAT) + .await; + assert_eq!(share_link.uses_remaining, 5); + + // Get token as 'friend' and download the profile + let download = api + .download_minecraft_profile_deserialized(&share_link.url_identifier, FRIEND_USER_PAT) + .await; + // Check cloudflare helper route with a bad token (eg: the profile id), should fail let resp = api .check_download_minecraft_profile_token(&share_link.url_identifier, &override_file_url) From df9e87c668d8ab6021419b01b51291003649823c Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Fri, 29 Dec 2023 13:01:48 -0800 Subject: [PATCH 06/25] switched to make sharing a one time invite and repeated downloads --- ...a016674d095cd25e0bbbb80a3916a6ee93942.json | 23 ++ ...b2e959501eec5cfd5c0c248f212fee9da63f5.json | 14 ++ ...1ac9fbc4f19ded56907af55ba16b7e8886b66.json | 19 -- ...2f2801d50b2920a16bfd76d806b9f015860c2.json | 22 -- ...56cee5199279b1c367b48b14b31a5f1a5446e.json | 18 ++ ...1fc6f8f295d6b60f55c566dd6be931e67bd46.json | 14 -- ...57146d2d6f2631c8572cf5aa59dce26f28ef.json} | 10 +- ...59742e321a61dbb7098d8384d2bc20a9ae9e.json} | 18 +- ...023bc5f3e36b657d81d99a18d27a336aee8e8.json | 14 -- ...572ea5ef594ef504f5610ffb52a45c70da797.json | 15 ++ ...ea06412935228c1cfe977cfb72790e43560c.json} | 10 +- ...68c3aae9a9b9055adc2c4ae305836135d369.json} | 4 +- ...922a8a2bc142d955e450f44aa57a75db61d6f.json | 15 ++ ...8e70304543ac7c59212dc3c5cdd7f6d12acd.json} | 4 +- ...34895bc0e77084f1afa6a02caf35798de9da.json} | 6 +- ...e6a1d0bb17a0bccefc5d52f775808cba8361.json} | 10 +- ...b8f8e0020b19472980b52c9f693a6cb55976.json} | 6 +- migrations/20231226012200_shared_modpacks.sql | 17 +- src/database/models/minecraft_profile_item.rs | 101 +++++---- src/models/v3/minecraft/profile.rs | 27 ++- src/routes/v3/minecraft/profiles.rs | 189 +++++++++++----- tests/common/api_v3/minecraft_profile.rs | 26 ++- tests/common/database.rs | 7 +- tests/common/dummy_data.rs | 2 +- tests/files/dummy_data.sql | 6 +- tests/profiles.rs | 204 ++++++++++-------- 26 files changed, 490 insertions(+), 311 deletions(-) create mode 100644 .sqlx/query-0427f77ed1297f9b05b523f763aa016674d095cd25e0bbbb80a3916a6ee93942.json create mode 100644 .sqlx/query-38eda3e5bd977af134c73e1268fb2e959501eec5cfd5c0c248f212fee9da63f5.json delete mode 100644 .sqlx/query-3b7b52a25bcebe13b8f8587f3551ac9fbc4f19ded56907af55ba16b7e8886b66.json delete mode 100644 .sqlx/query-4a3f92a0c4ae0bc0f1229e929c12f2801d50b2920a16bfd76d806b9f015860c2.json create mode 100644 .sqlx/query-76fbbbfc1641ce8e7560d976a6556cee5199279b1c367b48b14b31a5f1a5446e.json delete mode 100644 .sqlx/query-84d499c7d691a6996781e5f50a51fc6f8f295d6b60f55c566dd6be931e67bd46.json rename .sqlx/{query-6d84c45d9a8b88e0ddb827fe547f5a0d3c56c25577ef9e1ce86a018f60742be9.json => query-a7732d7e398e5f6dacef43a1e11957146d2d6f2631c8572cf5aa59dce26f28ef.json} (71%) rename .sqlx/{query-c11470b8ddaa4f482b6b56c9eb3041ca8f3f4b7209f13b26ce7edfe2d56dc7f1.json => query-b5bc3c2ba4c1fa034829db0e414e59742e321a61dbb7098d8384d2bc20a9ae9e.json} (65%) delete mode 100644 .sqlx/query-c264230159f693aa2fa5d42e5bf023bc5f3e36b657d81d99a18d27a336aee8e8.json create mode 100644 .sqlx/query-d790254ed6c8cf094d8e80ec0fe572ea5ef594ef504f5610ffb52a45c70da797.json rename .sqlx/{query-0f1c4409c8b9c834618e4f5bff22a8862bad5cff33974d3cfa437299a4f843a5.json => query-e0828fcc27da9fe35ed0fdfb3699ea06412935228c1cfe977cfb72790e43560c.json} (70%) rename .sqlx/{query-b42deb72b1513f7385f18aff9f19fb872714ead7aa28a5ec20d79b00b6cee672.json => query-edd6d2b62cac266852675ae75d6968c3aae9a9b9055adc2c4ae305836135d369.json} (62%) create mode 100644 .sqlx/query-f8f3c599bfcc34817cbb3d08ddf922a8a2bc142d955e450f44aa57a75db61d6f.json rename .sqlx/{query-a4951e28b9d45554644f14e41cc236cab1d5f571a1d1433e6e1d042410365bf3.json => query-f91b50a1ce610a5864acc4d559a48e70304543ac7c59212dc3c5cdd7f6d12acd.json} (54%) rename .sqlx/{query-44eedb7dbf77302430161c5c71a88884acd4d2359eeea55ad2946cd213ed8383.json => query-fb911dec94b1a2d04d699f639e8e34895bc0e77084f1afa6a02caf35798de9da.json} (76%) rename .sqlx/{query-dda33960b6c82d05f1a6136f2c3f4944eca0d313925195fefe1e12b79330cb1e.json => query-fd3b2321af19366304bcf46b3cf8e6a1d0bb17a0bccefc5d52f775808cba8361.json} (71%) rename .sqlx/{query-736e8a7045935d214332fbaef5d8aeb766d2d5605c61d2037b9f56499affaf6a.json => query-ff2f40e94b11fbd4b706faec648db8f8e0020b19472980b52c9f693a6cb55976.json} (62%) diff --git a/.sqlx/query-0427f77ed1297f9b05b523f763aa016674d095cd25e0bbbb80a3916a6ee93942.json b/.sqlx/query-0427f77ed1297f9b05b523f763aa016674d095cd25e0bbbb80a3916a6ee93942.json new file mode 100644 index 00000000..c78c16a6 --- /dev/null +++ b/.sqlx/query-0427f77ed1297f9b05b523f763aa016674d095cd25e0bbbb80a3916a6ee93942.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO shared_profiles (\n id, name, owner_id, icon_url, created, updated,\n game_version_id, loader_id, loader_version, maximum_users\n )\n VALUES (\n $1, $2, $3, $4, $5, $6, \n $7, $8, $9, $10\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Varchar", + "Int8", + "Varchar", + "Timestamptz", + "Timestamptz", + "Int4", + "Int4", + "Varchar", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "0427f77ed1297f9b05b523f763aa016674d095cd25e0bbbb80a3916a6ee93942" +} diff --git a/.sqlx/query-38eda3e5bd977af134c73e1268fb2e959501eec5cfd5c0c248f212fee9da63f5.json b/.sqlx/query-38eda3e5bd977af134c73e1268fb2e959501eec5cfd5c0c248f212fee9da63f5.json new file mode 100644 index 00000000..c0d8964a --- /dev/null +++ b/.sqlx/query-38eda3e5bd977af134c73e1268fb2e959501eec5cfd5c0c248f212fee9da63f5.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM shared_profiles_users\n WHERE shared_profile_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "38eda3e5bd977af134c73e1268fb2e959501eec5cfd5c0c248f212fee9da63f5" +} diff --git a/.sqlx/query-3b7b52a25bcebe13b8f8587f3551ac9fbc4f19ded56907af55ba16b7e8886b66.json b/.sqlx/query-3b7b52a25bcebe13b8f8587f3551ac9fbc4f19ded56907af55ba16b7e8886b66.json deleted file mode 100644 index ab54518c..00000000 --- a/.sqlx/query-3b7b52a25bcebe13b8f8587f3551ac9fbc4f19ded56907af55ba16b7e8886b66.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO shared_profiles_links (\n id, link, shared_profile_id, created, expires, uses_remaining\n )\n VALUES (\n $1, $2, $3, $4, $5, $6\n )\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Varchar", - "Int8", - "Timestamptz", - "Timestamptz", - "Int4" - ] - }, - "nullable": [] - }, - "hash": "3b7b52a25bcebe13b8f8587f3551ac9fbc4f19ded56907af55ba16b7e8886b66" -} diff --git a/.sqlx/query-4a3f92a0c4ae0bc0f1229e929c12f2801d50b2920a16bfd76d806b9f015860c2.json b/.sqlx/query-4a3f92a0c4ae0bc0f1229e929c12f2801d50b2920a16bfd76d806b9f015860c2.json deleted file mode 100644 index db2fad5c..00000000 --- a/.sqlx/query-4a3f92a0c4ae0bc0f1229e929c12f2801d50b2920a16bfd76d806b9f015860c2.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO shared_profiles (\n id, name, owner_id, icon_url, created, updated, \n game_version_id, loader_id, loader_version\n )\n VALUES (\n $1, $2, $3, $4, $5, $6, \n $7, $8, $9\n )\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Varchar", - "Int8", - "Varchar", - "Timestamptz", - "Timestamptz", - "Int4", - "Int4", - "Varchar" - ] - }, - "nullable": [] - }, - "hash": "4a3f92a0c4ae0bc0f1229e929c12f2801d50b2920a16bfd76d806b9f015860c2" -} diff --git a/.sqlx/query-76fbbbfc1641ce8e7560d976a6556cee5199279b1c367b48b14b31a5f1a5446e.json b/.sqlx/query-76fbbbfc1641ce8e7560d976a6556cee5199279b1c367b48b14b31a5f1a5446e.json new file mode 100644 index 00000000..b4f9a387 --- /dev/null +++ b/.sqlx/query-76fbbbfc1641ce8e7560d976a6556cee5199279b1c367b48b14b31a5f1a5446e.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO shared_profiles_links (\n id, link, shared_profile_id, created, expires\n )\n VALUES (\n $1, $2, $3, $4, $5\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Varchar", + "Int8", + "Timestamptz", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "76fbbbfc1641ce8e7560d976a6556cee5199279b1c367b48b14b31a5f1a5446e" +} diff --git a/.sqlx/query-84d499c7d691a6996781e5f50a51fc6f8f295d6b60f55c566dd6be931e67bd46.json b/.sqlx/query-84d499c7d691a6996781e5f50a51fc6f8f295d6b60f55c566dd6be931e67bd46.json deleted file mode 100644 index 197033ac..00000000 --- a/.sqlx/query-84d499c7d691a6996781e5f50a51fc6f8f295d6b60f55c566dd6be931e67bd46.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE shared_profiles_links SET uses_remaining = uses_remaining - 1 WHERE id = $1", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [] - }, - "hash": "84d499c7d691a6996781e5f50a51fc6f8f295d6b60f55c566dd6be931e67bd46" -} diff --git a/.sqlx/query-6d84c45d9a8b88e0ddb827fe547f5a0d3c56c25577ef9e1ce86a018f60742be9.json b/.sqlx/query-a7732d7e398e5f6dacef43a1e11957146d2d6f2631c8572cf5aa59dce26f28ef.json similarity index 71% rename from .sqlx/query-6d84c45d9a8b88e0ddb827fe547f5a0d3c56c25577ef9e1ce86a018f60742be9.json rename to .sqlx/query-a7732d7e398e5f6dacef43a1e11957146d2d6f2631c8572cf5aa59dce26f28ef.json index 0cdb5ab7..03ae8e07 100644 --- a/.sqlx/query-6d84c45d9a8b88e0ddb827fe547f5a0d3c56c25577ef9e1ce86a018f60742be9.json +++ b/.sqlx/query-a7732d7e398e5f6dacef43a1e11957146d2d6f2631c8572cf5aa59dce26f28ef.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT id, link, shared_profile_id, created, expires, uses_remaining\n FROM shared_profiles_links spl\n WHERE spl.id = $1\n ", + "query": "\n SELECT id, link, shared_profile_id, created, expires\n FROM shared_profiles_links spl\n WHERE spl.shared_profile_id = $1\n ", "describe": { "columns": [ { @@ -27,11 +27,6 @@ "ordinal": 4, "name": "expires", "type_info": "Timestamptz" - }, - { - "ordinal": 5, - "name": "uses_remaining", - "type_info": "Int4" } ], "parameters": { @@ -44,9 +39,8 @@ false, false, false, - false, false ] }, - "hash": "6d84c45d9a8b88e0ddb827fe547f5a0d3c56c25577ef9e1ce86a018f60742be9" + "hash": "a7732d7e398e5f6dacef43a1e11957146d2d6f2631c8572cf5aa59dce26f28ef" } diff --git a/.sqlx/query-c11470b8ddaa4f482b6b56c9eb3041ca8f3f4b7209f13b26ce7edfe2d56dc7f1.json b/.sqlx/query-b5bc3c2ba4c1fa034829db0e414e59742e321a61dbb7098d8384d2bc20a9ae9e.json similarity index 65% rename from .sqlx/query-c11470b8ddaa4f482b6b56c9eb3041ca8f3f4b7209f13b26ce7edfe2d56dc7f1.json rename to .sqlx/query-b5bc3c2ba4c1fa034829db0e414e59742e321a61dbb7098d8384d2bc20a9ae9e.json index 53e8d7a2..33721794 100644 --- a/.sqlx/query-c11470b8ddaa4f482b6b56c9eb3041ca8f3f4b7209f13b26ce7edfe2d56dc7f1.json +++ b/.sqlx/query-b5bc3c2ba4c1fa034829db0e414e59742e321a61dbb7098d8384d2bc20a9ae9e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT sp.id, sp.name, sp.owner_id, sp.icon_url, sp.created, sp.updated, sp.game_version_id, sp.loader_id, l.loader, sp.loader_version\n FROM shared_profiles sp \n LEFT JOIN loaders l ON l.id = sp.loader_id\n WHERE sp.id = ANY($1)\n GROUP BY sp.id, l.id\n ", + "query": "\n SELECT sp.id, sp.name, sp.owner_id, sp.icon_url, sp.created, sp.updated, sp.game_version_id, sp.loader_id, l.loader, sp.loader_version, sp.maximum_users,\n ARRAY_AGG(spu.user_id) as users\n FROM shared_profiles sp \n LEFT JOIN loaders l ON l.id = sp.loader_id\n LEFT JOIN shared_profiles_users spu ON spu.shared_profile_id = sp.id\n WHERE sp.id = ANY($1)\n GROUP BY sp.id, l.id\n ", "describe": { "columns": [ { @@ -52,6 +52,16 @@ "ordinal": 9, "name": "loader_version", "type_info": "Varchar" + }, + { + "ordinal": 10, + "name": "maximum_users", + "type_info": "Int4" + }, + { + "ordinal": 11, + "name": "users", + "type_info": "Int8Array" } ], "parameters": { @@ -69,8 +79,10 @@ false, false, false, - false + false, + false, + null ] }, - "hash": "c11470b8ddaa4f482b6b56c9eb3041ca8f3f4b7209f13b26ce7edfe2d56dc7f1" + "hash": "b5bc3c2ba4c1fa034829db0e414e59742e321a61dbb7098d8384d2bc20a9ae9e" } diff --git a/.sqlx/query-c264230159f693aa2fa5d42e5bf023bc5f3e36b657d81d99a18d27a336aee8e8.json b/.sqlx/query-c264230159f693aa2fa5d42e5bf023bc5f3e36b657d81d99a18d27a336aee8e8.json deleted file mode 100644 index 7f183487..00000000 --- a/.sqlx/query-c264230159f693aa2fa5d42e5bf023bc5f3e36b657d81d99a18d27a336aee8e8.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n DELETE FROM cdn_auth_tokens\n WHERE shared_profiles_links_id IN (\n SELECT id FROM shared_profiles_links\n WHERE shared_profile_id = $1\n )\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [] - }, - "hash": "c264230159f693aa2fa5d42e5bf023bc5f3e36b657d81d99a18d27a336aee8e8" -} diff --git a/.sqlx/query-d790254ed6c8cf094d8e80ec0fe572ea5ef594ef504f5610ffb52a45c70da797.json b/.sqlx/query-d790254ed6c8cf094d8e80ec0fe572ea5ef594ef504f5610ffb52a45c70da797.json new file mode 100644 index 00000000..6ccb6e6d --- /dev/null +++ b/.sqlx/query-d790254ed6c8cf094d8e80ec0fe572ea5ef594ef504f5610ffb52a45c70da797.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO shared_profiles_users (\n shared_profile_id, user_id\n )\n VALUES (\n $1, $2\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "d790254ed6c8cf094d8e80ec0fe572ea5ef594ef504f5610ffb52a45c70da797" +} diff --git a/.sqlx/query-0f1c4409c8b9c834618e4f5bff22a8862bad5cff33974d3cfa437299a4f843a5.json b/.sqlx/query-e0828fcc27da9fe35ed0fdfb3699ea06412935228c1cfe977cfb72790e43560c.json similarity index 70% rename from .sqlx/query-0f1c4409c8b9c834618e4f5bff22a8862bad5cff33974d3cfa437299a4f843a5.json rename to .sqlx/query-e0828fcc27da9fe35ed0fdfb3699ea06412935228c1cfe977cfb72790e43560c.json index bb571980..b67cc8d5 100644 --- a/.sqlx/query-0f1c4409c8b9c834618e4f5bff22a8862bad5cff33974d3cfa437299a4f843a5.json +++ b/.sqlx/query-e0828fcc27da9fe35ed0fdfb3699ea06412935228c1cfe977cfb72790e43560c.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT id, link, shared_profile_id, created, expires, uses_remaining\n FROM shared_profiles_links spl\n WHERE spl.shared_profile_id = $1\n ", + "query": "\n SELECT id, link, shared_profile_id, created, expires\n FROM shared_profiles_links spl\n WHERE spl.id = $1\n ", "describe": { "columns": [ { @@ -27,11 +27,6 @@ "ordinal": 4, "name": "expires", "type_info": "Timestamptz" - }, - { - "ordinal": 5, - "name": "uses_remaining", - "type_info": "Int4" } ], "parameters": { @@ -44,9 +39,8 @@ false, false, false, - false, false ] }, - "hash": "0f1c4409c8b9c834618e4f5bff22a8862bad5cff33974d3cfa437299a4f843a5" + "hash": "e0828fcc27da9fe35ed0fdfb3699ea06412935228c1cfe977cfb72790e43560c" } diff --git a/.sqlx/query-b42deb72b1513f7385f18aff9f19fb872714ead7aa28a5ec20d79b00b6cee672.json b/.sqlx/query-edd6d2b62cac266852675ae75d6968c3aae9a9b9055adc2c4ae305836135d369.json similarity index 62% rename from .sqlx/query-b42deb72b1513f7385f18aff9f19fb872714ead7aa28a5ec20d79b00b6cee672.json rename to .sqlx/query-edd6d2b62cac266852675ae75d6968c3aae9a9b9055adc2c4ae305836135d369.json index 00167151..3c70d16e 100644 --- a/.sqlx/query-b42deb72b1513f7385f18aff9f19fb872714ead7aa28a5ec20d79b00b6cee672.json +++ b/.sqlx/query-edd6d2b62cac266852675ae75d6968c3aae9a9b9055adc2c4ae305836135d369.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n DELETE FROM cdn_auth_tokens\n WHERE shared_profiles_links_id = $1\n ", + "query": "\n DELETE FROM cdn_auth_tokens\n WHERE shared_profiles_id = $1\n ", "describe": { "columns": [], "parameters": { @@ -10,5 +10,5 @@ }, "nullable": [] }, - "hash": "b42deb72b1513f7385f18aff9f19fb872714ead7aa28a5ec20d79b00b6cee672" + "hash": "edd6d2b62cac266852675ae75d6968c3aae9a9b9055adc2c4ae305836135d369" } diff --git a/.sqlx/query-f8f3c599bfcc34817cbb3d08ddf922a8a2bc142d955e450f44aa57a75db61d6f.json b/.sqlx/query-f8f3c599bfcc34817cbb3d08ddf922a8a2bc142d955e450f44aa57a75db61d6f.json new file mode 100644 index 00000000..d4b1347a --- /dev/null +++ b/.sqlx/query-f8f3c599bfcc34817cbb3d08ddf922a8a2bc142d955e450f44aa57a75db61d6f.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO shared_profiles_users (shared_profile_id, user_id) VALUES ($1, $2)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "f8f3c599bfcc34817cbb3d08ddf922a8a2bc142d955e450f44aa57a75db61d6f" +} diff --git a/.sqlx/query-a4951e28b9d45554644f14e41cc236cab1d5f571a1d1433e6e1d042410365bf3.json b/.sqlx/query-f91b50a1ce610a5864acc4d559a48e70304543ac7c59212dc3c5cdd7f6d12acd.json similarity index 54% rename from .sqlx/query-a4951e28b9d45554644f14e41cc236cab1d5f571a1d1433e6e1d042410365bf3.json rename to .sqlx/query-f91b50a1ce610a5864acc4d559a48e70304543ac7c59212dc3c5cdd7f6d12acd.json index 52d4b047..5bb21544 100644 --- a/.sqlx/query-a4951e28b9d45554644f14e41cc236cab1d5f571a1d1433e6e1d042410365bf3.json +++ b/.sqlx/query-f91b50a1ce610a5864acc4d559a48e70304543ac7c59212dc3c5cdd7f6d12acd.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO cdn_auth_tokens (\n token, shared_profiles_links_id, user_id, created, expires\n )\n VALUES (\n $1, $2, $3, $4, $5\n )\n ", + "query": "\n INSERT INTO cdn_auth_tokens (\n token, shared_profiles_id, user_id, created, expires\n )\n VALUES (\n $1, $2, $3, $4, $5\n )\n ", "describe": { "columns": [], "parameters": { @@ -14,5 +14,5 @@ }, "nullable": [] }, - "hash": "a4951e28b9d45554644f14e41cc236cab1d5f571a1d1433e6e1d042410365bf3" + "hash": "f91b50a1ce610a5864acc4d559a48e70304543ac7c59212dc3c5cdd7f6d12acd" } diff --git a/.sqlx/query-44eedb7dbf77302430161c5c71a88884acd4d2359eeea55ad2946cd213ed8383.json b/.sqlx/query-fb911dec94b1a2d04d699f639e8e34895bc0e77084f1afa6a02caf35798de9da.json similarity index 76% rename from .sqlx/query-44eedb7dbf77302430161c5c71a88884acd4d2359eeea55ad2946cd213ed8383.json rename to .sqlx/query-fb911dec94b1a2d04d699f639e8e34895bc0e77084f1afa6a02caf35798de9da.json index bbf56901..ae4a41e7 100644 --- a/.sqlx/query-44eedb7dbf77302430161c5c71a88884acd4d2359eeea55ad2946cd213ed8383.json +++ b/.sqlx/query-fb911dec94b1a2d04d699f639e8e34895bc0e77084f1afa6a02caf35798de9da.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT token, user_id, shared_profiles_links_id, created, expires\n FROM cdn_auth_tokens cat\n WHERE cat.token = $1\n ", + "query": "\n SELECT token, user_id, shared_profiles_id, created, expires\n FROM cdn_auth_tokens cat\n WHERE cat.token = $1\n ", "describe": { "columns": [ { @@ -15,7 +15,7 @@ }, { "ordinal": 2, - "name": "shared_profiles_links_id", + "name": "shared_profiles_id", "type_info": "Int8" }, { @@ -42,5 +42,5 @@ false ] }, - "hash": "44eedb7dbf77302430161c5c71a88884acd4d2359eeea55ad2946cd213ed8383" + "hash": "fb911dec94b1a2d04d699f639e8e34895bc0e77084f1afa6a02caf35798de9da" } diff --git a/.sqlx/query-dda33960b6c82d05f1a6136f2c3f4944eca0d313925195fefe1e12b79330cb1e.json b/.sqlx/query-fd3b2321af19366304bcf46b3cf8e6a1d0bb17a0bccefc5d52f775808cba8361.json similarity index 71% rename from .sqlx/query-dda33960b6c82d05f1a6136f2c3f4944eca0d313925195fefe1e12b79330cb1e.json rename to .sqlx/query-fd3b2321af19366304bcf46b3cf8e6a1d0bb17a0bccefc5d52f775808cba8361.json index a8d460a5..f809f633 100644 --- a/.sqlx/query-dda33960b6c82d05f1a6136f2c3f4944eca0d313925195fefe1e12b79330cb1e.json +++ b/.sqlx/query-fd3b2321af19366304bcf46b3cf8e6a1d0bb17a0bccefc5d52f775808cba8361.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT id, link, shared_profile_id, created, expires, uses_remaining\n FROM shared_profiles_links spl\n WHERE spl.link = $1\n ", + "query": "\n SELECT id, link, shared_profile_id, created, expires\n FROM shared_profiles_links spl\n WHERE spl.link = $1\n ", "describe": { "columns": [ { @@ -27,11 +27,6 @@ "ordinal": 4, "name": "expires", "type_info": "Timestamptz" - }, - { - "ordinal": 5, - "name": "uses_remaining", - "type_info": "Int4" } ], "parameters": { @@ -44,9 +39,8 @@ false, false, false, - false, false ] }, - "hash": "dda33960b6c82d05f1a6136f2c3f4944eca0d313925195fefe1e12b79330cb1e" + "hash": "fd3b2321af19366304bcf46b3cf8e6a1d0bb17a0bccefc5d52f775808cba8361" } diff --git a/.sqlx/query-736e8a7045935d214332fbaef5d8aeb766d2d5605c61d2037b9f56499affaf6a.json b/.sqlx/query-ff2f40e94b11fbd4b706faec648db8f8e0020b19472980b52c9f693a6cb55976.json similarity index 62% rename from .sqlx/query-736e8a7045935d214332fbaef5d8aeb766d2d5605c61d2037b9f56499affaf6a.json rename to .sqlx/query-ff2f40e94b11fbd4b706faec648db8f8e0020b19472980b52c9f693a6cb55976.json index b515106d..03e70840 100644 --- a/.sqlx/query-736e8a7045935d214332fbaef5d8aeb766d2d5605c61d2037b9f56499affaf6a.json +++ b/.sqlx/query-ff2f40e94b11fbd4b706faec648db8f8e0020b19472980b52c9f693a6cb55976.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT cat.token, cat.user_id, cat.shared_profiles_links_id, cat.created, cat.expires\n FROM cdn_auth_tokens cat\n INNER JOIN shared_profiles_links spl ON spl.id = cat.shared_profiles_links_id\n WHERE spl.id = $1 AND spl.shared_profile_id IN (\n SELECT id FROM shared_profiles sp WHERE sp.owner_id = $2\n )\n ", + "query": "\n SELECT cat.token, cat.user_id, cat.shared_profiles_id, cat.created, cat.expires\n FROM cdn_auth_tokens cat\n INNER JOIN shared_profiles_links spl ON spl.id = cat.shared_profiles_id\n WHERE spl.id = $1 AND cat.user_id = $2\n ", "describe": { "columns": [ { @@ -15,7 +15,7 @@ }, { "ordinal": 2, - "name": "shared_profiles_links_id", + "name": "shared_profiles_id", "type_info": "Int8" }, { @@ -43,5 +43,5 @@ false ] }, - "hash": "736e8a7045935d214332fbaef5d8aeb766d2d5605c61d2037b9f56499affaf6a" + "hash": "ff2f40e94b11fbd4b706faec648db8f8e0020b19472980b52c9f693a6cb55976" } diff --git a/migrations/20231226012200_shared_modpacks.sql b/migrations/20231226012200_shared_modpacks.sql index db8f4221..ec21c0c6 100644 --- a/migrations/20231226012200_shared_modpacks.sql +++ b/migrations/20231226012200_shared_modpacks.sql @@ -7,6 +7,8 @@ CREATE TABLE shared_profiles ( updated timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, created timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + maximum_users integer NOT NULL, + game_version_id int NOT NULL REFERENCES loader_field_enum_values(id), loader_id int NOT NULL REFERENCES loaders(id), loader_version varchar(255) NOT NULL @@ -33,21 +35,26 @@ CREATE TABLE shared_profiles_links ( link varchar(48) NOT NULL UNIQUE, -- extension of the url that identifies this (ie profiles/afgxxczsewq) shared_profile_id bigint NOT NULL REFERENCES shared_profiles(id), created timestamptz NOT NULL DEFAULT now(), - expires timestamptz NOT NULL, - uses_remaining integer NOT NULL DEFAULT 0 -- one less use each time you generate a cdn_auth_token + expires timestamptz NOT NULL +); + +CREATE TABLE shared_profiles_users ( + shared_profile_id bigint NOT NULL REFERENCES shared_profiles(id), + user_id bigint NOT NULL REFERENCES users(id), + CONSTRAINT shared_profiles_users_unique UNIQUE (shared_profile_id, user_id) ); -- Index off 'link' CREATE INDEX shared_profiles_links_link_idx ON shared_profiles_links(link); --- One generated tokens for downloading files +-- generated tokens for downloading files CREATE TABLE cdn_auth_tokens ( token varchar(255) PRIMARY KEY, - shared_profiles_links_id bigint NOT NULL REFERENCES shared_profiles_links(id), + shared_profiles_id bigint NOT NULL REFERENCES shared_profiles(id), user_id bigint NOT NULL REFERENCES users(id), created timestamptz NOT NULL DEFAULT now(), expires timestamptz NOT NULL, -- unique combinations of shared_profiles_links_id and user_id - CONSTRAINT cdn_auth_tokens_unique UNIQUE (shared_profiles_links_id, user_id) + CONSTRAINT cdn_auth_tokens_unique UNIQUE (shared_profiles_id, user_id) ); \ No newline at end of file diff --git a/src/database/models/minecraft_profile_item.rs b/src/database/models/minecraft_profile_item.rs index 4f7f823f..f3688f26 100644 --- a/src/database/models/minecraft_profile_item.rs +++ b/src/database/models/minecraft_profile_item.rs @@ -25,6 +25,9 @@ pub struct MinecraftProfile { pub game_version_id: LoaderFieldEnumValueId, pub loader_version: String, + pub maximum_users: i32, + pub users: Vec, + // These represent the same loader pub loader_id: LoaderId, pub loader: String, @@ -41,12 +44,12 @@ impl MinecraftProfile { sqlx::query!( " INSERT INTO shared_profiles ( - id, name, owner_id, icon_url, created, updated, - game_version_id, loader_id, loader_version + id, name, owner_id, icon_url, created, updated, + game_version_id, loader_id, loader_version, maximum_users ) VALUES ( $1, $2, $3, $4, $5, $6, - $7, $8, $9 + $7, $8, $9, $10 ) ", self.id as MinecraftProfileId, @@ -58,10 +61,29 @@ impl MinecraftProfile { self.game_version_id as LoaderFieldEnumValueId, self.loader_id as LoaderId, self.loader_version, + self.maximum_users, ) .execute(&mut **transaction) .await?; + // Insert users + for user_id in &self.users { + sqlx::query!( + " + INSERT INTO shared_profiles_users ( + shared_profile_id, user_id + ) + VALUES ( + $1, $2 + ) + ", + self.id as MinecraftProfileId, + user_id.0, + ) + .execute(&mut **transaction) + .await?; + } + Ok(()) } @@ -74,10 +96,7 @@ impl MinecraftProfile { sqlx::query!( " DELETE FROM cdn_auth_tokens - WHERE shared_profiles_links_id IN ( - SELECT id FROM shared_profiles_links - WHERE shared_profile_id = $1 - ) + WHERE shared_profiles_id = $1 ", id as MinecraftProfileId, ) @@ -95,6 +114,17 @@ impl MinecraftProfile { .execute(&mut **transaction) .await?; + // Delete shared_profiles_users + sqlx::query!( + " + DELETE FROM shared_profiles_users + WHERE shared_profile_id = $1 + ", + id as MinecraftProfileId, + ) + .execute(&mut **transaction) + .await?; + sqlx::query!( " DELETE FROM shared_profiles_mods @@ -208,9 +238,11 @@ impl MinecraftProfile { // One to many for shared_profiles to loaders, so can safely group by shared_profile_id let db_profiles: Vec = sqlx::query!( " - SELECT sp.id, sp.name, sp.owner_id, sp.icon_url, sp.created, sp.updated, sp.game_version_id, sp.loader_id, l.loader, sp.loader_version + SELECT sp.id, sp.name, sp.owner_id, sp.icon_url, sp.created, sp.updated, sp.game_version_id, sp.loader_id, l.loader, sp.loader_version, sp.maximum_users, + ARRAY_AGG(spu.user_id) as users FROM shared_profiles sp LEFT JOIN loaders l ON l.id = sp.loader_id + LEFT JOIN shared_profiles_users spu ON spu.shared_profile_id = sp.id WHERE sp.id = ANY($1) GROUP BY sp.id, l.id ", @@ -230,9 +262,11 @@ impl MinecraftProfile { created: m.created, owner_id: UserId(m.owner_id), game_version_id: LoaderFieldEnumValueId(m.game_version_id), + users: m.users.unwrap_or_default().into_iter().map(UserId).collect(), loader_id: LoaderId(m.loader_id), loader_version: m.loader_version, loader: m.loader, + maximum_users: m.maximum_users, versions, overrides: files } @@ -277,7 +311,6 @@ pub struct MinecraftProfileLink { pub shared_profile_id: MinecraftProfileId, pub created: DateTime, pub expires: DateTime, - pub uses_remaining: i32, } impl MinecraftProfileLink { @@ -288,10 +321,10 @@ impl MinecraftProfileLink { sqlx::query!( " INSERT INTO shared_profiles_links ( - id, link, shared_profile_id, created, expires, uses_remaining + id, link, shared_profile_id, created, expires ) VALUES ( - $1, $2, $3, $4, $5, $6 + $1, $2, $3, $4, $5 ) ", self.id.0, @@ -299,7 +332,6 @@ impl MinecraftProfileLink { self.shared_profile_id.0, self.created, self.expires, - self.uses_remaining, ) .execute(&mut **transaction) .await?; @@ -318,7 +350,7 @@ impl MinecraftProfileLink { let links = sqlx::query!( " - SELECT id, link, shared_profile_id, created, expires, uses_remaining + SELECT id, link, shared_profile_id, created, expires FROM shared_profiles_links spl WHERE spl.shared_profile_id = $1 ", @@ -332,7 +364,6 @@ impl MinecraftProfileLink { shared_profile_id: MinecraftProfileId(m.shared_profile_id), created: m.created, expires: m.expires, - uses_remaining: m.uses_remaining, })) }) .try_collect::>() @@ -352,7 +383,7 @@ impl MinecraftProfileLink { let link = sqlx::query!( " - SELECT id, link, shared_profile_id, created, expires, uses_remaining + SELECT id, link, shared_profile_id, created, expires FROM shared_profiles_links spl WHERE spl.id = $1 ", @@ -366,13 +397,12 @@ impl MinecraftProfileLink { shared_profile_id: MinecraftProfileId(m.shared_profile_id), created: m.created, expires: m.expires, - uses_remaining: m.uses_remaining, }); Ok(link) } - // DELETE in here needs to clear all fields as well to prevent orphaned data + // TODO: DELETE in here needs to clear all fields as well to prevent orphaned data pub async fn get_url<'a, 'b, E>( url_identifier: &str, @@ -385,7 +415,7 @@ impl MinecraftProfileLink { let link = sqlx::query!( " - SELECT id, link, shared_profile_id, created, expires, uses_remaining + SELECT id, link, shared_profile_id, created, expires FROM shared_profiles_links spl WHERE spl.link = $1 ", @@ -399,7 +429,6 @@ impl MinecraftProfileLink { shared_profile_id: MinecraftProfileId(m.shared_profile_id), created: m.created, expires: m.expires, - uses_remaining: m.uses_remaining, }); Ok(link) @@ -409,7 +438,7 @@ impl MinecraftProfileLink { #[derive(Clone, Debug, Serialize, Deserialize)] pub struct MinecraftProfileLinkToken { pub token: String, - pub shared_profiles_links_id: MinecraftProfileLinkId, + pub shared_profiles_id: MinecraftProfileId, pub user_id: UserId, pub created: DateTime, pub expires: DateTime, @@ -423,14 +452,14 @@ impl MinecraftProfileLinkToken { sqlx::query!( " INSERT INTO cdn_auth_tokens ( - token, shared_profiles_links_id, user_id, created, expires + token, shared_profiles_id, user_id, created, expires ) VALUES ( $1, $2, $3, $4, $5 ) ", self.token, - self.shared_profiles_links_id.0, + self.shared_profiles_id.0, self.user_id.0, self.created, self.expires, @@ -452,7 +481,7 @@ impl MinecraftProfileLinkToken { let token = sqlx::query!( " - SELECT token, user_id, shared_profiles_links_id, created, expires + SELECT token, user_id, shared_profiles_id, created, expires FROM cdn_auth_tokens cat WHERE cat.token = $1 ", @@ -463,7 +492,7 @@ impl MinecraftProfileLinkToken { .map(|m| MinecraftProfileLinkToken { token: m.token, user_id: UserId(m.user_id), - shared_profiles_links_id: MinecraftProfileLinkId(m.shared_profiles_links_id), + shared_profiles_id: MinecraftProfileId(m.shared_profiles_id), created: m.created, expires: m.expires, }); @@ -471,29 +500,25 @@ impl MinecraftProfileLinkToken { Ok(token) } - // Get existing token for link and user - pub async fn get_from_link_user<'a, 'b, E>( - profile_link_id: MinecraftProfileLinkId, + // Get existing token for profile and user + pub async fn get_from_profile_user<'a, 'b, E>( + profile_id: MinecraftProfileId, user_id: UserId, executor: E, ) -> Result, DatabaseError> where E: sqlx::Acquire<'a, Database = sqlx::Postgres>, { - println!( - "Getting for link {} and user {}", - profile_link_id.0, user_id.0 - ); let mut exec = executor.acquire().await?; let token = sqlx::query!( " - SELECT cat.token, cat.user_id, cat.shared_profiles_links_id, cat.created, cat.expires + SELECT cat.token, cat.user_id, cat.shared_profiles_id, cat.created, cat.expires FROM cdn_auth_tokens cat - INNER JOIN shared_profiles_links spl ON spl.id = cat.shared_profiles_links_id + INNER JOIN shared_profiles_links spl ON spl.id = cat.shared_profiles_id WHERE spl.id = $1 AND cat.user_id = $2 ", - profile_link_id.0, + profile_id.0, user_id.0 ) .fetch_optional(&mut *exec) @@ -501,7 +526,7 @@ impl MinecraftProfileLinkToken { .map(|m| MinecraftProfileLinkToken { token: m.token, user_id: UserId(m.user_id), - shared_profiles_links_id: MinecraftProfileLinkId(m.shared_profiles_links_id), + shared_profiles_id: MinecraftProfileId(m.shared_profiles_id), created: m.created, expires: m.expires, }); @@ -527,15 +552,15 @@ impl MinecraftProfileLinkToken { } pub async fn delete_all( - shared_profile_link_id: MinecraftProfileLinkId, + shared_profile_id: MinecraftProfileId, transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, ) -> Result<(), DatabaseError> { sqlx::query!( " DELETE FROM cdn_auth_tokens - WHERE shared_profiles_links_id = $1 + WHERE shared_profiles_id = $1 ", - shared_profile_link_id.0 + shared_profile_id.0 ) .execute(&mut **transaction) .await?; diff --git a/src/models/v3/minecraft/profile.rs b/src/models/v3/minecraft/profile.rs index 279c7341..d081d99f 100644 --- a/src/models/v3/minecraft/profile.rs +++ b/src/models/v3/minecraft/profile.rs @@ -9,7 +9,7 @@ use crate::{ }; // How many uses should a share link have before it becomes invalid? -pub const DEFAULT_STARTING_LINK_USES: u32 = 5; +pub const DEFAULT_PROFILE_MAX_USERS: u32 = 5; /// The ID of a specific profile, encoded as base62 for usage in the API #[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug)] @@ -34,6 +34,12 @@ pub struct MinecraftProfile { /// The icon of the project. pub icon_url: Option, + // Maximum number of users that can be associated with this profile + pub max_users: u32, + // Users that are associated with this profile + // Hidden if the user is not the owner + pub users: Option>, + /// The loader pub loader: String, /// The loader version @@ -48,8 +54,17 @@ pub struct MinecraftProfile { pub override_install_paths: Vec, } -impl From for MinecraftProfile { - fn from(profile: database::models::minecraft_profile_item::MinecraftProfile) -> Self { +impl MinecraftProfile { + pub fn from( + profile: database::models::minecraft_profile_item::MinecraftProfile, + current_user_id: Option, + ) -> Self { + let users = if Some(profile.owner_id) == current_user_id { + Some(profile.users.into_iter().map(|v| v.into()).collect()) + } else { + None + }; + Self { id: profile.id.into(), owner_id: profile.owner_id.into(), @@ -57,6 +72,8 @@ impl From for Minecr created: profile.created, updated: profile.updated, icon_url: profile.icon_url, + max_users: profile.maximum_users as u32, + users, loader: profile.loader, loader_version: profile.loader_version, game_version_id: profile.game_version_id, @@ -71,7 +88,6 @@ pub struct MinecraftProfileShareLink { pub url_identifier: String, pub url: String, // Includes the url identifier, intentionally redundant pub profile_id: MinecraftProfileId, - pub uses_remaining: u32, pub created: DateTime, pub expires: DateTime, } @@ -83,7 +99,7 @@ impl From // Generate URL for easy access let profile_id: MinecraftProfileId = link.shared_profile_id.into(); let url = format!( - "{}/v3/minecraft/profile/{}/download/{}", + "{}/v3/minecraft/profile/{}/accept/{}", dotenvy::var("SELF_ADDR").unwrap(), profile_id, link.link_identifier @@ -93,7 +109,6 @@ impl From url_identifier: link.link_identifier, url, profile_id, - uses_remaining: link.uses_remaining as u32, created: link.created, expires: link.expires, } diff --git a/src/routes/v3/minecraft/profiles.rs b/src/routes/v3/minecraft/profiles.rs index 38ad32ce..d0ab47f9 100644 --- a/src/routes/v3/minecraft/profiles.rs +++ b/src/routes/v3/minecraft/profiles.rs @@ -10,7 +10,7 @@ use crate::file_hosting::FileHost; use crate::models::ids::base62_impl::parse_base62; use crate::models::ids::VersionId; use crate::models::minecraft::profile::{ - MinecraftProfile, MinecraftProfileId, MinecraftProfileShareLink, DEFAULT_STARTING_LINK_USES, + MinecraftProfile, MinecraftProfileId, MinecraftProfileShareLink, DEFAULT_PROFILE_MAX_USERS, }; use crate::models::pats::Scopes; use crate::queue::session::AuthQueue; @@ -53,6 +53,10 @@ pub fn config(cfg: &mut web::ServiceConfig) { "{id}/share/{url_identifier}", web::get().to(profile_link_get), ) + .route( + "{id}/accept/{url_identifier}", + web::post().to(accept_share_link), + ) .route("{id}/download", web::get().to(profile_download)) .route("{id}/icon", web::patch().to(profile_icon_edit)) .route("{id}/icon", web::delete().to(delete_profile_icon)), @@ -155,6 +159,8 @@ pub async fn profile_create( loader_id, loader: profile_create_data.loader, loader_version: profile_create_data.loader_version, + maximum_users: DEFAULT_PROFILE_MAX_USERS as i32, + users: vec![current_user.id.into()], versions, overrides: Vec::new(), }; @@ -162,7 +168,10 @@ pub async fn profile_create( profile_builder_actual.insert(&mut transaction).await?; transaction.commit().await?; - let profile = models::minecraft::profile::MinecraftProfile::from(profile_builder); + let profile = models::minecraft::profile::MinecraftProfile::from( + profile_builder, + Some(current_user.id.into()), + ); Ok(HttpResponse::Ok().json(profile)) } @@ -172,11 +181,23 @@ pub struct MinecraftProfileIds { } // Get several minecraft profiles by their ids pub async fn profiles_get( + req: HttpRequest, web::Query(ids): web::Query, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { - // No user check ,as any user/scope can view profiles. + let user_id = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + None, // No scopes required to read your own links + ) + .await + .ok() + .map(|x| x.1.id.into()); + // In addition, private information (ie: CDN links, tokens, anything outside of the list of hosted versions and install paths) is not returned let ids = serde_json::from_str::>(&ids.ids)?; let ids = ids @@ -189,7 +210,7 @@ pub async fn profiles_get( .await?; let profiles = profiles_data .into_iter() - .map(MinecraftProfile::from) + .map(|x| MinecraftProfile::from(x, user_id)) .collect::>(); Ok(HttpResponse::Ok().json(profiles)) @@ -197,12 +218,25 @@ pub async fn profiles_get( // Get a minecraft profile by its id pub async fn profile_get( + req: HttpRequest, info: web::Path<(String,)>, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { let string = info.into_inner().0; + let user_id = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + None, // No scopes required to read your own links + ) + .await + .ok() + .map(|x| x.1.id.into()); + // No user check ,as any user/scope can view profiles. // In addition, private information (ie: CDN links, tokens, anything outside of the list of hosted versions and install paths) is not returned let id = database::models::MinecraftProfileId(parse_base62(&string)? as i64); @@ -210,7 +244,7 @@ pub async fn profile_get( database::models::minecraft_profile_item::MinecraftProfile::get(id, &**pool, &redis) .await?; if let Some(data) = profile_data { - return Ok(HttpResponse::Ok().json(MinecraftProfile::from(data))); + return Ok(HttpResponse::Ok().json(MinecraftProfile::from(data, user_id))); } Err(ApiError::NotFound) } @@ -470,8 +504,6 @@ pub async fn profile_share( link_identifier: identifier.clone(), created: Utc::now(), expires: Utc::now() + chrono::Duration::days(7), - - uses_remaining: DEFAULT_STARTING_LINK_USES as i32, }; link.insert(&mut transaction).await?; transaction.commit().await?; @@ -483,7 +515,7 @@ pub async fn profile_share( } // See the status of a link to a profile by its id -// This is used by the launcher to check if the link is still valid, expired, or has uses left. +// This is used by the to check if the link is expired, etc. pub async fn profile_link_get( req: HttpRequest, info: web::Path<(String, String)>, @@ -526,6 +558,83 @@ pub async fn profile_link_get( } } +// Accept a share link to a profile +// This adds the user to the team +// TODO: With above change, this is the API link that is translated from a modrinth:// link by the launcher, which would then download it +pub async fn accept_share_link( + req: HttpRequest, + info: web::Path<(MinecraftProfileId, String)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let (profile_id, url_identifier) = info.into_inner(); + + // Must be logged in to accept + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::MINECRAFT_PROFILE_WRITE]), + ) + .await?; + + // Fetch the profile information of the desired minecraft profile + let link_data = database::models::minecraft_profile_item::MinecraftProfileLink::get_url( + &url_identifier, + &**pool, + ) + .await? + .ok_or_else(|| ApiError::NotFound)?; + + // Confirm it matches the profile id + if link_data.shared_profile_id != profile_id.into() { + return Err(ApiError::NotFound); + } + + let data = database::models::minecraft_profile_item::MinecraftProfile::get( + link_data.shared_profile_id, + &**pool, + &redis, + ) + .await? + .ok_or_else(|| ApiError::NotFound)?; + + // Confirm this is not our profile + if data.owner_id == user_option.1.id.into() { + return Err(ApiError::InvalidInput( + "You cannot accept your own share link".to_string(), + )); + } + + // Confirm we are not already on the team + if data.users.iter().any(|x| *x == user_option.1.id.into()) { + return Err(ApiError::InvalidInput( + "You are already on this profile's team".to_string(), + )); + } + + // Confirm we are not over the maximum users + if data.maximum_users <= data.users.len() as i32 { + return Err(ApiError::InvalidInput( + "This profile has too many users".to_string(), + )); + } + + // Add the user to the team + sqlx::query!( + "INSERT INTO shared_profiles_users (shared_profile_id, user_id) VALUES ($1, $2)", + data.id.0 as i64, + user_option.1.id.0 as i64 + ) + .execute(&**pool) + .await?; + minecraft_profile_item::MinecraftProfile::clear_cache(data.id, &redis).await?; + + Ok(HttpResponse::NoContent().finish()) +} + #[derive(Serialize, Deserialize)] pub struct ProfileDownload { // temporary authorization token for the CDN, for downloading the profile files @@ -540,17 +649,16 @@ pub struct ProfileDownload { } // Download a minecraft profile -// This converts a share link into a temporary authorization token for the CDN -// TODO: With above change, this is the API link that is translated from a modrinth:// link by the launcher +// Only the owner of the profile or an invited user can download pub async fn profile_download( req: HttpRequest, - info: web::Path<(String,)>, + info: web::Path<(MinecraftProfileId,)>, pool: web::Data, redis: web::Data, session_queue: web::Data, ) -> Result { let cdn_url = dotenvy::var("CDN_URL")?; - let url_identifier = info.into_inner().0; + let profile_id = info.into_inner().0; // Must be logged in to download let user_option = get_user_from_headers( @@ -563,18 +671,8 @@ pub async fn profile_download( .await?; // Fetch the profile information of the desired minecraft profile - let Some(profile_link_data) = - database::models::minecraft_profile_item::MinecraftProfileLink::get_url( - &url_identifier, - &**pool, - ) - .await? - else { - return Err(ApiError::NotFound); - }; - let Some(profile) = database::models::minecraft_profile_item::MinecraftProfile::get( - profile_link_data.shared_profile_id, + profile_id.into(), &**pool, &redis, ) @@ -583,17 +681,23 @@ pub async fn profile_download( return Err(ApiError::NotFound); }; + // Check if this user is on the profile user list + if !profile.users.contains(&user_option.1.id.into()) { + return Err(ApiError::CustomAuthentication( + "You are not on this profile's team".to_string(), + )); + } + let mut transaction = pool.begin().await?; // Check no token exists for the username and profile let existing_token = - database::models::minecraft_profile_item::MinecraftProfileLinkToken::get_from_link_user( - profile_link_data.id, + database::models::minecraft_profile_item::MinecraftProfileLinkToken::get_from_profile_user( + profile.id, user_option.1.id.into(), &mut *transaction, ) .await?; - println!("Existing token: {:?}", existing_token); if let Some(token) = existing_token { // Check if the token is still valid if token.expires > Utc::now() { @@ -618,23 +722,6 @@ pub async fn profile_download( .await?; } - println!("Uses remaining: {}", profile_link_data.uses_remaining); - - // If there's no token, or the token is invalid, create a new one - if profile_link_data.uses_remaining < 1 { - return Err(ApiError::InvalidInput( - "No more downloads remaining".to_string(), - )); - } - - // Reduce the number of downloads remaining - sqlx::query!( - "UPDATE shared_profiles_links SET uses_remaining = uses_remaining - 1 WHERE id = $1", - profile_link_data.id.0 - ) - .execute(&mut *transaction) - .await?; - // Create a new cdn auth token let token = database::models::minecraft_profile_item::MinecraftProfileLinkToken { user_id: user_option.1.id.into(), // This user is requesting the download @@ -643,7 +730,7 @@ pub async fn profile_download( .take(32) .map(char::from) .collect::(), - shared_profiles_links_id: profile_link_data.id, + shared_profiles_id: profile.id, created: Utc::now(), expires: Utc::now() + chrono::Duration::minutes(5), }; @@ -700,34 +787,22 @@ pub async fn profile_token_check( )); } - // Get share link - let share_link = database::models::minecraft_profile_item::MinecraftProfileLink::get( - token.shared_profiles_links_id, - &**pool, - ) - .await? - .ok_or_else(|| ApiError::Authentication(AuthenticationError::InvalidAuthMethod))?; - // Get valid urls for the profile let profile = database::models::minecraft_profile_item::MinecraftProfile::get( - share_link.shared_profile_id, + token.shared_profiles_id, &**pool, &redis, ) .await? .ok_or_else(|| ApiError::Authentication(AuthenticationError::InvalidAuthMethod))?; - println!("Profile: {:?}", profile); // Check the token is valid for the requested file let file_url_hash = file_url .split(&format!("{cdn_url}/custom_files/")) .nth(1) .ok_or_else(|| ApiError::Authentication(AuthenticationError::InvalidAuthMethod))?; - println!("File url hash: {}", file_url_hash); let valid = profile.overrides.iter().any(|x| x.0 == file_url_hash); - - println!("Valid: {}", valid); if !valid { Err(ApiError::Authentication( AuthenticationError::InvalidAuthMethod, diff --git a/tests/common/api_v3/minecraft_profile.rs b/tests/common/api_v3/minecraft_profile.rs index f1644ab9..857e8da7 100644 --- a/tests/common/api_v3/minecraft_profile.rs +++ b/tests/common/api_v3/minecraft_profile.rs @@ -226,27 +226,41 @@ impl ApiV3 { test::read_body_json(resp).await } - pub async fn download_minecraft_profile( + pub async fn accept_minecraft_profile_share_link( &self, + profile_id: &str, url_identifier: &str, pat: Option<&str>, ) -> ServiceResponse { - let req = TestRequest::get() + let req = TestRequest::post() .uri(&format!( - "/v3/minecraft/profile/{}/download", - url_identifier + "/v3/minecraft/profile/{}/accept/{}", + profile_id, url_identifier )) .append_pat(pat) .to_request(); self.call(req).await } + // Get links and token + pub async fn download_minecraft_profile( + &self, + profile_id: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = TestRequest::get() + .uri(&format!("/v3/minecraft/profile/{}/download", profile_id)) + .append_pat(pat) + .to_request(); + self.call(req).await + } + pub async fn download_minecraft_profile_deserialized( &self, - url_identifier: &str, + profile_id: &str, pat: Option<&str>, ) -> ProfileDownload { - let resp = self.download_minecraft_profile(url_identifier, pat).await; + let resp = self.download_minecraft_profile(profile_id, pat).await; assert_eq!(resp.status(), 200); test::read_body_json(resp).await } diff --git a/tests/common/database.rs b/tests/common/database.rs index ba6e9cae..e4811634 100644 --- a/tests/common/database.rs +++ b/tests/common/database.rs @@ -18,19 +18,22 @@ pub const ADMIN_USER_ID: &str = "1"; pub const MOD_USER_ID: &str = "2"; pub const USER_USER_ID: &str = "3"; // This is the 'main' user ID, and is used for most tests. pub const FRIEND_USER_ID: &str = "4"; // This is exactly the same as USER_USER_ID, but could be used for testing friend-only endpoints (ie: teams, etc) -pub const ENEMY_USER_ID: &str = "5"; // This is exactly the same as USER_USER_ID, but could be used for testing friend-only endpoints (ie: teams, etc) +pub const OTHER_FRIEND_USER_ID: &str = "5"; // This is exactly the same as USER_USER_ID, but could be used for testing friend-only endpoints (ie: teams, etc) +pub const ENEMY_USER_ID: &str = "6"; // This is exactly the same as USER_USER_ID, but could be used for testing friend-only endpoints (ie: teams, etc) pub const ADMIN_USER_ID_PARSED: i64 = 1; pub const MOD_USER_ID_PARSED: i64 = 2; pub const USER_USER_ID_PARSED: i64 = 3; pub const FRIEND_USER_ID_PARSED: i64 = 4; -pub const ENEMY_USER_ID_PARSED: i64 = 5; +pub const OTHER_FRIEND_USER_ID_PARSED: i64 = 5; +pub const ENEMY_USER_ID_PARSED: i64 = 6; // These are full-scoped PATs- as if the user was logged in (including illegal scopes). pub const ADMIN_USER_PAT: Option<&str> = Some("mrp_patadmin"); pub const MOD_USER_PAT: Option<&str> = Some("mrp_patmoderator"); pub const USER_USER_PAT: Option<&str> = Some("mrp_patuser"); pub const FRIEND_USER_PAT: Option<&str> = Some("mrp_patfriend"); +pub const OTHER_FRIEND_USER_PAT: Option<&str> = Some("mrp_patotherfriend"); pub const ENEMY_USER_PAT: Option<&str> = Some("mrp_patenemy"); const TEMPLATE_DATABASE_NAME: &str = "labrinth_tests_template"; diff --git a/tests/common/dummy_data.rs b/tests/common/dummy_data.rs index 731a33d9..8c59ecd6 100644 --- a/tests/common/dummy_data.rs +++ b/tests/common/dummy_data.rs @@ -24,7 +24,7 @@ use super::{ use super::{asserts::assert_status, database::USER_USER_ID, get_json_val_str}; -pub const DUMMY_DATA_UPDATE: i64 = 6; +pub const DUMMY_DATA_UPDATE: i64 = 7; #[allow(dead_code)] pub const DUMMY_CATEGORIES: &[&str] = &[ diff --git a/tests/files/dummy_data.sql b/tests/files/dummy_data.sql index d322eb38..622554c8 100644 --- a/tests/files/dummy_data.sql +++ b/tests/files/dummy_data.sql @@ -8,7 +8,8 @@ INSERT INTO users (id, username, name, email, role) VALUES (1, 'admin', 'Adminis INSERT INTO users (id, username, name, email, role) VALUES (2, 'moderator', 'Moderator Test', 'moderator@modrinth.com', 'moderator'); INSERT INTO users (id, username, name, email, role) VALUES (3, 'user', 'User Test', 'user@modrinth.com', 'developer'); INSERT INTO users (id, username, name, email, role) VALUES (4, 'friend', 'Friend Test', 'friend@modrinth.com', 'developer'); -INSERT INTO users (id, username, name, email, role) VALUES (5, 'enemy', 'Enemy Test', 'enemy@modrinth.com', 'developer'); +INSERT INTO users (id, username, name, email, role) VALUES (5, 'other_friend', 'Other Friend Test', 'otherfriend@modrinth.com', 'developer'); +INSERT INTO users (id, username, name, email, role) VALUES (6, 'enemy', 'Enemy Test', 'enemy@modrinth.com', 'developer'); -- Full PATs for each user, with different scopes -- These are not legal PATs, as they contain all scopes- they mimic permissions of a logged in user @@ -17,7 +18,8 @@ INSERT INTO pats (id, user_id, name, access_token, scopes, expires) VALUES (50, INSERT INTO pats (id, user_id, name, access_token, scopes, expires) VALUES (51, 2, 'moderator-pat', 'mrp_patmoderator', $1, '2030-08-18 15:48:58.435729+00'); INSERT INTO pats (id, user_id, name, access_token, scopes, expires) VALUES (52, 3, 'user-pat', 'mrp_patuser', $1, '2030-08-18 15:48:58.435729+00'); INSERT INTO pats (id, user_id, name, access_token, scopes, expires) VALUES (53, 4, 'friend-pat', 'mrp_patfriend', $1, '2030-08-18 15:48:58.435729+00'); -INSERT INTO pats (id, user_id, name, access_token, scopes, expires) VALUES (54, 5, 'enemy-pat', 'mrp_patenemy', $1, '2030-08-18 15:48:58.435729+00'); +INSERT INTO pats (id, user_id, name, access_token, scopes, expires) VALUES (54, 5, 'other-friend-pat', 'mrp_patotherfriend', $1, '2030-08-18 15:48:58.435729+00'); +INSERT INTO pats (id, user_id, name, access_token, scopes, expires) VALUES (55, 6, 'enemy-pat', 'mrp_patenemy', $1, '2030-08-18 15:48:58.435729+00'); INSERT INTO loaders (id, loader) VALUES (5, 'fabric'); INSERT INTO loaders_project_types (joining_loader_id, joining_project_type_id) VALUES (5,1); diff --git a/tests/profiles.rs b/tests/profiles.rs index 78557b6e..6951c2ce 100644 --- a/tests/profiles.rs +++ b/tests/profiles.rs @@ -5,8 +5,8 @@ use common::api_v3::ApiV3; use common::database::*; use common::environment::with_test_environment; use common::environment::TestEnvironment; -use labrinth::database; use labrinth::models::minecraft::profile::MinecraftProfile; +use labrinth::models::users::UserId; use crate::common::api_v3::minecraft_profile::MinecraftProfileOverride; use crate::common::dummy_data::DummyImage; @@ -195,6 +195,86 @@ async fn create_modify_profile() { .await; } +#[actix_rt::test] +async fn accept_share_link() { + with_test_environment(None, |test_env: TestEnvironment| async move { + // Get download links for a created profile (including failure), create a share link, and create the correct number of tokens based on that + // They should expire after a time + let api = &test_env.api; + + // Create a simple profile + let profile = api + .create_minecraft_profile("test", "fabric", "1.0.0", "1.20.1", vec![], USER_USER_PAT) + .await; + assert_eq!(profile.status(), 200); + let profile: MinecraftProfile = test::read_body_json(profile).await; + let id = profile.id.to_string(); + let users: Vec = profile.users.unwrap(); + assert_eq!(users.len(), 1); + assert_eq!(users[0].0, USER_USER_ID_PARSED as u64); + + // Friend can't see the profile user yet, but can see the profile + let profile = api + .get_minecraft_profile_deserialized(&id, FRIEND_USER_PAT) + .await; + assert_eq!(profile.users, None); + + // As 'user', try to generate a download link for the profile + let share_link = api + .generate_minecraft_profile_share_link_deserialized(&id, USER_USER_PAT) + .await; + + // Links should be internally consistent and match the expected format + assert_eq!( + share_link.url, + format!( + "{}/v3/minecraft/profile/{}/accept/{}", + dotenvy::var("SELF_ADDR").unwrap(), + id, + share_link.url_identifier + ) + ); + + // Link is an 'accept' link, when visited using any user token using POST, it should add the user to the profile + // As 'friend', accept the share link + let resp = api + .accept_minecraft_profile_share_link(&id, &share_link.url_identifier, FRIEND_USER_PAT) + .await; + assert_eq!(resp.status(), 204); + + // Profile users should now include the friend + let profile = api + .get_minecraft_profile_deserialized(&id, USER_USER_PAT) + .await; + let mut users = profile.users.unwrap(); + users.sort_by(|a, b| a.0.cmp(&b.0)); + assert_eq!(users.len(), 2); + assert_eq!(users[0].0, USER_USER_ID_PARSED as u64); + assert_eq!(users[1].0, FRIEND_USER_ID_PARSED as u64); + + // Add all of test dummy users until we hit the limit, the last one should fail + let dummy_user_pats = [ + USER_USER_PAT, // Fails because owner (and already added) + FRIEND_USER_PAT, // Fails because already added + OTHER_FRIEND_USER_PAT, + MOD_USER_PAT, + ADMIN_USER_PAT, + ENEMY_USER_PAT, // Fails because too many users + ]; + for (i, pat) in dummy_user_pats.iter().enumerate().take(4 + 1) { + let resp = api + .accept_minecraft_profile_share_link(&id, &share_link.url_identifier, *pat) + .await; + if i == 0 || i == 1 || i == 6 { + assert_eq!(resp.status(), 400); + } else { + assert_eq!(resp.status(), 204); + } + } + }) + .await; +} + #[actix_rt::test] async fn download_profile() { with_test_environment(None, |test_env: TestEnvironment| async move { @@ -224,35 +304,32 @@ async fn download_profile() { assert_eq!(resp.status(), 204); // As 'user', try to generate a download link for the profile + let resp = api.download_minecraft_profile(&id, USER_USER_PAT).await; + assert_eq!(resp.status(), 200); + + // As 'friend', try to get the download links for the profile + // Not invited yet, should fail + let resp = api.download_minecraft_profile(&id, FRIEND_USER_PAT).await; + assert_eq!(resp.status(), 401); + + // As 'user', try to generate a share link for the profile, and accept it as 'friend' let share_link = api .generate_minecraft_profile_share_link_deserialized(&id, USER_USER_PAT) .await; - // Links should add up - assert_eq!(share_link.uses_remaining, 5); - assert_eq!( - share_link.url, - format!( - "{}/v3/minecraft/profile/{}/download/{}", - dotenvy::var("SELF_ADDR").unwrap(), - id, - share_link.url_identifier - ) - ); - - // As 'friend', try to get the download links for the profile - // *Anyone* with the link can get - let download_old = api - .download_minecraft_profile_deserialized(&share_link.url_identifier, FRIEND_USER_PAT) + let resp = api + .accept_minecraft_profile_share_link(&id, &share_link.url_identifier, FRIEND_USER_PAT) .await; + assert_eq!(resp.status(), 204); + // As 'friend', try to get the download links for the profile + // Should succeed let mut download = api - .download_minecraft_profile_deserialized(&share_link.url_identifier, FRIEND_USER_PAT) + .download_minecraft_profile_deserialized(&id, FRIEND_USER_PAT) .await; - // TODO: expiry test - - // Repeated calls should return the same link assuming not expired and same user - assert_eq!(download_old.auth_token, download.auth_token); + // But enemy should fail + let resp = api.download_minecraft_profile(&id, ENEMY_USER_PAT).await; + assert_eq!(resp.status(), 401); // Download url should be: // - CDN url @@ -266,84 +343,35 @@ async fn download_profile() { format!("{}/custom_files/{}", dotenvy::var("CDN_URL").unwrap(), hash) ); - // This generates a token, and now the link should have 4 uses remaining - let share_link = api - .get_minecraft_profile_share_link_deserialized( - &id, - &share_link.url_identifier, - USER_USER_PAT, - ) - .await; - assert_eq!(share_link.uses_remaining, 4); - - // Generate more tokens until we run ouut - for i in 0..=4 { - let resp = api - .download_minecraft_profile(&share_link.url_identifier, FRIEND_USER_PAT) - .await; - println!("resp: {:?}", resp.response().body()); - assert_eq!(resp.status(), 200); - - let share_link = api - .get_minecraft_profile_share_link_deserialized( - &id, - &share_link.url_identifier, - USER_USER_PAT, - ) - .await; - assert_eq!(share_link.uses_remaining, 4 - i); - - // Manually delete the created token. This forces it the get route regenerate a new one, and allolws us to - // do a decrement test for the uses remaining. - // We use a database call for the sake of the tests which currently only has 5 users - let mut transaction = test_env.db.pool.begin().await.unwrap(); - let id = database::models::minecraft_profile_item::MinecraftProfileLink::get_url( - &share_link.url_identifier, - &mut transaction, - ) - .await - .unwrap() - .unwrap() - .id; - database::models::minecraft_profile_item::MinecraftProfileLinkToken::delete_all( - id, - &mut transaction, - ) - .await - .unwrap(); - transaction.commit().await.unwrap(); - } - - // Now we should be out of tokens + // Check cloudflare helper route with a bad token (eg: the profile id), or bad url should fail let resp = api - .download_minecraft_profile(&share_link.url_identifier, FRIEND_USER_PAT) - .await; - assert_eq!(resp.status(), 400); - - // Repeat the process to get a new token - // Generate a new token which shoould have 5 uses remaining - let share_link = api - .generate_minecraft_profile_share_link_deserialized(&id, USER_USER_PAT) + .check_download_minecraft_profile_token(&share_link.url_identifier, &override_file_url) .await; - assert_eq!(share_link.uses_remaining, 5); - - // Get token as 'friend' and download the profile - let download = api - .download_minecraft_profile_deserialized(&share_link.url_identifier, FRIEND_USER_PAT) + assert_eq!(resp.status(), 401); + let resp = api + .check_download_minecraft_profile_token(&share_link.url, &override_file_url) .await; + assert_eq!(resp.status(), 401); - // Check cloudflare helper route with a bad token (eg: the profile id), should fail let resp = api - .check_download_minecraft_profile_token(&share_link.url_identifier, &override_file_url) + .check_download_minecraft_profile_token(&id, &override_file_url) .await; assert_eq!(resp.status(), 401); + let resp = api - .check_download_minecraft_profile_token(&share_link.url, &override_file_url) + .check_download_minecraft_profile_token(&download.auth_token, "bad_url") .await; assert_eq!(resp.status(), 401); let resp = api - .check_download_minecraft_profile_token(&id, &override_file_url) + .check_download_minecraft_profile_token( + &download.auth_token, + &format!( + "{}/custom_files/{}", + dotenvy::var("CDN_URL").unwrap(), + "example_hash" + ), + ) .await; assert_eq!(resp.status(), 401); From 97a9afb55f50864d35c328e60ae214adfb5e3c7e Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Fri, 29 Dec 2023 16:59:47 -0800 Subject: [PATCH 07/25] profile deletion, tests --- ...92e94684f1a3e7e13bfc3fc5c39f095375a61.json | 22 ++ ...ae0c5945e4eeb962bd36939c596b0b52999e.json} | 4 +- ...e8080901b2492e554631e16d88197fa89d9a.json} | 6 +- ...ea6fa37938d302cd8ac291d483a670a4206b.json} | 6 +- ...7f29025b1b022b5a977e1e8802b14817daf71.json | 15 + ...281434f3fca004424ac6b859fbc2419e964e9.json | 15 + ...c98068a871f69f102131030a7ab80009bc8a5.json | 14 + ...b5e6c135c673cc99a6feab16ae7f2017200f5.json | 24 ++ ...532b5e2e1e05ff276c6441e2e2b3d627df201.json | 14 + ...d96ae5b60bd5a8bfdd48a005792326795246.json} | 4 +- ...3ed6b4954f9648c5ec4a77b2f3d5ed83dd6c.json} | 4 +- ...426d1398560166b6c9796fe13821b70dd6a78.json | 14 + migrations/20231226012200_shared_modpacks.sql | 4 +- src/database/models/minecraft_profile_item.rs | 34 +- src/models/v3/minecraft/profile.rs | 2 +- src/routes/v3/minecraft/profiles.rs | 193 ++++++++++- tests/common/api_v3/minecraft_profile.rs | 30 +- tests/profiles.rs | 303 +++++++++++++++++- 18 files changed, 662 insertions(+), 46 deletions(-) create mode 100644 .sqlx/query-156566dd89830c1d24d07698c5c92e94684f1a3e7e13bfc3fc5c39f095375a61.json rename .sqlx/{query-b5bc3c2ba4c1fa034829db0e414e59742e321a61dbb7098d8384d2bc20a9ae9e.json => query-1877647bc2e255578344a8a88579ae0c5945e4eeb962bd36939c596b0b52999e.json} (78%) rename .sqlx/{query-fb911dec94b1a2d04d699f639e8e34895bc0e77084f1afa6a02caf35798de9da.json => query-3199ae443ed4a0155a199c04da2fe8080901b2492e554631e16d88197fa89d9a.json} (70%) rename .sqlx/{query-ff2f40e94b11fbd4b706faec648db8f8e0020b19472980b52c9f693a6cb55976.json => query-4326b7e5a87b9a1c9d9714189d3dea6fa37938d302cd8ac291d483a670a4206b.json} (68%) create mode 100644 .sqlx/query-6bb352e420c099ca81aef1912307f29025b1b022b5a977e1e8802b14817daf71.json create mode 100644 .sqlx/query-951adc782b5217df22b7881b0b0281434f3fca004424ac6b859fbc2419e964e9.json create mode 100644 .sqlx/query-a1e58d8dbffff01cb4a25274fb4c98068a871f69f102131030a7ab80009bc8a5.json create mode 100644 .sqlx/query-aeae06e0779379838d3ef86fdfbb5e6c135c673cc99a6feab16ae7f2017200f5.json create mode 100644 .sqlx/query-b047f915672649b3007d362bd50532b5e2e1e05ff276c6441e2e2b3d627df201.json rename .sqlx/{query-edd6d2b62cac266852675ae75d6968c3aae9a9b9055adc2c4ae305836135d369.json => query-bf6d2b9cbab036a1ca51995b0e96d96ae5b60bd5a8bfdd48a005792326795246.json} (63%) rename .sqlx/{query-f91b50a1ce610a5864acc4d559a48e70304543ac7c59212dc3c5cdd7f6d12acd.json => query-dc5c523ce1b6632b978bd1eb749d3ed6b4954f9648c5ec4a77b2f3d5ed83dd6c.json} (55%) create mode 100644 .sqlx/query-fb3a5fad9a94c4323446b4752ba426d1398560166b6c9796fe13821b70dd6a78.json diff --git a/.sqlx/query-156566dd89830c1d24d07698c5c92e94684f1a3e7e13bfc3fc5c39f095375a61.json b/.sqlx/query-156566dd89830c1d24d07698c5c92e94684f1a3e7e13bfc3fc5c39f095375a61.json new file mode 100644 index 00000000..d909e173 --- /dev/null +++ b/.sqlx/query-156566dd89830c1d24d07698c5c92e94684f1a3e7e13bfc3fc5c39f095375a61.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT file_hash FROM shared_profiles_mods\n WHERE file_hash = ANY($1::text[])\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "file_hash", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "TextArray" + ] + }, + "nullable": [ + true + ] + }, + "hash": "156566dd89830c1d24d07698c5c92e94684f1a3e7e13bfc3fc5c39f095375a61" +} diff --git a/.sqlx/query-b5bc3c2ba4c1fa034829db0e414e59742e321a61dbb7098d8384d2bc20a9ae9e.json b/.sqlx/query-1877647bc2e255578344a8a88579ae0c5945e4eeb962bd36939c596b0b52999e.json similarity index 78% rename from .sqlx/query-b5bc3c2ba4c1fa034829db0e414e59742e321a61dbb7098d8384d2bc20a9ae9e.json rename to .sqlx/query-1877647bc2e255578344a8a88579ae0c5945e4eeb962bd36939c596b0b52999e.json index 33721794..17b6a1ff 100644 --- a/.sqlx/query-b5bc3c2ba4c1fa034829db0e414e59742e321a61dbb7098d8384d2bc20a9ae9e.json +++ b/.sqlx/query-1877647bc2e255578344a8a88579ae0c5945e4eeb962bd36939c596b0b52999e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT sp.id, sp.name, sp.owner_id, sp.icon_url, sp.created, sp.updated, sp.game_version_id, sp.loader_id, l.loader, sp.loader_version, sp.maximum_users,\n ARRAY_AGG(spu.user_id) as users\n FROM shared_profiles sp \n LEFT JOIN loaders l ON l.id = sp.loader_id\n LEFT JOIN shared_profiles_users spu ON spu.shared_profile_id = sp.id\n WHERE sp.id = ANY($1)\n GROUP BY sp.id, l.id\n ", + "query": "\n SELECT sp.id, sp.name, sp.owner_id, sp.icon_url, sp.created, sp.updated, sp.game_version_id, sp.loader_id, l.loader, sp.loader_version, sp.maximum_users,\n ARRAY_AGG(DISTINCT spu.user_id) filter (WHERE spu.user_id IS NOT NULL) as users\n FROM shared_profiles sp \n LEFT JOIN loaders l ON l.id = sp.loader_id\n LEFT JOIN shared_profiles_users spu ON spu.shared_profile_id = sp.id\n WHERE sp.id = ANY($1)\n GROUP BY sp.id, l.id\n ", "describe": { "columns": [ { @@ -84,5 +84,5 @@ null ] }, - "hash": "b5bc3c2ba4c1fa034829db0e414e59742e321a61dbb7098d8384d2bc20a9ae9e" + "hash": "1877647bc2e255578344a8a88579ae0c5945e4eeb962bd36939c596b0b52999e" } diff --git a/.sqlx/query-fb911dec94b1a2d04d699f639e8e34895bc0e77084f1afa6a02caf35798de9da.json b/.sqlx/query-3199ae443ed4a0155a199c04da2fe8080901b2492e554631e16d88197fa89d9a.json similarity index 70% rename from .sqlx/query-fb911dec94b1a2d04d699f639e8e34895bc0e77084f1afa6a02caf35798de9da.json rename to .sqlx/query-3199ae443ed4a0155a199c04da2fe8080901b2492e554631e16d88197fa89d9a.json index ae4a41e7..23d634c7 100644 --- a/.sqlx/query-fb911dec94b1a2d04d699f639e8e34895bc0e77084f1afa6a02caf35798de9da.json +++ b/.sqlx/query-3199ae443ed4a0155a199c04da2fe8080901b2492e554631e16d88197fa89d9a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT token, user_id, shared_profiles_id, created, expires\n FROM cdn_auth_tokens cat\n WHERE cat.token = $1\n ", + "query": "\n SELECT token, user_id, shared_profile_id, created, expires\n FROM cdn_auth_tokens cat\n WHERE cat.token = $1\n ", "describe": { "columns": [ { @@ -15,7 +15,7 @@ }, { "ordinal": 2, - "name": "shared_profiles_id", + "name": "shared_profile_id", "type_info": "Int8" }, { @@ -42,5 +42,5 @@ false ] }, - "hash": "fb911dec94b1a2d04d699f639e8e34895bc0e77084f1afa6a02caf35798de9da" + "hash": "3199ae443ed4a0155a199c04da2fe8080901b2492e554631e16d88197fa89d9a" } diff --git a/.sqlx/query-ff2f40e94b11fbd4b706faec648db8f8e0020b19472980b52c9f693a6cb55976.json b/.sqlx/query-4326b7e5a87b9a1c9d9714189d3dea6fa37938d302cd8ac291d483a670a4206b.json similarity index 68% rename from .sqlx/query-ff2f40e94b11fbd4b706faec648db8f8e0020b19472980b52c9f693a6cb55976.json rename to .sqlx/query-4326b7e5a87b9a1c9d9714189d3dea6fa37938d302cd8ac291d483a670a4206b.json index 03e70840..2dac5f82 100644 --- a/.sqlx/query-ff2f40e94b11fbd4b706faec648db8f8e0020b19472980b52c9f693a6cb55976.json +++ b/.sqlx/query-4326b7e5a87b9a1c9d9714189d3dea6fa37938d302cd8ac291d483a670a4206b.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT cat.token, cat.user_id, cat.shared_profiles_id, cat.created, cat.expires\n FROM cdn_auth_tokens cat\n INNER JOIN shared_profiles_links spl ON spl.id = cat.shared_profiles_id\n WHERE spl.id = $1 AND cat.user_id = $2\n ", + "query": "\n SELECT cat.token, cat.user_id, cat.shared_profile_id, cat.created, cat.expires\n FROM cdn_auth_tokens cat\n INNER JOIN shared_profiles sp ON sp.id = cat.shared_profile_id\n WHERE sp.id = $1 AND cat.user_id = $2\n ", "describe": { "columns": [ { @@ -15,7 +15,7 @@ }, { "ordinal": 2, - "name": "shared_profiles_id", + "name": "shared_profile_id", "type_info": "Int8" }, { @@ -43,5 +43,5 @@ false ] }, - "hash": "ff2f40e94b11fbd4b706faec648db8f8e0020b19472980b52c9f693a6cb55976" + "hash": "4326b7e5a87b9a1c9d9714189d3dea6fa37938d302cd8ac291d483a670a4206b" } diff --git a/.sqlx/query-6bb352e420c099ca81aef1912307f29025b1b022b5a977e1e8802b14817daf71.json b/.sqlx/query-6bb352e420c099ca81aef1912307f29025b1b022b5a977e1e8802b14817daf71.json new file mode 100644 index 00000000..10a9708a --- /dev/null +++ b/.sqlx/query-6bb352e420c099ca81aef1912307f29025b1b022b5a977e1e8802b14817daf71.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM cdn_auth_tokens WHERE shared_profile_id = $1 AND user_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "6bb352e420c099ca81aef1912307f29025b1b022b5a977e1e8802b14817daf71" +} diff --git a/.sqlx/query-951adc782b5217df22b7881b0b0281434f3fca004424ac6b859fbc2419e964e9.json b/.sqlx/query-951adc782b5217df22b7881b0b0281434f3fca004424ac6b859fbc2419e964e9.json new file mode 100644 index 00000000..fec998dd --- /dev/null +++ b/.sqlx/query-951adc782b5217df22b7881b0b0281434f3fca004424ac6b859fbc2419e964e9.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM shared_profiles_users WHERE shared_profile_id = $1 AND user_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "951adc782b5217df22b7881b0b0281434f3fca004424ac6b859fbc2419e964e9" +} diff --git a/.sqlx/query-a1e58d8dbffff01cb4a25274fb4c98068a871f69f102131030a7ab80009bc8a5.json b/.sqlx/query-a1e58d8dbffff01cb4a25274fb4c98068a871f69f102131030a7ab80009bc8a5.json new file mode 100644 index 00000000..4816ca9c --- /dev/null +++ b/.sqlx/query-a1e58d8dbffff01cb4a25274fb4c98068a871f69f102131030a7ab80009bc8a5.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE shared_profiles\n SET updated = NOW()\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "a1e58d8dbffff01cb4a25274fb4c98068a871f69f102131030a7ab80009bc8a5" +} diff --git a/.sqlx/query-aeae06e0779379838d3ef86fdfbb5e6c135c673cc99a6feab16ae7f2017200f5.json b/.sqlx/query-aeae06e0779379838d3ef86fdfbb5e6c135c673cc99a6feab16ae7f2017200f5.json new file mode 100644 index 00000000..767980e3 --- /dev/null +++ b/.sqlx/query-aeae06e0779379838d3ef86fdfbb5e6c135c673cc99a6feab16ae7f2017200f5.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM shared_profiles_mods\n WHERE (shared_profile_id = $1 AND (file_hash = ANY($2::text[]) OR install_path = ANY($3::text[])))\n RETURNING file_hash\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "file_hash", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int8", + "TextArray", + "TextArray" + ] + }, + "nullable": [ + true + ] + }, + "hash": "aeae06e0779379838d3ef86fdfbb5e6c135c673cc99a6feab16ae7f2017200f5" +} diff --git a/.sqlx/query-b047f915672649b3007d362bd50532b5e2e1e05ff276c6441e2e2b3d627df201.json b/.sqlx/query-b047f915672649b3007d362bd50532b5e2e1e05ff276c6441e2e2b3d627df201.json new file mode 100644 index 00000000..5cafc4fa --- /dev/null +++ b/.sqlx/query-b047f915672649b3007d362bd50532b5e2e1e05ff276c6441e2e2b3d627df201.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE shared_profiles\n SET updated = NOW()\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "b047f915672649b3007d362bd50532b5e2e1e05ff276c6441e2e2b3d627df201" +} diff --git a/.sqlx/query-edd6d2b62cac266852675ae75d6968c3aae9a9b9055adc2c4ae305836135d369.json b/.sqlx/query-bf6d2b9cbab036a1ca51995b0e96d96ae5b60bd5a8bfdd48a005792326795246.json similarity index 63% rename from .sqlx/query-edd6d2b62cac266852675ae75d6968c3aae9a9b9055adc2c4ae305836135d369.json rename to .sqlx/query-bf6d2b9cbab036a1ca51995b0e96d96ae5b60bd5a8bfdd48a005792326795246.json index 3c70d16e..3043ea62 100644 --- a/.sqlx/query-edd6d2b62cac266852675ae75d6968c3aae9a9b9055adc2c4ae305836135d369.json +++ b/.sqlx/query-bf6d2b9cbab036a1ca51995b0e96d96ae5b60bd5a8bfdd48a005792326795246.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n DELETE FROM cdn_auth_tokens\n WHERE shared_profiles_id = $1\n ", + "query": "\n DELETE FROM cdn_auth_tokens\n WHERE shared_profile_id = $1\n ", "describe": { "columns": [], "parameters": { @@ -10,5 +10,5 @@ }, "nullable": [] }, - "hash": "edd6d2b62cac266852675ae75d6968c3aae9a9b9055adc2c4ae305836135d369" + "hash": "bf6d2b9cbab036a1ca51995b0e96d96ae5b60bd5a8bfdd48a005792326795246" } diff --git a/.sqlx/query-f91b50a1ce610a5864acc4d559a48e70304543ac7c59212dc3c5cdd7f6d12acd.json b/.sqlx/query-dc5c523ce1b6632b978bd1eb749d3ed6b4954f9648c5ec4a77b2f3d5ed83dd6c.json similarity index 55% rename from .sqlx/query-f91b50a1ce610a5864acc4d559a48e70304543ac7c59212dc3c5cdd7f6d12acd.json rename to .sqlx/query-dc5c523ce1b6632b978bd1eb749d3ed6b4954f9648c5ec4a77b2f3d5ed83dd6c.json index 5bb21544..1ac05f9d 100644 --- a/.sqlx/query-f91b50a1ce610a5864acc4d559a48e70304543ac7c59212dc3c5cdd7f6d12acd.json +++ b/.sqlx/query-dc5c523ce1b6632b978bd1eb749d3ed6b4954f9648c5ec4a77b2f3d5ed83dd6c.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO cdn_auth_tokens (\n token, shared_profiles_id, user_id, created, expires\n )\n VALUES (\n $1, $2, $3, $4, $5\n )\n ", + "query": "\n INSERT INTO cdn_auth_tokens (\n token, shared_profile_id, user_id, created, expires\n )\n VALUES (\n $1, $2, $3, $4, $5\n )\n ", "describe": { "columns": [], "parameters": { @@ -14,5 +14,5 @@ }, "nullable": [] }, - "hash": "f91b50a1ce610a5864acc4d559a48e70304543ac7c59212dc3c5cdd7f6d12acd" + "hash": "dc5c523ce1b6632b978bd1eb749d3ed6b4954f9648c5ec4a77b2f3d5ed83dd6c" } diff --git a/.sqlx/query-fb3a5fad9a94c4323446b4752ba426d1398560166b6c9796fe13821b70dd6a78.json b/.sqlx/query-fb3a5fad9a94c4323446b4752ba426d1398560166b6c9796fe13821b70dd6a78.json new file mode 100644 index 00000000..13867556 --- /dev/null +++ b/.sqlx/query-fb3a5fad9a94c4323446b4752ba426d1398560166b6c9796fe13821b70dd6a78.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM shared_profiles\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "fb3a5fad9a94c4323446b4752ba426d1398560166b6c9796fe13821b70dd6a78" +} diff --git a/migrations/20231226012200_shared_modpacks.sql b/migrations/20231226012200_shared_modpacks.sql index ec21c0c6..c7627cb0 100644 --- a/migrations/20231226012200_shared_modpacks.sql +++ b/migrations/20231226012200_shared_modpacks.sql @@ -50,11 +50,11 @@ CREATE INDEX shared_profiles_links_link_idx ON shared_profiles_links(link); -- generated tokens for downloading files CREATE TABLE cdn_auth_tokens ( token varchar(255) PRIMARY KEY, - shared_profiles_id bigint NOT NULL REFERENCES shared_profiles(id), + shared_profile_id bigint NOT NULL REFERENCES shared_profiles(id), user_id bigint NOT NULL REFERENCES users(id), created timestamptz NOT NULL DEFAULT now(), expires timestamptz NOT NULL, -- unique combinations of shared_profiles_links_id and user_id - CONSTRAINT cdn_auth_tokens_unique UNIQUE (shared_profiles_id, user_id) + CONSTRAINT cdn_auth_tokens_unique UNIQUE (shared_profile_id, user_id) ); \ No newline at end of file diff --git a/src/database/models/minecraft_profile_item.rs b/src/database/models/minecraft_profile_item.rs index f3688f26..2f774be6 100644 --- a/src/database/models/minecraft_profile_item.rs +++ b/src/database/models/minecraft_profile_item.rs @@ -96,7 +96,7 @@ impl MinecraftProfile { sqlx::query!( " DELETE FROM cdn_auth_tokens - WHERE shared_profiles_id = $1 + WHERE shared_profile_id = $1 ", id as MinecraftProfileId, ) @@ -145,6 +145,16 @@ impl MinecraftProfile { .execute(&mut **transaction) .await?; + sqlx::query!( + " + DELETE FROM shared_profiles + WHERE id = $1 + ", + id as MinecraftProfileId, + ) + .execute(&mut **transaction) + .await?; + MinecraftProfile::clear_cache(id, redis).await?; Ok(Some(())) @@ -239,7 +249,7 @@ impl MinecraftProfile { let db_profiles: Vec = sqlx::query!( " SELECT sp.id, sp.name, sp.owner_id, sp.icon_url, sp.created, sp.updated, sp.game_version_id, sp.loader_id, l.loader, sp.loader_version, sp.maximum_users, - ARRAY_AGG(spu.user_id) as users + ARRAY_AGG(DISTINCT spu.user_id) filter (WHERE spu.user_id IS NOT NULL) as users FROM shared_profiles sp LEFT JOIN loaders l ON l.id = sp.loader_id LEFT JOIN shared_profiles_users spu ON spu.shared_profile_id = sp.id @@ -438,7 +448,7 @@ impl MinecraftProfileLink { #[derive(Clone, Debug, Serialize, Deserialize)] pub struct MinecraftProfileLinkToken { pub token: String, - pub shared_profiles_id: MinecraftProfileId, + pub shared_profile_id: MinecraftProfileId, pub user_id: UserId, pub created: DateTime, pub expires: DateTime, @@ -452,14 +462,14 @@ impl MinecraftProfileLinkToken { sqlx::query!( " INSERT INTO cdn_auth_tokens ( - token, shared_profiles_id, user_id, created, expires + token, shared_profile_id, user_id, created, expires ) VALUES ( $1, $2, $3, $4, $5 ) ", self.token, - self.shared_profiles_id.0, + self.shared_profile_id.0, self.user_id.0, self.created, self.expires, @@ -481,7 +491,7 @@ impl MinecraftProfileLinkToken { let token = sqlx::query!( " - SELECT token, user_id, shared_profiles_id, created, expires + SELECT token, user_id, shared_profile_id, created, expires FROM cdn_auth_tokens cat WHERE cat.token = $1 ", @@ -492,7 +502,7 @@ impl MinecraftProfileLinkToken { .map(|m| MinecraftProfileLinkToken { token: m.token, user_id: UserId(m.user_id), - shared_profiles_id: MinecraftProfileId(m.shared_profiles_id), + shared_profile_id: MinecraftProfileId(m.shared_profile_id), created: m.created, expires: m.expires, }); @@ -513,10 +523,10 @@ impl MinecraftProfileLinkToken { let token = sqlx::query!( " - SELECT cat.token, cat.user_id, cat.shared_profiles_id, cat.created, cat.expires + SELECT cat.token, cat.user_id, cat.shared_profile_id, cat.created, cat.expires FROM cdn_auth_tokens cat - INNER JOIN shared_profiles_links spl ON spl.id = cat.shared_profiles_id - WHERE spl.id = $1 AND cat.user_id = $2 + INNER JOIN shared_profiles sp ON sp.id = cat.shared_profile_id + WHERE sp.id = $1 AND cat.user_id = $2 ", profile_id.0, user_id.0 @@ -526,7 +536,7 @@ impl MinecraftProfileLinkToken { .map(|m| MinecraftProfileLinkToken { token: m.token, user_id: UserId(m.user_id), - shared_profiles_id: MinecraftProfileId(m.shared_profiles_id), + shared_profile_id: MinecraftProfileId(m.shared_profile_id), created: m.created, expires: m.expires, }); @@ -558,7 +568,7 @@ impl MinecraftProfileLinkToken { sqlx::query!( " DELETE FROM cdn_auth_tokens - WHERE shared_profiles_id = $1 + WHERE shared_profile_id = $1 ", shared_profile_id.0 ) diff --git a/src/models/v3/minecraft/profile.rs b/src/models/v3/minecraft/profile.rs index d081d99f..2665d7dd 100644 --- a/src/models/v3/minecraft/profile.rs +++ b/src/models/v3/minecraft/profile.rs @@ -29,7 +29,7 @@ pub struct MinecraftProfile { pub name: String, /// The date at which the project was first created. pub created: DateTime, - /// The date at which the project was last updated. + /// The date at which the project was last updated (versions/override were added/removed) pub updated: DateTime, /// The icon of the project. pub icon_url: Option, diff --git a/src/routes/v3/minecraft/profiles.rs b/src/routes/v3/minecraft/profiles.rs index d0ab47f9..f5606337 100644 --- a/src/routes/v3/minecraft/profiles.rs +++ b/src/routes/v3/minecraft/profiles.rs @@ -8,7 +8,7 @@ use crate::database::models::{ use crate::database::redis::RedisPool; use crate::file_hosting::FileHost; use crate::models::ids::base62_impl::parse_base62; -use crate::models::ids::VersionId; +use crate::models::ids::{UserId, VersionId}; use crate::models::minecraft::profile::{ MinecraftProfile, MinecraftProfileId, MinecraftProfileShareLink, DEFAULT_PROFILE_MAX_USERS, }; @@ -48,6 +48,10 @@ pub fn config(cfg: &mut web::ServiceConfig) { "{id}/override", web::post().to(minecraft_profile_add_override), ) + .route( + "{id}/override", + web::delete().to(minecraft_profile_remove_overrides), + ) .route("{id}/share", web::get().to(profile_share)) .route( "{id}/share/{url_identifier}", @@ -269,6 +273,9 @@ pub struct EditMinecraftProfile { pub game_version: Option, // The list of versions to include in the profile (does not include overrides) pub versions: Option>, + + // You can remove users from your invite list here + pub remove_users: Option>, } // Edit a minecraft profile @@ -400,10 +407,44 @@ pub async fn profile_edit( .execute(&mut *transaction) .await?; } + + // Set updated + sqlx::query!( + " + UPDATE shared_profiles + SET updated = NOW() + WHERE id = $1 + ", + data.id.0, + ) + .execute(&mut *transaction) + .await?; + } + if let Some(remove_users) = edit_data.remove_users { + for user in remove_users { + // Remove user from list + sqlx::query!( + "DELETE FROM shared_profiles_users WHERE shared_profile_id = $1 AND user_id = $2", + data.id.0 as i64, + user.0 as i64 + ) + .execute(&mut *transaction) + .await?; + + // In addition, invalidate tokens for this user and profile + sqlx::query!( + "DELETE FROM cdn_auth_tokens WHERE shared_profile_id = $1 AND user_id = $2", + data.id.0 as i64, + user.0 as i64 + ) + .execute(&mut *transaction) + .await?; + } } + transaction.commit().await?; minecraft_profile_item::MinecraftProfile::clear_cache(data.id, &redis).await?; - return Ok(HttpResponse::Ok().finish()); + return Ok(HttpResponse::NoContent().finish()); } else { return Err(ApiError::CustomAuthentication( "You are not the owner of this profile".to_string(), @@ -449,7 +490,12 @@ pub async fn profile_delete( .await?; transaction.commit().await?; minecraft_profile_item::MinecraftProfile::clear_cache(data.id, &redis).await?; - return Ok(HttpResponse::Ok().finish()); + return Ok(HttpResponse::NoContent().finish()); + } else if data.users.contains(&user_option.1.id.into()) { + // We know it exists, but still can't delete it + return Err(ApiError::CustomAuthentication( + "You are not the owner of this profile".to_string(), + )); } } @@ -730,7 +776,7 @@ pub async fn profile_download( .take(32) .map(char::from) .collect::(), - shared_profiles_id: profile.id, + shared_profile_id: profile.id, created: Utc::now(), expires: Utc::now() + chrono::Duration::minutes(5), }; @@ -789,7 +835,7 @@ pub async fn profile_token_check( // Get valid urls for the profile let profile = database::models::minecraft_profile_item::MinecraftProfile::get( - token.shared_profiles_id, + token.shared_profile_id, &**pool, &redis, ) @@ -1133,6 +1179,18 @@ pub async fn minecraft_profile_add_override( .execute(&mut *transaction) .await?; + // Set updated + sqlx::query!( + " + UPDATE shared_profiles + SET updated = NOW() + WHERE id = $1 + ", + profile_item.id.0, + ) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; database::models::minecraft_profile_item::MinecraftProfile::clear_cache( @@ -1143,3 +1201,128 @@ pub async fn minecraft_profile_add_override( Ok(HttpResponse::NoContent().body("")) } + +#[derive(Serialize, Deserialize)] +pub struct RemoveOverrides { + // Either will work, or some combination, to identify the overrides to remove + pub install_paths: Option>, + pub hashes: Option>, +} + +pub async fn minecraft_profile_remove_overrides( + req: HttpRequest, + client_id: web::Path, + pool: web::Data, + data: web::Json, + redis: web::Data, + file_host: web::Data>, + session_queue: web::Data, +) -> Result { + let client_id = client_id.into_inner(); + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::MINECRAFT_PROFILE_WRITE]), + ) + .await? + .1; + + // Check if this is our profile + let profile_item = database::models::minecraft_profile_item::MinecraftProfile::get( + client_id.into(), + &**pool, + &redis, + ) + .await? + .ok_or_else(|| { + CreateError::InvalidInput("The specified profile does not exist!".to_string()) + })?; + + if !user.role.is_mod() && profile_item.owner_id != user.id.into() { + return Err(CreateError::CustomAuthenticationError( + "You don't have permission to remove overrides.".to_string(), + )); + } + + let delete_hashes = data.hashes.clone().unwrap_or_default(); + let delete_install_paths = data.install_paths.clone().unwrap_or_default(); + + let overrides = profile_item + .overrides + .into_iter() + .filter(|(hash, path)| delete_hashes.contains(hash) || delete_install_paths.contains(path)) + .collect::>(); + + let delete_hashes = overrides.iter().map(|x| x.0.clone()).collect::>(); + let delete_install_paths = overrides + .iter() + .map(|x| x.1.to_string_lossy().to_string()) + .collect::>(); + + let mut transaction = pool.begin().await?; + let deleted_hashes = sqlx::query!( + " + DELETE FROM shared_profiles_mods + WHERE (shared_profile_id = $1 AND (file_hash = ANY($2::text[]) OR install_path = ANY($3::text[]))) + RETURNING file_hash + ", + profile_item.id.0, + &delete_hashes[..], + &delete_install_paths[..], + ) + .fetch_all(&mut *transaction) + .await?.into_iter().filter_map(|x| x.file_hash).collect::>(); + + let still_existing_hashes = sqlx::query!( + " + SELECT file_hash FROM shared_profiles_mods + WHERE file_hash = ANY($1::text[]) + ", + &deleted_hashes[..], + ) + .fetch_all(&mut *transaction) + .await? + .into_iter() + .filter_map(|x| x.file_hash) + .collect::>(); + + // Set updated + sqlx::query!( + " + UPDATE shared_profiles + SET updated = NOW() + WHERE id = $1 + ", + profile_item.id.0, + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + + // We want to delete files from the server that are no longer used by any profile + let hashes_to_delete = deleted_hashes + .into_iter() + .filter(|x| !still_existing_hashes.contains(x)) + .collect::>(); + let hashes_to_delete = hashes_to_delete + .iter() + .map(|x| x.as_str()) + .collect::>(); + + for hash in hashes_to_delete { + file_host + .delete_file_version("", &format!("custom_files/{}", hash)) + .await?; + } + + database::models::minecraft_profile_item::MinecraftProfile::clear_cache( + profile_item.id, + &redis, + ) + .await?; + + Ok(HttpResponse::NoContent().body("")) +} diff --git a/tests/common/api_v3/minecraft_profile.rs b/tests/common/api_v3/minecraft_profile.rs index 857e8da7..c0dc40c5 100644 --- a/tests/common/api_v3/minecraft_profile.rs +++ b/tests/common/api_v3/minecraft_profile.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use actix_web::{ dev::ServiceResponse, test::{self, TestRequest}, @@ -57,6 +59,7 @@ impl ApiV3 { self.call(req).await } + #[allow(clippy::too_many_arguments)] pub async fn edit_minecraft_profile( &self, id: &str, @@ -64,6 +67,7 @@ impl ApiV3 { loader: Option<&str>, loader_version: Option<&str>, versions: Option>, + remove_users: Option>, pat: Option<&str>, ) -> ServiceResponse { let req = test::TestRequest::patch() @@ -73,7 +77,8 @@ impl ApiV3 { "name": name, "loader": loader, "loader_version": loader_version, - "versions": versions + "versions": versions, + "remove_users": remove_users })) .to_request(); self.call(req).await @@ -97,6 +102,14 @@ impl ApiV3 { test::read_body_json(resp).await } + pub async fn delete_minecraft_profile(&self, id: &str, pat: Option<&str>) -> ServiceResponse { + let req = TestRequest::delete() + .uri(&format!("/v3/minecraft/profile/{}", id)) + .append_pat(pat) + .to_request(); + self.call(req).await + } + pub async fn edit_minecraft_profile_icon( &self, id: &str, @@ -159,17 +172,19 @@ impl ApiV3 { self.call(req).await } - pub async fn delete_minecraft_profile_override( + pub async fn delete_minecraft_profile_overrides( &self, id: &str, - file_name: &str, + install_paths: Option<&[&PathBuf]>, + hashes: Option<&[&str]>, pat: Option<&str>, ) -> ServiceResponse { let req = TestRequest::delete() - .uri(&format!( - "/v3/minecraft/profile/{}/overrides/{}", - id, file_name - )) + .uri(&format!("/v3/minecraft/profile/{}/override", id)) + .set_json(json!({ + "install_paths": install_paths, + "hashes": hashes + })) .append_pat(pat) .to_request(); self.call(req).await @@ -261,6 +276,7 @@ impl ApiV3 { pat: Option<&str>, ) -> ProfileDownload { let resp = self.download_minecraft_profile(profile_id, pat).await; + println!("{:#?}", resp.response().body()); assert_eq!(resp.status(), 200); test::read_body_json(resp).await } diff --git a/tests/profiles.rs b/tests/profiles.rs index 6951c2ce..f52ca89a 100644 --- a/tests/profiles.rs +++ b/tests/profiles.rs @@ -77,6 +77,7 @@ async fn create_modify_profile() { let profile = api .get_minecraft_profile_deserialized(&id, USER_USER_PAT) .await; + let updated = profile.updated; // Save this- it will update when we modify the versions/overrides assert_eq!(profile.name, "test"); assert_eq!(profile.loader, "fabric"); @@ -92,6 +93,7 @@ async fn create_modify_profile() { Some("fake-loader"), None, None, + None, USER_USER_PAT, ) .await; @@ -117,6 +119,7 @@ async fn create_modify_profile() { Some("fabric"), None, Some(vec!["unparseable-version"]), + None, USER_USER_PAT, ) .await; @@ -130,6 +133,7 @@ async fn create_modify_profile() { Some("fabric"), None, None, + None, FRIEND_USER_PAT, ) .await; @@ -144,6 +148,7 @@ async fn create_modify_profile() { assert_eq!(profile.loader_version, "1.0.0"); assert_eq!(profile.versions, vec![]); assert_eq!(profile.icon_url, None); + assert_eq!(profile.updated, updated); // A successful modification let resp = api @@ -153,10 +158,11 @@ async fn create_modify_profile() { Some("forge"), Some("1.0.1"), Some(vec![&alpha_version_id]), + None, USER_USER_PAT, ) .await; - assert_eq!(resp.status(), 200); + assert_eq!(resp.status(), 204); // Get the profile and check the properties let profile = api @@ -167,6 +173,8 @@ async fn create_modify_profile() { assert_eq!(profile.loader_version, "1.0.1"); assert_eq!(profile.versions, vec![alpha_version_id_parsed]); assert_eq!(profile.icon_url, None); + assert!(profile.updated > updated); + let updated = profile.updated; // Modify the profile again let resp = api @@ -176,10 +184,11 @@ async fn create_modify_profile() { Some("fabric"), Some("1.0.0"), Some(vec![]), + None, USER_USER_PAT, ) .await; - assert_eq!(resp.status(), 200); + assert_eq!(resp.status(), 204); // Get the profile and check the properties let profile = api @@ -191,6 +200,7 @@ async fn create_modify_profile() { assert_eq!(profile.loader_version, "1.0.0"); assert_eq!(profile.versions, vec![]); assert_eq!(profile.icon_url, None); + assert!(profile.updated > updated); }) .await; } @@ -275,6 +285,85 @@ async fn accept_share_link() { .await; } +#[actix_rt::test] +async fn delete_profile() { + with_test_environment(None, |test_env: TestEnvironment| async move { + // They should expire after a time + let api = &test_env.api; + + let alpha_version_id = &test_env.dummy.project_alpha.version_id.to_string(); + + // Create a simple profile + let profile = api + .create_minecraft_profile( + "test", + "fabric", + "1.0.0", + "1.20.1", + vec![alpha_version_id], + USER_USER_PAT, + ) + .await; + assert_eq!(profile.status(), 200); + let profile: MinecraftProfile = test::read_body_json(profile).await; + let id = profile.id.to_string(); + + // Add an override file to the profile + let resp = api + .add_minecraft_profile_overrides( + &id, + vec![MinecraftProfileOverride::new( + TestFile::BasicMod, + "mods/test.jar", + )], + USER_USER_PAT, + ) + .await; + assert_eq!(resp.status(), 204); + + // Invite a friend to the profile and accept it + let share_link = api + .generate_minecraft_profile_share_link_deserialized(&id, USER_USER_PAT) + .await; + let resp = api + .accept_minecraft_profile_share_link(&id, &share_link.url_identifier, FRIEND_USER_PAT) + .await; + assert_eq!(resp.status(), 204); + + // Get a token as the friend + let token = api + .download_minecraft_profile_deserialized(&id, FRIEND_USER_PAT) + .await; + + // Confirm it works + let resp = api + .check_download_minecraft_profile_token(&token.auth_token, &token.override_cdns[0].0) + .await; + assert_eq!(resp.status(), 200); + + // Delete the profile as the friend + // Should fail + let resp = api.delete_minecraft_profile(&id, FRIEND_USER_PAT).await; + assert_eq!(resp.status(), 401); + + // Delete the profile as the user + // Should succeed + let resp = api.delete_minecraft_profile(&id, USER_USER_PAT).await; + assert_eq!(resp.status(), 204); + + // Confirm the profile is gone + let resp = api.get_minecraft_profile(&id, USER_USER_PAT).await; + assert_eq!(resp.status(), 404); + + // Confirm the token is gone + let resp = api + .check_download_minecraft_profile_token(&token.auth_token, &token.override_cdns[0].0) + .await; + assert_eq!(resp.status(), 401); + }) + .await; +} + #[actix_rt::test] async fn download_profile() { with_test_environment(None, |test_env: TestEnvironment| async move { @@ -377,10 +466,46 @@ async fn download_profile() { // Check cloudflare helper route to confirm this is a valid allowable access token // We attach it as an authorization token and call the route - let download = api + let resp = api .check_download_minecraft_profile_token(&download.auth_token, &override_file_url) .await; - assert_eq!(download.status(), 200); + assert_eq!(resp.status(), 200); + + // As user, remove friend from profile + let resp = api + .edit_minecraft_profile( + &id, + None, + None, + None, + None, + Some(vec![FRIEND_USER_ID]), + USER_USER_PAT, + ) + .await; + assert_eq!(resp.status(), 204); + + // Confirm friend is no longer on the profile + let profile = api + .get_minecraft_profile_deserialized(&id, USER_USER_PAT) + .await; + assert_eq!(profile.users.unwrap().len(), 1); + + // Confirm friend can no longer download the profile + let resp = api.download_minecraft_profile(&id, FRIEND_USER_PAT).await; + assert_eq!(resp.status(), 401); + + // Confirm token invalidation + let resp = api + .check_download_minecraft_profile_token(&download.auth_token, &override_file_url) + .await; + assert_eq!(resp.status(), 401); + + // Confirm user can still download the profile + let resp = api + .download_minecraft_profile_deserialized(&id, USER_USER_PAT) + .await; + assert_eq!(resp.override_cdns.len(), 1); }) .await; } @@ -441,6 +566,7 @@ async fn add_remove_profile_versions() { .await; assert_eq!(profile.status(), 200); let profile: MinecraftProfile = test::read_body_json(profile).await; + let updated = profile.updated; // Save this- it will update when we modify the versions/overrides // Add a hosted version to the profile let resp = api @@ -450,10 +576,11 @@ async fn add_remove_profile_versions() { None, None, Some(vec![&alpha_version_id]), + None, USER_USER_PAT, ) .await; - assert_eq!(resp.status(), 200); + assert_eq!(resp.status(), 204); // Add an override file to the profile let resp = api @@ -468,6 +595,19 @@ async fn add_remove_profile_versions() { .await; assert_eq!(resp.status(), 204); + // Add a second version to the profile + let resp = api + .add_minecraft_profile_overrides( + &profile.id.to_string(), + vec![MinecraftProfileOverride::new( + TestFile::BasicModDifferent, + "mods/test_different.jar", + )], + USER_USER_PAT, + ) + .await; + assert_eq!(resp.status(), 204); + // Get the profile and check the versions let profile = api .get_minecraft_profile_deserialized(&profile.id.to_string(), USER_USER_PAT) @@ -478,10 +618,158 @@ async fn add_remove_profile_versions() { ); assert_eq!( profile.override_install_paths, + vec![ + PathBuf::from("mods/test.jar"), + PathBuf::from("mods/test_different.jar") + ] + ); + assert!(profile.updated > updated); + let updated = profile.updated; + + // Create a second profile using the same hashes, but ENEMY_USER_PAT + let profile_enemy = api + .create_minecraft_profile("test2", "fabric", "1.0.0", "1.20.1", vec![], ENEMY_USER_PAT) + .await; + assert_eq!(profile_enemy.status(), 200); + let profile_enemy: MinecraftProfile = test::read_body_json(profile_enemy).await; + let id_enemy = profile_enemy.id.to_string(); + + // Add the same override to the profile + let resp = api + .add_minecraft_profile_overrides( + &id_enemy, + vec![MinecraftProfileOverride::new( + TestFile::BasicMod, + "mods/test.jar", + )], + ENEMY_USER_PAT, + ) + .await; + assert_eq!(resp.status(), 204); + + // Get the profile and check the versions + let profile_enemy = api + .get_minecraft_profile_deserialized(&id_enemy, ENEMY_USER_PAT) + .await; + assert_eq!( + profile_enemy.override_install_paths, vec![PathBuf::from("mods/test.jar")] ); - // + // Attempt to delete the override test.jar from the user's profile + // Should succeed + let resp = api + .delete_minecraft_profile_overrides( + &profile.id.to_string(), + Some(&[&PathBuf::from("mods/test.jar")]), + None, + USER_USER_PAT, + ) + .await; + assert_eq!(resp.status(), 204); + + // Should still exist in the enemy's profile, but not the user's + let profile_enemy = api + .get_minecraft_profile_deserialized(&id_enemy, ENEMY_USER_PAT) + .await; + assert_eq!( + profile_enemy.override_install_paths, + vec![PathBuf::from("mods/test.jar")] + ); + + let profile = api + .get_minecraft_profile_deserialized(&profile.id.to_string(), USER_USER_PAT) + .await; + assert_eq!( + profile.override_install_paths, + vec![PathBuf::from("mods/test_different.jar")] + ); + assert!(profile.updated > updated); + let updated = profile.updated; + + // TODO: put a test here for confirming the file's existence once tests are set up to do so + // The file should still exist in the CDN here, as the enemy still has it + + // Attempt to delete the override test_different.jar from the enemy's profile (One they don't have) + // Should fail + // First, by path + let resp = api + .delete_minecraft_profile_overrides( + &id_enemy, + Some(&[&PathBuf::from("mods/test_different.jar")]), + None, + ENEMY_USER_PAT, + ) + .await; + assert_eq!(resp.status(), 204); // Allow failure, it just doesn't delete anything + + // Then, by hash + let resp = api + .delete_minecraft_profile_overrides( + &id_enemy, + None, + Some(&[sha1::Sha1::from(TestFile::BasicModDifferent.bytes()) + .hexdigest() + .as_str()]), + ENEMY_USER_PAT, + ) + .await; + assert_eq!(resp.status(), 204); // Allow failure, it just doesn't delete anything + + // Confirm user still has it + let profile = api + .get_minecraft_profile_deserialized(&profile.id.to_string(), USER_USER_PAT) + .await; + assert_eq!( + profile.override_install_paths, + vec![PathBuf::from("mods/test_different.jar")] + ); + + // TODO: put a test here for confirming the file's existence once tests are set up to do so + // The file should still exist in the CDN here, as the enemy can't delete it + + // Now delete the override test_different.jar from the user's profile (by hash this time) + // Should succeed + let resp = api + .delete_minecraft_profile_overrides( + &profile.id.to_string(), + None, + Some(&[sha1::Sha1::from(TestFile::BasicModDifferent.bytes()) + .hexdigest() + .as_str()]), + USER_USER_PAT, + ) + .await; + assert_eq!(resp.status(), 204); + + // Confirm user no longer has it + let profile = api + .get_minecraft_profile_deserialized(&profile.id.to_string(), USER_USER_PAT) + .await; + assert_eq!(profile.override_install_paths, Vec::::new()); + assert!(profile.updated > updated); + + // In addition, delete "alpha_version_id" from the user's profile + // Should succeed + let resp = api + .edit_minecraft_profile( + &profile.id.to_string(), + None, + None, + None, + Some(vec![]), + None, + USER_USER_PAT, + ) + .await; + assert_eq!(resp.status(), 204); + + // Confirm user no longer has it + let profile = api + .get_minecraft_profile_deserialized(&profile.id.to_string(), USER_USER_PAT) + .await; + assert_eq!(profile.versions, vec![]); + // TODO: version deletion -> affects these (including an 'updated'!) }) .await; } @@ -521,10 +809,11 @@ async fn hidden_versions_are_forbidden() { None, None, Some(vec![&beta_version_id]), + None, FRIEND_USER_PAT, ) .await; - assert_eq!(resp.status(), 200); + assert_eq!(resp.status(), 204); // Get the profile and check the versions // Empty, because alpha is removed, and beta is not visible From abce9c1cf7ba5ad252724bb6721e24684fb4ec98 Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Fri, 29 Dec 2023 17:38:17 -0800 Subject: [PATCH 08/25] sha and upload limit --- src/routes/v3/minecraft/profiles.rs | 12 ++++++++---- tests/profiles.rs | 19 ++++++++++++------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/routes/v3/minecraft/profiles.rs b/src/routes/v3/minecraft/profiles.rs index f5606337..8ad525b9 100644 --- a/src/routes/v3/minecraft/profiles.rs +++ b/src/routes/v3/minecraft/profiles.rs @@ -29,6 +29,7 @@ use rand::distributions::Alphanumeric; use rand::{Rng, SeedableRng}; use rand_chacha::ChaCha20Rng; use serde::{Deserialize, Serialize}; +use sha2::Digest; use sqlx::PgPool; use std::path::PathBuf; use std::sync::Arc; @@ -916,7 +917,7 @@ pub async fn profile_icon_edit( let color = crate::util::img::get_color_from_img(&bytes)?; - let hash = sha1::Sha1::from(&bytes).hexdigest(); + let hash = format!("{:x}", sha2::Sha512::digest(&bytes)); let id: MinecraftProfileId = profile_item.id.into(); let upload_data = file_host .upload_file( @@ -1122,8 +1123,11 @@ pub async fn minecraft_profile_add_override( CreateError::InvalidInput(String::from("Upload must have a name")) })?; - let data = - read_from_field(&mut field, 262144, "Icons must be smaller than 256KiB").await?; + let data = read_from_field( + &mut field, 500 * (1 << 20), + "Project file exceeds the maximum of 500MiB. Contact a moderator or admin to request permission to upload larger files." + ).await?; + let install_path = files .iter() .find(|x| x.file_name == name) @@ -1136,7 +1140,7 @@ pub async fn minecraft_profile_add_override( .install_path .clone(); - let hash = sha1::Sha1::from(&data).hexdigest(); + let hash = format!("{:x}", sha2::Sha512::digest(&data)); file_host .upload_file( diff --git a/tests/profiles.rs b/tests/profiles.rs index f52ca89a..0d9e3193 100644 --- a/tests/profiles.rs +++ b/tests/profiles.rs @@ -7,6 +7,7 @@ use common::environment::with_test_environment; use common::environment::TestEnvironment; use labrinth::models::minecraft::profile::MinecraftProfile; use labrinth::models::users::UserId; +use sha2::Digest; use crate::common::api_v3::minecraft_profile::MinecraftProfileOverride; use crate::common::dummy_data::DummyImage; @@ -426,7 +427,7 @@ async fn download_profile() { // - hash assert_eq!(download.override_cdns.len(), 1); let override_file_url = download.override_cdns.remove(0).0; - let hash = sha1::Sha1::from(TestFile::BasicMod.bytes()).hexdigest(); + let hash = format!("{:x}", sha2::Sha512::digest(&TestFile::BasicMod.bytes())); assert_eq!( override_file_url, format!("{}/custom_files/{}", dotenvy::var("CDN_URL").unwrap(), hash) @@ -708,9 +709,11 @@ async fn add_remove_profile_versions() { .delete_minecraft_profile_overrides( &id_enemy, None, - Some(&[sha1::Sha1::from(TestFile::BasicModDifferent.bytes()) - .hexdigest() - .as_str()]), + Some(&[format!( + "{:x}", + sha2::Sha512::digest(&TestFile::BasicModDifferent.bytes()) + ) + .as_str()]), ENEMY_USER_PAT, ) .await; @@ -734,9 +737,11 @@ async fn add_remove_profile_versions() { .delete_minecraft_profile_overrides( &profile.id.to_string(), None, - Some(&[sha1::Sha1::from(TestFile::BasicModDifferent.bytes()) - .hexdigest() - .as_str()]), + Some(&[format!( + "{:x}", + sha2::Sha512::digest(&TestFile::BasicModDifferent.bytes()) + ) + .as_str()]), USER_USER_PAT, ) .await; From 65d8a0afb440a10048ac7bc53e427bd5cfad5484 Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Fri, 29 Dec 2023 17:41:24 -0800 Subject: [PATCH 09/25] todo --- src/database/models/minecraft_profile_item.rs | 2 -- src/routes/v3/minecraft/profiles.rs | 2 +- tests/profiles.rs | 1 - 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/database/models/minecraft_profile_item.rs b/src/database/models/minecraft_profile_item.rs index 2f774be6..3d85a877 100644 --- a/src/database/models/minecraft_profile_item.rs +++ b/src/database/models/minecraft_profile_item.rs @@ -412,8 +412,6 @@ impl MinecraftProfileLink { Ok(link) } - // TODO: DELETE in here needs to clear all fields as well to prevent orphaned data - pub async fn get_url<'a, 'b, E>( url_identifier: &str, executor: E, diff --git a/src/routes/v3/minecraft/profiles.rs b/src/routes/v3/minecraft/profiles.rs index 8ad525b9..14ce3443 100644 --- a/src/routes/v3/minecraft/profiles.rs +++ b/src/routes/v3/minecraft/profiles.rs @@ -800,7 +800,7 @@ pub async fn profile_download( #[derive(Deserialize)] pub struct TokenUrl { - pub url: String, // TODO: could take a vec instead? + pub url: String, // TODO: Could take a vec instead for mass checking- revisit after cloudflare worker is done } // Used by cloudflare to check headers and permit CDN downloads for a pack diff --git a/tests/profiles.rs b/tests/profiles.rs index 0d9e3193..6bf69922 100644 --- a/tests/profiles.rs +++ b/tests/profiles.rs @@ -774,7 +774,6 @@ async fn add_remove_profile_versions() { .get_minecraft_profile_deserialized(&profile.id.to_string(), USER_USER_PAT) .await; assert_eq!(profile.versions, vec![]); - // TODO: version deletion -> affects these (including an 'updated'!) }) .await; } From 2739d62e080e28359ea9507356ae804a33960e0a Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Tue, 2 Jan 2024 17:40:43 -0800 Subject: [PATCH 10/25] simplifies- only uses user token --- ...fe8080901b2492e554631e16d88197fa89d9a.json | 46 ----- ...dea6fa37938d302cd8ac291d483a670a4206b.json | 47 ----- ...7f29025b1b022b5a977e1e8802b14817daf71.json | 15 -- ...e740e8b116a83d029889e8a10a232f5abf7b4.json | 14 -- ...6d96ae5b60bd5a8bfdd48a005792326795246.json | 14 -- ...d3ed6b4954f9648c5ec4a77b2f3d5ed83dd6c.json | 18 -- ...39e33614c2b53ff3b4c1124d0a2e9da53f9ea.json | 22 +++ migrations/20231226012200_shared_modpacks.sql | 14 +- src/database/models/minecraft_profile_item.rs | 169 +++--------------- src/routes/v3/minecraft/profiles.rs | 109 +++-------- tests/common/api_v3/minecraft_profile.rs | 5 +- tests/profiles.rs | 23 +-- 12 files changed, 82 insertions(+), 414 deletions(-) delete mode 100644 .sqlx/query-3199ae443ed4a0155a199c04da2fe8080901b2492e554631e16d88197fa89d9a.json delete mode 100644 .sqlx/query-4326b7e5a87b9a1c9d9714189d3dea6fa37938d302cd8ac291d483a670a4206b.json delete mode 100644 .sqlx/query-6bb352e420c099ca81aef1912307f29025b1b022b5a977e1e8802b14817daf71.json delete mode 100644 .sqlx/query-99b663374cfb33a34bb3358049fe740e8b116a83d029889e8a10a232f5abf7b4.json delete mode 100644 .sqlx/query-bf6d2b9cbab036a1ca51995b0e96d96ae5b60bd5a8bfdd48a005792326795246.json delete mode 100644 .sqlx/query-dc5c523ce1b6632b978bd1eb749d3ed6b4954f9648c5ec4a77b2f3d5ed83dd6c.json create mode 100644 .sqlx/query-f5e2e1cb44e42aca9ea0edb8b8f39e33614c2b53ff3b4c1124d0a2e9da53f9ea.json diff --git a/.sqlx/query-3199ae443ed4a0155a199c04da2fe8080901b2492e554631e16d88197fa89d9a.json b/.sqlx/query-3199ae443ed4a0155a199c04da2fe8080901b2492e554631e16d88197fa89d9a.json deleted file mode 100644 index 23d634c7..00000000 --- a/.sqlx/query-3199ae443ed4a0155a199c04da2fe8080901b2492e554631e16d88197fa89d9a.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT token, user_id, shared_profile_id, created, expires\n FROM cdn_auth_tokens cat\n WHERE cat.token = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "token", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "user_id", - "type_info": "Int8" - }, - { - "ordinal": 2, - "name": "shared_profile_id", - "type_info": "Int8" - }, - { - "ordinal": 3, - "name": "created", - "type_info": "Timestamptz" - }, - { - "ordinal": 4, - "name": "expires", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - false, - false, - false - ] - }, - "hash": "3199ae443ed4a0155a199c04da2fe8080901b2492e554631e16d88197fa89d9a" -} diff --git a/.sqlx/query-4326b7e5a87b9a1c9d9714189d3dea6fa37938d302cd8ac291d483a670a4206b.json b/.sqlx/query-4326b7e5a87b9a1c9d9714189d3dea6fa37938d302cd8ac291d483a670a4206b.json deleted file mode 100644 index 2dac5f82..00000000 --- a/.sqlx/query-4326b7e5a87b9a1c9d9714189d3dea6fa37938d302cd8ac291d483a670a4206b.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT cat.token, cat.user_id, cat.shared_profile_id, cat.created, cat.expires\n FROM cdn_auth_tokens cat\n INNER JOIN shared_profiles sp ON sp.id = cat.shared_profile_id\n WHERE sp.id = $1 AND cat.user_id = $2\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "token", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "user_id", - "type_info": "Int8" - }, - { - "ordinal": 2, - "name": "shared_profile_id", - "type_info": "Int8" - }, - { - "ordinal": 3, - "name": "created", - "type_info": "Timestamptz" - }, - { - "ordinal": 4, - "name": "expires", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Int8", - "Int8" - ] - }, - "nullable": [ - false, - false, - false, - false, - false - ] - }, - "hash": "4326b7e5a87b9a1c9d9714189d3dea6fa37938d302cd8ac291d483a670a4206b" -} diff --git a/.sqlx/query-6bb352e420c099ca81aef1912307f29025b1b022b5a977e1e8802b14817daf71.json b/.sqlx/query-6bb352e420c099ca81aef1912307f29025b1b022b5a977e1e8802b14817daf71.json deleted file mode 100644 index 10a9708a..00000000 --- a/.sqlx/query-6bb352e420c099ca81aef1912307f29025b1b022b5a977e1e8802b14817daf71.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM cdn_auth_tokens WHERE shared_profile_id = $1 AND user_id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "6bb352e420c099ca81aef1912307f29025b1b022b5a977e1e8802b14817daf71" -} diff --git a/.sqlx/query-99b663374cfb33a34bb3358049fe740e8b116a83d029889e8a10a232f5abf7b4.json b/.sqlx/query-99b663374cfb33a34bb3358049fe740e8b116a83d029889e8a10a232f5abf7b4.json deleted file mode 100644 index c04ad998..00000000 --- a/.sqlx/query-99b663374cfb33a34bb3358049fe740e8b116a83d029889e8a10a232f5abf7b4.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n DELETE FROM cdn_auth_tokens\n WHERE token = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [] - }, - "hash": "99b663374cfb33a34bb3358049fe740e8b116a83d029889e8a10a232f5abf7b4" -} diff --git a/.sqlx/query-bf6d2b9cbab036a1ca51995b0e96d96ae5b60bd5a8bfdd48a005792326795246.json b/.sqlx/query-bf6d2b9cbab036a1ca51995b0e96d96ae5b60bd5a8bfdd48a005792326795246.json deleted file mode 100644 index 3043ea62..00000000 --- a/.sqlx/query-bf6d2b9cbab036a1ca51995b0e96d96ae5b60bd5a8bfdd48a005792326795246.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n DELETE FROM cdn_auth_tokens\n WHERE shared_profile_id = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [] - }, - "hash": "bf6d2b9cbab036a1ca51995b0e96d96ae5b60bd5a8bfdd48a005792326795246" -} diff --git a/.sqlx/query-dc5c523ce1b6632b978bd1eb749d3ed6b4954f9648c5ec4a77b2f3d5ed83dd6c.json b/.sqlx/query-dc5c523ce1b6632b978bd1eb749d3ed6b4954f9648c5ec4a77b2f3d5ed83dd6c.json deleted file mode 100644 index 1ac05f9d..00000000 --- a/.sqlx/query-dc5c523ce1b6632b978bd1eb749d3ed6b4954f9648c5ec4a77b2f3d5ed83dd6c.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO cdn_auth_tokens (\n token, shared_profile_id, user_id, created, expires\n )\n VALUES (\n $1, $2, $3, $4, $5\n )\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Int8", - "Int8", - "Timestamptz", - "Timestamptz" - ] - }, - "nullable": [] - }, - "hash": "dc5c523ce1b6632b978bd1eb749d3ed6b4954f9648c5ec4a77b2f3d5ed83dd6c" -} diff --git a/.sqlx/query-f5e2e1cb44e42aca9ea0edb8b8f39e33614c2b53ff3b4c1124d0a2e9da53f9ea.json b/.sqlx/query-f5e2e1cb44e42aca9ea0edb8b8f39e33614c2b53ff3b4c1124d0a2e9da53f9ea.json new file mode 100644 index 00000000..8a8a29a7 --- /dev/null +++ b/.sqlx/query-f5e2e1cb44e42aca9ea0edb8b8f39e33614c2b53ff3b4c1124d0a2e9da53f9ea.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT sp.id\n FROM shared_profiles sp \n LEFT JOIN shared_profiles_users spu ON spu.shared_profile_id = sp.id\n WHERE spu.user_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "f5e2e1cb44e42aca9ea0edb8b8f39e33614c2b53ff3b4c1124d0a2e9da53f9ea" +} diff --git a/migrations/20231226012200_shared_modpacks.sql b/migrations/20231226012200_shared_modpacks.sql index c7627cb0..46715017 100644 --- a/migrations/20231226012200_shared_modpacks.sql +++ b/migrations/20231226012200_shared_modpacks.sql @@ -45,16 +45,4 @@ CREATE TABLE shared_profiles_users ( ); -- Index off 'link' -CREATE INDEX shared_profiles_links_link_idx ON shared_profiles_links(link); - --- generated tokens for downloading files -CREATE TABLE cdn_auth_tokens ( - token varchar(255) PRIMARY KEY, - shared_profile_id bigint NOT NULL REFERENCES shared_profiles(id), - user_id bigint NOT NULL REFERENCES users(id), - created timestamptz NOT NULL DEFAULT now(), - expires timestamptz NOT NULL, - - -- unique combinations of shared_profiles_links_id and user_id - CONSTRAINT cdn_auth_tokens_unique UNIQUE (shared_profile_id, user_id) -); \ No newline at end of file +CREATE INDEX shared_profiles_links_link_idx ON shared_profiles_links(link); \ No newline at end of file diff --git a/src/database/models/minecraft_profile_item.rs b/src/database/models/minecraft_profile_item.rs index 3d85a877..3069f614 100644 --- a/src/database/models/minecraft_profile_item.rs +++ b/src/database/models/minecraft_profile_item.rs @@ -92,17 +92,6 @@ impl MinecraftProfile { transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, redis: &RedisPool, ) -> Result, DatabaseError> { - // Delete shared_profiles_links_tokens - sqlx::query!( - " - DELETE FROM cdn_auth_tokens - WHERE shared_profile_id = $1 - ", - id as MinecraftProfileId, - ) - .execute(&mut **transaction) - .await?; - // Delete shared_profiles_links sqlx::query!( " @@ -173,6 +162,30 @@ impl MinecraftProfile { .map(|x| x.into_iter().next()) } + pub async fn get_ids_for_user<'a, E>( + user_id: UserId, + exec: E, + ) -> Result, DatabaseError> + where + E: sqlx::Acquire<'a, Database = sqlx::Postgres>, + { + let mut exec = exec.acquire().await?; + let db_profiles: Vec = sqlx::query!( + " + SELECT sp.id + FROM shared_profiles sp + LEFT JOIN shared_profiles_users spu ON spu.shared_profile_id = sp.id + WHERE spu.user_id = $1 + ", + user_id.0 + ) + .fetch_many(&mut *exec) + .try_filter_map(|e| async { Ok(e.right().map(|m| MinecraftProfileId(m.id))) }) + .try_collect::>() + .await?; + Ok(db_profiles) + } + pub async fn get_many<'a, E>( ids: &[MinecraftProfileId], exec: E, @@ -443,140 +456,6 @@ impl MinecraftProfileLink { } } -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct MinecraftProfileLinkToken { - pub token: String, - pub shared_profile_id: MinecraftProfileId, - pub user_id: UserId, - pub created: DateTime, - pub expires: DateTime, -} - -impl MinecraftProfileLinkToken { - pub async fn insert( - &self, - transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, - ) -> Result<(), DatabaseError> { - sqlx::query!( - " - INSERT INTO cdn_auth_tokens ( - token, shared_profile_id, user_id, created, expires - ) - VALUES ( - $1, $2, $3, $4, $5 - ) - ", - self.token, - self.shared_profile_id.0, - self.user_id.0, - self.created, - self.expires, - ) - .execute(&mut **transaction) - .await?; - - Ok(()) - } - - pub async fn get_token<'a, 'b, E>( - token: &str, - executor: E, - ) -> Result, DatabaseError> - where - E: sqlx::Acquire<'a, Database = sqlx::Postgres>, - { - let mut exec = executor.acquire().await?; - - let token = sqlx::query!( - " - SELECT token, user_id, shared_profile_id, created, expires - FROM cdn_auth_tokens cat - WHERE cat.token = $1 - ", - token - ) - .fetch_optional(&mut *exec) - .await? - .map(|m| MinecraftProfileLinkToken { - token: m.token, - user_id: UserId(m.user_id), - shared_profile_id: MinecraftProfileId(m.shared_profile_id), - created: m.created, - expires: m.expires, - }); - - Ok(token) - } - - // Get existing token for profile and user - pub async fn get_from_profile_user<'a, 'b, E>( - profile_id: MinecraftProfileId, - user_id: UserId, - executor: E, - ) -> Result, DatabaseError> - where - E: sqlx::Acquire<'a, Database = sqlx::Postgres>, - { - let mut exec = executor.acquire().await?; - - let token = sqlx::query!( - " - SELECT cat.token, cat.user_id, cat.shared_profile_id, cat.created, cat.expires - FROM cdn_auth_tokens cat - INNER JOIN shared_profiles sp ON sp.id = cat.shared_profile_id - WHERE sp.id = $1 AND cat.user_id = $2 - ", - profile_id.0, - user_id.0 - ) - .fetch_optional(&mut *exec) - .await? - .map(|m| MinecraftProfileLinkToken { - token: m.token, - user_id: UserId(m.user_id), - shared_profile_id: MinecraftProfileId(m.shared_profile_id), - created: m.created, - expires: m.expires, - }); - - Ok(token) - } - - pub async fn delete( - token: &str, - transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, - ) -> Result<(), DatabaseError> { - sqlx::query!( - " - DELETE FROM cdn_auth_tokens - WHERE token = $1 - ", - token - ) - .execute(&mut **transaction) - .await?; - - Ok(()) - } - - pub async fn delete_all( - shared_profile_id: MinecraftProfileId, - transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, - ) -> Result<(), DatabaseError> { - sqlx::query!( - " - DELETE FROM cdn_auth_tokens - WHERE shared_profile_id = $1 - ", - shared_profile_id.0 - ) - .execute(&mut **transaction) - .await?; - - Ok(()) - } -} - pub struct MinecraftProfileOverride { pub file_hash: String, pub url: String, diff --git a/src/routes/v3/minecraft/profiles.rs b/src/routes/v3/minecraft/profiles.rs index 14ce3443..298e4a2e 100644 --- a/src/routes/v3/minecraft/profiles.rs +++ b/src/routes/v3/minecraft/profiles.rs @@ -431,15 +431,6 @@ pub async fn profile_edit( ) .execute(&mut *transaction) .await?; - - // In addition, invalidate tokens for this user and profile - sqlx::query!( - "DELETE FROM cdn_auth_tokens WHERE shared_profile_id = $1 AND user_id = $2", - data.id.0 as i64, - user.0 as i64 - ) - .execute(&mut *transaction) - .await?; } } @@ -684,9 +675,6 @@ pub async fn accept_share_link( #[derive(Serialize, Deserialize)] pub struct ProfileDownload { - // temporary authorization token for the CDN, for downloading the profile files - pub auth_token: String, - // Version ids for modrinth-hosted versions pub version_ids: Vec, @@ -735,64 +723,13 @@ pub async fn profile_download( )); } - let mut transaction = pool.begin().await?; - - // Check no token exists for the username and profile - let existing_token = - database::models::minecraft_profile_item::MinecraftProfileLinkToken::get_from_profile_user( - profile.id, - user_option.1.id.into(), - &mut *transaction, - ) - .await?; - if let Some(token) = existing_token { - // Check if the token is still valid - if token.expires > Utc::now() { - // Simply return the token - transaction.commit().await?; - return Ok(HttpResponse::Ok().json(ProfileDownload { - auth_token: token.token, - version_ids: profile.versions.iter().map(|x| (*x).into()).collect(), - override_cdns: profile - .overrides - .into_iter() - .map(|x| (format!("{}/custom_files/{}", cdn_url, x.0), x.1)) - .collect::>(), - })); - } - - // If we're here, the token is invalid, so delete it, and create a new one if we can - database::models::minecraft_profile_item::MinecraftProfileLinkToken::delete( - &token.token, - &mut transaction, - ) - .await?; - } - - // Create a new cdn auth token - let token = database::models::minecraft_profile_item::MinecraftProfileLinkToken { - user_id: user_option.1.id.into(), // This user is requesting the download - token: ChaCha20Rng::from_entropy() - .sample_iter(&Alphanumeric) - .take(32) - .map(char::from) - .collect::(), - shared_profile_id: profile.id, - created: Utc::now(), - expires: Utc::now() + chrono::Duration::minutes(5), - }; - token.insert(&mut transaction).await?; - let override_cdns = profile .overrides .into_iter() .map(|x| (format!("{}/custom_files/{}", cdn_url, x.0), x.1)) .collect::>(); - transaction.commit().await?; - minecraft_profile_item::MinecraftProfile::clear_cache(profile.id, &redis).await?; Ok(HttpResponse::Ok().json(ProfileDownload { - auth_token: token.token, version_ids: profile.versions.iter().map(|x| (*x).into()).collect(), override_cdns, })) @@ -804,44 +741,46 @@ pub struct TokenUrl { } // Used by cloudflare to check headers and permit CDN downloads for a pack -// Checks headers for 'authorization: xxyyzz' where xxyyzz is a valid token +// Checks headers for 'authorization: xxyyzz' where xxyyzz is a valid user authorization token // that allows for downloading of url 'url' pub async fn profile_token_check( req: HttpRequest, file_url: web::Query, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { let cdn_url = dotenvy::var("CDN_URL")?; let file_url = file_url.into_inner().url; - // Extract token from 'authorization' of headers - let token = req - .headers() - .get("authorization") - .and_then(|h| h.to_str().ok()) - .ok_or_else(|| ApiError::Authentication(AuthenticationError::InvalidAuthMethod))?; - - let token = database::models::minecraft_profile_item::MinecraftProfileLinkToken::get_token( - token, &**pool, + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::MINECRAFT_PROFILE_DOWNLOAD]), ) .await? - .ok_or_else(|| ApiError::Authentication(AuthenticationError::InvalidAuthMethod))?; + .1; - if token.expires <= Utc::now() { - return Err(ApiError::Authentication( - AuthenticationError::InvalidAuthMethod, - )); - } + // Get all profiles for the user + let profile_ids = database::models::minecraft_profile_item::MinecraftProfile::get_ids_for_user( + user.id.into(), + &**pool, + ) + .await?; - // Get valid urls for the profile - let profile = database::models::minecraft_profile_item::MinecraftProfile::get( - token.shared_profile_id, + let profiles = database::models::minecraft_profile_item::MinecraftProfile::get_many( + &profile_ids, &**pool, &redis, ) - .await? - .ok_or_else(|| ApiError::Authentication(AuthenticationError::InvalidAuthMethod))?; + .await?; + + let all_allowed_urls = profiles + .into_iter() + .flat_map(|x| x.overrides.into_iter().map(|x| x.0)) + .collect::>(); // Check the token is valid for the requested file let file_url_hash = file_url @@ -849,7 +788,7 @@ pub async fn profile_token_check( .nth(1) .ok_or_else(|| ApiError::Authentication(AuthenticationError::InvalidAuthMethod))?; - let valid = profile.overrides.iter().any(|x| x.0 == file_url_hash); + let valid = all_allowed_urls.iter().any(|x| x == file_url_hash); if !valid { Err(ApiError::Authentication( AuthenticationError::InvalidAuthMethod, diff --git a/tests/common/api_v3/minecraft_profile.rs b/tests/common/api_v3/minecraft_profile.rs index c0dc40c5..c5f6368c 100644 --- a/tests/common/api_v3/minecraft_profile.rs +++ b/tests/common/api_v3/minecraft_profile.rs @@ -276,22 +276,21 @@ impl ApiV3 { pat: Option<&str>, ) -> ProfileDownload { let resp = self.download_minecraft_profile(profile_id, pat).await; - println!("{:#?}", resp.response().body()); assert_eq!(resp.status(), 200); test::read_body_json(resp).await } pub async fn check_download_minecraft_profile_token( &self, - token: &str, url: &str, // Full URL, the route will parse it + pat: Option<&str>, ) -> ServiceResponse { let req = TestRequest::get() .uri(&format!( "/v3/minecraft/check_token?url={url}", url = urlencoding::encode(url) )) - .append_header(("Authorization", token)) + .append_pat(pat) .to_request(); self.call(req).await } diff --git a/tests/profiles.rs b/tests/profiles.rs index 6bf69922..a804b809 100644 --- a/tests/profiles.rs +++ b/tests/profiles.rs @@ -338,7 +338,7 @@ async fn delete_profile() { // Confirm it works let resp = api - .check_download_minecraft_profile_token(&token.auth_token, &token.override_cdns[0].0) + .check_download_minecraft_profile_token(&token.override_cdns[0].0, FRIEND_USER_PAT) .await; assert_eq!(resp.status(), 200); @@ -358,7 +358,7 @@ async fn delete_profile() { // Confirm the token is gone let resp = api - .check_download_minecraft_profile_token(&token.auth_token, &token.override_cdns[0].0) + .check_download_minecraft_profile_token(&token.override_cdns[0].0, FRIEND_USER_PAT) .await; assert_eq!(resp.status(), 401); }) @@ -433,34 +433,29 @@ async fn download_profile() { format!("{}/custom_files/{}", dotenvy::var("CDN_URL").unwrap(), hash) ); - // Check cloudflare helper route with a bad token (eg: the profile id), or bad url should fail + // Check cloudflare helper route with a bad token (eg: the wrong user, or no user), or bad url should fail let resp = api - .check_download_minecraft_profile_token(&share_link.url_identifier, &override_file_url) + .check_download_minecraft_profile_token(&override_file_url, None) .await; assert_eq!(resp.status(), 401); let resp = api - .check_download_minecraft_profile_token(&share_link.url, &override_file_url) + .check_download_minecraft_profile_token(&override_file_url, ENEMY_USER_PAT) .await; assert_eq!(resp.status(), 401); let resp = api - .check_download_minecraft_profile_token(&id, &override_file_url) - .await; - assert_eq!(resp.status(), 401); - - let resp = api - .check_download_minecraft_profile_token(&download.auth_token, "bad_url") + .check_download_minecraft_profile_token("bad_url", FRIEND_USER_PAT) .await; assert_eq!(resp.status(), 401); let resp = api .check_download_minecraft_profile_token( - &download.auth_token, &format!( "{}/custom_files/{}", dotenvy::var("CDN_URL").unwrap(), "example_hash" ), + FRIEND_USER_PAT, ) .await; assert_eq!(resp.status(), 401); @@ -468,7 +463,7 @@ async fn download_profile() { // Check cloudflare helper route to confirm this is a valid allowable access token // We attach it as an authorization token and call the route let resp = api - .check_download_minecraft_profile_token(&download.auth_token, &override_file_url) + .check_download_minecraft_profile_token(&override_file_url, FRIEND_USER_PAT) .await; assert_eq!(resp.status(), 200); @@ -498,7 +493,7 @@ async fn download_profile() { // Confirm token invalidation let resp = api - .check_download_minecraft_profile_token(&download.auth_token, &override_file_url) + .check_download_minecraft_profile_token(&override_file_url, FRIEND_USER_PAT) .await; assert_eq!(resp.status(), 401); From 51ad5dded20f0df06a92542ecafadb354c76d069 Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Fri, 5 Jan 2024 12:23:14 -0800 Subject: [PATCH 11/25] uses enums --- ...b7c36d707622bd397c0f72bb12d3ac3d80d2.json} | 5 +- ...8bf10ae263c70a1a8650817206b2397ed378.json} | 22 +- migrations/20231226012200_shared_modpacks.sql | 7 +- ...profile_item.rs => client_profile_item.rs} | 199 +++++++----- src/database/models/ids.rs | 28 +- src/database/models/mod.rs | 2 +- src/models/mod.rs | 2 +- src/models/v3/{minecraft => client}/mod.rs | 0 .../v3/{minecraft => client}/profile.rs | 66 +++- src/models/v3/ids.rs | 4 +- src/models/v3/mod.rs | 2 +- src/models/v3/pats.rs | 12 +- src/routes/v3/{minecraft => client}/mod.rs | 0 .../v3/{minecraft => client}/profiles.rs | 287 ++++++++--------- src/routes/v3/mod.rs | 4 +- ...minecraft_profile.rs => client_profile.rs} | 93 +++--- tests/common/api_v3/mod.rs | 2 +- tests/common/asserts.rs | 7 +- tests/profiles.rs | 290 +++++++++--------- 19 files changed, 576 insertions(+), 456 deletions(-) rename .sqlx/{query-0427f77ed1297f9b05b523f763aa016674d095cd25e0bbbb80a3916a6ee93942.json => query-8ec2964dcf8e9c17f698a7a06dddb7c36d707622bd397c0f72bb12d3ac3d80d2.json} (66%) rename .sqlx/{query-1877647bc2e255578344a8a88579ae0c5945e4eeb962bd36939c596b0b52999e.json => query-d0341d17fbd638d8f07e2cece3568bf10ae263c70a1a8650817206b2397ed378.json} (59%) rename src/database/models/{minecraft_profile_item.rs => client_profile_item.rs} (68%) rename src/models/v3/{minecraft => client}/mod.rs (100%) rename src/models/v3/{minecraft => client}/profile.rs (64%) rename src/routes/v3/{minecraft => client}/mod.rs (100%) rename src/routes/v3/{minecraft => client}/profiles.rs (82%) rename tests/common/api_v3/{minecraft_profile.rs => client_profile.rs} (72%) diff --git a/.sqlx/query-0427f77ed1297f9b05b523f763aa016674d095cd25e0bbbb80a3916a6ee93942.json b/.sqlx/query-8ec2964dcf8e9c17f698a7a06dddb7c36d707622bd397c0f72bb12d3ac3d80d2.json similarity index 66% rename from .sqlx/query-0427f77ed1297f9b05b523f763aa016674d095cd25e0bbbb80a3916a6ee93942.json rename to .sqlx/query-8ec2964dcf8e9c17f698a7a06dddb7c36d707622bd397c0f72bb12d3ac3d80d2.json index c78c16a6..ba5902ec 100644 --- a/.sqlx/query-0427f77ed1297f9b05b523f763aa016674d095cd25e0bbbb80a3916a6ee93942.json +++ b/.sqlx/query-8ec2964dcf8e9c17f698a7a06dddb7c36d707622bd397c0f72bb12d3ac3d80d2.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO shared_profiles (\n id, name, owner_id, icon_url, created, updated,\n game_version_id, loader_id, loader_version, maximum_users\n )\n VALUES (\n $1, $2, $3, $4, $5, $6, \n $7, $8, $9, $10\n )\n ", + "query": "\n INSERT INTO shared_profiles (\n id, name, owner_id, icon_url, created, updated,\n game_version_id, loader_id, loader_version, maximum_users, game_id\n )\n VALUES (\n $1, $2, $3, $4, $5, $6, \n $7, $8, $9, $10, $11\n )\n ", "describe": { "columns": [], "parameters": { @@ -14,10 +14,11 @@ "Int4", "Int4", "Varchar", + "Int4", "Int4" ] }, "nullable": [] }, - "hash": "0427f77ed1297f9b05b523f763aa016674d095cd25e0bbbb80a3916a6ee93942" + "hash": "8ec2964dcf8e9c17f698a7a06dddb7c36d707622bd397c0f72bb12d3ac3d80d2" } diff --git a/.sqlx/query-1877647bc2e255578344a8a88579ae0c5945e4eeb962bd36939c596b0b52999e.json b/.sqlx/query-d0341d17fbd638d8f07e2cece3568bf10ae263c70a1a8650817206b2397ed378.json similarity index 59% rename from .sqlx/query-1877647bc2e255578344a8a88579ae0c5945e4eeb962bd36939c596b0b52999e.json rename to .sqlx/query-d0341d17fbd638d8f07e2cece3568bf10ae263c70a1a8650817206b2397ed378.json index 17b6a1ff..3dd76cf3 100644 --- a/.sqlx/query-1877647bc2e255578344a8a88579ae0c5945e4eeb962bd36939c596b0b52999e.json +++ b/.sqlx/query-d0341d17fbd638d8f07e2cece3568bf10ae263c70a1a8650817206b2397ed378.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT sp.id, sp.name, sp.owner_id, sp.icon_url, sp.created, sp.updated, sp.game_version_id, sp.loader_id, l.loader, sp.loader_version, sp.maximum_users,\n ARRAY_AGG(DISTINCT spu.user_id) filter (WHERE spu.user_id IS NOT NULL) as users\n FROM shared_profiles sp \n LEFT JOIN loaders l ON l.id = sp.loader_id\n LEFT JOIN shared_profiles_users spu ON spu.shared_profile_id = sp.id\n WHERE sp.id = ANY($1)\n GROUP BY sp.id, l.id\n ", + "query": "\n SELECT sp.id, sp.name, sp.owner_id, sp.icon_url, sp.created, sp.updated, sp.game_version_id, sp.loader_id,\n l.loader, sp.loader_version, sp.maximum_users, g.name as game_name, g.id as game_id, lfev.value as \"game_version?\",\n ARRAY_AGG(DISTINCT spu.user_id) filter (WHERE spu.user_id IS NOT NULL) as users\n FROM shared_profiles sp \n LEFT JOIN loaders l ON l.id = sp.loader_id\n LEFT JOIN shared_profiles_users spu ON spu.shared_profile_id = sp.id\n INNER JOIN games g ON g.id = sp.game_id\n LEFT JOIN loader_field_enum_values lfev ON sp.game_version_id = lfev.id\n WHERE sp.id = ANY($1)\n GROUP BY sp.id, l.id, g.id, lfev.id\n ", "describe": { "columns": [ { @@ -60,6 +60,21 @@ }, { "ordinal": 11, + "name": "game_name", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "game_id", + "type_info": "Int4" + }, + { + "ordinal": 13, + "name": "game_version?", + "type_info": "Varchar" + }, + { + "ordinal": 14, "name": "users", "type_info": "Int8Array" } @@ -76,6 +91,9 @@ true, false, false, + true, + false, + false, false, false, false, @@ -84,5 +102,5 @@ null ] }, - "hash": "1877647bc2e255578344a8a88579ae0c5945e4eeb962bd36939c596b0b52999e" + "hash": "d0341d17fbd638d8f07e2cece3568bf10ae263c70a1a8650817206b2397ed378" } diff --git a/migrations/20231226012200_shared_modpacks.sql b/migrations/20231226012200_shared_modpacks.sql index 46715017..ce92efd6 100644 --- a/migrations/20231226012200_shared_modpacks.sql +++ b/migrations/20231226012200_shared_modpacks.sql @@ -9,9 +9,12 @@ CREATE TABLE shared_profiles ( maximum_users integer NOT NULL, - game_version_id int NOT NULL REFERENCES loader_field_enum_values(id), loader_id int NOT NULL REFERENCES loaders(id), - loader_version varchar(255) NOT NULL + loader_version varchar(255) NOT NULL, + + game_id int NOT NULL REFERENCES games(id), + game_version_id int NULL REFERENCES loader_field_enum_values(id) -- Minecraft java + ); CREATE TABLE shared_profiles_mods ( diff --git a/src/database/models/minecraft_profile_item.rs b/src/database/models/client_profile_item.rs similarity index 68% rename from src/database/models/minecraft_profile_item.rs rename to src/database/models/client_profile_item.rs index 3069f614..2611fb0d 100644 --- a/src/database/models/minecraft_profile_item.rs +++ b/src/database/models/client_profile_item.rs @@ -11,18 +11,19 @@ use serde::{Deserialize, Serialize}; // Hash and install path type Override = (String, PathBuf); -pub const MINECRAFT_PROFILES_NAMESPACE: &str = "minecraft_profiles"; +pub const CLIENT_PROFILES_NAMESPACE: &str = "client_profiles"; #[derive(Clone, Debug, Serialize, Deserialize)] -pub struct MinecraftProfile { - pub id: MinecraftProfileId, +pub struct ClientProfile { + pub id: ClientProfileId, pub name: String, pub owner_id: UserId, pub icon_url: Option, pub created: DateTime, pub updated: DateTime, - pub game_version_id: LoaderFieldEnumValueId, + pub game: ClientProfileGame, + pub loader_version: String, pub maximum_users: i32, @@ -36,7 +37,52 @@ pub struct MinecraftProfile { pub overrides: Vec, } -impl MinecraftProfile { +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum ClientProfileGame { + Minecraft { + game_id: GameId, + game_name: String, + game_version_id: LoaderFieldEnumValueId, + game_version: String, + }, + Unknown { + game_id: GameId, + game_name: String, + }, +} + +impl ClientProfileGame { + pub fn from( + game_name: String, + game_id: GameId, + game_version: Option<(String, LoaderFieldEnumValueId)>, + ) -> Self { + match game_name.as_str() { + "minecraft" => { + if let Some((game_version, game_version_id)) = game_version { + Self::Minecraft { + game_id, + game_name, + game_version_id, + game_version, + } + } else { + Self::Unknown { game_id, game_name } + } + } + _ => Self::Unknown { game_id, game_name }, + } + } + + pub fn game_id(&self) -> GameId { + match self { + Self::Minecraft { game_id, .. } => *game_id, + Self::Unknown { game_id, .. } => *game_id, + } + } +} + +impl ClientProfile { pub async fn insert( &self, transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, @@ -45,23 +91,31 @@ impl MinecraftProfile { " INSERT INTO shared_profiles ( id, name, owner_id, icon_url, created, updated, - game_version_id, loader_id, loader_version, maximum_users + game_version_id, loader_id, loader_version, maximum_users, game_id ) VALUES ( $1, $2, $3, $4, $5, $6, - $7, $8, $9, $10 + $7, $8, $9, $10, $11 ) ", - self.id as MinecraftProfileId, + self.id as ClientProfileId, self.name, self.owner_id as UserId, self.icon_url, self.created, self.updated, - self.game_version_id as LoaderFieldEnumValueId, + if let ClientProfileGame::Minecraft { + game_version_id, .. + } = &self.game + { + Some(game_version_id.0) + } else { + None + }, self.loader_id as LoaderId, self.loader_version, self.maximum_users, + self.game.game_id().0 ) .execute(&mut **transaction) .await?; @@ -77,7 +131,7 @@ impl MinecraftProfile { $1, $2 ) ", - self.id as MinecraftProfileId, + self.id as ClientProfileId, user_id.0, ) .execute(&mut **transaction) @@ -88,7 +142,7 @@ impl MinecraftProfile { } pub async fn remove( - id: MinecraftProfileId, + id: ClientProfileId, transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, redis: &RedisPool, ) -> Result, DatabaseError> { @@ -98,7 +152,7 @@ impl MinecraftProfile { DELETE FROM shared_profiles_links WHERE shared_profile_id = $1 ", - id as MinecraftProfileId, + id as ClientProfileId, ) .execute(&mut **transaction) .await?; @@ -109,7 +163,7 @@ impl MinecraftProfile { DELETE FROM shared_profiles_users WHERE shared_profile_id = $1 ", - id as MinecraftProfileId, + id as ClientProfileId, ) .execute(&mut **transaction) .await?; @@ -119,7 +173,7 @@ impl MinecraftProfile { DELETE FROM shared_profiles_mods WHERE shared_profile_id = $1 ", - id as MinecraftProfileId, + id as ClientProfileId, ) .execute(&mut **transaction) .await?; @@ -129,7 +183,7 @@ impl MinecraftProfile { DELETE FROM shared_profiles_links WHERE shared_profile_id = $1 ", - id as MinecraftProfileId, + id as ClientProfileId, ) .execute(&mut **transaction) .await?; @@ -139,21 +193,21 @@ impl MinecraftProfile { DELETE FROM shared_profiles WHERE id = $1 ", - id as MinecraftProfileId, + id as ClientProfileId, ) .execute(&mut **transaction) .await?; - MinecraftProfile::clear_cache(id, redis).await?; + ClientProfile::clear_cache(id, redis).await?; Ok(Some(())) } pub async fn get<'a, 'b, E>( - id: MinecraftProfileId, + id: ClientProfileId, executor: E, redis: &RedisPool, - ) -> Result, DatabaseError> + ) -> Result, DatabaseError> where E: sqlx::Acquire<'a, Database = sqlx::Postgres>, { @@ -165,12 +219,12 @@ impl MinecraftProfile { pub async fn get_ids_for_user<'a, E>( user_id: UserId, exec: E, - ) -> Result, DatabaseError> + ) -> Result, DatabaseError> where E: sqlx::Acquire<'a, Database = sqlx::Postgres>, { let mut exec = exec.acquire().await?; - let db_profiles: Vec = sqlx::query!( + let db_profiles: Vec = sqlx::query!( " SELECT sp.id FROM shared_profiles sp @@ -180,17 +234,17 @@ impl MinecraftProfile { user_id.0 ) .fetch_many(&mut *exec) - .try_filter_map(|e| async { Ok(e.right().map(|m| MinecraftProfileId(m.id))) }) - .try_collect::>() + .try_filter_map(|e| async { Ok(e.right().map(|m| ClientProfileId(m.id))) }) + .try_collect::>() .await?; Ok(db_profiles) } pub async fn get_many<'a, E>( - ids: &[MinecraftProfileId], + ids: &[ClientProfileId], exec: E, redis: &RedisPool, - ) -> Result, DatabaseError> + ) -> Result, DatabaseError> where E: sqlx::Acquire<'a, Database = sqlx::Postgres>, { @@ -202,15 +256,15 @@ impl MinecraftProfile { let mut exec = exec.acquire().await?; let mut found_profiles = Vec::new(); - let mut remaining_ids: Vec = ids.to_vec(); + let mut remaining_ids: Vec = ids.to_vec(); if !ids.is_empty() { let profiles = redis - .multi_get::(MINECRAFT_PROFILES_NAMESPACE, ids.iter().map(|x| x.0)) + .multi_get::(CLIENT_PROFILES_NAMESPACE, ids.iter().map(|x| x.0)) .await?; for profile in profiles { if let Some(profile) = - profile.and_then(|x| serde_json::from_str::(&x).ok()) + profile.and_then(|x| serde_json::from_str::(&x).ok()) { remaining_ids.retain(|x| profile.id != *x); found_profiles.push(profile); @@ -221,8 +275,8 @@ impl MinecraftProfile { if !remaining_ids.is_empty() { type AttachedProjectsMap = ( - DashMap>, - DashMap>, + DashMap>, + DashMap>, ); let shared_profiles_mods: AttachedProjectsMap = sqlx::query!( " @@ -241,14 +295,14 @@ impl MinecraftProfile { let install_path = m.install_path; if let Some(version_id) = version_id { acc_versions - .entry(MinecraftProfileId(m.shared_profile_id)) + .entry(ClientProfileId(m.shared_profile_id)) .or_default() .push(version_id); } if let (Some(install_path), Some(file_hash)) = (install_path, file_hash) { acc_overrides - .entry(MinecraftProfileId(m.shared_profile_id)) + .entry(ClientProfileId(m.shared_profile_id)) .or_default() .push((file_hash, PathBuf::from(install_path))); } @@ -259,32 +313,41 @@ impl MinecraftProfile { .await?; // One to many for shared_profiles to loaders, so can safely group by shared_profile_id - let db_profiles: Vec = sqlx::query!( - " - SELECT sp.id, sp.name, sp.owner_id, sp.icon_url, sp.created, sp.updated, sp.game_version_id, sp.loader_id, l.loader, sp.loader_version, sp.maximum_users, + let db_profiles: Vec = sqlx::query!( + r#" + SELECT sp.id, sp.name, sp.owner_id, sp.icon_url, sp.created, sp.updated, sp.game_version_id, sp.loader_id, + l.loader, sp.loader_version, sp.maximum_users, g.name as game_name, g.id as game_id, lfev.value as "game_version?", ARRAY_AGG(DISTINCT spu.user_id) filter (WHERE spu.user_id IS NOT NULL) as users FROM shared_profiles sp LEFT JOIN loaders l ON l.id = sp.loader_id LEFT JOIN shared_profiles_users spu ON spu.shared_profile_id = sp.id + INNER JOIN games g ON g.id = sp.game_id + LEFT JOIN loader_field_enum_values lfev ON sp.game_version_id = lfev.id WHERE sp.id = ANY($1) - GROUP BY sp.id, l.id - ", + GROUP BY sp.id, l.id, g.id, lfev.id + "#, &remaining_ids.iter().map(|x| x.0).collect::>() ) .fetch_many(&mut *exec) .try_filter_map(|e| async { Ok(e.right().map(|m| { - let id = MinecraftProfileId(m.id); + let id = ClientProfileId(m.id); let versions = shared_profiles_mods.0.get(&id).map(|x| x.value().clone()).unwrap_or_default(); let files = shared_profiles_mods.1.get(&id).map(|x| x.value().clone()).unwrap_or_default(); - MinecraftProfile { + + let game_version = match (m.game_version, m.game_version_id) { + (Some(game_version), Some(game_version_id)) => Some((game_version, LoaderFieldEnumValueId(game_version_id))), + _ => None + }; + let game = ClientProfileGame::from(m.game_name, GameId(m.game_id), game_version); + ClientProfile { id, name: m.name, icon_url: m.icon_url, updated: m.updated, created: m.created, owner_id: UserId(m.owner_id), - game_version_id: LoaderFieldEnumValueId(m.game_version_id), + game, users: m.users.unwrap_or_default().into_iter().map(UserId).collect(), loader_id: LoaderId(m.loader_id), loader_version: m.loader_version, @@ -295,17 +358,12 @@ impl MinecraftProfile { } })) }) - .try_collect::>() + .try_collect::>() .await?; for profile in db_profiles { redis - .set_serialized_to_json( - MINECRAFT_PROFILES_NAMESPACE, - profile.id.0, - &profile, - None, - ) + .set_serialized_to_json(CLIENT_PROFILES_NAMESPACE, profile.id.0, &profile, None) .await?; found_profiles.push(profile); } @@ -314,29 +372,26 @@ impl MinecraftProfile { Ok(found_profiles) } - pub async fn clear_cache( - id: MinecraftProfileId, - redis: &RedisPool, - ) -> Result<(), DatabaseError> { + pub async fn clear_cache(id: ClientProfileId, redis: &RedisPool) -> Result<(), DatabaseError> { let mut redis = redis.connect().await?; redis - .delete_many([(MINECRAFT_PROFILES_NAMESPACE, Some(id.0.to_string()))]) + .delete_many([(CLIENT_PROFILES_NAMESPACE, Some(id.0.to_string()))]) .await?; Ok(()) } } #[derive(Clone, Debug, Serialize, Deserialize)] -pub struct MinecraftProfileLink { - pub id: MinecraftProfileLinkId, +pub struct ClientProfileLink { + pub id: ClientProfileLinkId, pub link_identifier: String, - pub shared_profile_id: MinecraftProfileId, + pub shared_profile_id: ClientProfileId, pub created: DateTime, pub expires: DateTime, } -impl MinecraftProfileLink { +impl ClientProfileLink { pub async fn insert( &self, transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, @@ -363,9 +418,9 @@ impl MinecraftProfileLink { } pub async fn list<'a, 'b, E>( - shared_profile_id: MinecraftProfileId, + shared_profile_id: ClientProfileId, executor: E, - ) -> Result, DatabaseError> + ) -> Result, DatabaseError> where E: sqlx::Acquire<'a, Database = sqlx::Postgres>, { @@ -381,24 +436,24 @@ impl MinecraftProfileLink { ) .fetch_many(&mut *exec) .try_filter_map(|e| async { - Ok(e.right().map(|m| MinecraftProfileLink { - id: MinecraftProfileLinkId(m.id), + Ok(e.right().map(|m| ClientProfileLink { + id: ClientProfileLinkId(m.id), link_identifier: m.link, - shared_profile_id: MinecraftProfileId(m.shared_profile_id), + shared_profile_id: ClientProfileId(m.shared_profile_id), created: m.created, expires: m.expires, })) }) - .try_collect::>() + .try_collect::>() .await?; Ok(links) } pub async fn get<'a, 'b, E>( - id: MinecraftProfileLinkId, + id: ClientProfileLinkId, executor: E, - ) -> Result, DatabaseError> + ) -> Result, DatabaseError> where E: sqlx::Acquire<'a, Database = sqlx::Postgres>, { @@ -414,10 +469,10 @@ impl MinecraftProfileLink { ) .fetch_optional(&mut *exec) .await? - .map(|m| MinecraftProfileLink { - id: MinecraftProfileLinkId(m.id), + .map(|m| ClientProfileLink { + id: ClientProfileLinkId(m.id), link_identifier: m.link, - shared_profile_id: MinecraftProfileId(m.shared_profile_id), + shared_profile_id: ClientProfileId(m.shared_profile_id), created: m.created, expires: m.expires, }); @@ -428,7 +483,7 @@ impl MinecraftProfileLink { pub async fn get_url<'a, 'b, E>( url_identifier: &str, executor: E, - ) -> Result, DatabaseError> + ) -> Result, DatabaseError> where E: sqlx::Acquire<'a, Database = sqlx::Postgres>, { @@ -444,10 +499,10 @@ impl MinecraftProfileLink { ) .fetch_optional(&mut *exec) .await? - .map(|m| MinecraftProfileLink { - id: MinecraftProfileLinkId(m.id), + .map(|m| ClientProfileLink { + id: ClientProfileLinkId(m.id), link_identifier: m.link, - shared_profile_id: MinecraftProfileId(m.shared_profile_id), + shared_profile_id: ClientProfileId(m.shared_profile_id), created: m.created, expires: m.expires, }); @@ -456,7 +511,7 @@ impl MinecraftProfileLink { } } -pub struct MinecraftProfileOverride { +pub struct ClientProfileOverride { pub file_hash: String, pub url: String, pub install_path: PathBuf, diff --git a/src/database/models/ids.rs b/src/database/models/ids.rs index 953f9405..83b49e01 100644 --- a/src/database/models/ids.rs +++ b/src/database/models/ids.rs @@ -193,19 +193,19 @@ generate_ids!( ); generate_ids!( - pub generate_minecraft_profile_id, - MinecraftProfileId, + pub generate_client_profile_id, + ClientProfileId, 8, "SELECT EXISTS(SELECT 1 FROM shared_profiles WHERE id=$1)", - MinecraftProfileId + ClientProfileId ); generate_ids!( - pub generate_minecraft_profile_link_id, - MinecraftProfileLinkId, + pub generate_client_profile_link_id, + ClientProfileLinkId, 8, "SELECT EXISTS(SELECT 1 FROM shared_profiles_links WHERE id=$1)", - MinecraftProfileLinkId + ClientProfileLinkId ); #[derive(Copy, Clone, Debug, PartialEq, Eq, Type, Hash, Serialize, Deserialize)] @@ -329,11 +329,11 @@ pub struct PayoutId(pub i64); #[derive(Copy, Clone, Debug, Type, Serialize, Deserialize, Eq, PartialEq, Hash)] #[sqlx(transparent)] -pub struct MinecraftProfileId(pub i64); +pub struct ClientProfileId(pub i64); #[derive(Copy, Clone, Debug, Type, Serialize, Deserialize, Eq, PartialEq, Hash)] #[sqlx(transparent)] -pub struct MinecraftProfileLinkId(pub i64); +pub struct ClientProfileLinkId(pub i64); use crate::models::ids; @@ -489,14 +489,14 @@ impl From for ids::PayoutId { } } -impl From for MinecraftProfileId { - fn from(id: ids::MinecraftProfileId) -> Self { - MinecraftProfileId(id.0 as i64) +impl From for ClientProfileId { + fn from(id: ids::ClientProfileId) -> Self { + ClientProfileId(id.0 as i64) } } -impl From for ids::MinecraftProfileId { - fn from(id: MinecraftProfileId) -> Self { - ids::MinecraftProfileId(id.0 as u64) +impl From for ids::ClientProfileId { + fn from(id: ClientProfileId) -> Self { + ids::ClientProfileId(id.0 as u64) } } diff --git a/src/database/models/mod.rs b/src/database/models/mod.rs index 1893df6b..d1a6f847 100644 --- a/src/database/models/mod.rs +++ b/src/database/models/mod.rs @@ -1,13 +1,13 @@ use thiserror::Error; pub mod categories; +pub mod client_profile_item; pub mod collection_item; pub mod flow_item; pub mod ids; pub mod image_item; pub mod legacy_loader_fields; pub mod loader_fields; -pub mod minecraft_profile_item; pub mod notification_item; pub mod oauth_client_authorization_item; pub mod oauth_client_item; diff --git a/src/models/mod.rs b/src/models/mod.rs index 3c420a7b..6b8dda7a 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -3,10 +3,10 @@ pub mod v2; pub mod v3; pub use v3::analytics; +pub use v3::client; pub use v3::collections; pub use v3::ids; pub use v3::images; -pub use v3::minecraft; pub use v3::notifications; pub use v3::oauth_clients; pub use v3::organizations; diff --git a/src/models/v3/minecraft/mod.rs b/src/models/v3/client/mod.rs similarity index 100% rename from src/models/v3/minecraft/mod.rs rename to src/models/v3/client/mod.rs diff --git a/src/models/v3/minecraft/profile.rs b/src/models/v3/client/profile.rs similarity index 64% rename from src/models/v3/minecraft/profile.rs rename to src/models/v3/client/profile.rs index 2665d7dd..f1b11aae 100644 --- a/src/models/v3/minecraft/profile.rs +++ b/src/models/v3/client/profile.rs @@ -4,7 +4,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use crate::{ - database::{self, models::LoaderFieldEnumValueId}, + database, models::ids::{Base62Id, UserId, VersionId}, }; @@ -15,13 +15,13 @@ pub const DEFAULT_PROFILE_MAX_USERS: u32 = 5; #[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug)] #[serde(from = "Base62Id")] #[serde(into = "Base62Id")] -pub struct MinecraftProfileId(pub u64); +pub struct ClientProfileId(pub u64); /// A project returned from the API #[derive(Serialize, Deserialize, Clone)] -pub struct MinecraftProfile { +pub struct ClientProfile { /// The ID of the profile, encoded as a base62 string. - pub id: MinecraftProfileId, + pub id: ClientProfileId, /// The person that has ownership of this profile. pub owner_id: UserId, @@ -44,8 +44,10 @@ pub struct MinecraftProfile { pub loader: String, /// The loader version pub loader_version: String, - /// Minecraft game version id - pub game_version_id: LoaderFieldEnumValueId, + + /// Game-specific information + #[serde(flatten)] + pub game: ClientProfileGame, /// Modrinth-associated versions pub versions: Vec, @@ -54,9 +56,41 @@ pub struct MinecraftProfile { pub override_install_paths: Vec, } -impl MinecraftProfile { +#[derive(Serialize, Deserialize, Clone)] +#[serde(tag = "game")] +pub enum ClientProfileGame { + #[serde(rename = "minecraft-java")] + Minecraft { + /// Game Id (constant for Minecraft) + game_name: String, + /// Client game version id + game_version: String, + }, + #[serde(rename = "unknown")] + Unknown, +} + +impl From for ClientProfileGame { + fn from(game: database::models::client_profile_item::ClientProfileGame) -> Self { + match game { + database::models::client_profile_item::ClientProfileGame::Minecraft { + game_name, + game_version, + .. + } => Self::Minecraft { + game_name, + game_version, + }, + database::models::client_profile_item::ClientProfileGame::Unknown { .. } => { + Self::Unknown + } + } + } +} + +impl ClientProfile { pub fn from( - profile: database::models::minecraft_profile_item::MinecraftProfile, + profile: database::models::client_profile_item::ClientProfile, current_user_id: Option, ) -> Self { let users = if Some(profile.owner_id) == current_user_id { @@ -76,7 +110,7 @@ impl MinecraftProfile { users, loader: profile.loader, loader_version: profile.loader_version, - game_version_id: profile.game_version_id, + game: profile.game.into(), versions: profile.versions.into_iter().map(Into::into).collect(), override_install_paths: profile.overrides.into_iter().map(|(_, v)| v).collect(), } @@ -84,22 +118,20 @@ impl MinecraftProfile { } #[derive(Serialize, Deserialize, Clone)] -pub struct MinecraftProfileShareLink { +pub struct ClientProfileShareLink { pub url_identifier: String, pub url: String, // Includes the url identifier, intentionally redundant - pub profile_id: MinecraftProfileId, + pub profile_id: ClientProfileId, pub created: DateTime, pub expires: DateTime, } -impl From - for MinecraftProfileShareLink -{ - fn from(link: database::models::minecraft_profile_item::MinecraftProfileLink) -> Self { +impl From for ClientProfileShareLink { + fn from(link: database::models::client_profile_item::ClientProfileLink) -> Self { // Generate URL for easy access - let profile_id: MinecraftProfileId = link.shared_profile_id.into(); + let profile_id: ClientProfileId = link.shared_profile_id.into(); let url = format!( - "{}/v3/minecraft/profile/{}/accept/{}", + "{}/v3/client/profile/{}/accept/{}", dotenvy::var("SELF_ADDR").unwrap(), profile_id, link.link_identifier diff --git a/src/models/v3/ids.rs b/src/models/v3/ids.rs index d134f331..00b3d5fa 100644 --- a/src/models/v3/ids.rs +++ b/src/models/v3/ids.rs @@ -1,8 +1,8 @@ use thiserror::Error; +pub use super::client::profile::ClientProfileId; pub use super::collections::CollectionId; pub use super::images::ImageId; -pub use super::minecraft::profile::MinecraftProfileId; pub use super::notifications::NotificationId; pub use super::oauth_clients::OAuthClientAuthorizationId; pub use super::oauth_clients::{OAuthClientId, OAuthRedirectUriId}; @@ -130,7 +130,7 @@ base62_id_impl!(OAuthClientId, OAuthClientId); base62_id_impl!(OAuthRedirectUriId, OAuthRedirectUriId); base62_id_impl!(OAuthClientAuthorizationId, OAuthClientAuthorizationId); base62_id_impl!(PayoutId, PayoutId); -base62_id_impl!(MinecraftProfileId, MinecraftProfileId); +base62_id_impl!(ClientProfileId, ClientProfileId); pub mod base62_impl { use serde::de::{self, Deserializer, Visitor}; diff --git a/src/models/v3/mod.rs b/src/models/v3/mod.rs index 11f1a64d..fdcc8af8 100644 --- a/src/models/v3/mod.rs +++ b/src/models/v3/mod.rs @@ -1,8 +1,8 @@ pub mod analytics; +pub mod client; pub mod collections; pub mod ids; pub mod images; -pub mod minecraft; pub mod notifications; pub mod oauth_clients; pub mod organizations; diff --git a/src/models/v3/pats.rs b/src/models/v3/pats.rs index e9c6a317..685edf15 100644 --- a/src/models/v3/pats.rs +++ b/src/models/v3/pats.rs @@ -106,12 +106,12 @@ bitflags::bitflags! { // only accessible by modrinth-issued sessions const SESSION_ACCESS = 1 << 39; - // create a minecraft profile - const MINECRAFT_PROFILE_CREATE = 1 << 40; - // edit a minecraft profile - const MINECRAFT_PROFILE_WRITE = 1 << 41; - // download a minecraft profile - const MINECRAFT_PROFILE_DOWNLOAD = 1 << 42; + // create a client profile + const CLIENT_PROFILE_CREATE = 1 << 40; + // edit a client profile + const CLIENT_PROFILE_WRITE = 1 << 41; + // download a client profile + const CLIENT_PROFILE_DOWNLOAD = 1 << 42; const NONE = 0b0; diff --git a/src/routes/v3/minecraft/mod.rs b/src/routes/v3/client/mod.rs similarity index 100% rename from src/routes/v3/minecraft/mod.rs rename to src/routes/v3/client/mod.rs diff --git a/src/routes/v3/minecraft/profiles.rs b/src/routes/v3/client/profiles.rs similarity index 82% rename from src/routes/v3/minecraft/profiles.rs rename to src/routes/v3/client/profiles.rs index 298e4a2e..d17dd6ac 100644 --- a/src/routes/v3/minecraft/profiles.rs +++ b/src/routes/v3/client/profiles.rs @@ -2,16 +2,15 @@ use crate::auth::checks::filter_visible_version_ids; use crate::auth::{get_user_from_headers, AuthenticationError}; use crate::database::models::legacy_loader_fields::MinecraftGameVersion; use crate::database::models::{ - generate_minecraft_profile_id, generate_minecraft_profile_link_id, minecraft_profile_item, - version_item, + client_profile_item, generate_client_profile_id, generate_client_profile_link_id, version_item, }; use crate::database::redis::RedisPool; use crate::file_hosting::FileHost; +use crate::models::client::profile::{ + ClientProfile, ClientProfileId, ClientProfileShareLink, DEFAULT_PROFILE_MAX_USERS, +}; use crate::models::ids::base62_impl::parse_base62; use crate::models::ids::{UserId, VersionId}; -use crate::models::minecraft::profile::{ - MinecraftProfile, MinecraftProfileId, MinecraftProfileShareLink, DEFAULT_PROFILE_MAX_USERS, -}; use crate::models::pats::Scopes; use crate::queue::session::AuthQueue; use crate::routes::v3::project_creation::CreateError; @@ -37,7 +36,7 @@ use validator::Validate; pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( - web::scope("minecraft") + web::scope("client") .route("profile", web::post().to(profile_create)) .route("check_token", web::get().to(profile_token_check)) .service( @@ -45,13 +44,10 @@ pub fn config(cfg: &mut web::ServiceConfig) { .route("{id}", web::get().to(profile_get)) .route("{id}", web::patch().to(profile_edit)) .route("{id}", web::delete().to(profile_delete)) + .route("{id}/override", web::post().to(client_profile_add_override)) .route( "{id}/override", - web::post().to(minecraft_profile_add_override), - ) - .route( - "{id}/override", - web::delete().to(minecraft_profile_remove_overrides), + web::delete().to(client_profile_remove_overrides), ) .route("{id}/share", web::get().to(profile_share)) .route( @@ -81,13 +77,24 @@ pub struct ProfileCreateData { pub loader: String, // The loader version pub loader_version: String, - // The game version string (parsed to a game version) - pub game_version: String, // The list of versions to include in the profile (does not include overrides) pub versions: Vec, + + #[serde(flatten)] + pub game: ProfileCreateDataGame, +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(tag = "game")] +pub enum ProfileCreateDataGame { + #[serde(rename = "minecraft-java")] + MinecraftJava { + // The game version string (parsed to a game version) + game_version: String, + }, } -// Create a new minecraft profile +// Create a new client profile pub async fn profile_create( req: HttpRequest, profile_create_data: web::Json, @@ -96,14 +103,14 @@ pub async fn profile_create( session_queue: Data, ) -> Result { let profile_create_data = profile_create_data.into_inner(); - + println!("creat {:?}", serde_json::to_string(&profile_create_data)); // The currently logged in user let current_user = get_user_from_headers( &req, &**client, &redis, &session_queue, - Some(&[Scopes::MINECRAFT_PROFILE_CREATE]), + Some(&[Scopes::CLIENT_PROFILE_CREATE]), ) .await? .1; @@ -112,12 +119,33 @@ pub async fn profile_create( .validate() .map_err(|err| CreateError::InvalidInput(validation_errors_to_string(err, None)))?; - let game_version_id = MinecraftGameVersion::list(&**client, &redis) - .await? - .into_iter() - .find(|x| x.version == profile_create_data.game_version) - .ok_or_else(|| CreateError::InvalidInput("Invalid Minecraft game version".to_string()))? - .id; + let game: client_profile_item::ClientProfileGame = match profile_create_data.game { + ProfileCreateDataGame::MinecraftJava { game_version } => { + let game = database::models::loader_fields::Game::get_slug( + "minecraft-java", + &**client, + &redis, + ) + .await? + .ok_or_else(|| CreateError::InvalidInput("Invalid Client game".to_string()))?; + + let game_version_id = MinecraftGameVersion::list(&**client, &redis) + .await? + .into_iter() + .find(|x| x.version == game_version) + .ok_or_else(|| { + CreateError::InvalidInput("Invalid Client game version".to_string()) + })? + .id; + + client_profile_item::ClientProfileGame::Minecraft { + game_id: game.id, + game_name: "minecraft-java".to_string(), + game_version_id, + game_version, + } + } + }; let loader_id = database::models::loader_fields::Loader::get_id( &profile_create_data.loader, @@ -129,8 +157,8 @@ pub async fn profile_create( let mut transaction = client.begin().await?; - let profile_id: database::models::MinecraftProfileId = - generate_minecraft_profile_id(&mut transaction).await?; + let profile_id: database::models::ClientProfileId = + generate_client_profile_id(&mut transaction).await?; let version_ids = profile_create_data .versions @@ -153,14 +181,14 @@ pub async fn profile_create( .await .map_err(|_| CreateError::InvalidInput("Could not fetch submitted version ids".to_string()))?; - let profile_builder_actual = minecraft_profile_item::MinecraftProfile { + let profile_builder_actual = client_profile_item::ClientProfile { id: profile_id, name: profile_create_data.name.clone(), owner_id: current_user.id.into(), icon_url: None, created: Utc::now(), updated: Utc::now(), - game_version_id, + game, loader_id, loader: profile_create_data.loader, loader_version: profile_create_data.loader_version, @@ -173,21 +201,19 @@ pub async fn profile_create( profile_builder_actual.insert(&mut transaction).await?; transaction.commit().await?; - let profile = models::minecraft::profile::MinecraftProfile::from( - profile_builder, - Some(current_user.id.into()), - ); + let profile = + models::client::profile::ClientProfile::from(profile_builder, Some(current_user.id.into())); Ok(HttpResponse::Ok().json(profile)) } #[derive(Serialize, Deserialize)] -pub struct MinecraftProfileIds { +pub struct ClientProfileIds { pub ids: String, } -// Get several minecraft profiles by their ids +// Get several client profiles by their ids pub async fn profiles_get( req: HttpRequest, - web::Query(ids): web::Query, + web::Query(ids): web::Query, pool: web::Data, redis: web::Data, session_queue: web::Data, @@ -207,21 +233,21 @@ pub async fn profiles_get( let ids = serde_json::from_str::>(&ids.ids)?; let ids = ids .into_iter() - .map(|x| parse_base62(x).map(|x| database::models::MinecraftProfileId(x as i64))) + .map(|x| parse_base62(x).map(|x| database::models::ClientProfileId(x as i64))) .collect::, _>>()?; let profiles_data = - database::models::minecraft_profile_item::MinecraftProfile::get_many(&ids, &**pool, &redis) + database::models::client_profile_item::ClientProfile::get_many(&ids, &**pool, &redis) .await?; let profiles = profiles_data .into_iter() - .map(|x| MinecraftProfile::from(x, user_id)) + .map(|x| ClientProfile::from(x, user_id)) .collect::>(); Ok(HttpResponse::Ok().json(profiles)) } -// Get a minecraft profile by its id +// Get a client profile by its id pub async fn profile_get( req: HttpRequest, info: web::Path<(String,)>, @@ -244,18 +270,17 @@ pub async fn profile_get( // No user check ,as any user/scope can view profiles. // In addition, private information (ie: CDN links, tokens, anything outside of the list of hosted versions and install paths) is not returned - let id = database::models::MinecraftProfileId(parse_base62(&string)? as i64); + let id = database::models::ClientProfileId(parse_base62(&string)? as i64); let profile_data = - database::models::minecraft_profile_item::MinecraftProfile::get(id, &**pool, &redis) - .await?; + database::models::client_profile_item::ClientProfile::get(id, &**pool, &redis).await?; if let Some(data) = profile_data { - return Ok(HttpResponse::Ok().json(MinecraftProfile::from(data, user_id))); + return Ok(HttpResponse::Ok().json(ClientProfile::from(data, user_id))); } Err(ApiError::NotFound) } #[derive(Serialize, Deserialize, Validate, Clone)] -pub struct EditMinecraftProfile { +pub struct EditClientProfile { #[validate( length(min = 3, max = 64), custom(function = "crate::util::validate::validate_name") @@ -279,11 +304,11 @@ pub struct EditMinecraftProfile { pub remove_users: Option>, } -// Edit a minecraft profile +// Edit a client profile pub async fn profile_edit( req: HttpRequest, info: web::Path<(String,)>, - edit_data: web::Json, + edit_data: web::Json, pool: web::Data, redis: web::Data, session_queue: web::Data, @@ -296,19 +321,16 @@ pub async fn profile_edit( &**pool, &redis, &session_queue, - Some(&[Scopes::MINECRAFT_PROFILE_WRITE]), + Some(&[Scopes::CLIENT_PROFILE_WRITE]), ) .await?; // Confirm this is our project, then if so, edit - let id = database::models::MinecraftProfileId(parse_base62(&string)? as i64); + let id = database::models::ClientProfileId(parse_base62(&string)? as i64); let mut transaction = pool.begin().await?; - let profile_data = database::models::minecraft_profile_item::MinecraftProfile::get( - id, - &mut *transaction, - &redis, - ) - .await?; + let profile_data = + database::models::client_profile_item::ClientProfile::get(id, &mut *transaction, &redis) + .await?; if let Some(data) = profile_data { if data.owner_id == user_option.1.id.into() { @@ -357,7 +379,7 @@ pub async fn profile_edit( .into_iter() .find(|x| x.version == game_version) .ok_or_else(|| { - ApiError::InvalidInput("Invalid Minecraft game version".to_string()) + ApiError::InvalidInput("Invalid Client game version".to_string()) })? .id; @@ -435,7 +457,7 @@ pub async fn profile_edit( } transaction.commit().await?; - minecraft_profile_item::MinecraftProfile::clear_cache(data.id, &redis).await?; + client_profile_item::ClientProfile::clear_cache(data.id, &redis).await?; return Ok(HttpResponse::NoContent().finish()); } else { return Err(ApiError::CustomAuthentication( @@ -446,7 +468,7 @@ pub async fn profile_edit( Err(ApiError::NotFound) } -// Delete a minecraft profile +// Delete a client profile pub async fn profile_delete( req: HttpRequest, info: web::Path<(String,)>, @@ -462,26 +484,25 @@ pub async fn profile_delete( &**pool, &redis, &session_queue, - Some(&[Scopes::MINECRAFT_PROFILE_WRITE]), + Some(&[Scopes::CLIENT_PROFILE_WRITE]), ) .await?; // Confirm this is our project, then if so, delete - let id = database::models::MinecraftProfileId(parse_base62(&string)? as i64); + let id = database::models::ClientProfileId(parse_base62(&string)? as i64); let profile_data = - database::models::minecraft_profile_item::MinecraftProfile::get(id, &**pool, &redis) - .await?; + database::models::client_profile_item::ClientProfile::get(id, &**pool, &redis).await?; if let Some(data) = profile_data { if data.owner_id == user_option.1.id.into() { let mut transaction = pool.begin().await?; - database::models::minecraft_profile_item::MinecraftProfile::remove( + database::models::client_profile_item::ClientProfile::remove( data.id, &mut transaction, &redis, ) .await?; transaction.commit().await?; - minecraft_profile_item::MinecraftProfile::clear_cache(data.id, &redis).await?; + client_profile_item::ClientProfile::clear_cache(data.id, &redis).await?; return Ok(HttpResponse::NoContent().finish()); } else if data.users.contains(&user_option.1.id.into()) { // We know it exists, but still can't delete it @@ -494,7 +515,7 @@ pub async fn profile_delete( Err(ApiError::NotFound) } -// Share a minecraft profile with a friend. +// Share a client profile with a friend. // This generates a link struct, including the field 'url' // that can be shared with friends to generate a token a limited number of times. // TODO: This link should not be an API link, but a modrinth:// link that is translatable to an API link by the launcher @@ -513,15 +534,14 @@ pub async fn profile_share( &**pool, &redis, &session_queue, - Some(&[Scopes::MINECRAFT_PROFILE_WRITE]), + Some(&[Scopes::CLIENT_PROFILE_WRITE]), ) .await?; // Confirm this is our project, then if so, share - let id = database::models::MinecraftProfileId(parse_base62(&string)? as i64); + let id = database::models::ClientProfileId(parse_base62(&string)? as i64); let profile_data = - database::models::minecraft_profile_item::MinecraftProfile::get(id, &**pool, &redis) - .await?; + database::models::client_profile_item::ClientProfile::get(id, &**pool, &redis).await?; if let Some(data) = profile_data { if data.owner_id == user_option.1.id.into() { @@ -534,9 +554,9 @@ pub async fn profile_share( // Generate a new share link id let mut transaction = pool.begin().await?; - let profile_link_id = generate_minecraft_profile_link_id(&mut transaction).await?; + let profile_link_id = generate_client_profile_link_id(&mut transaction).await?; - let link = database::models::minecraft_profile_item::MinecraftProfileLink { + let link = database::models::client_profile_item::ClientProfileLink { id: profile_link_id, shared_profile_id: data.id, link_identifier: identifier.clone(), @@ -545,8 +565,8 @@ pub async fn profile_share( }; link.insert(&mut transaction).await?; transaction.commit().await?; - minecraft_profile_item::MinecraftProfile::clear_cache(data.id, &redis).await?; - return Ok(HttpResponse::Ok().json(MinecraftProfileShareLink::from(link))); + client_profile_item::ClientProfile::clear_cache(data.id, &redis).await?; + return Ok(HttpResponse::Ok().json(ClientProfileShareLink::from(link))); } } Err(ApiError::NotFound) @@ -573,14 +593,12 @@ pub async fn profile_link_get( .await?; // Confirm this is our project, then if so, share - let link_data = database::models::minecraft_profile_item::MinecraftProfileLink::get_url( - &url_identifier, - &**pool, - ) - .await? - .ok_or_else(|| ApiError::NotFound)?; + let link_data = + database::models::client_profile_item::ClientProfileLink::get_url(&url_identifier, &**pool) + .await? + .ok_or_else(|| ApiError::NotFound)?; - let data = database::models::minecraft_profile_item::MinecraftProfile::get( + let data = database::models::client_profile_item::ClientProfile::get( link_data.shared_profile_id, &**pool, &redis, @@ -590,7 +608,7 @@ pub async fn profile_link_get( // Only view link meta information if the user is the owner of the profile if data.owner_id == user_option.1.id.into() { - Ok(HttpResponse::Ok().json(MinecraftProfileShareLink::from(link_data))) + Ok(HttpResponse::Ok().json(ClientProfileShareLink::from(link_data))) } else { Err(ApiError::NotFound) } @@ -601,7 +619,7 @@ pub async fn profile_link_get( // TODO: With above change, this is the API link that is translated from a modrinth:// link by the launcher, which would then download it pub async fn accept_share_link( req: HttpRequest, - info: web::Path<(MinecraftProfileId, String)>, + info: web::Path<(ClientProfileId, String)>, pool: web::Data, redis: web::Data, session_queue: web::Data, @@ -614,24 +632,22 @@ pub async fn accept_share_link( &**pool, &redis, &session_queue, - Some(&[Scopes::MINECRAFT_PROFILE_WRITE]), + Some(&[Scopes::CLIENT_PROFILE_WRITE]), ) .await?; - // Fetch the profile information of the desired minecraft profile - let link_data = database::models::minecraft_profile_item::MinecraftProfileLink::get_url( - &url_identifier, - &**pool, - ) - .await? - .ok_or_else(|| ApiError::NotFound)?; + // Fetch the profile information of the desired client profile + let link_data = + database::models::client_profile_item::ClientProfileLink::get_url(&url_identifier, &**pool) + .await? + .ok_or_else(|| ApiError::NotFound)?; // Confirm it matches the profile id if link_data.shared_profile_id != profile_id.into() { return Err(ApiError::NotFound); } - let data = database::models::minecraft_profile_item::MinecraftProfile::get( + let data = database::models::client_profile_item::ClientProfile::get( link_data.shared_profile_id, &**pool, &redis, @@ -668,7 +684,7 @@ pub async fn accept_share_link( ) .execute(&**pool) .await?; - minecraft_profile_item::MinecraftProfile::clear_cache(data.id, &redis).await?; + client_profile_item::ClientProfile::clear_cache(data.id, &redis).await?; Ok(HttpResponse::NoContent().finish()) } @@ -683,11 +699,11 @@ pub struct ProfileDownload { pub override_cdns: Vec<(String, PathBuf)>, } -// Download a minecraft profile +// Download a client profile // Only the owner of the profile or an invited user can download pub async fn profile_download( req: HttpRequest, - info: web::Path<(MinecraftProfileId,)>, + info: web::Path<(ClientProfileId,)>, pool: web::Data, redis: web::Data, session_queue: web::Data, @@ -701,12 +717,12 @@ pub async fn profile_download( &**pool, &redis, &session_queue, - Some(&[Scopes::MINECRAFT_PROFILE_DOWNLOAD]), + Some(&[Scopes::CLIENT_PROFILE_DOWNLOAD]), ) .await?; - // Fetch the profile information of the desired minecraft profile - let Some(profile) = database::models::minecraft_profile_item::MinecraftProfile::get( + // Fetch the profile information of the desired client profile + let Some(profile) = database::models::client_profile_item::ClientProfile::get( profile_id.into(), &**pool, &redis, @@ -758,19 +774,19 @@ pub async fn profile_token_check( &**pool, &redis, &session_queue, - Some(&[Scopes::MINECRAFT_PROFILE_DOWNLOAD]), + Some(&[Scopes::CLIENT_PROFILE_DOWNLOAD]), ) .await? .1; // Get all profiles for the user - let profile_ids = database::models::minecraft_profile_item::MinecraftProfile::get_ids_for_user( + let profile_ids = database::models::client_profile_item::ClientProfile::get_ids_for_user( user.id.into(), &**pool, ) .await?; - let profiles = database::models::minecraft_profile_item::MinecraftProfile::get_many( + let profiles = database::models::client_profile_item::ClientProfile::get_many( &profile_ids, &**pool, &redis, @@ -807,7 +823,7 @@ pub struct Extension { pub async fn profile_icon_edit( web::Query(ext): web::Query, req: HttpRequest, - info: web::Path<(MinecraftProfileId,)>, + info: web::Path<(ClientProfileId,)>, pool: web::Data, redis: web::Data, file_host: web::Data>, @@ -821,21 +837,18 @@ pub async fn profile_icon_edit( &**pool, &redis, &session_queue, - Some(&[Scopes::MINECRAFT_PROFILE_WRITE]), + Some(&[Scopes::CLIENT_PROFILE_WRITE]), ) .await? .1; let id = info.into_inner().0; - let profile_item = database::models::minecraft_profile_item::MinecraftProfile::get( - id.into(), - &**pool, - &redis, - ) - .await? - .ok_or_else(|| { - ApiError::InvalidInput("The specified profile does not exist!".to_string()) - })?; + let profile_item = + database::models::client_profile_item::ClientProfile::get(id.into(), &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The specified profile does not exist!".to_string()) + })?; if !user.role.is_mod() && profile_item.owner_id != user.id.into() { return Err(ApiError::CustomAuthentication( @@ -857,7 +870,7 @@ pub async fn profile_icon_edit( let color = crate::util::img::get_color_from_img(&bytes)?; let hash = format!("{:x}", sha2::Sha512::digest(&bytes)); - let id: MinecraftProfileId = profile_item.id.into(); + let id: ClientProfileId = profile_item.id.into(); let upload_data = file_host .upload_file( content_type, @@ -876,17 +889,14 @@ pub async fn profile_icon_edit( ", format!("{}/{}", cdn_url, upload_data.file_name), color.map(|x| x as i32), - profile_item.id as database::models::ids::MinecraftProfileId, + profile_item.id as database::models::ids::ClientProfileId, ) .execute(&mut *transaction) .await?; transaction.commit().await?; - database::models::minecraft_profile_item::MinecraftProfile::clear_cache( - profile_item.id, - &redis, - ) - .await?; + database::models::client_profile_item::ClientProfile::clear_cache(profile_item.id, &redis) + .await?; Ok(HttpResponse::NoContent().body("")) } else { @@ -899,7 +909,7 @@ pub async fn profile_icon_edit( pub async fn delete_profile_icon( req: HttpRequest, - info: web::Path<(MinecraftProfileId,)>, + info: web::Path<(ClientProfileId,)>, pool: web::Data, redis: web::Data, file_host: web::Data>, @@ -910,14 +920,14 @@ pub async fn delete_profile_icon( &**pool, &redis, &session_queue, - Some(&[Scopes::MINECRAFT_PROFILE_WRITE]), + Some(&[Scopes::CLIENT_PROFILE_WRITE]), ) .await? .1; let id = info.into_inner().0; let profile_item = - database::models::minecraft_profile_item::MinecraftProfile::get(id.into(), &**pool, &redis) + database::models::client_profile_item::ClientProfile::get(id.into(), &**pool, &redis) .await? .ok_or_else(|| { ApiError::InvalidInput("The specified profile does not exist!".to_string()) @@ -946,23 +956,20 @@ pub async fn delete_profile_icon( SET icon_url = NULL, color = NULL WHERE (id = $1) ", - profile_item.id as database::models::ids::MinecraftProfileId, + profile_item.id as database::models::ids::ClientProfileId, ) .execute(&mut *transaction) .await?; transaction.commit().await?; - database::models::minecraft_profile_item::MinecraftProfile::clear_cache( - profile_item.id, - &redis, - ) - .await?; + database::models::client_profile_item::ClientProfile::clear_cache(profile_item.id, &redis) + .await?; Ok(HttpResponse::NoContent().body("")) } -// Add a new override mod to a minecraft profile, by uploading it to the CDN +// Add a new override mod to a client profile, by uploading it to the CDN // Accepts a multipart field // the first part is called `data` and contains a json array of objects with the following fields: // file_name: String @@ -975,9 +982,9 @@ struct MultipartFile { } #[allow(clippy::too_many_arguments)] -pub async fn minecraft_profile_add_override( +pub async fn client_profile_add_override( req: HttpRequest, - client_id: web::Path, + client_id: web::Path, pool: web::Data, redis: web::Data, file_host: web::Data>, @@ -990,13 +997,13 @@ pub async fn minecraft_profile_add_override( &**pool, &redis, &session_queue, - Some(&[Scopes::MINECRAFT_PROFILE_WRITE]), + Some(&[Scopes::CLIENT_PROFILE_WRITE]), ) .await? .1; // Check if this is our profile - let profile_item = database::models::minecraft_profile_item::MinecraftProfile::get( + let profile_item = database::models::client_profile_item::ClientProfile::get( client_id.into(), &**pool, &redis, @@ -1136,11 +1143,8 @@ pub async fn minecraft_profile_add_override( transaction.commit().await?; - database::models::minecraft_profile_item::MinecraftProfile::clear_cache( - profile_item.id, - &redis, - ) - .await?; + database::models::client_profile_item::ClientProfile::clear_cache(profile_item.id, &redis) + .await?; Ok(HttpResponse::NoContent().body("")) } @@ -1152,9 +1156,9 @@ pub struct RemoveOverrides { pub hashes: Option>, } -pub async fn minecraft_profile_remove_overrides( +pub async fn client_profile_remove_overrides( req: HttpRequest, - client_id: web::Path, + client_id: web::Path, pool: web::Data, data: web::Json, redis: web::Data, @@ -1167,13 +1171,13 @@ pub async fn minecraft_profile_remove_overrides( &**pool, &redis, &session_queue, - Some(&[Scopes::MINECRAFT_PROFILE_WRITE]), + Some(&[Scopes::CLIENT_PROFILE_WRITE]), ) .await? .1; // Check if this is our profile - let profile_item = database::models::minecraft_profile_item::MinecraftProfile::get( + let profile_item = database::models::client_profile_item::ClientProfile::get( client_id.into(), &**pool, &redis, @@ -1261,11 +1265,8 @@ pub async fn minecraft_profile_remove_overrides( .await?; } - database::models::minecraft_profile_item::MinecraftProfile::clear_cache( - profile_item.id, - &redis, - ) - .await?; + database::models::client_profile_item::ClientProfile::clear_cache(profile_item.id, &redis) + .await?; Ok(HttpResponse::NoContent().body("")) } diff --git a/src/routes/v3/mod.rs b/src/routes/v3/mod.rs index 29fce5d0..94179131 100644 --- a/src/routes/v3/mod.rs +++ b/src/routes/v3/mod.rs @@ -4,9 +4,9 @@ use actix_web::{web, HttpResponse}; use serde_json::json; pub mod analytics_get; +pub mod client; pub mod collections; pub mod images; -pub mod minecraft; pub mod moderation; pub mod notifications; pub mod organizations; @@ -32,7 +32,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { .configure(analytics_get::config) .configure(collections::config) .configure(images::config) - .configure(minecraft::profiles::config) + .configure(client::profiles::config) .configure(moderation::config) .configure(notifications::config) .configure(organizations::config) diff --git a/tests/common/api_v3/minecraft_profile.rs b/tests/common/api_v3/client_profile.rs similarity index 72% rename from tests/common/api_v3/minecraft_profile.rs rename to tests/common/api_v3/client_profile.rs index c5f6368c..8aeaf397 100644 --- a/tests/common/api_v3/minecraft_profile.rs +++ b/tests/common/api_v3/client_profile.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; +use actix_http::StatusCode; use actix_web::{ dev::ServiceResponse, test::{self, TestRequest}, @@ -7,25 +8,26 @@ use actix_web::{ use bytes::Bytes; use itertools::Itertools; use labrinth::{ - models::minecraft::profile::{MinecraftProfile, MinecraftProfileShareLink}, - routes::v3::minecraft::profiles::ProfileDownload, + models::client::profile::{ClientProfile, ClientProfileShareLink}, + routes::v3::client::profiles::ProfileDownload, util::actix::{AppendsMultipart, MultipartSegment, MultipartSegmentData}, }; use serde_json::json; use crate::common::{ api_common::{request_data::ImageData, Api, AppendsOptionalPat}, + asserts::assert_status, dummy_data::TestFile, }; use super::ApiV3; -pub struct MinecraftProfileOverride { +pub struct ClientProfileOverride { pub file_name: String, pub install_path: String, pub bytes: Vec, } -impl MinecraftProfileOverride { +impl ClientProfileOverride { pub fn new(test_file: TestFile, install_path: &str) -> Self { Self { file_name: test_file.filename(), @@ -36,7 +38,7 @@ impl MinecraftProfileOverride { } impl ApiV3 { - pub async fn create_minecraft_profile( + pub async fn create_client_profile( &self, name: &str, loader: &str, @@ -46,12 +48,13 @@ impl ApiV3 { pat: Option<&str>, ) -> ServiceResponse { let req = test::TestRequest::post() - .uri("/v3/minecraft/profile") + .uri("/v3/client/profile") .append_pat(pat) .set_json(json!({ "name": name, "loader": loader, "loader_version": loader_version, + "game": "minecraft-java", "game_version": game_version, "versions": versions })) @@ -60,7 +63,7 @@ impl ApiV3 { } #[allow(clippy::too_many_arguments)] - pub async fn edit_minecraft_profile( + pub async fn edit_client_profile( &self, id: &str, name: Option<&str>, @@ -71,7 +74,7 @@ impl ApiV3 { pat: Option<&str>, ) -> ServiceResponse { let req = test::TestRequest::patch() - .uri(&format!("/v3/minecraft/profile/{}", id)) + .uri(&format!("/v3/client/profile/{}", id)) .append_pat(pat) .set_json(json!({ "name": name, @@ -84,33 +87,33 @@ impl ApiV3 { self.call(req).await } - pub async fn get_minecraft_profile(&self, id: &str, pat: Option<&str>) -> ServiceResponse { + pub async fn get_client_profile(&self, id: &str, pat: Option<&str>) -> ServiceResponse { let req = TestRequest::get() - .uri(&format!("/v3/minecraft/profile/{}", id)) + .uri(&format!("/v3/client/profile/{}", id)) .append_pat(pat) .to_request(); self.call(req).await } - pub async fn get_minecraft_profile_deserialized( + pub async fn get_client_profile_deserialized( &self, id: &str, pat: Option<&str>, - ) -> MinecraftProfile { - let resp = self.get_minecraft_profile(id, pat).await; - assert_eq!(resp.status(), 200); + ) -> ClientProfile { + let resp = self.get_client_profile(id, pat).await; + assert_status(&resp, StatusCode::OK); test::read_body_json(resp).await } - pub async fn delete_minecraft_profile(&self, id: &str, pat: Option<&str>) -> ServiceResponse { + pub async fn delete_client_profile(&self, id: &str, pat: Option<&str>) -> ServiceResponse { let req = TestRequest::delete() - .uri(&format!("/v3/minecraft/profile/{}", id)) + .uri(&format!("/v3/client/profile/{}", id)) .append_pat(pat) .to_request(); self.call(req).await } - pub async fn edit_minecraft_profile_icon( + pub async fn edit_client_profile_icon( &self, id: &str, icon: Option, @@ -119,7 +122,7 @@ impl ApiV3 { if let Some(icon) = icon { let req = TestRequest::patch() .uri(&format!( - "/v3/minecraft/profile/{}/icon?ext={}", + "/v3/client/profile/{}/icon?ext={}", id, icon.extension )) .append_pat(pat) @@ -128,17 +131,17 @@ impl ApiV3 { self.call(req).await } else { let req = TestRequest::delete() - .uri(&format!("/v3/minecraft/profile/{}/icon", id)) + .uri(&format!("/v3/client/profile/{}/icon", id)) .append_pat(pat) .to_request(); self.call(req).await } } - pub async fn add_minecraft_profile_overrides( + pub async fn add_client_profile_overrides( &self, id: &str, - overrides: Vec, + overrides: Vec, pat: Option<&str>, ) -> ServiceResponse { let mut data = Vec::new(); @@ -165,14 +168,14 @@ impl ApiV3 { .collect_vec(); let req = TestRequest::post() - .uri(&format!("/v3/minecraft/profile/{}/override", id)) + .uri(&format!("/v3/client/profile/{}/override", id)) .append_pat(pat) .set_multipart(multipart_segments) .to_request(); self.call(req).await } - pub async fn delete_minecraft_profile_overrides( + pub async fn delete_client_profile_overrides( &self, id: &str, install_paths: Option<&[&PathBuf]>, @@ -180,7 +183,7 @@ impl ApiV3 { pat: Option<&str>, ) -> ServiceResponse { let req = TestRequest::delete() - .uri(&format!("/v3/minecraft/profile/{}/override", id)) + .uri(&format!("/v3/client/profile/{}/override", id)) .set_json(json!({ "install_paths": install_paths, "hashes": hashes @@ -190,29 +193,29 @@ impl ApiV3 { self.call(req).await } - pub async fn generate_minecraft_profile_share_link( + pub async fn generate_client_profile_share_link( &self, id: &str, pat: Option<&str>, ) -> ServiceResponse { let req = TestRequest::get() - .uri(&format!("/v3/minecraft/profile/{}/share", id)) + .uri(&format!("/v3/client/profile/{}/share", id)) .append_pat(pat) .to_request(); self.call(req).await } - pub async fn generate_minecraft_profile_share_link_deserialized( + pub async fn generate_client_profile_share_link_deserialized( &self, id: &str, pat: Option<&str>, - ) -> MinecraftProfileShareLink { - let resp = self.generate_minecraft_profile_share_link(id, pat).await; - assert_eq!(resp.status(), 200); + ) -> ClientProfileShareLink { + let resp = self.generate_client_profile_share_link(id, pat).await; + assert_status(&resp, StatusCode::OK); test::read_body_json(resp).await } - pub async fn get_minecraft_profile_share_link( + pub async fn get_client_profile_share_link( &self, profile_id: &str, url_identifier: &str, @@ -220,7 +223,7 @@ impl ApiV3 { ) -> ServiceResponse { let req = TestRequest::get() .uri(&format!( - "/v3/minecraft/profile/{}/share/{}", + "/v3/client/profile/{}/share/{}", profile_id, url_identifier )) .append_pat(pat) @@ -228,20 +231,20 @@ impl ApiV3 { self.call(req).await } - pub async fn get_minecraft_profile_share_link_deserialized( + pub async fn get_client_profile_share_link_deserialized( &self, profile_id: &str, url_identifier: &str, pat: Option<&str>, - ) -> MinecraftProfileShareLink { + ) -> ClientProfileShareLink { let resp = self - .get_minecraft_profile_share_link(profile_id, url_identifier, pat) + .get_client_profile_share_link(profile_id, url_identifier, pat) .await; - assert_eq!(resp.status(), 200); + assert_status(&resp, StatusCode::OK); test::read_body_json(resp).await } - pub async fn accept_minecraft_profile_share_link( + pub async fn accept_client_profile_share_link( &self, profile_id: &str, url_identifier: &str, @@ -249,7 +252,7 @@ impl ApiV3 { ) -> ServiceResponse { let req = TestRequest::post() .uri(&format!( - "/v3/minecraft/profile/{}/accept/{}", + "/v3/client/profile/{}/accept/{}", profile_id, url_identifier )) .append_pat(pat) @@ -258,36 +261,36 @@ impl ApiV3 { } // Get links and token - pub async fn download_minecraft_profile( + pub async fn download_client_profile( &self, profile_id: &str, pat: Option<&str>, ) -> ServiceResponse { let req = TestRequest::get() - .uri(&format!("/v3/minecraft/profile/{}/download", profile_id)) + .uri(&format!("/v3/client/profile/{}/download", profile_id)) .append_pat(pat) .to_request(); self.call(req).await } - pub async fn download_minecraft_profile_deserialized( + pub async fn download_client_profile_deserialized( &self, profile_id: &str, pat: Option<&str>, ) -> ProfileDownload { - let resp = self.download_minecraft_profile(profile_id, pat).await; - assert_eq!(resp.status(), 200); + let resp = self.download_client_profile(profile_id, pat).await; + assert_status(&resp, StatusCode::OK); test::read_body_json(resp).await } - pub async fn check_download_minecraft_profile_token( + pub async fn check_download_client_profile_token( &self, url: &str, // Full URL, the route will parse it pat: Option<&str>, ) -> ServiceResponse { let req = TestRequest::get() .uri(&format!( - "/v3/minecraft/check_token?url={url}", + "/v3/client/check_token?url={url}", url = urlencoding::encode(url) )) .append_pat(pat) diff --git a/tests/common/api_v3/mod.rs b/tests/common/api_v3/mod.rs index 6ffbc5a7..973914ac 100644 --- a/tests/common/api_v3/mod.rs +++ b/tests/common/api_v3/mod.rs @@ -9,8 +9,8 @@ use async_trait::async_trait; use labrinth::LabrinthConfig; use std::rc::Rc; +pub mod client_profile; pub mod collections; -pub mod minecraft_profile; pub mod oauth; pub mod oauth_clients; pub mod organization; diff --git a/tests/common/asserts.rs b/tests/common/asserts.rs index 5a1be6d8..ab203448 100644 --- a/tests/common/asserts.rs +++ b/tests/common/asserts.rs @@ -7,7 +7,12 @@ use labrinth::models::v3::projects::Version; use super::api_common::models::CommonVersion; pub fn assert_status(response: &actix_web::dev::ServiceResponse, status: actix_http::StatusCode) { - assert_eq!(response.status(), status, "{:#?}", response.response()); + assert_eq!( + response.status(), + status, + "{:#?}", + response.response().body() + ); } pub fn assert_version_ids(versions: &[Version], expected_ids: Vec) { diff --git a/tests/profiles.rs b/tests/profiles.rs index a804b809..bfe68454 100644 --- a/tests/profiles.rs +++ b/tests/profiles.rs @@ -1,15 +1,17 @@ use std::path::PathBuf; +use actix_http::StatusCode; use actix_web::test; use common::api_v3::ApiV3; use common::database::*; use common::environment::with_test_environment; use common::environment::TestEnvironment; -use labrinth::models::minecraft::profile::MinecraftProfile; +use labrinth::models::client::profile::ClientProfile; use labrinth::models::users::UserId; use sha2::Digest; -use crate::common::api_v3::minecraft_profile::MinecraftProfileOverride; +use crate::common::api_v3::client_profile::ClientProfileOverride; +use crate::common::asserts::assert_status; use crate::common::dummy_data::DummyImage; use crate::common::dummy_data::TestFile; @@ -31,7 +33,7 @@ async fn create_modify_profile() { // - unparseable version (not to be confused with parseable but nonexistent version, which is simply ignored) // - fake game version let resp = api - .create_minecraft_profile( + .create_client_profile( "test", "fake-loader", "1.0.0", @@ -40,16 +42,16 @@ async fn create_modify_profile() { USER_USER_PAT, ) .await; - assert_eq!(resp.status(), 400); + assert_status(&resp, StatusCode::BAD_REQUEST); // Currently fake version for loader is not checked // let resp = api - // .create_minecraft_profile("test", "fabric", "fake", "1.20.1", vec![], USER_USER_PAT) + // .create_client_profile("test", "fabric", "fake", "1.20.1", vec![], USER_USER_PAT) // .await; - // assert_eq!(resp.status(), 400); + // assert_status(&resp, StatusCode::BAD_REQUEST); let resp = api - .create_minecraft_profile( + .create_client_profile( "test", "fabric", "1.0.0", @@ -58,25 +60,25 @@ async fn create_modify_profile() { USER_USER_PAT, ) .await; - assert_eq!(resp.status(), 400); + assert_status(&resp, StatusCode::BAD_REQUEST); let resp = api - .create_minecraft_profile("test", "fabric", "1.0.0", "1.19.1", vec![], USER_USER_PAT) + .create_client_profile("test", "fabric", "1.0.0", "1.19.1", vec![], USER_USER_PAT) .await; - assert_eq!(resp.status(), 400); + assert_status(&resp, StatusCode::BAD_REQUEST); // Create a simple profile // should succeed let profile = api - .create_minecraft_profile("test", "fabric", "1.0.0", "1.20.1", vec![], USER_USER_PAT) + .create_client_profile("test", "fabric", "1.0.0", "1.20.1", vec![], USER_USER_PAT) .await; - assert_eq!(profile.status(), 200); - let profile: MinecraftProfile = test::read_body_json(profile).await; + assert_status(&profile, StatusCode::OK); + let profile: ClientProfile = test::read_body_json(profile).await; let id = profile.id.to_string(); // Get the profile and check the properties are correct let profile = api - .get_minecraft_profile_deserialized(&id, USER_USER_PAT) + .get_client_profile_deserialized(&id, USER_USER_PAT) .await; let updated = profile.updated; // Save this- it will update when we modify the versions/overrides @@ -88,7 +90,7 @@ async fn create_modify_profile() { // Modify the profile illegally in the same ways let resp = api - .edit_minecraft_profile( + .edit_client_profile( &profile.id.to_string(), None, Some("fake-loader"), @@ -98,11 +100,11 @@ async fn create_modify_profile() { USER_USER_PAT, ) .await; - assert_eq!(resp.status(), 400); + assert_status(&resp, StatusCode::BAD_REQUEST); // Currently fake version for loader is not checked // let resp = api - // .edit_minecraft_profile( + // .edit_client_profile( // &profile.id.to_string(), // None, // Some("fabric"), @@ -111,10 +113,10 @@ async fn create_modify_profile() { // USER_USER_PAT, // ) // .await; - // assert_eq!(resp.status(), 400); + // assert_status(&resp, StatusCode::BAD_REQUEST); let resp = api - .edit_minecraft_profile( + .edit_client_profile( &profile.id.to_string(), None, Some("fabric"), @@ -124,11 +126,11 @@ async fn create_modify_profile() { USER_USER_PAT, ) .await; - assert_eq!(resp.status(), 400); + assert_status(&resp, StatusCode::BAD_REQUEST); // Can't modify the profile as another user let resp = api - .edit_minecraft_profile( + .edit_client_profile( &profile.id.to_string(), None, Some("fabric"), @@ -138,11 +140,11 @@ async fn create_modify_profile() { FRIEND_USER_PAT, ) .await; - assert_eq!(resp.status(), 401); + assert_status(&resp, StatusCode::UNAUTHORIZED); // Get and make sure the properties are the same let profile = api - .get_minecraft_profile_deserialized(&id, USER_USER_PAT) + .get_client_profile_deserialized(&id, USER_USER_PAT) .await; assert_eq!(profile.name, "test"); assert_eq!(profile.loader, "fabric"); @@ -153,7 +155,7 @@ async fn create_modify_profile() { // A successful modification let resp = api - .edit_minecraft_profile( + .edit_client_profile( &profile.id.to_string(), Some("test2"), Some("forge"), @@ -163,11 +165,11 @@ async fn create_modify_profile() { USER_USER_PAT, ) .await; - assert_eq!(resp.status(), 204); + assert_status(&resp, StatusCode::NO_CONTENT); // Get the profile and check the properties let profile = api - .get_minecraft_profile_deserialized(&id, USER_USER_PAT) + .get_client_profile_deserialized(&id, USER_USER_PAT) .await; assert_eq!(profile.name, "test2"); assert_eq!(profile.loader, "forge"); @@ -179,7 +181,7 @@ async fn create_modify_profile() { // Modify the profile again let resp = api - .edit_minecraft_profile( + .edit_client_profile( &profile.id.to_string(), Some("test3"), Some("fabric"), @@ -189,11 +191,11 @@ async fn create_modify_profile() { USER_USER_PAT, ) .await; - assert_eq!(resp.status(), 204); + assert_status(&resp, StatusCode::NO_CONTENT); // Get the profile and check the properties let profile = api - .get_minecraft_profile_deserialized(&id, USER_USER_PAT) + .get_client_profile_deserialized(&id, USER_USER_PAT) .await; assert_eq!(profile.name, "test3"); @@ -215,10 +217,10 @@ async fn accept_share_link() { // Create a simple profile let profile = api - .create_minecraft_profile("test", "fabric", "1.0.0", "1.20.1", vec![], USER_USER_PAT) + .create_client_profile("test", "fabric", "1.0.0", "1.20.1", vec![], USER_USER_PAT) .await; - assert_eq!(profile.status(), 200); - let profile: MinecraftProfile = test::read_body_json(profile).await; + assert_status(&profile, StatusCode::OK); + let profile: ClientProfile = test::read_body_json(profile).await; let id = profile.id.to_string(); let users: Vec = profile.users.unwrap(); assert_eq!(users.len(), 1); @@ -226,20 +228,20 @@ async fn accept_share_link() { // Friend can't see the profile user yet, but can see the profile let profile = api - .get_minecraft_profile_deserialized(&id, FRIEND_USER_PAT) + .get_client_profile_deserialized(&id, FRIEND_USER_PAT) .await; assert_eq!(profile.users, None); // As 'user', try to generate a download link for the profile let share_link = api - .generate_minecraft_profile_share_link_deserialized(&id, USER_USER_PAT) + .generate_client_profile_share_link_deserialized(&id, USER_USER_PAT) .await; // Links should be internally consistent and match the expected format assert_eq!( share_link.url, format!( - "{}/v3/minecraft/profile/{}/accept/{}", + "{}/v3/client/profile/{}/accept/{}", dotenvy::var("SELF_ADDR").unwrap(), id, share_link.url_identifier @@ -249,13 +251,13 @@ async fn accept_share_link() { // Link is an 'accept' link, when visited using any user token using POST, it should add the user to the profile // As 'friend', accept the share link let resp = api - .accept_minecraft_profile_share_link(&id, &share_link.url_identifier, FRIEND_USER_PAT) + .accept_client_profile_share_link(&id, &share_link.url_identifier, FRIEND_USER_PAT) .await; - assert_eq!(resp.status(), 204); + assert_status(&resp, StatusCode::NO_CONTENT); // Profile users should now include the friend let profile = api - .get_minecraft_profile_deserialized(&id, USER_USER_PAT) + .get_client_profile_deserialized(&id, USER_USER_PAT) .await; let mut users = profile.users.unwrap(); users.sort_by(|a, b| a.0.cmp(&b.0)); @@ -274,12 +276,12 @@ async fn accept_share_link() { ]; for (i, pat) in dummy_user_pats.iter().enumerate().take(4 + 1) { let resp = api - .accept_minecraft_profile_share_link(&id, &share_link.url_identifier, *pat) + .accept_client_profile_share_link(&id, &share_link.url_identifier, *pat) .await; if i == 0 || i == 1 || i == 6 { - assert_eq!(resp.status(), 400); + assert_status(&resp, StatusCode::BAD_REQUEST); } else { - assert_eq!(resp.status(), 204); + assert_status(&resp, StatusCode::NO_CONTENT); } } }) @@ -296,7 +298,7 @@ async fn delete_profile() { // Create a simple profile let profile = api - .create_minecraft_profile( + .create_client_profile( "test", "fabric", "1.0.0", @@ -305,62 +307,62 @@ async fn delete_profile() { USER_USER_PAT, ) .await; - assert_eq!(profile.status(), 200); - let profile: MinecraftProfile = test::read_body_json(profile).await; + assert_status(&profile, StatusCode::OK); + let profile: ClientProfile = test::read_body_json(profile).await; let id = profile.id.to_string(); // Add an override file to the profile let resp = api - .add_minecraft_profile_overrides( + .add_client_profile_overrides( &id, - vec![MinecraftProfileOverride::new( + vec![ClientProfileOverride::new( TestFile::BasicMod, "mods/test.jar", )], USER_USER_PAT, ) .await; - assert_eq!(resp.status(), 204); + assert_status(&resp, StatusCode::NO_CONTENT); // Invite a friend to the profile and accept it let share_link = api - .generate_minecraft_profile_share_link_deserialized(&id, USER_USER_PAT) + .generate_client_profile_share_link_deserialized(&id, USER_USER_PAT) .await; let resp = api - .accept_minecraft_profile_share_link(&id, &share_link.url_identifier, FRIEND_USER_PAT) + .accept_client_profile_share_link(&id, &share_link.url_identifier, FRIEND_USER_PAT) .await; - assert_eq!(resp.status(), 204); + assert_status(&resp, StatusCode::NO_CONTENT); // Get a token as the friend let token = api - .download_minecraft_profile_deserialized(&id, FRIEND_USER_PAT) + .download_client_profile_deserialized(&id, FRIEND_USER_PAT) .await; // Confirm it works let resp = api - .check_download_minecraft_profile_token(&token.override_cdns[0].0, FRIEND_USER_PAT) + .check_download_client_profile_token(&token.override_cdns[0].0, FRIEND_USER_PAT) .await; - assert_eq!(resp.status(), 200); + assert_status(&resp, StatusCode::OK); // Delete the profile as the friend // Should fail - let resp = api.delete_minecraft_profile(&id, FRIEND_USER_PAT).await; - assert_eq!(resp.status(), 401); + let resp = api.delete_client_profile(&id, FRIEND_USER_PAT).await; + assert_status(&resp, StatusCode::UNAUTHORIZED); // Delete the profile as the user // Should succeed - let resp = api.delete_minecraft_profile(&id, USER_USER_PAT).await; - assert_eq!(resp.status(), 204); + let resp = api.delete_client_profile(&id, USER_USER_PAT).await; + assert_status(&resp, StatusCode::NO_CONTENT); // Confirm the profile is gone - let resp = api.get_minecraft_profile(&id, USER_USER_PAT).await; - assert_eq!(resp.status(), 404); + let resp = api.get_client_profile(&id, USER_USER_PAT).await; + assert_status(&resp, StatusCode::NOT_FOUND); // Confirm the token is gone let resp = api - .check_download_minecraft_profile_token(&token.override_cdns[0].0, FRIEND_USER_PAT) + .check_download_client_profile_token(&token.override_cdns[0].0, FRIEND_USER_PAT) .await; - assert_eq!(resp.status(), 401); + assert_status(&resp, StatusCode::UNAUTHORIZED); }) .await; } @@ -374,52 +376,52 @@ async fn download_profile() { // Create a simple profile let profile = api - .create_minecraft_profile("test", "fabric", "1.0.0", "1.20.1", vec![], USER_USER_PAT) + .create_client_profile("test", "fabric", "1.0.0", "1.20.1", vec![], USER_USER_PAT) .await; - assert_eq!(profile.status(), 200); - let profile: MinecraftProfile = test::read_body_json(profile).await; + assert_status(&profile, StatusCode::OK); + let profile: ClientProfile = test::read_body_json(profile).await; let id = profile.id.to_string(); // Add an override file to the profile let resp = api - .add_minecraft_profile_overrides( + .add_client_profile_overrides( &id, - vec![MinecraftProfileOverride::new( + vec![ClientProfileOverride::new( TestFile::BasicMod, "mods/test.jar", )], USER_USER_PAT, ) .await; - assert_eq!(resp.status(), 204); + assert_status(&resp, StatusCode::NO_CONTENT); // As 'user', try to generate a download link for the profile - let resp = api.download_minecraft_profile(&id, USER_USER_PAT).await; - assert_eq!(resp.status(), 200); + let resp = api.download_client_profile(&id, USER_USER_PAT).await; + assert_status(&resp, StatusCode::OK); // As 'friend', try to get the download links for the profile // Not invited yet, should fail - let resp = api.download_minecraft_profile(&id, FRIEND_USER_PAT).await; - assert_eq!(resp.status(), 401); + let resp = api.download_client_profile(&id, FRIEND_USER_PAT).await; + assert_status(&resp, StatusCode::UNAUTHORIZED); // As 'user', try to generate a share link for the profile, and accept it as 'friend' let share_link = api - .generate_minecraft_profile_share_link_deserialized(&id, USER_USER_PAT) + .generate_client_profile_share_link_deserialized(&id, USER_USER_PAT) .await; let resp = api - .accept_minecraft_profile_share_link(&id, &share_link.url_identifier, FRIEND_USER_PAT) + .accept_client_profile_share_link(&id, &share_link.url_identifier, FRIEND_USER_PAT) .await; - assert_eq!(resp.status(), 204); + assert_status(&resp, StatusCode::NO_CONTENT); // As 'friend', try to get the download links for the profile // Should succeed let mut download = api - .download_minecraft_profile_deserialized(&id, FRIEND_USER_PAT) + .download_client_profile_deserialized(&id, FRIEND_USER_PAT) .await; // But enemy should fail - let resp = api.download_minecraft_profile(&id, ENEMY_USER_PAT).await; - assert_eq!(resp.status(), 401); + let resp = api.download_client_profile(&id, ENEMY_USER_PAT).await; + assert_status(&resp, StatusCode::UNAUTHORIZED); // Download url should be: // - CDN url @@ -435,21 +437,21 @@ async fn download_profile() { // Check cloudflare helper route with a bad token (eg: the wrong user, or no user), or bad url should fail let resp = api - .check_download_minecraft_profile_token(&override_file_url, None) + .check_download_client_profile_token(&override_file_url, None) .await; - assert_eq!(resp.status(), 401); + assert_status(&resp, StatusCode::UNAUTHORIZED); let resp = api - .check_download_minecraft_profile_token(&override_file_url, ENEMY_USER_PAT) + .check_download_client_profile_token(&override_file_url, ENEMY_USER_PAT) .await; - assert_eq!(resp.status(), 401); + assert_status(&resp, StatusCode::UNAUTHORIZED); let resp = api - .check_download_minecraft_profile_token("bad_url", FRIEND_USER_PAT) + .check_download_client_profile_token("bad_url", FRIEND_USER_PAT) .await; - assert_eq!(resp.status(), 401); + assert_status(&resp, StatusCode::UNAUTHORIZED); let resp = api - .check_download_minecraft_profile_token( + .check_download_client_profile_token( &format!( "{}/custom_files/{}", dotenvy::var("CDN_URL").unwrap(), @@ -458,18 +460,18 @@ async fn download_profile() { FRIEND_USER_PAT, ) .await; - assert_eq!(resp.status(), 401); + assert_status(&resp, StatusCode::UNAUTHORIZED); // Check cloudflare helper route to confirm this is a valid allowable access token // We attach it as an authorization token and call the route let resp = api - .check_download_minecraft_profile_token(&override_file_url, FRIEND_USER_PAT) + .check_download_client_profile_token(&override_file_url, FRIEND_USER_PAT) .await; - assert_eq!(resp.status(), 200); + assert_status(&resp, StatusCode::OK); // As user, remove friend from profile let resp = api - .edit_minecraft_profile( + .edit_client_profile( &id, None, None, @@ -479,27 +481,27 @@ async fn download_profile() { USER_USER_PAT, ) .await; - assert_eq!(resp.status(), 204); + assert_status(&resp, StatusCode::NO_CONTENT); // Confirm friend is no longer on the profile let profile = api - .get_minecraft_profile_deserialized(&id, USER_USER_PAT) + .get_client_profile_deserialized(&id, USER_USER_PAT) .await; assert_eq!(profile.users.unwrap().len(), 1); // Confirm friend can no longer download the profile - let resp = api.download_minecraft_profile(&id, FRIEND_USER_PAT).await; - assert_eq!(resp.status(), 401); + let resp = api.download_client_profile(&id, FRIEND_USER_PAT).await; + assert_status(&resp, StatusCode::UNAUTHORIZED); // Confirm token invalidation let resp = api - .check_download_minecraft_profile_token(&override_file_url, FRIEND_USER_PAT) + .check_download_client_profile_token(&override_file_url, FRIEND_USER_PAT) .await; - assert_eq!(resp.status(), 401); + assert_status(&resp, StatusCode::UNAUTHORIZED); // Confirm user can still download the profile let resp = api - .download_minecraft_profile_deserialized(&id, USER_USER_PAT) + .download_client_profile_deserialized(&id, USER_USER_PAT) .await; assert_eq!(resp.override_cdns.len(), 1); }) @@ -514,36 +516,36 @@ async fn add_remove_profile_icon() { // Create a simple profile let profile = api - .create_minecraft_profile("test", "fabric", "1.0.0", "1.20.1", vec![], USER_USER_PAT) + .create_client_profile("test", "fabric", "1.0.0", "1.20.1", vec![], USER_USER_PAT) .await; - assert_eq!(profile.status(), 200); - let profile: MinecraftProfile = test::read_body_json(profile).await; + assert_status(&profile, StatusCode::OK); + let profile: ClientProfile = test::read_body_json(profile).await; // Add an icon to the profile let icon = api - .edit_minecraft_profile_icon( + .edit_client_profile_icon( &profile.id.to_string(), Some(DummyImage::SmallIcon.get_icon_data()), USER_USER_PAT, ) .await; - assert_eq!(icon.status(), 204); + assert_status(&icon, StatusCode::NO_CONTENT); // Get the profile and check the icon let profile = api - .get_minecraft_profile_deserialized(&profile.id.to_string(), USER_USER_PAT) + .get_client_profile_deserialized(&profile.id.to_string(), USER_USER_PAT) .await; assert!(profile.icon_url.is_some()); // Remove the icon from the profile let icon = api - .edit_minecraft_profile_icon(&profile.id.to_string(), None, USER_USER_PAT) + .edit_client_profile_icon(&profile.id.to_string(), None, USER_USER_PAT) .await; - assert_eq!(icon.status(), 204); + assert_status(&icon, StatusCode::NO_CONTENT); // Get the profile and check the icon let profile = api - .get_minecraft_profile_deserialized(&profile.id.to_string(), USER_USER_PAT) + .get_client_profile_deserialized(&profile.id.to_string(), USER_USER_PAT) .await; assert!(profile.icon_url.is_none()); }) @@ -558,15 +560,15 @@ async fn add_remove_profile_versions() { let alpha_version_id = test_env.dummy.project_alpha.version_id.to_string(); // Create a simple profile let profile = api - .create_minecraft_profile("test", "fabric", "1.0.0", "1.20.1", vec![], USER_USER_PAT) + .create_client_profile("test", "fabric", "1.0.0", "1.20.1", vec![], USER_USER_PAT) .await; - assert_eq!(profile.status(), 200); - let profile: MinecraftProfile = test::read_body_json(profile).await; + assert_status(&profile, StatusCode::OK); + let profile: ClientProfile = test::read_body_json(profile).await; let updated = profile.updated; // Save this- it will update when we modify the versions/overrides // Add a hosted version to the profile let resp = api - .edit_minecraft_profile( + .edit_client_profile( &profile.id.to_string(), None, None, @@ -576,37 +578,37 @@ async fn add_remove_profile_versions() { USER_USER_PAT, ) .await; - assert_eq!(resp.status(), 204); + assert_status(&resp, StatusCode::NO_CONTENT); // Add an override file to the profile let resp = api - .add_minecraft_profile_overrides( + .add_client_profile_overrides( &profile.id.to_string(), - vec![MinecraftProfileOverride::new( + vec![ClientProfileOverride::new( TestFile::BasicMod, "mods/test.jar", )], USER_USER_PAT, ) .await; - assert_eq!(resp.status(), 204); + assert_status(&resp, StatusCode::NO_CONTENT); // Add a second version to the profile let resp = api - .add_minecraft_profile_overrides( + .add_client_profile_overrides( &profile.id.to_string(), - vec![MinecraftProfileOverride::new( + vec![ClientProfileOverride::new( TestFile::BasicModDifferent, "mods/test_different.jar", )], USER_USER_PAT, ) .await; - assert_eq!(resp.status(), 204); + assert_status(&resp, StatusCode::NO_CONTENT); // Get the profile and check the versions let profile = api - .get_minecraft_profile_deserialized(&profile.id.to_string(), USER_USER_PAT) + .get_client_profile_deserialized(&profile.id.to_string(), USER_USER_PAT) .await; assert_eq!( profile.versions, @@ -624,28 +626,28 @@ async fn add_remove_profile_versions() { // Create a second profile using the same hashes, but ENEMY_USER_PAT let profile_enemy = api - .create_minecraft_profile("test2", "fabric", "1.0.0", "1.20.1", vec![], ENEMY_USER_PAT) + .create_client_profile("test2", "fabric", "1.0.0", "1.20.1", vec![], ENEMY_USER_PAT) .await; - assert_eq!(profile_enemy.status(), 200); - let profile_enemy: MinecraftProfile = test::read_body_json(profile_enemy).await; + assert_status(&profile_enemy, StatusCode::OK); + let profile_enemy: ClientProfile = test::read_body_json(profile_enemy).await; let id_enemy = profile_enemy.id.to_string(); // Add the same override to the profile let resp = api - .add_minecraft_profile_overrides( + .add_client_profile_overrides( &id_enemy, - vec![MinecraftProfileOverride::new( + vec![ClientProfileOverride::new( TestFile::BasicMod, "mods/test.jar", )], ENEMY_USER_PAT, ) .await; - assert_eq!(resp.status(), 204); + assert_status(&resp, StatusCode::NO_CONTENT); // Get the profile and check the versions let profile_enemy = api - .get_minecraft_profile_deserialized(&id_enemy, ENEMY_USER_PAT) + .get_client_profile_deserialized(&id_enemy, ENEMY_USER_PAT) .await; assert_eq!( profile_enemy.override_install_paths, @@ -655,18 +657,18 @@ async fn add_remove_profile_versions() { // Attempt to delete the override test.jar from the user's profile // Should succeed let resp = api - .delete_minecraft_profile_overrides( + .delete_client_profile_overrides( &profile.id.to_string(), Some(&[&PathBuf::from("mods/test.jar")]), None, USER_USER_PAT, ) .await; - assert_eq!(resp.status(), 204); + assert_status(&resp, StatusCode::NO_CONTENT); // Should still exist in the enemy's profile, but not the user's let profile_enemy = api - .get_minecraft_profile_deserialized(&id_enemy, ENEMY_USER_PAT) + .get_client_profile_deserialized(&id_enemy, ENEMY_USER_PAT) .await; assert_eq!( profile_enemy.override_install_paths, @@ -674,7 +676,7 @@ async fn add_remove_profile_versions() { ); let profile = api - .get_minecraft_profile_deserialized(&profile.id.to_string(), USER_USER_PAT) + .get_client_profile_deserialized(&profile.id.to_string(), USER_USER_PAT) .await; assert_eq!( profile.override_install_paths, @@ -690,18 +692,18 @@ async fn add_remove_profile_versions() { // Should fail // First, by path let resp = api - .delete_minecraft_profile_overrides( + .delete_client_profile_overrides( &id_enemy, Some(&[&PathBuf::from("mods/test_different.jar")]), None, ENEMY_USER_PAT, ) .await; - assert_eq!(resp.status(), 204); // Allow failure, it just doesn't delete anything + assert_status(&resp, StatusCode::NO_CONTENT); // Allow failure to return success, it just doesn't delete anything // Then, by hash let resp = api - .delete_minecraft_profile_overrides( + .delete_client_profile_overrides( &id_enemy, None, Some(&[format!( @@ -712,11 +714,11 @@ async fn add_remove_profile_versions() { ENEMY_USER_PAT, ) .await; - assert_eq!(resp.status(), 204); // Allow failure, it just doesn't delete anything + assert_status(&resp, StatusCode::NO_CONTENT); // Allow failure to return success, it just doesn't delete anything // Confirm user still has it let profile = api - .get_minecraft_profile_deserialized(&profile.id.to_string(), USER_USER_PAT) + .get_client_profile_deserialized(&profile.id.to_string(), USER_USER_PAT) .await; assert_eq!( profile.override_install_paths, @@ -729,7 +731,7 @@ async fn add_remove_profile_versions() { // Now delete the override test_different.jar from the user's profile (by hash this time) // Should succeed let resp = api - .delete_minecraft_profile_overrides( + .delete_client_profile_overrides( &profile.id.to_string(), None, Some(&[format!( @@ -740,11 +742,11 @@ async fn add_remove_profile_versions() { USER_USER_PAT, ) .await; - assert_eq!(resp.status(), 204); + assert_status(&resp, StatusCode::NO_CONTENT); // Confirm user no longer has it let profile = api - .get_minecraft_profile_deserialized(&profile.id.to_string(), USER_USER_PAT) + .get_client_profile_deserialized(&profile.id.to_string(), USER_USER_PAT) .await; assert_eq!(profile.override_install_paths, Vec::::new()); assert!(profile.updated > updated); @@ -752,7 +754,7 @@ async fn add_remove_profile_versions() { // In addition, delete "alpha_version_id" from the user's profile // Should succeed let resp = api - .edit_minecraft_profile( + .edit_client_profile( &profile.id.to_string(), None, None, @@ -762,11 +764,11 @@ async fn add_remove_profile_versions() { USER_USER_PAT, ) .await; - assert_eq!(resp.status(), 204); + assert_status(&resp, StatusCode::NO_CONTENT); // Confirm user no longer has it let profile = api - .get_minecraft_profile_deserialized(&profile.id.to_string(), USER_USER_PAT) + .get_client_profile_deserialized(&profile.id.to_string(), USER_USER_PAT) .await; assert_eq!(profile.versions, vec![]); }) @@ -786,7 +788,7 @@ async fn hidden_versions_are_forbidden() { // Create a simple profile, as FRIEND, with beta version, which is not visible to FRIEND // This should not include the beta version let profile = api - .create_minecraft_profile( + .create_client_profile( "test", "fabric", "1.0.0", @@ -795,14 +797,14 @@ async fn hidden_versions_are_forbidden() { FRIEND_USER_PAT, ) .await; - assert_eq!(profile.status(), 200); - let profile: MinecraftProfile = test::read_body_json(profile).await; + assert_status(&profile, StatusCode::OK); + let profile: ClientProfile = test::read_body_json(profile).await; assert_eq!(profile.versions, vec![alpha_version_id_parsed]); // Edit profile, as FRIEND, with beta version, which is not visible to FRIEND // This should fail let resp = api - .edit_minecraft_profile( + .edit_client_profile( &profile.id.to_string(), None, None, @@ -812,12 +814,12 @@ async fn hidden_versions_are_forbidden() { FRIEND_USER_PAT, ) .await; - assert_eq!(resp.status(), 204); + assert_status(&resp, StatusCode::NO_CONTENT); // Get the profile and check the versions // Empty, because alpha is removed, and beta is not visible let profile = api - .get_minecraft_profile_deserialized(&profile.id.to_string(), FRIEND_USER_PAT) + .get_client_profile_deserialized(&profile.id.to_string(), FRIEND_USER_PAT) .await; assert_eq!(profile.versions, vec![]); }) From 961af8ab22ada39d8fe35d9f77e79d64ce010856 Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Fri, 5 Jan 2024 16:02:17 -0800 Subject: [PATCH 12/25] removed println --- src/routes/v3/client/profiles.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/routes/v3/client/profiles.rs b/src/routes/v3/client/profiles.rs index d17dd6ac..165560d5 100644 --- a/src/routes/v3/client/profiles.rs +++ b/src/routes/v3/client/profiles.rs @@ -103,7 +103,6 @@ pub async fn profile_create( session_queue: Data, ) -> Result { let profile_create_data = profile_create_data.into_inner(); - println!("creat {:?}", serde_json::to_string(&profile_create_data)); // The currently logged in user let current_user = get_user_from_headers( &req, From eadae95076189200c4fd242f8969d3f7a758b31d Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Fri, 12 Jan 2024 14:14:51 -0800 Subject: [PATCH 13/25] merge fixes --- src/routes/v3/client/profiles.rs | 4 +- tests/common/api_v3/client_profile.rs | 18 +++-- tests/profiles.rs | 107 +++++++++++++------------- 3 files changed, 65 insertions(+), 64 deletions(-) diff --git a/src/routes/v3/client/profiles.rs b/src/routes/v3/client/profiles.rs index 165560d5..7e5112e1 100644 --- a/src/routes/v3/client/profiles.rs +++ b/src/routes/v3/client/profiles.rs @@ -128,7 +128,7 @@ pub async fn profile_create( .await? .ok_or_else(|| CreateError::InvalidInput("Invalid Client game".to_string()))?; - let game_version_id = MinecraftGameVersion::list(&**client, &redis) + let game_version_id = MinecraftGameVersion::list(None, None, &**client, &redis) .await? .into_iter() .find(|x| x.version == game_version) @@ -372,7 +372,7 @@ pub async fn profile_edit( if let Some(game_version) = edit_data.game_version { let new_game_id = database::models::legacy_loader_fields::MinecraftGameVersion::list( - &**pool, &redis, + None, None, &**pool, &redis, ) .await? .into_iter() diff --git a/tests/common/api_v3/client_profile.rs b/tests/common/api_v3/client_profile.rs index 8aeaf397..b91799dd 100644 --- a/tests/common/api_v3/client_profile.rs +++ b/tests/common/api_v3/client_profile.rs @@ -14,10 +14,12 @@ use labrinth::{ }; use serde_json::json; -use crate::common::{ - api_common::{request_data::ImageData, Api, AppendsOptionalPat}, - asserts::assert_status, - dummy_data::TestFile, +use crate::{ + assert_status, + common::{ + api_common::{request_data::ImageData, Api, AppendsOptionalPat}, + dummy_data::TestFile, + }, }; use super::ApiV3; @@ -101,7 +103,7 @@ impl ApiV3 { pat: Option<&str>, ) -> ClientProfile { let resp = self.get_client_profile(id, pat).await; - assert_status(&resp, StatusCode::OK); + assert_status!(&resp, StatusCode::OK); test::read_body_json(resp).await } @@ -211,7 +213,7 @@ impl ApiV3 { pat: Option<&str>, ) -> ClientProfileShareLink { let resp = self.generate_client_profile_share_link(id, pat).await; - assert_status(&resp, StatusCode::OK); + assert_status!(&resp, StatusCode::OK); test::read_body_json(resp).await } @@ -240,7 +242,7 @@ impl ApiV3 { let resp = self .get_client_profile_share_link(profile_id, url_identifier, pat) .await; - assert_status(&resp, StatusCode::OK); + assert_status!(&resp, StatusCode::OK); test::read_body_json(resp).await } @@ -279,7 +281,7 @@ impl ApiV3 { pat: Option<&str>, ) -> ProfileDownload { let resp = self.download_client_profile(profile_id, pat).await; - assert_status(&resp, StatusCode::OK); + assert_status!(&resp, StatusCode::OK); test::read_body_json(resp).await } diff --git a/tests/profiles.rs b/tests/profiles.rs index bfe68454..312b27f9 100644 --- a/tests/profiles.rs +++ b/tests/profiles.rs @@ -11,7 +11,6 @@ use labrinth::models::users::UserId; use sha2::Digest; use crate::common::api_v3::client_profile::ClientProfileOverride; -use crate::common::asserts::assert_status; use crate::common::dummy_data::DummyImage; use crate::common::dummy_data::TestFile; @@ -42,13 +41,13 @@ async fn create_modify_profile() { USER_USER_PAT, ) .await; - assert_status(&resp, StatusCode::BAD_REQUEST); + assert_status!(&resp, StatusCode::BAD_REQUEST); // Currently fake version for loader is not checked // let resp = api // .create_client_profile("test", "fabric", "fake", "1.20.1", vec![], USER_USER_PAT) // .await; - // assert_status(&resp, StatusCode::BAD_REQUEST); + // assert_status!(&resp, StatusCode::BAD_REQUEST); let resp = api .create_client_profile( @@ -60,19 +59,19 @@ async fn create_modify_profile() { USER_USER_PAT, ) .await; - assert_status(&resp, StatusCode::BAD_REQUEST); + assert_status!(&resp, StatusCode::BAD_REQUEST); let resp = api .create_client_profile("test", "fabric", "1.0.0", "1.19.1", vec![], USER_USER_PAT) .await; - assert_status(&resp, StatusCode::BAD_REQUEST); + assert_status!(&resp, StatusCode::BAD_REQUEST); // Create a simple profile // should succeed let profile = api .create_client_profile("test", "fabric", "1.0.0", "1.20.1", vec![], USER_USER_PAT) .await; - assert_status(&profile, StatusCode::OK); + assert_status!(&profile, StatusCode::OK); let profile: ClientProfile = test::read_body_json(profile).await; let id = profile.id.to_string(); @@ -100,7 +99,7 @@ async fn create_modify_profile() { USER_USER_PAT, ) .await; - assert_status(&resp, StatusCode::BAD_REQUEST); + assert_status!(&resp, StatusCode::BAD_REQUEST); // Currently fake version for loader is not checked // let resp = api @@ -113,7 +112,7 @@ async fn create_modify_profile() { // USER_USER_PAT, // ) // .await; - // assert_status(&resp, StatusCode::BAD_REQUEST); + // assert_status!(&resp, StatusCode::BAD_REQUEST); let resp = api .edit_client_profile( @@ -126,7 +125,7 @@ async fn create_modify_profile() { USER_USER_PAT, ) .await; - assert_status(&resp, StatusCode::BAD_REQUEST); + assert_status!(&resp, StatusCode::BAD_REQUEST); // Can't modify the profile as another user let resp = api @@ -140,7 +139,7 @@ async fn create_modify_profile() { FRIEND_USER_PAT, ) .await; - assert_status(&resp, StatusCode::UNAUTHORIZED); + assert_status!(&resp, StatusCode::UNAUTHORIZED); // Get and make sure the properties are the same let profile = api @@ -165,7 +164,7 @@ async fn create_modify_profile() { USER_USER_PAT, ) .await; - assert_status(&resp, StatusCode::NO_CONTENT); + assert_status!(&resp, StatusCode::NO_CONTENT); // Get the profile and check the properties let profile = api @@ -191,7 +190,7 @@ async fn create_modify_profile() { USER_USER_PAT, ) .await; - assert_status(&resp, StatusCode::NO_CONTENT); + assert_status!(&resp, StatusCode::NO_CONTENT); // Get the profile and check the properties let profile = api @@ -219,7 +218,7 @@ async fn accept_share_link() { let profile = api .create_client_profile("test", "fabric", "1.0.0", "1.20.1", vec![], USER_USER_PAT) .await; - assert_status(&profile, StatusCode::OK); + assert_status!(&profile, StatusCode::OK); let profile: ClientProfile = test::read_body_json(profile).await; let id = profile.id.to_string(); let users: Vec = profile.users.unwrap(); @@ -253,7 +252,7 @@ async fn accept_share_link() { let resp = api .accept_client_profile_share_link(&id, &share_link.url_identifier, FRIEND_USER_PAT) .await; - assert_status(&resp, StatusCode::NO_CONTENT); + assert_status!(&resp, StatusCode::NO_CONTENT); // Profile users should now include the friend let profile = api @@ -279,9 +278,9 @@ async fn accept_share_link() { .accept_client_profile_share_link(&id, &share_link.url_identifier, *pat) .await; if i == 0 || i == 1 || i == 6 { - assert_status(&resp, StatusCode::BAD_REQUEST); + assert_status!(&resp, StatusCode::BAD_REQUEST); } else { - assert_status(&resp, StatusCode::NO_CONTENT); + assert_status!(&resp, StatusCode::NO_CONTENT); } } }) @@ -307,7 +306,7 @@ async fn delete_profile() { USER_USER_PAT, ) .await; - assert_status(&profile, StatusCode::OK); + assert_status!(&profile, StatusCode::OK); let profile: ClientProfile = test::read_body_json(profile).await; let id = profile.id.to_string(); @@ -322,7 +321,7 @@ async fn delete_profile() { USER_USER_PAT, ) .await; - assert_status(&resp, StatusCode::NO_CONTENT); + assert_status!(&resp, StatusCode::NO_CONTENT); // Invite a friend to the profile and accept it let share_link = api @@ -331,7 +330,7 @@ async fn delete_profile() { let resp = api .accept_client_profile_share_link(&id, &share_link.url_identifier, FRIEND_USER_PAT) .await; - assert_status(&resp, StatusCode::NO_CONTENT); + assert_status!(&resp, StatusCode::NO_CONTENT); // Get a token as the friend let token = api @@ -342,27 +341,27 @@ async fn delete_profile() { let resp = api .check_download_client_profile_token(&token.override_cdns[0].0, FRIEND_USER_PAT) .await; - assert_status(&resp, StatusCode::OK); + assert_status!(&resp, StatusCode::OK); // Delete the profile as the friend // Should fail let resp = api.delete_client_profile(&id, FRIEND_USER_PAT).await; - assert_status(&resp, StatusCode::UNAUTHORIZED); + assert_status!(&resp, StatusCode::UNAUTHORIZED); // Delete the profile as the user // Should succeed let resp = api.delete_client_profile(&id, USER_USER_PAT).await; - assert_status(&resp, StatusCode::NO_CONTENT); + assert_status!(&resp, StatusCode::NO_CONTENT); // Confirm the profile is gone let resp = api.get_client_profile(&id, USER_USER_PAT).await; - assert_status(&resp, StatusCode::NOT_FOUND); + assert_status!(&resp, StatusCode::NOT_FOUND); // Confirm the token is gone let resp = api .check_download_client_profile_token(&token.override_cdns[0].0, FRIEND_USER_PAT) .await; - assert_status(&resp, StatusCode::UNAUTHORIZED); + assert_status!(&resp, StatusCode::UNAUTHORIZED); }) .await; } @@ -378,7 +377,7 @@ async fn download_profile() { let profile = api .create_client_profile("test", "fabric", "1.0.0", "1.20.1", vec![], USER_USER_PAT) .await; - assert_status(&profile, StatusCode::OK); + assert_status!(&profile, StatusCode::OK); let profile: ClientProfile = test::read_body_json(profile).await; let id = profile.id.to_string(); @@ -393,16 +392,16 @@ async fn download_profile() { USER_USER_PAT, ) .await; - assert_status(&resp, StatusCode::NO_CONTENT); + assert_status!(&resp, StatusCode::NO_CONTENT); // As 'user', try to generate a download link for the profile let resp = api.download_client_profile(&id, USER_USER_PAT).await; - assert_status(&resp, StatusCode::OK); + assert_status!(&resp, StatusCode::OK); // As 'friend', try to get the download links for the profile // Not invited yet, should fail let resp = api.download_client_profile(&id, FRIEND_USER_PAT).await; - assert_status(&resp, StatusCode::UNAUTHORIZED); + assert_status!(&resp, StatusCode::UNAUTHORIZED); // As 'user', try to generate a share link for the profile, and accept it as 'friend' let share_link = api @@ -411,7 +410,7 @@ async fn download_profile() { let resp = api .accept_client_profile_share_link(&id, &share_link.url_identifier, FRIEND_USER_PAT) .await; - assert_status(&resp, StatusCode::NO_CONTENT); + assert_status!(&resp, StatusCode::NO_CONTENT); // As 'friend', try to get the download links for the profile // Should succeed @@ -421,7 +420,7 @@ async fn download_profile() { // But enemy should fail let resp = api.download_client_profile(&id, ENEMY_USER_PAT).await; - assert_status(&resp, StatusCode::UNAUTHORIZED); + assert_status!(&resp, StatusCode::UNAUTHORIZED); // Download url should be: // - CDN url @@ -439,16 +438,16 @@ async fn download_profile() { let resp = api .check_download_client_profile_token(&override_file_url, None) .await; - assert_status(&resp, StatusCode::UNAUTHORIZED); + assert_status!(&resp, StatusCode::UNAUTHORIZED); let resp = api .check_download_client_profile_token(&override_file_url, ENEMY_USER_PAT) .await; - assert_status(&resp, StatusCode::UNAUTHORIZED); + assert_status!(&resp, StatusCode::UNAUTHORIZED); let resp = api .check_download_client_profile_token("bad_url", FRIEND_USER_PAT) .await; - assert_status(&resp, StatusCode::UNAUTHORIZED); + assert_status!(&resp, StatusCode::UNAUTHORIZED); let resp = api .check_download_client_profile_token( @@ -460,14 +459,14 @@ async fn download_profile() { FRIEND_USER_PAT, ) .await; - assert_status(&resp, StatusCode::UNAUTHORIZED); + assert_status!(&resp, StatusCode::UNAUTHORIZED); // Check cloudflare helper route to confirm this is a valid allowable access token // We attach it as an authorization token and call the route let resp = api .check_download_client_profile_token(&override_file_url, FRIEND_USER_PAT) .await; - assert_status(&resp, StatusCode::OK); + assert_status!(&resp, StatusCode::OK); // As user, remove friend from profile let resp = api @@ -481,7 +480,7 @@ async fn download_profile() { USER_USER_PAT, ) .await; - assert_status(&resp, StatusCode::NO_CONTENT); + assert_status!(&resp, StatusCode::NO_CONTENT); // Confirm friend is no longer on the profile let profile = api @@ -491,13 +490,13 @@ async fn download_profile() { // Confirm friend can no longer download the profile let resp = api.download_client_profile(&id, FRIEND_USER_PAT).await; - assert_status(&resp, StatusCode::UNAUTHORIZED); + assert_status!(&resp, StatusCode::UNAUTHORIZED); // Confirm token invalidation let resp = api .check_download_client_profile_token(&override_file_url, FRIEND_USER_PAT) .await; - assert_status(&resp, StatusCode::UNAUTHORIZED); + assert_status!(&resp, StatusCode::UNAUTHORIZED); // Confirm user can still download the profile let resp = api @@ -518,7 +517,7 @@ async fn add_remove_profile_icon() { let profile = api .create_client_profile("test", "fabric", "1.0.0", "1.20.1", vec![], USER_USER_PAT) .await; - assert_status(&profile, StatusCode::OK); + assert_status!(&profile, StatusCode::OK); let profile: ClientProfile = test::read_body_json(profile).await; // Add an icon to the profile @@ -529,7 +528,7 @@ async fn add_remove_profile_icon() { USER_USER_PAT, ) .await; - assert_status(&icon, StatusCode::NO_CONTENT); + assert_status!(&icon, StatusCode::NO_CONTENT); // Get the profile and check the icon let profile = api @@ -541,7 +540,7 @@ async fn add_remove_profile_icon() { let icon = api .edit_client_profile_icon(&profile.id.to_string(), None, USER_USER_PAT) .await; - assert_status(&icon, StatusCode::NO_CONTENT); + assert_status!(&icon, StatusCode::NO_CONTENT); // Get the profile and check the icon let profile = api @@ -562,7 +561,7 @@ async fn add_remove_profile_versions() { let profile = api .create_client_profile("test", "fabric", "1.0.0", "1.20.1", vec![], USER_USER_PAT) .await; - assert_status(&profile, StatusCode::OK); + assert_status!(&profile, StatusCode::OK); let profile: ClientProfile = test::read_body_json(profile).await; let updated = profile.updated; // Save this- it will update when we modify the versions/overrides @@ -578,7 +577,7 @@ async fn add_remove_profile_versions() { USER_USER_PAT, ) .await; - assert_status(&resp, StatusCode::NO_CONTENT); + assert_status!(&resp, StatusCode::NO_CONTENT); // Add an override file to the profile let resp = api @@ -591,7 +590,7 @@ async fn add_remove_profile_versions() { USER_USER_PAT, ) .await; - assert_status(&resp, StatusCode::NO_CONTENT); + assert_status!(&resp, StatusCode::NO_CONTENT); // Add a second version to the profile let resp = api @@ -604,7 +603,7 @@ async fn add_remove_profile_versions() { USER_USER_PAT, ) .await; - assert_status(&resp, StatusCode::NO_CONTENT); + assert_status!(&resp, StatusCode::NO_CONTENT); // Get the profile and check the versions let profile = api @@ -628,7 +627,7 @@ async fn add_remove_profile_versions() { let profile_enemy = api .create_client_profile("test2", "fabric", "1.0.0", "1.20.1", vec![], ENEMY_USER_PAT) .await; - assert_status(&profile_enemy, StatusCode::OK); + assert_status!(&profile_enemy, StatusCode::OK); let profile_enemy: ClientProfile = test::read_body_json(profile_enemy).await; let id_enemy = profile_enemy.id.to_string(); @@ -643,7 +642,7 @@ async fn add_remove_profile_versions() { ENEMY_USER_PAT, ) .await; - assert_status(&resp, StatusCode::NO_CONTENT); + assert_status!(&resp, StatusCode::NO_CONTENT); // Get the profile and check the versions let profile_enemy = api @@ -664,7 +663,7 @@ async fn add_remove_profile_versions() { USER_USER_PAT, ) .await; - assert_status(&resp, StatusCode::NO_CONTENT); + assert_status!(&resp, StatusCode::NO_CONTENT); // Should still exist in the enemy's profile, but not the user's let profile_enemy = api @@ -699,7 +698,7 @@ async fn add_remove_profile_versions() { ENEMY_USER_PAT, ) .await; - assert_status(&resp, StatusCode::NO_CONTENT); // Allow failure to return success, it just doesn't delete anything + assert_status!(&resp, StatusCode::NO_CONTENT); // Allow failure to return success, it just doesn't delete anything // Then, by hash let resp = api @@ -714,7 +713,7 @@ async fn add_remove_profile_versions() { ENEMY_USER_PAT, ) .await; - assert_status(&resp, StatusCode::NO_CONTENT); // Allow failure to return success, it just doesn't delete anything + assert_status!(&resp, StatusCode::NO_CONTENT); // Allow failure to return success, it just doesn't delete anything // Confirm user still has it let profile = api @@ -742,7 +741,7 @@ async fn add_remove_profile_versions() { USER_USER_PAT, ) .await; - assert_status(&resp, StatusCode::NO_CONTENT); + assert_status!(&resp, StatusCode::NO_CONTENT); // Confirm user no longer has it let profile = api @@ -764,7 +763,7 @@ async fn add_remove_profile_versions() { USER_USER_PAT, ) .await; - assert_status(&resp, StatusCode::NO_CONTENT); + assert_status!(&resp, StatusCode::NO_CONTENT); // Confirm user no longer has it let profile = api @@ -797,7 +796,7 @@ async fn hidden_versions_are_forbidden() { FRIEND_USER_PAT, ) .await; - assert_status(&profile, StatusCode::OK); + assert_status!(&profile, StatusCode::OK); let profile: ClientProfile = test::read_body_json(profile).await; assert_eq!(profile.versions, vec![alpha_version_id_parsed]); @@ -814,7 +813,7 @@ async fn hidden_versions_are_forbidden() { FRIEND_USER_PAT, ) .await; - assert_status(&resp, StatusCode::NO_CONTENT); + assert_status!(&resp, StatusCode::NO_CONTENT); // Get the profile and check the versions // Empty, because alpha is removed, and beta is not visible From 5caf6834bc4f3c7f6c0ab1c75732af40a02f8570 Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Fri, 12 Jan 2024 16:49:26 -0800 Subject: [PATCH 14/25] temporary commit --- migrations/20231226012200_shared_modpacks.sql | 4 +- src/database/models/client_profile_item.rs | 81 +++++-------------- src/models/v3/client/profile.rs | 18 ++--- src/routes/{v3 => internal}/client/mod.rs | 0 .../{v3 => internal}/client/profiles.rs | 14 +--- src/routes/internal/mod.rs | 2 + src/routes/v3/mod.rs | 2 - tests/common/api_v3/client_profile.rs | 28 +++---- tests/profiles.rs | 8 +- 9 files changed, 51 insertions(+), 106 deletions(-) rename src/routes/{v3 => internal}/client/mod.rs (100%) rename src/routes/{v3 => internal}/client/profiles.rs (98%) diff --git a/migrations/20231226012200_shared_modpacks.sql b/migrations/20231226012200_shared_modpacks.sql index ce92efd6..c5a2b98c 100644 --- a/migrations/20231226012200_shared_modpacks.sql +++ b/migrations/20231226012200_shared_modpacks.sql @@ -7,10 +7,8 @@ CREATE TABLE shared_profiles ( updated timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, created timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, - maximum_users integer NOT NULL, - loader_id int NOT NULL REFERENCES loaders(id), - loader_version varchar(255) NOT NULL, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, game_id int NOT NULL REFERENCES games(id), game_version_id int NULL REFERENCES loader_field_enum_values(id) -- Minecraft java diff --git a/src/database/models/client_profile_item.rs b/src/database/models/client_profile_item.rs index 2611fb0d..936384d9 100644 --- a/src/database/models/client_profile_item.rs +++ b/src/database/models/client_profile_item.rs @@ -22,11 +22,10 @@ pub struct ClientProfile { pub created: DateTime, pub updated: DateTime, - pub game: ClientProfileGame, + pub game_id: GameId, + pub game_name: String, + pub metadata: ClientProfileMetadata, - pub loader_version: String, - - pub maximum_users: i32, pub users: Vec, // These represent the same loader @@ -38,48 +37,13 @@ pub struct ClientProfile { } #[derive(Clone, Debug, Serialize, Deserialize)] -pub enum ClientProfileGame { +pub enum ClientProfileMetadata { Minecraft { - game_id: GameId, - game_name: String, + loader_version: String, game_version_id: LoaderFieldEnumValueId, game_version: String, }, - Unknown { - game_id: GameId, - game_name: String, - }, -} - -impl ClientProfileGame { - pub fn from( - game_name: String, - game_id: GameId, - game_version: Option<(String, LoaderFieldEnumValueId)>, - ) -> Self { - match game_name.as_str() { - "minecraft" => { - if let Some((game_version, game_version_id)) = game_version { - Self::Minecraft { - game_id, - game_name, - game_version_id, - game_version, - } - } else { - Self::Unknown { game_id, game_name } - } - } - _ => Self::Unknown { game_id, game_name }, - } - } - - pub fn game_id(&self) -> GameId { - match self { - Self::Minecraft { game_id, .. } => *game_id, - Self::Unknown { game_id, .. } => *game_id, - } - } + Unknown, } impl ClientProfile { @@ -87,15 +51,20 @@ impl ClientProfile { &self, transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, ) -> Result<(), DatabaseError> { + + let metadata = serde_json::to_value(&self.metadata).map_err(|e| { + DatabaseError::SchemaError(format!("Could not serialize metadata: {}", e)) + })?; + sqlx::query!( " INSERT INTO shared_profiles ( id, name, owner_id, icon_url, created, updated, - game_version_id, loader_id, loader_version, maximum_users, game_id + loader_id, game_id, metadata ) VALUES ( $1, $2, $3, $4, $5, $6, - $7, $8, $9, $10, $11 + $7, $8, $9 ) ", self.id as ClientProfileId, @@ -104,18 +73,9 @@ impl ClientProfile { self.icon_url, self.created, self.updated, - if let ClientProfileGame::Minecraft { - game_version_id, .. - } = &self.game - { - Some(game_version_id.0) - } else { - None - }, self.loader_id as LoaderId, - self.loader_version, - self.maximum_users, - self.game.game_id().0 + self.game_id.0, + metadata ) .execute(&mut **transaction) .await?; @@ -316,7 +276,7 @@ impl ClientProfile { let db_profiles: Vec = sqlx::query!( r#" SELECT sp.id, sp.name, sp.owner_id, sp.icon_url, sp.created, sp.updated, sp.game_version_id, sp.loader_id, - l.loader, sp.loader_version, sp.maximum_users, g.name as game_name, g.id as game_id, lfev.value as "game_version?", + l.loader, sp.loader_version, g.name as game_name, g.id as game_id, lfev.value as "game_version?", ARRAY_AGG(DISTINCT spu.user_id) filter (WHERE spu.user_id IS NOT NULL) as users FROM shared_profiles sp LEFT JOIN loaders l ON l.id = sp.loader_id @@ -339,7 +299,8 @@ impl ClientProfile { (Some(game_version), Some(game_version_id)) => Some((game_version, LoaderFieldEnumValueId(game_version_id))), _ => None }; - let game = ClientProfileGame::from(m.game_name, GameId(m.game_id), game_version); + let game_id = GameId(m.game_id); + let metadata = serde_json::from_value::(m.metadata).unwrap_or(ClientProfileMetadata::Unknown); ClientProfile { id, name: m.name, @@ -347,12 +308,12 @@ impl ClientProfile { updated: m.updated, created: m.created, owner_id: UserId(m.owner_id), - game, + game_id, users: m.users.unwrap_or_default().into_iter().map(UserId).collect(), loader_id: LoaderId(m.loader_id), - loader_version: m.loader_version, + game_name: m.game_name, + metadata, loader: m.loader, - maximum_users: m.maximum_users, versions, overrides: files } diff --git a/src/models/v3/client/profile.rs b/src/models/v3/client/profile.rs index f1b11aae..ec60d77b 100644 --- a/src/models/v3/client/profile.rs +++ b/src/models/v3/client/profile.rs @@ -8,9 +8,6 @@ use crate::{ models::ids::{Base62Id, UserId, VersionId}, }; -// How many uses should a share link have before it becomes invalid? -pub const DEFAULT_PROFILE_MAX_USERS: u32 = 5; - /// The ID of a specific profile, encoded as base62 for usage in the API #[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug)] #[serde(from = "Base62Id")] @@ -34,8 +31,6 @@ pub struct ClientProfile { /// The icon of the project. pub icon_url: Option, - // Maximum number of users that can be associated with this profile - pub max_users: u32, // Users that are associated with this profile // Hidden if the user is not the owner pub users: Option>, @@ -47,7 +42,7 @@ pub struct ClientProfile { /// Game-specific information #[serde(flatten)] - pub game: ClientProfileGame, + pub game: ClientProfileMetadata, /// Modrinth-associated versions pub versions: Vec, @@ -58,7 +53,7 @@ pub struct ClientProfile { #[derive(Serialize, Deserialize, Clone)] #[serde(tag = "game")] -pub enum ClientProfileGame { +pub enum ClientProfileMetadata { #[serde(rename = "minecraft-java")] Minecraft { /// Game Id (constant for Minecraft) @@ -70,10 +65,10 @@ pub enum ClientProfileGame { Unknown, } -impl From for ClientProfileGame { - fn from(game: database::models::client_profile_item::ClientProfileGame) -> Self { +impl From for ClientProfileMetadata { + fn from(game: database::models::client_profile_item::ClientProfileMetadata) -> Self { match game { - database::models::client_profile_item::ClientProfileGame::Minecraft { + database::models::client_profile_item::ClientProfileMetadata::Minecraft { game_name, game_version, .. @@ -81,7 +76,7 @@ impl From for ClientPr game_name, game_version, }, - database::models::client_profile_item::ClientProfileGame::Unknown { .. } => { + database::models::client_profile_item::ClientProfileMetadata::Unknown { .. } => { Self::Unknown } } @@ -106,7 +101,6 @@ impl ClientProfile { created: profile.created, updated: profile.updated, icon_url: profile.icon_url, - max_users: profile.maximum_users as u32, users, loader: profile.loader, loader_version: profile.loader_version, diff --git a/src/routes/v3/client/mod.rs b/src/routes/internal/client/mod.rs similarity index 100% rename from src/routes/v3/client/mod.rs rename to src/routes/internal/client/mod.rs diff --git a/src/routes/v3/client/profiles.rs b/src/routes/internal/client/profiles.rs similarity index 98% rename from src/routes/v3/client/profiles.rs rename to src/routes/internal/client/profiles.rs index 7e5112e1..b49f4ec8 100644 --- a/src/routes/v3/client/profiles.rs +++ b/src/routes/internal/client/profiles.rs @@ -7,7 +7,7 @@ use crate::database::models::{ use crate::database::redis::RedisPool; use crate::file_hosting::FileHost; use crate::models::client::profile::{ - ClientProfile, ClientProfileId, ClientProfileShareLink, DEFAULT_PROFILE_MAX_USERS, + ClientProfile, ClientProfileId, ClientProfileShareLink, }; use crate::models::ids::base62_impl::parse_base62; use crate::models::ids::{UserId, VersionId}; @@ -118,7 +118,7 @@ pub async fn profile_create( .validate() .map_err(|err| CreateError::InvalidInput(validation_errors_to_string(err, None)))?; - let game: client_profile_item::ClientProfileGame = match profile_create_data.game { + let game: client_profile_item::ClientProfileMetadata = match profile_create_data.game { ProfileCreateDataGame::MinecraftJava { game_version } => { let game = database::models::loader_fields::Game::get_slug( "minecraft-java", @@ -137,7 +137,7 @@ pub async fn profile_create( })? .id; - client_profile_item::ClientProfileGame::Minecraft { + client_profile_item::ClientProfileMetadata::Minecraft { game_id: game.id, game_name: "minecraft-java".to_string(), game_version_id, @@ -191,7 +191,6 @@ pub async fn profile_create( loader_id, loader: profile_create_data.loader, loader_version: profile_create_data.loader_version, - maximum_users: DEFAULT_PROFILE_MAX_USERS as i32, users: vec![current_user.id.into()], versions, overrides: Vec::new(), @@ -668,13 +667,6 @@ pub async fn accept_share_link( )); } - // Confirm we are not over the maximum users - if data.maximum_users <= data.users.len() as i32 { - return Err(ApiError::InvalidInput( - "This profile has too many users".to_string(), - )); - } - // Add the user to the team sqlx::query!( "INSERT INTO shared_profiles_users (shared_profile_id, user_id) VALUES ($1, $2)", diff --git a/src/routes/internal/mod.rs b/src/routes/internal/mod.rs index 81ac4c9b..77aec77f 100644 --- a/src/routes/internal/mod.rs +++ b/src/routes/internal/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod admin; +pub mod client; pub mod flows; pub mod pats; pub mod session; @@ -12,6 +13,7 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) { actix_web::web::scope("_internal") .wrap(default_cors()) .configure(admin::config) + .configure(client::profiles::config) // TODO: write tests that catch these .configure(oauth_clients::config) .configure(session::config) diff --git a/src/routes/v3/mod.rs b/src/routes/v3/mod.rs index 94179131..a5165fec 100644 --- a/src/routes/v3/mod.rs +++ b/src/routes/v3/mod.rs @@ -4,7 +4,6 @@ use actix_web::{web, HttpResponse}; use serde_json::json; pub mod analytics_get; -pub mod client; pub mod collections; pub mod images; pub mod moderation; @@ -32,7 +31,6 @@ pub fn config(cfg: &mut web::ServiceConfig) { .configure(analytics_get::config) .configure(collections::config) .configure(images::config) - .configure(client::profiles::config) .configure(moderation::config) .configure(notifications::config) .configure(organizations::config) diff --git a/tests/common/api_v3/client_profile.rs b/tests/common/api_v3/client_profile.rs index b91799dd..be676f82 100644 --- a/tests/common/api_v3/client_profile.rs +++ b/tests/common/api_v3/client_profile.rs @@ -9,7 +9,7 @@ use bytes::Bytes; use itertools::Itertools; use labrinth::{ models::client::profile::{ClientProfile, ClientProfileShareLink}, - routes::v3::client::profiles::ProfileDownload, + routes::internal::client::profiles::ProfileDownload, util::actix::{AppendsMultipart, MultipartSegment, MultipartSegmentData}, }; use serde_json::json; @@ -50,7 +50,7 @@ impl ApiV3 { pat: Option<&str>, ) -> ServiceResponse { let req = test::TestRequest::post() - .uri("/v3/client/profile") + .uri("/_internal/client/profile") .append_pat(pat) .set_json(json!({ "name": name, @@ -76,7 +76,7 @@ impl ApiV3 { pat: Option<&str>, ) -> ServiceResponse { let req = test::TestRequest::patch() - .uri(&format!("/v3/client/profile/{}", id)) + .uri(&format!("/_internal/client/profile/{}", id)) .append_pat(pat) .set_json(json!({ "name": name, @@ -91,7 +91,7 @@ impl ApiV3 { pub async fn get_client_profile(&self, id: &str, pat: Option<&str>) -> ServiceResponse { let req = TestRequest::get() - .uri(&format!("/v3/client/profile/{}", id)) + .uri(&format!("/_internal/client/profile/{}", id)) .append_pat(pat) .to_request(); self.call(req).await @@ -109,7 +109,7 @@ impl ApiV3 { pub async fn delete_client_profile(&self, id: &str, pat: Option<&str>) -> ServiceResponse { let req = TestRequest::delete() - .uri(&format!("/v3/client/profile/{}", id)) + .uri(&format!("/_internal/client/profile/{}", id)) .append_pat(pat) .to_request(); self.call(req).await @@ -124,7 +124,7 @@ impl ApiV3 { if let Some(icon) = icon { let req = TestRequest::patch() .uri(&format!( - "/v3/client/profile/{}/icon?ext={}", + "/_internal/client/profile/{}/icon?ext={}", id, icon.extension )) .append_pat(pat) @@ -133,7 +133,7 @@ impl ApiV3 { self.call(req).await } else { let req = TestRequest::delete() - .uri(&format!("/v3/client/profile/{}/icon", id)) + .uri(&format!("/_internal/client/profile/{}/icon", id)) .append_pat(pat) .to_request(); self.call(req).await @@ -170,7 +170,7 @@ impl ApiV3 { .collect_vec(); let req = TestRequest::post() - .uri(&format!("/v3/client/profile/{}/override", id)) + .uri(&format!("/_internal/client/profile/{}/override", id)) .append_pat(pat) .set_multipart(multipart_segments) .to_request(); @@ -185,7 +185,7 @@ impl ApiV3 { pat: Option<&str>, ) -> ServiceResponse { let req = TestRequest::delete() - .uri(&format!("/v3/client/profile/{}/override", id)) + .uri(&format!("/_internal/client/profile/{}/override", id)) .set_json(json!({ "install_paths": install_paths, "hashes": hashes @@ -201,7 +201,7 @@ impl ApiV3 { pat: Option<&str>, ) -> ServiceResponse { let req = TestRequest::get() - .uri(&format!("/v3/client/profile/{}/share", id)) + .uri(&format!("/_internal/client/profile/{}/share", id)) .append_pat(pat) .to_request(); self.call(req).await @@ -225,7 +225,7 @@ impl ApiV3 { ) -> ServiceResponse { let req = TestRequest::get() .uri(&format!( - "/v3/client/profile/{}/share/{}", + "/_internal/client/profile/{}/share/{}", profile_id, url_identifier )) .append_pat(pat) @@ -254,7 +254,7 @@ impl ApiV3 { ) -> ServiceResponse { let req = TestRequest::post() .uri(&format!( - "/v3/client/profile/{}/accept/{}", + "/_internal/client/profile/{}/accept/{}", profile_id, url_identifier )) .append_pat(pat) @@ -269,7 +269,7 @@ impl ApiV3 { pat: Option<&str>, ) -> ServiceResponse { let req = TestRequest::get() - .uri(&format!("/v3/client/profile/{}/download", profile_id)) + .uri(&format!("/_internal/client/profile/{}/download", profile_id)) .append_pat(pat) .to_request(); self.call(req).await @@ -292,7 +292,7 @@ impl ApiV3 { ) -> ServiceResponse { let req = TestRequest::get() .uri(&format!( - "/v3/client/check_token?url={url}", + "/_internal/client/check_token?url={url}", url = urlencoding::encode(url) )) .append_pat(pat) diff --git a/tests/profiles.rs b/tests/profiles.rs index 312b27f9..b950ee81 100644 --- a/tests/profiles.rs +++ b/tests/profiles.rs @@ -101,7 +101,7 @@ async fn create_modify_profile() { .await; assert_status!(&resp, StatusCode::BAD_REQUEST); - // Currently fake version for loader is not checked + // TODO: Currently fake version for loader is not checked // let resp = api // .edit_client_profile( // &profile.id.to_string(), @@ -264,20 +264,20 @@ async fn accept_share_link() { assert_eq!(users[0].0, USER_USER_ID_PARSED as u64); assert_eq!(users[1].0, FRIEND_USER_ID_PARSED as u64); - // Add all of test dummy users until we hit the limit, the last one should fail + // Add all of test dummy users until we hit the limit let dummy_user_pats = [ USER_USER_PAT, // Fails because owner (and already added) FRIEND_USER_PAT, // Fails because already added OTHER_FRIEND_USER_PAT, MOD_USER_PAT, ADMIN_USER_PAT, - ENEMY_USER_PAT, // Fails because too many users + ENEMY_USER_PAT // If we add a 'max_users' field, this last test could be modified to fail ]; for (i, pat) in dummy_user_pats.iter().enumerate().take(4 + 1) { let resp = api .accept_client_profile_share_link(&id, &share_link.url_identifier, *pat) .await; - if i == 0 || i == 1 || i == 6 { + if i == 0 || i == 1 { assert_status!(&resp, StatusCode::BAD_REQUEST); } else { assert_status!(&resp, StatusCode::NO_CONTENT); From 34517dea48ca9506e98dbfb6e6257c27fdc6c56a Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Fri, 12 Jan 2024 18:32:53 -0800 Subject: [PATCH 15/25] revs --- ...b0b7bc097d9e99f26843c4c10fa6b969b9d4.json} | 8 +- ...222ab04048529502e40bfdd9c4115d1bb424c.json | 15 --- ...b1b68a23c7cd75677a4c095a406cdb07a7fc.json} | 24 +--- ...793c82beff50d82259748027bd721317c2a16.json | 15 --- ...fa744d9edb5f5149f54b9f04338fa7e98e4b1.json | 15 +++ src/database/models/client_profile_item.rs | 8 +- src/models/v3/client/profile.rs | 13 +-- src/routes/internal/client/profiles.rs | 108 +++++++++++------- tests/common/api_v3/client_profile.rs | 5 +- tests/profiles.rs | 46 ++++++-- 10 files changed, 139 insertions(+), 118 deletions(-) rename .sqlx/{query-8ec2964dcf8e9c17f698a7a06dddb7c36d707622bd397c0f72bb12d3ac3d80d2.json => query-16a3482f7dc853615594607413adb0b7bc097d9e99f26843c4c10fa6b969b9d4.json} (54%) delete mode 100644 .sqlx/query-231dc53976b134f645818f66fbb222ab04048529502e40bfdd9c4115d1bb424c.json rename .sqlx/{query-d0341d17fbd638d8f07e2cece3568bf10ae263c70a1a8650817206b2397ed378.json => query-477c02c4dd36d372683be4e98af1b1b68a23c7cd75677a4c095a406cdb07a7fc.json} (60%) delete mode 100644 .sqlx/query-4b06c3a537a2bf453d0c8f01a76793c82beff50d82259748027bd721317c2a16.json create mode 100644 .sqlx/query-d77c356d92fc2e99ed8e8072d9dfa744d9edb5f5149f54b9f04338fa7e98e4b1.json diff --git a/.sqlx/query-8ec2964dcf8e9c17f698a7a06dddb7c36d707622bd397c0f72bb12d3ac3d80d2.json b/.sqlx/query-16a3482f7dc853615594607413adb0b7bc097d9e99f26843c4c10fa6b969b9d4.json similarity index 54% rename from .sqlx/query-8ec2964dcf8e9c17f698a7a06dddb7c36d707622bd397c0f72bb12d3ac3d80d2.json rename to .sqlx/query-16a3482f7dc853615594607413adb0b7bc097d9e99f26843c4c10fa6b969b9d4.json index ba5902ec..364b7876 100644 --- a/.sqlx/query-8ec2964dcf8e9c17f698a7a06dddb7c36d707622bd397c0f72bb12d3ac3d80d2.json +++ b/.sqlx/query-16a3482f7dc853615594607413adb0b7bc097d9e99f26843c4c10fa6b969b9d4.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO shared_profiles (\n id, name, owner_id, icon_url, created, updated,\n game_version_id, loader_id, loader_version, maximum_users, game_id\n )\n VALUES (\n $1, $2, $3, $4, $5, $6, \n $7, $8, $9, $10, $11\n )\n ", + "query": "\n INSERT INTO shared_profiles (\n id, name, owner_id, icon_url, created, updated,\n loader_id, game_id, metadata\n )\n VALUES (\n $1, $2, $3, $4, $5, $6, \n $7, $8, $9\n )\n ", "describe": { "columns": [], "parameters": { @@ -13,12 +13,10 @@ "Timestamptz", "Int4", "Int4", - "Varchar", - "Int4", - "Int4" + "Jsonb" ] }, "nullable": [] }, - "hash": "8ec2964dcf8e9c17f698a7a06dddb7c36d707622bd397c0f72bb12d3ac3d80d2" + "hash": "16a3482f7dc853615594607413adb0b7bc097d9e99f26843c4c10fa6b969b9d4" } diff --git a/.sqlx/query-231dc53976b134f645818f66fbb222ab04048529502e40bfdd9c4115d1bb424c.json b/.sqlx/query-231dc53976b134f645818f66fbb222ab04048529502e40bfdd9c4115d1bb424c.json deleted file mode 100644 index fe84abeb..00000000 --- a/.sqlx/query-231dc53976b134f645818f66fbb222ab04048529502e40bfdd9c4115d1bb424c.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE shared_profiles SET loader_version = $1 WHERE id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "231dc53976b134f645818f66fbb222ab04048529502e40bfdd9c4115d1bb424c" -} diff --git a/.sqlx/query-d0341d17fbd638d8f07e2cece3568bf10ae263c70a1a8650817206b2397ed378.json b/.sqlx/query-477c02c4dd36d372683be4e98af1b1b68a23c7cd75677a4c095a406cdb07a7fc.json similarity index 60% rename from .sqlx/query-d0341d17fbd638d8f07e2cece3568bf10ae263c70a1a8650817206b2397ed378.json rename to .sqlx/query-477c02c4dd36d372683be4e98af1b1b68a23c7cd75677a4c095a406cdb07a7fc.json index 3dd76cf3..850ddd3d 100644 --- a/.sqlx/query-d0341d17fbd638d8f07e2cece3568bf10ae263c70a1a8650817206b2397ed378.json +++ b/.sqlx/query-477c02c4dd36d372683be4e98af1b1b68a23c7cd75677a4c095a406cdb07a7fc.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT sp.id, sp.name, sp.owner_id, sp.icon_url, sp.created, sp.updated, sp.game_version_id, sp.loader_id,\n l.loader, sp.loader_version, sp.maximum_users, g.name as game_name, g.id as game_id, lfev.value as \"game_version?\",\n ARRAY_AGG(DISTINCT spu.user_id) filter (WHERE spu.user_id IS NOT NULL) as users\n FROM shared_profiles sp \n LEFT JOIN loaders l ON l.id = sp.loader_id\n LEFT JOIN shared_profiles_users spu ON spu.shared_profile_id = sp.id\n INNER JOIN games g ON g.id = sp.game_id\n LEFT JOIN loader_field_enum_values lfev ON sp.game_version_id = lfev.id\n WHERE sp.id = ANY($1)\n GROUP BY sp.id, l.id, g.id, lfev.id\n ", + "query": "\n SELECT sp.id, sp.name, sp.owner_id, sp.icon_url, sp.created, sp.updated, sp.game_version_id, sp.loader_id,\n l.loader, g.name as game_name, g.id as game_id, sp.metadata,\n ARRAY_AGG(DISTINCT spu.user_id) filter (WHERE spu.user_id IS NOT NULL) as users\n FROM shared_profiles sp \n LEFT JOIN loaders l ON l.id = sp.loader_id\n LEFT JOIN shared_profiles_users spu ON spu.shared_profile_id = sp.id\n INNER JOIN games g ON g.id = sp.game_id\n LEFT JOIN loader_field_enum_values lfev ON sp.game_version_id = lfev.id\n WHERE sp.id = ANY($1)\n GROUP BY sp.id, l.id, g.id, lfev.id\n ", "describe": { "columns": [ { @@ -50,31 +50,21 @@ }, { "ordinal": 9, - "name": "loader_version", + "name": "game_name", "type_info": "Varchar" }, { "ordinal": 10, - "name": "maximum_users", + "name": "game_id", "type_info": "Int4" }, { "ordinal": 11, - "name": "game_name", - "type_info": "Varchar" + "name": "metadata", + "type_info": "Jsonb" }, { "ordinal": 12, - "name": "game_id", - "type_info": "Int4" - }, - { - "ordinal": 13, - "name": "game_version?", - "type_info": "Varchar" - }, - { - "ordinal": 14, "name": "users", "type_info": "Int8Array" } @@ -97,10 +87,8 @@ false, false, false, - false, - false, null ] }, - "hash": "d0341d17fbd638d8f07e2cece3568bf10ae263c70a1a8650817206b2397ed378" + "hash": "477c02c4dd36d372683be4e98af1b1b68a23c7cd75677a4c095a406cdb07a7fc" } diff --git a/.sqlx/query-4b06c3a537a2bf453d0c8f01a76793c82beff50d82259748027bd721317c2a16.json b/.sqlx/query-4b06c3a537a2bf453d0c8f01a76793c82beff50d82259748027bd721317c2a16.json deleted file mode 100644 index 31aa0a57..00000000 --- a/.sqlx/query-4b06c3a537a2bf453d0c8f01a76793c82beff50d82259748027bd721317c2a16.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE shared_profiles SET game_version_id = $1 WHERE id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int4", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "4b06c3a537a2bf453d0c8f01a76793c82beff50d82259748027bd721317c2a16" -} diff --git a/.sqlx/query-d77c356d92fc2e99ed8e8072d9dfa744d9edb5f5149f54b9f04338fa7e98e4b1.json b/.sqlx/query-d77c356d92fc2e99ed8e8072d9dfa744d9edb5f5149f54b9f04338fa7e98e4b1.json new file mode 100644 index 00000000..bacf6512 --- /dev/null +++ b/.sqlx/query-d77c356d92fc2e99ed8e8072d9dfa744d9edb5f5149f54b9f04338fa7e98e4b1.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE shared_profiles SET metadata = $1 WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Jsonb", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "d77c356d92fc2e99ed8e8072d9dfa744d9edb5f5149f54b9f04338fa7e98e4b1" +} diff --git a/src/database/models/client_profile_item.rs b/src/database/models/client_profile_item.rs index 936384d9..fea9dda6 100644 --- a/src/database/models/client_profile_item.rs +++ b/src/database/models/client_profile_item.rs @@ -51,7 +51,6 @@ impl ClientProfile { &self, transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, ) -> Result<(), DatabaseError> { - let metadata = serde_json::to_value(&self.metadata).map_err(|e| { DatabaseError::SchemaError(format!("Could not serialize metadata: {}", e)) })?; @@ -276,7 +275,7 @@ impl ClientProfile { let db_profiles: Vec = sqlx::query!( r#" SELECT sp.id, sp.name, sp.owner_id, sp.icon_url, sp.created, sp.updated, sp.game_version_id, sp.loader_id, - l.loader, sp.loader_version, g.name as game_name, g.id as game_id, lfev.value as "game_version?", + l.loader, g.name as game_name, g.id as game_id, sp.metadata, ARRAY_AGG(DISTINCT spu.user_id) filter (WHERE spu.user_id IS NOT NULL) as users FROM shared_profiles sp LEFT JOIN loaders l ON l.id = sp.loader_id @@ -294,11 +293,6 @@ impl ClientProfile { let id = ClientProfileId(m.id); let versions = shared_profiles_mods.0.get(&id).map(|x| x.value().clone()).unwrap_or_default(); let files = shared_profiles_mods.1.get(&id).map(|x| x.value().clone()).unwrap_or_default(); - - let game_version = match (m.game_version, m.game_version_id) { - (Some(game_version), Some(game_version_id)) => Some((game_version, LoaderFieldEnumValueId(game_version_id))), - _ => None - }; let game_id = GameId(m.game_id); let metadata = serde_json::from_value::(m.metadata).unwrap_or(ClientProfileMetadata::Unknown); ClientProfile { diff --git a/src/models/v3/client/profile.rs b/src/models/v3/client/profile.rs index ec60d77b..b5fc8fc4 100644 --- a/src/models/v3/client/profile.rs +++ b/src/models/v3/client/profile.rs @@ -37,8 +37,6 @@ pub struct ClientProfile { /// The loader pub loader: String, - /// The loader version - pub loader_version: String, /// Game-specific information #[serde(flatten)] @@ -56,10 +54,10 @@ pub struct ClientProfile { pub enum ClientProfileMetadata { #[serde(rename = "minecraft-java")] Minecraft { - /// Game Id (constant for Minecraft) - game_name: String, /// Client game version id game_version: String, + /// Loader version + loader_version: String, }, #[serde(rename = "unknown")] Unknown, @@ -69,11 +67,11 @@ impl From for Clie fn from(game: database::models::client_profile_item::ClientProfileMetadata) -> Self { match game { database::models::client_profile_item::ClientProfileMetadata::Minecraft { - game_name, + loader_version, game_version, .. } => Self::Minecraft { - game_name, + loader_version, game_version, }, database::models::client_profile_item::ClientProfileMetadata::Unknown { .. } => { @@ -103,8 +101,7 @@ impl ClientProfile { icon_url: profile.icon_url, users, loader: profile.loader, - loader_version: profile.loader_version, - game: profile.game.into(), + game: profile.metadata.into(), versions: profile.versions.into_iter().map(Into::into).collect(), override_install_paths: profile.overrides.into_iter().map(|(_, v)| v).collect(), } diff --git a/src/routes/internal/client/profiles.rs b/src/routes/internal/client/profiles.rs index b49f4ec8..5e3f3eb8 100644 --- a/src/routes/internal/client/profiles.rs +++ b/src/routes/internal/client/profiles.rs @@ -6,9 +6,7 @@ use crate::database::models::{ }; use crate::database::redis::RedisPool; use crate::file_hosting::FileHost; -use crate::models::client::profile::{ - ClientProfile, ClientProfileId, ClientProfileShareLink, -}; +use crate::models::client::profile::{ClientProfile, ClientProfileId, ClientProfileShareLink}; use crate::models::ids::base62_impl::parse_base62; use crate::models::ids::{UserId, VersionId}; use crate::models::pats::Scopes; @@ -118,6 +116,8 @@ pub async fn profile_create( .validate() .map_err(|err| CreateError::InvalidInput(validation_errors_to_string(err, None)))?; + let game_id; + let game_name; let game: client_profile_item::ClientProfileMetadata = match profile_create_data.game { ProfileCreateDataGame::MinecraftJava { game_version } => { let game = database::models::loader_fields::Game::get_slug( @@ -128,6 +128,9 @@ pub async fn profile_create( .await? .ok_or_else(|| CreateError::InvalidInput("Invalid Client game".to_string()))?; + game_id = game.id; + game_name = game.name; + let game_version_id = MinecraftGameVersion::list(None, None, &**client, &redis) .await? .into_iter() @@ -138,8 +141,7 @@ pub async fn profile_create( .id; client_profile_item::ClientProfileMetadata::Minecraft { - game_id: game.id, - game_name: "minecraft-java".to_string(), + loader_version: profile_create_data.loader_version, game_version_id, game_version, } @@ -187,10 +189,11 @@ pub async fn profile_create( icon_url: None, created: Utc::now(), updated: Utc::now(), - game, + metadata: game, + game_id, + game_name, loader_id, loader: profile_create_data.loader, - loader_version: profile_create_data.loader_version, users: vec![current_user.id.into()], versions, overrides: Vec::new(), @@ -291,15 +294,17 @@ pub struct EditClientProfile { )] // The loader string (parsed to a loader) pub loader: Option, - // The loader version - pub loader_version: Option, - // The game version string (parsed to a game version) - pub game_version: Option, // The list of versions to include in the profile (does not include overrides) pub versions: Option>, - // You can remove users from your invite list here pub remove_users: Option>, + + // As these fields affect metadata but do not yet use the 'loader_fields' system, + // we simply list them here and compare them to the existing metadata. + // The loader version + pub loader_version: Option, + // The game version string (parsed to a game version) + pub game_version: Option, } // Edit a client profile @@ -359,36 +364,6 @@ pub async fn profile_edit( .execute(&mut *transaction) .await?; } - if let Some(loader_version) = edit_data.loader_version { - sqlx::query!( - "UPDATE shared_profiles SET loader_version = $1 WHERE id = $2", - loader_version, - data.id.0 - ) - .execute(&mut *transaction) - .await?; - } - if let Some(game_version) = edit_data.game_version { - let new_game_id = - database::models::legacy_loader_fields::MinecraftGameVersion::list( - None, None, &**pool, &redis, - ) - .await? - .into_iter() - .find(|x| x.version == game_version) - .ok_or_else(|| { - ApiError::InvalidInput("Invalid Client game version".to_string()) - })? - .id; - - sqlx::query!( - "UPDATE shared_profiles SET game_version_id = $1 WHERE id = $2", - new_game_id.0, - data.id.0 - ) - .execute(&mut *transaction) - .await?; - } if let Some(versions) = edit_data.versions { let version_ids = versions.into_iter().map(|x| x.into()).collect::>(); let versions = @@ -454,6 +429,55 @@ pub async fn profile_edit( } } + // Edit the metadata fields + if edit_data.loader_version.is_some() || edit_data.game_version.is_some() { + let mut metadata = data.metadata.clone(); + + match &mut metadata { + client_profile_item::ClientProfileMetadata::Minecraft { + loader_version, + game_version_id, + game_version, + } => { + if let Some(new_loader_version) = edit_data.loader_version { + *loader_version = new_loader_version; + } + + if let Some(new_game_version) = edit_data.game_version { + let new_game_id = + database::models::legacy_loader_fields::MinecraftGameVersion::list( + None, None, &**pool, &redis, + ) + .await? + .into_iter() + .find(|x| x.version == new_game_version) + .ok_or_else(|| { + ApiError::InvalidInput( + "Invalid Client game version".to_string(), + ) + })? + .id; + + *game_version_id = new_game_id; + *game_version = new_game_version; + } + } + client_profile_item::ClientProfileMetadata::Unknown => { + return Err(ApiError::InvalidInput( + "Cannot edit metadata of unknown profile".to_string(), + )); + } + } + + sqlx::query!( + "UPDATE shared_profiles SET metadata = $1 WHERE id = $2", + serde_json::to_value(metadata)?, + data.id.0 + ) + .execute(&mut *transaction) + .await?; + } + transaction.commit().await?; client_profile_item::ClientProfile::clear_cache(data.id, &redis).await?; return Ok(HttpResponse::NoContent().finish()); diff --git a/tests/common/api_v3/client_profile.rs b/tests/common/api_v3/client_profile.rs index be676f82..c8579a81 100644 --- a/tests/common/api_v3/client_profile.rs +++ b/tests/common/api_v3/client_profile.rs @@ -269,7 +269,10 @@ impl ApiV3 { pat: Option<&str>, ) -> ServiceResponse { let req = TestRequest::get() - .uri(&format!("/_internal/client/profile/{}/download", profile_id)) + .uri(&format!( + "/_internal/client/profile/{}/download", + profile_id + )) .append_pat(pat) .to_request(); self.call(req).await diff --git a/tests/profiles.rs b/tests/profiles.rs index b950ee81..ed8a9ba2 100644 --- a/tests/profiles.rs +++ b/tests/profiles.rs @@ -7,6 +7,7 @@ use common::database::*; use common::environment::with_test_environment; use common::environment::TestEnvironment; use labrinth::models::client::profile::ClientProfile; +use labrinth::models::client::profile::ClientProfileMetadata; use labrinth::models::users::UserId; use sha2::Digest; @@ -80,10 +81,17 @@ async fn create_modify_profile() { .get_client_profile_deserialized(&id, USER_USER_PAT) .await; let updated = profile.updated; // Save this- it will update when we modify the versions/overrides - + let ClientProfileMetadata::Minecraft { + game_version, + loader_version, + } = profile.game + else { + panic!("Wrong metadata type") + }; assert_eq!(profile.name, "test"); assert_eq!(profile.loader, "fabric"); - assert_eq!(profile.loader_version, "1.0.0"); + assert_eq!(loader_version, "1.0.0"); + assert_eq!(game_version, "1.20.1"); assert_eq!(profile.versions, vec![]); assert_eq!(profile.icon_url, None); @@ -147,7 +155,15 @@ async fn create_modify_profile() { .await; assert_eq!(profile.name, "test"); assert_eq!(profile.loader, "fabric"); - assert_eq!(profile.loader_version, "1.0.0"); + let ClientProfileMetadata::Minecraft { + game_version, + loader_version, + } = profile.game + else { + panic!("Wrong metadata type") + }; + assert_eq!(loader_version, "1.0.0"); + assert_eq!(game_version, "1.20.1"); assert_eq!(profile.versions, vec![]); assert_eq!(profile.icon_url, None); assert_eq!(profile.updated, updated); @@ -172,7 +188,15 @@ async fn create_modify_profile() { .await; assert_eq!(profile.name, "test2"); assert_eq!(profile.loader, "forge"); - assert_eq!(profile.loader_version, "1.0.1"); + let ClientProfileMetadata::Minecraft { + game_version, + loader_version, + } = profile.game + else { + panic!("Wrong metadata type") + }; + assert_eq!(loader_version, "1.0.1"); + assert_eq!(game_version, "1.20.1"); assert_eq!(profile.versions, vec![alpha_version_id_parsed]); assert_eq!(profile.icon_url, None); assert!(profile.updated > updated); @@ -199,7 +223,15 @@ async fn create_modify_profile() { assert_eq!(profile.name, "test3"); assert_eq!(profile.loader, "fabric"); - assert_eq!(profile.loader_version, "1.0.0"); + let ClientProfileMetadata::Minecraft { + game_version, + loader_version, + } = profile.game + else { + panic!("Wrong metadata type") + }; + assert_eq!(loader_version, "1.0.0"); + assert_eq!(game_version, "1.20.1"); assert_eq!(profile.versions, vec![]); assert_eq!(profile.icon_url, None); assert!(profile.updated > updated); @@ -271,13 +303,13 @@ async fn accept_share_link() { OTHER_FRIEND_USER_PAT, MOD_USER_PAT, ADMIN_USER_PAT, - ENEMY_USER_PAT // If we add a 'max_users' field, this last test could be modified to fail + ENEMY_USER_PAT, // If we add a 'max_users' field, this last test could be modified to fail ]; for (i, pat) in dummy_user_pats.iter().enumerate().take(4 + 1) { let resp = api .accept_client_profile_share_link(&id, &share_link.url_identifier, *pat) .await; - if i == 0 || i == 1 { + if i == 0 || i == 1 { assert_status!(&resp, StatusCode::BAD_REQUEST); } else { assert_status!(&resp, StatusCode::NO_CONTENT); From 584a20af1bb5f1658c922f03bfdf4899c929a248 Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Fri, 12 Jan 2024 19:39:01 -0800 Subject: [PATCH 16/25] revs --- ...a75a7e1706ee408d20ae1819098cddbad3aa.json} | 5 +- ...7837e78eddbf546756569b16e8dec8f16354.json} | 14 ++---- ...a192b37229dfa7a930b946566fefbf6e1378.json} | 14 ++---- ...8e6a1d0bb17a0bccefc5d52f775808cba8361.json | 46 ------------------- migrations/20231226012200_shared_modpacks.sql | 6 +-- src/database/models/client_profile_item.rs | 42 ++--------------- src/database/models/ids.rs | 10 ++++ src/models/v3/client/profile.rs | 15 ++++-- src/models/v3/ids.rs | 2 + src/routes/internal/client/profiles.rs | 31 +++++++------ tests/profiles.rs | 10 ++-- 11 files changed, 61 insertions(+), 134 deletions(-) rename .sqlx/{query-76fbbbfc1641ce8e7560d976a6556cee5199279b1c367b48b14b31a5f1a5446e.json => query-4e4a618fb1e8281777e5083a20d2a75a7e1706ee408d20ae1819098cddbad3aa.json} (51%) rename .sqlx/{query-e0828fcc27da9fe35ed0fdfb3699ea06412935228c1cfe977cfb72790e43560c.json => query-7e40a01e8cbc2740cf8a840443e77837e78eddbf546756569b16e8dec8f16354.json} (63%) rename .sqlx/{query-a7732d7e398e5f6dacef43a1e11957146d2d6f2631c8572cf5aa59dce26f28ef.json => query-f26bed47f72b6732c54794d47c05a192b37229dfa7a930b946566fefbf6e1378.json} (62%) delete mode 100644 .sqlx/query-fd3b2321af19366304bcf46b3cf8e6a1d0bb17a0bccefc5d52f775808cba8361.json diff --git a/.sqlx/query-76fbbbfc1641ce8e7560d976a6556cee5199279b1c367b48b14b31a5f1a5446e.json b/.sqlx/query-4e4a618fb1e8281777e5083a20d2a75a7e1706ee408d20ae1819098cddbad3aa.json similarity index 51% rename from .sqlx/query-76fbbbfc1641ce8e7560d976a6556cee5199279b1c367b48b14b31a5f1a5446e.json rename to .sqlx/query-4e4a618fb1e8281777e5083a20d2a75a7e1706ee408d20ae1819098cddbad3aa.json index b4f9a387..71b8d612 100644 --- a/.sqlx/query-76fbbbfc1641ce8e7560d976a6556cee5199279b1c367b48b14b31a5f1a5446e.json +++ b/.sqlx/query-4e4a618fb1e8281777e5083a20d2a75a7e1706ee408d20ae1819098cddbad3aa.json @@ -1,12 +1,11 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO shared_profiles_links (\n id, link, shared_profile_id, created, expires\n )\n VALUES (\n $1, $2, $3, $4, $5\n )\n ", + "query": "\n INSERT INTO shared_profiles_links (\n id, shared_profile_id, created, expires\n )\n VALUES (\n $1, $2, $3, $4\n )\n ", "describe": { "columns": [], "parameters": { "Left": [ "Int8", - "Varchar", "Int8", "Timestamptz", "Timestamptz" @@ -14,5 +13,5 @@ }, "nullable": [] }, - "hash": "76fbbbfc1641ce8e7560d976a6556cee5199279b1c367b48b14b31a5f1a5446e" + "hash": "4e4a618fb1e8281777e5083a20d2a75a7e1706ee408d20ae1819098cddbad3aa" } diff --git a/.sqlx/query-e0828fcc27da9fe35ed0fdfb3699ea06412935228c1cfe977cfb72790e43560c.json b/.sqlx/query-7e40a01e8cbc2740cf8a840443e77837e78eddbf546756569b16e8dec8f16354.json similarity index 63% rename from .sqlx/query-e0828fcc27da9fe35ed0fdfb3699ea06412935228c1cfe977cfb72790e43560c.json rename to .sqlx/query-7e40a01e8cbc2740cf8a840443e77837e78eddbf546756569b16e8dec8f16354.json index b67cc8d5..d9d8feb7 100644 --- a/.sqlx/query-e0828fcc27da9fe35ed0fdfb3699ea06412935228c1cfe977cfb72790e43560c.json +++ b/.sqlx/query-7e40a01e8cbc2740cf8a840443e77837e78eddbf546756569b16e8dec8f16354.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT id, link, shared_profile_id, created, expires\n FROM shared_profiles_links spl\n WHERE spl.id = $1\n ", + "query": "\n SELECT id, shared_profile_id, created, expires\n FROM shared_profiles_links spl\n WHERE spl.id = $1\n ", "describe": { "columns": [ { @@ -10,21 +10,16 @@ }, { "ordinal": 1, - "name": "link", - "type_info": "Varchar" - }, - { - "ordinal": 2, "name": "shared_profile_id", "type_info": "Int8" }, { - "ordinal": 3, + "ordinal": 2, "name": "created", "type_info": "Timestamptz" }, { - "ordinal": 4, + "ordinal": 3, "name": "expires", "type_info": "Timestamptz" } @@ -38,9 +33,8 @@ false, false, false, - false, false ] }, - "hash": "e0828fcc27da9fe35ed0fdfb3699ea06412935228c1cfe977cfb72790e43560c" + "hash": "7e40a01e8cbc2740cf8a840443e77837e78eddbf546756569b16e8dec8f16354" } diff --git a/.sqlx/query-a7732d7e398e5f6dacef43a1e11957146d2d6f2631c8572cf5aa59dce26f28ef.json b/.sqlx/query-f26bed47f72b6732c54794d47c05a192b37229dfa7a930b946566fefbf6e1378.json similarity index 62% rename from .sqlx/query-a7732d7e398e5f6dacef43a1e11957146d2d6f2631c8572cf5aa59dce26f28ef.json rename to .sqlx/query-f26bed47f72b6732c54794d47c05a192b37229dfa7a930b946566fefbf6e1378.json index 03ae8e07..8fc203d6 100644 --- a/.sqlx/query-a7732d7e398e5f6dacef43a1e11957146d2d6f2631c8572cf5aa59dce26f28ef.json +++ b/.sqlx/query-f26bed47f72b6732c54794d47c05a192b37229dfa7a930b946566fefbf6e1378.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT id, link, shared_profile_id, created, expires\n FROM shared_profiles_links spl\n WHERE spl.shared_profile_id = $1\n ", + "query": "\n SELECT id, shared_profile_id, created, expires\n FROM shared_profiles_links spl\n WHERE spl.shared_profile_id = $1\n ", "describe": { "columns": [ { @@ -10,21 +10,16 @@ }, { "ordinal": 1, - "name": "link", - "type_info": "Varchar" - }, - { - "ordinal": 2, "name": "shared_profile_id", "type_info": "Int8" }, { - "ordinal": 3, + "ordinal": 2, "name": "created", "type_info": "Timestamptz" }, { - "ordinal": 4, + "ordinal": 3, "name": "expires", "type_info": "Timestamptz" } @@ -38,9 +33,8 @@ false, false, false, - false, false ] }, - "hash": "a7732d7e398e5f6dacef43a1e11957146d2d6f2631c8572cf5aa59dce26f28ef" + "hash": "f26bed47f72b6732c54794d47c05a192b37229dfa7a930b946566fefbf6e1378" } diff --git a/.sqlx/query-fd3b2321af19366304bcf46b3cf8e6a1d0bb17a0bccefc5d52f775808cba8361.json b/.sqlx/query-fd3b2321af19366304bcf46b3cf8e6a1d0bb17a0bccefc5d52f775808cba8361.json deleted file mode 100644 index f809f633..00000000 --- a/.sqlx/query-fd3b2321af19366304bcf46b3cf8e6a1d0bb17a0bccefc5d52f775808cba8361.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT id, link, shared_profile_id, created, expires\n FROM shared_profiles_links spl\n WHERE spl.link = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "link", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "shared_profile_id", - "type_info": "Int8" - }, - { - "ordinal": 3, - "name": "created", - "type_info": "Timestamptz" - }, - { - "ordinal": 4, - "name": "expires", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - false, - false, - false - ] - }, - "hash": "fd3b2321af19366304bcf46b3cf8e6a1d0bb17a0bccefc5d52f775808cba8361" -} diff --git a/migrations/20231226012200_shared_modpacks.sql b/migrations/20231226012200_shared_modpacks.sql index c5a2b98c..59e07b66 100644 --- a/migrations/20231226012200_shared_modpacks.sql +++ b/migrations/20231226012200_shared_modpacks.sql @@ -32,8 +32,7 @@ CREATE TABLE shared_profiles_mods ( ); CREATE TABLE shared_profiles_links ( - id bigint PRIMARY KEY, -- id of the shared profile link (ignored in labrinth, for db use only) - link varchar(48) NOT NULL UNIQUE, -- extension of the url that identifies this (ie profiles/afgxxczsewq) + id bigint PRIMARY KEY, -- id of the shared profile link (doubles as the link identifier) shared_profile_id bigint NOT NULL REFERENCES shared_profiles(id), created timestamptz NOT NULL DEFAULT now(), expires timestamptz NOT NULL @@ -44,6 +43,3 @@ CREATE TABLE shared_profiles_users ( user_id bigint NOT NULL REFERENCES users(id), CONSTRAINT shared_profiles_users_unique UNIQUE (shared_profile_id, user_id) ); - --- Index off 'link' -CREATE INDEX shared_profiles_links_link_idx ON shared_profiles_links(link); \ No newline at end of file diff --git a/src/database/models/client_profile_item.rs b/src/database/models/client_profile_item.rs index fea9dda6..2149f399 100644 --- a/src/database/models/client_profile_item.rs +++ b/src/database/models/client_profile_item.rs @@ -340,7 +340,6 @@ impl ClientProfile { #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ClientProfileLink { pub id: ClientProfileLinkId, - pub link_identifier: String, pub shared_profile_id: ClientProfileId, pub created: DateTime, pub expires: DateTime, @@ -354,14 +353,13 @@ impl ClientProfileLink { sqlx::query!( " INSERT INTO shared_profiles_links ( - id, link, shared_profile_id, created, expires + id, shared_profile_id, created, expires ) VALUES ( - $1, $2, $3, $4, $5 + $1, $2, $3, $4 ) ", self.id.0, - self.link_identifier, self.shared_profile_id.0, self.created, self.expires, @@ -383,7 +381,7 @@ impl ClientProfileLink { let links = sqlx::query!( " - SELECT id, link, shared_profile_id, created, expires + SELECT id, shared_profile_id, created, expires FROM shared_profiles_links spl WHERE spl.shared_profile_id = $1 ", @@ -393,7 +391,6 @@ impl ClientProfileLink { .try_filter_map(|e| async { Ok(e.right().map(|m| ClientProfileLink { id: ClientProfileLinkId(m.id), - link_identifier: m.link, shared_profile_id: ClientProfileId(m.shared_profile_id), created: m.created, expires: m.expires, @@ -416,7 +413,7 @@ impl ClientProfileLink { let link = sqlx::query!( " - SELECT id, link, shared_profile_id, created, expires + SELECT id, shared_profile_id, created, expires FROM shared_profiles_links spl WHERE spl.id = $1 ", @@ -426,37 +423,6 @@ impl ClientProfileLink { .await? .map(|m| ClientProfileLink { id: ClientProfileLinkId(m.id), - link_identifier: m.link, - shared_profile_id: ClientProfileId(m.shared_profile_id), - created: m.created, - expires: m.expires, - }); - - Ok(link) - } - - pub async fn get_url<'a, 'b, E>( - url_identifier: &str, - executor: E, - ) -> Result, DatabaseError> - where - E: sqlx::Acquire<'a, Database = sqlx::Postgres>, - { - let mut exec = executor.acquire().await?; - - let link = sqlx::query!( - " - SELECT id, link, shared_profile_id, created, expires - FROM shared_profiles_links spl - WHERE spl.link = $1 - ", - url_identifier - ) - .fetch_optional(&mut *exec) - .await? - .map(|m| ClientProfileLink { - id: ClientProfileLinkId(m.id), - link_identifier: m.link, shared_profile_id: ClientProfileId(m.shared_profile_id), created: m.created, expires: m.expires, diff --git a/src/database/models/ids.rs b/src/database/models/ids.rs index 83b49e01..f488a8bf 100644 --- a/src/database/models/ids.rs +++ b/src/database/models/ids.rs @@ -500,3 +500,13 @@ impl From for ids::ClientProfileId { ids::ClientProfileId(id.0 as u64) } } +impl From for ClientProfileLinkId { + fn from(id: ids::ClientProfileLinkId) -> Self { + ClientProfileLinkId(id.0 as i64) + } +} +impl From for ids::ClientProfileLinkId { + fn from(id: ClientProfileLinkId) -> Self { + ids::ClientProfileLinkId(id.0 as u64) + } +} diff --git a/src/models/v3/client/profile.rs b/src/models/v3/client/profile.rs index b5fc8fc4..35d282ea 100644 --- a/src/models/v3/client/profile.rs +++ b/src/models/v3/client/profile.rs @@ -14,6 +14,12 @@ use crate::{ #[serde(into = "Base62Id")] pub struct ClientProfileId(pub u64); +/// The ID of a specific profile link, encoded as base62 for usage in the API +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug)] +#[serde(from = "Base62Id")] +#[serde(into = "Base62Id")] +pub struct ClientProfileLinkId(pub u64); + /// A project returned from the API #[derive(Serialize, Deserialize, Clone)] pub struct ClientProfile { @@ -110,8 +116,8 @@ impl ClientProfile { #[derive(Serialize, Deserialize, Clone)] pub struct ClientProfileShareLink { - pub url_identifier: String, - pub url: String, // Includes the url identifier, intentionally redundant + pub id: ClientProfileLinkId, // The url identifier, encoded as base62 + pub url: String, // Includes the url identifier, intentionally redundant pub profile_id: ClientProfileId, pub created: DateTime, pub expires: DateTime, @@ -121,15 +127,16 @@ impl From for ClientPr fn from(link: database::models::client_profile_item::ClientProfileLink) -> Self { // Generate URL for easy access let profile_id: ClientProfileId = link.shared_profile_id.into(); + let link_id: ClientProfileLinkId = link.id.into(); let url = format!( "{}/v3/client/profile/{}/accept/{}", dotenvy::var("SELF_ADDR").unwrap(), profile_id, - link.link_identifier + link_id ); Self { - url_identifier: link.link_identifier, + id: link_id, url, profile_id, created: link.created, diff --git a/src/models/v3/ids.rs b/src/models/v3/ids.rs index 00b3d5fa..ae87563e 100644 --- a/src/models/v3/ids.rs +++ b/src/models/v3/ids.rs @@ -1,6 +1,7 @@ use thiserror::Error; pub use super::client::profile::ClientProfileId; +pub use super::client::profile::ClientProfileLinkId; pub use super::collections::CollectionId; pub use super::images::ImageId; pub use super::notifications::NotificationId; @@ -131,6 +132,7 @@ base62_id_impl!(OAuthRedirectUriId, OAuthRedirectUriId); base62_id_impl!(OAuthClientAuthorizationId, OAuthClientAuthorizationId); base62_id_impl!(PayoutId, PayoutId); base62_id_impl!(ClientProfileId, ClientProfileId); +base62_id_impl!(ClientProfileLinkId, ClientProfileLinkId); pub mod base62_impl { use serde::de::{self, Deserializer, Visitor}; diff --git a/src/routes/internal/client/profiles.rs b/src/routes/internal/client/profiles.rs index 5e3f3eb8..d6abfbf3 100644 --- a/src/routes/internal/client/profiles.rs +++ b/src/routes/internal/client/profiles.rs @@ -6,7 +6,9 @@ use crate::database::models::{ }; use crate::database::redis::RedisPool; use crate::file_hosting::FileHost; -use crate::models::client::profile::{ClientProfile, ClientProfileId, ClientProfileShareLink}; +use crate::models::client::profile::{ + ClientProfile, ClientProfileId, ClientProfileLinkId, ClientProfileShareLink, +}; use crate::models::ids::base62_impl::parse_base62; use crate::models::ids::{UserId, VersionId}; use crate::models::pats::Scopes; @@ -568,7 +570,7 @@ pub async fn profile_share( if let Some(data) = profile_data { if data.owner_id == user_option.1.id.into() { // Generate a share link identifier - let identifier = ChaCha20Rng::from_entropy() + let _identifier = ChaCha20Rng::from_entropy() .sample_iter(&Alphanumeric) .take(8) .map(char::from) @@ -581,7 +583,6 @@ pub async fn profile_share( let link = database::models::client_profile_item::ClientProfileLink { id: profile_link_id, shared_profile_id: data.id, - link_identifier: identifier.clone(), created: Utc::now(), expires: Utc::now() + chrono::Duration::days(7), }; @@ -598,7 +599,7 @@ pub async fn profile_share( // This is used by the to check if the link is expired, etc. pub async fn profile_link_get( req: HttpRequest, - info: web::Path<(String, String)>, + info: web::Path<(String, ClientProfileLinkId)>, pool: web::Data, redis: web::Data, session_queue: web::Data, @@ -615,10 +616,12 @@ pub async fn profile_link_get( .await?; // Confirm this is our project, then if so, share - let link_data = - database::models::client_profile_item::ClientProfileLink::get_url(&url_identifier, &**pool) - .await? - .ok_or_else(|| ApiError::NotFound)?; + let link_data = database::models::client_profile_item::ClientProfileLink::get( + url_identifier.into(), + &**pool, + ) + .await? + .ok_or_else(|| ApiError::NotFound)?; let data = database::models::client_profile_item::ClientProfile::get( link_data.shared_profile_id, @@ -641,7 +644,7 @@ pub async fn profile_link_get( // TODO: With above change, this is the API link that is translated from a modrinth:// link by the launcher, which would then download it pub async fn accept_share_link( req: HttpRequest, - info: web::Path<(ClientProfileId, String)>, + info: web::Path<(ClientProfileId, ClientProfileLinkId)>, pool: web::Data, redis: web::Data, session_queue: web::Data, @@ -659,10 +662,12 @@ pub async fn accept_share_link( .await?; // Fetch the profile information of the desired client profile - let link_data = - database::models::client_profile_item::ClientProfileLink::get_url(&url_identifier, &**pool) - .await? - .ok_or_else(|| ApiError::NotFound)?; + let link_data = database::models::client_profile_item::ClientProfileLink::get( + url_identifier.into(), + &**pool, + ) + .await? + .ok_or_else(|| ApiError::NotFound)?; // Confirm it matches the profile id if link_data.shared_profile_id != profile_id.into() { diff --git a/tests/profiles.rs b/tests/profiles.rs index ed8a9ba2..f01634a8 100644 --- a/tests/profiles.rs +++ b/tests/profiles.rs @@ -275,14 +275,14 @@ async fn accept_share_link() { "{}/v3/client/profile/{}/accept/{}", dotenvy::var("SELF_ADDR").unwrap(), id, - share_link.url_identifier + share_link.id ) ); // Link is an 'accept' link, when visited using any user token using POST, it should add the user to the profile // As 'friend', accept the share link let resp = api - .accept_client_profile_share_link(&id, &share_link.url_identifier, FRIEND_USER_PAT) + .accept_client_profile_share_link(&id, &share_link.id.to_string(), FRIEND_USER_PAT) .await; assert_status!(&resp, StatusCode::NO_CONTENT); @@ -307,7 +307,7 @@ async fn accept_share_link() { ]; for (i, pat) in dummy_user_pats.iter().enumerate().take(4 + 1) { let resp = api - .accept_client_profile_share_link(&id, &share_link.url_identifier, *pat) + .accept_client_profile_share_link(&id, &share_link.id.to_string(), *pat) .await; if i == 0 || i == 1 { assert_status!(&resp, StatusCode::BAD_REQUEST); @@ -360,7 +360,7 @@ async fn delete_profile() { .generate_client_profile_share_link_deserialized(&id, USER_USER_PAT) .await; let resp = api - .accept_client_profile_share_link(&id, &share_link.url_identifier, FRIEND_USER_PAT) + .accept_client_profile_share_link(&id, &share_link.id.to_string(), FRIEND_USER_PAT) .await; assert_status!(&resp, StatusCode::NO_CONTENT); @@ -440,7 +440,7 @@ async fn download_profile() { .generate_client_profile_share_link_deserialized(&id, USER_USER_PAT) .await; let resp = api - .accept_client_profile_share_link(&id, &share_link.url_identifier, FRIEND_USER_PAT) + .accept_client_profile_share_link(&id, &share_link.id.to_string(), FRIEND_USER_PAT) .await; assert_status!(&resp, StatusCode::NO_CONTENT); From 047720917b8a8b80d343f7f15d74ac6a41dd33a3 Mon Sep 17 00:00:00 2001 From: Wyatt Date: Fri, 19 Jan 2024 12:42:54 -0800 Subject: [PATCH 17/25] moving computers --- migrations/20231226012200_shared_modpacks.sql | 4 +- src/database/models/client_profile_item.rs | 21 +++--- src/models/v3/client/profile.rs | 8 --- src/routes/internal/client/profiles.rs | 64 ++++++++++++------- tests/profiles.rs | 11 ---- 5 files changed, 56 insertions(+), 52 deletions(-) diff --git a/migrations/20231226012200_shared_modpacks.sql b/migrations/20231226012200_shared_modpacks.sql index 59e07b66..d47de19a 100644 --- a/migrations/20231226012200_shared_modpacks.sql +++ b/migrations/20231226012200_shared_modpacks.sql @@ -10,9 +10,7 @@ CREATE TABLE shared_profiles ( loader_id int NOT NULL REFERENCES loaders(id), metadata jsonb NOT NULL DEFAULT '{}'::jsonb, - game_id int NOT NULL REFERENCES games(id), - game_version_id int NULL REFERENCES loader_field_enum_values(id) -- Minecraft java - + game_id int NOT NULL REFERENCES games(id) ); CREATE TABLE shared_profiles_mods ( diff --git a/src/database/models/client_profile_item.rs b/src/database/models/client_profile_item.rs index 2149f399..6b3e151d 100644 --- a/src/database/models/client_profile_item.rs +++ b/src/database/models/client_profile_item.rs @@ -41,6 +41,9 @@ pub enum ClientProfileMetadata { Minecraft { loader_version: String, game_version_id: LoaderFieldEnumValueId, + // TODO: Currently, we store the game_version directly. If client profiles use more than just Minecraft, + // this should change to use a variant of dynamic loader field system that versions use, and fields like + // this would be loaded dynamically from the loader_field_enum_values table. game_version: String, }, Unknown, @@ -100,11 +103,12 @@ impl ClientProfile { Ok(()) } + // Returns the hashes of the files that were deleted, so they can be deleted from the file host pub async fn remove( id: ClientProfileId, transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, redis: &RedisPool, - ) -> Result, DatabaseError> { + ) -> Result, DatabaseError> { // Delete shared_profiles_links sqlx::query!( " @@ -127,15 +131,17 @@ impl ClientProfile { .execute(&mut **transaction) .await?; - sqlx::query!( + // Deletes attached files- we return the hashes so we can delete them from the file host if needed + let deleted_hashes = sqlx::query!( " DELETE FROM shared_profiles_mods WHERE shared_profile_id = $1 + RETURNING file_hash ", id as ClientProfileId, ) - .execute(&mut **transaction) - .await?; + .fetch_all(&mut **transaction) + .await?.into_iter().filter_map(|x| x.file_hash).collect::>(); sqlx::query!( " @@ -159,7 +165,7 @@ impl ClientProfile { ClientProfile::clear_cache(id, redis).await?; - Ok(Some(())) + Ok(deleted_hashes) } pub async fn get<'a, 'b, E>( @@ -274,16 +280,15 @@ impl ClientProfile { // One to many for shared_profiles to loaders, so can safely group by shared_profile_id let db_profiles: Vec = sqlx::query!( r#" - SELECT sp.id, sp.name, sp.owner_id, sp.icon_url, sp.created, sp.updated, sp.game_version_id, sp.loader_id, + SELECT sp.id, sp.name, sp.owner_id, sp.icon_url, sp.created, sp.updated, sp.loader_id, l.loader, g.name as game_name, g.id as game_id, sp.metadata, ARRAY_AGG(DISTINCT spu.user_id) filter (WHERE spu.user_id IS NOT NULL) as users FROM shared_profiles sp LEFT JOIN loaders l ON l.id = sp.loader_id LEFT JOIN shared_profiles_users spu ON spu.shared_profile_id = sp.id INNER JOIN games g ON g.id = sp.game_id - LEFT JOIN loader_field_enum_values lfev ON sp.game_version_id = lfev.id WHERE sp.id = ANY($1) - GROUP BY sp.id, l.id, g.id, lfev.id + GROUP BY sp.id, l.id, g.id "#, &remaining_ids.iter().map(|x| x.0).collect::>() ) diff --git a/src/models/v3/client/profile.rs b/src/models/v3/client/profile.rs index 35d282ea..45baac3c 100644 --- a/src/models/v3/client/profile.rs +++ b/src/models/v3/client/profile.rs @@ -117,7 +117,6 @@ impl ClientProfile { #[derive(Serialize, Deserialize, Clone)] pub struct ClientProfileShareLink { pub id: ClientProfileLinkId, // The url identifier, encoded as base62 - pub url: String, // Includes the url identifier, intentionally redundant pub profile_id: ClientProfileId, pub created: DateTime, pub expires: DateTime, @@ -128,16 +127,9 @@ impl From for ClientPr // Generate URL for easy access let profile_id: ClientProfileId = link.shared_profile_id.into(); let link_id: ClientProfileLinkId = link.id.into(); - let url = format!( - "{}/v3/client/profile/{}/accept/{}", - dotenvy::var("SELF_ADDR").unwrap(), - profile_id, - link_id - ); Self { id: link_id, - url, profile_id, created: link.created, expires: link.expires, diff --git a/src/routes/internal/client/profiles.rs b/src/routes/internal/client/profiles.rs index d6abfbf3..2203ae8e 100644 --- a/src/routes/internal/client/profiles.rs +++ b/src/routes/internal/client/profiles.rs @@ -499,6 +499,7 @@ pub async fn profile_delete( pool: web::Data, redis: web::Data, session_queue: web::Data, + file_host: web::Data>, ) -> Result { let string = info.into_inner().0; @@ -519,7 +520,8 @@ pub async fn profile_delete( if let Some(data) = profile_data { if data.owner_id == user_option.1.id.into() { let mut transaction = pool.begin().await?; - database::models::client_profile_item::ClientProfile::remove( + + let deleted_hashes = database::models::client_profile_item::ClientProfile::remove( data.id, &mut transaction, &redis, @@ -527,6 +529,10 @@ pub async fn profile_delete( .await?; transaction.commit().await?; client_profile_item::ClientProfile::clear_cache(data.id, &redis).await?; + + // Delete the files from the CDN if they are no longer used by any profile + delete_unused_files_from_host(deleted_hashes, &pool, &file_host).await?; + return Ok(HttpResponse::NoContent().finish()); } else if data.users.contains(&user_option.1.id.into()) { // We know it exists, but still can't delete it @@ -1184,7 +1190,7 @@ pub async fn client_profile_remove_overrides( redis: web::Data, file_host: web::Data>, session_queue: web::Data, -) -> Result { +) -> Result { let client_id = client_id.into_inner(); let user = get_user_from_headers( &req, @@ -1204,11 +1210,11 @@ pub async fn client_profile_remove_overrides( ) .await? .ok_or_else(|| { - CreateError::InvalidInput("The specified profile does not exist!".to_string()) + ApiError::InvalidInput("The specified profile does not exist!".to_string()) })?; if !user.role.is_mod() && profile_item.owner_id != user.id.into() { - return Err(CreateError::CustomAuthenticationError( + return Err(ApiError::CustomAuthentication( "You don't have permission to remove overrides.".to_string(), )); } @@ -1242,19 +1248,6 @@ pub async fn client_profile_remove_overrides( .fetch_all(&mut *transaction) .await?.into_iter().filter_map(|x| x.file_hash).collect::>(); - let still_existing_hashes = sqlx::query!( - " - SELECT file_hash FROM shared_profiles_mods - WHERE file_hash = ANY($1::text[]) - ", - &deleted_hashes[..], - ) - .fetch_all(&mut *transaction) - .await? - .into_iter() - .filter_map(|x| x.file_hash) - .collect::>(); - // Set updated sqlx::query!( " @@ -1269,6 +1262,36 @@ pub async fn client_profile_remove_overrides( transaction.commit().await?; + database::models::client_profile_item::ClientProfile::clear_cache(profile_item.id, &redis) + .await?; + + // Delete the files from the CDN if they are no longer used by any profile + delete_unused_files_from_host(deleted_hashes, &pool, &file_host).await?; + + Ok(HttpResponse::NoContent().body("")) +} + + +// For a list of deleted hashes, delete the files from the CDN if they are no longer used by any profile +async fn delete_unused_files_from_host( + deleted_hashes : Vec, + pool: &PgPool, + file_host: &Arc, +) -> Result<(), ApiError> { + // Get all hashes that are still used by any profile + let still_existing_hashes = sqlx::query!( + " + SELECT file_hash FROM shared_profiles_mods + WHERE file_hash = ANY($1::text[]) + ", + &deleted_hashes[..], + ) + .fetch_all(&*pool) + .await? + .into_iter() + .filter_map(|x| x.file_hash) + .collect::>(); + // We want to delete files from the server that are no longer used by any profile let hashes_to_delete = deleted_hashes .into_iter() @@ -1285,8 +1308,5 @@ pub async fn client_profile_remove_overrides( .await?; } - database::models::client_profile_item::ClientProfile::clear_cache(profile_item.id, &redis) - .await?; - - Ok(HttpResponse::NoContent().body("")) -} + Ok(()) +} \ No newline at end of file diff --git a/tests/profiles.rs b/tests/profiles.rs index f01634a8..71c2d59e 100644 --- a/tests/profiles.rs +++ b/tests/profiles.rs @@ -268,17 +268,6 @@ async fn accept_share_link() { .generate_client_profile_share_link_deserialized(&id, USER_USER_PAT) .await; - // Links should be internally consistent and match the expected format - assert_eq!( - share_link.url, - format!( - "{}/v3/client/profile/{}/accept/{}", - dotenvy::var("SELF_ADDR").unwrap(), - id, - share_link.id - ) - ); - // Link is an 'accept' link, when visited using any user token using POST, it should add the user to the profile // As 'friend', accept the share link let resp = api From 3d9e1183a676a744df4238e1c1f0b805ee29980e Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Fri, 19 Jan 2024 20:31:09 -0800 Subject: [PATCH 18/25] reorganized routes --- src/database/models/client_profile_item.rs | 116 +++++++-- src/models/v3/client/profile.rs | 48 ++-- src/routes/internal/client/profiles.rs | 266 ++++++++++++++------- tests/common/api_v3/client_profile.rs | 66 +++-- tests/profiles.rs | 227 ++++++++++++++---- 5 files changed, 505 insertions(+), 218 deletions(-) diff --git a/src/database/models/client_profile_item.rs b/src/database/models/client_profile_item.rs index 6b3e151d..ef696948 100644 --- a/src/database/models/client_profile_item.rs +++ b/src/database/models/client_profile_item.rs @@ -36,12 +36,18 @@ pub struct ClientProfile { pub overrides: Vec, } +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct QueryClientProfile { + pub inner: ClientProfile, + pub links: Vec, +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub enum ClientProfileMetadata { Minecraft { loader_version: String, game_version_id: LoaderFieldEnumValueId, - // TODO: Currently, we store the game_version directly. If client profiles use more than just Minecraft, + // TODO: Currently, we store the game_version directly. If client profiles use more than just Minecraft, // this should change to use a variant of dynamic loader field system that versions use, and fields like // this would be loaded dynamically from the loader_field_enum_values table. game_version: String, @@ -100,6 +106,24 @@ impl ClientProfile { .await?; } + // Insert versions + for version_id in &self.versions { + sqlx::query!( + " + INSERT INTO shared_profiles_mods ( + shared_profile_id, version_id + ) + VALUES ( + $1, $2 + ) + ", + self.id as ClientProfileId, + version_id.0, + ) + .execute(&mut **transaction) + .await?; + } + Ok(()) } @@ -141,7 +165,10 @@ impl ClientProfile { id as ClientProfileId, ) .fetch_all(&mut **transaction) - .await?.into_iter().filter_map(|x| x.file_hash).collect::>(); + .await? + .into_iter() + .filter_map(|x| x.file_hash) + .collect::>(); sqlx::query!( " @@ -172,7 +199,7 @@ impl ClientProfile { id: ClientProfileId, executor: E, redis: &RedisPool, - ) -> Result, DatabaseError> + ) -> Result, DatabaseError> where E: sqlx::Acquire<'a, Database = sqlx::Postgres>, { @@ -209,7 +236,7 @@ impl ClientProfile { ids: &[ClientProfileId], exec: E, redis: &RedisPool, - ) -> Result, DatabaseError> + ) -> Result, DatabaseError> where E: sqlx::Acquire<'a, Database = sqlx::Postgres>, { @@ -229,9 +256,9 @@ impl ClientProfile { .await?; for profile in profiles { if let Some(profile) = - profile.and_then(|x| serde_json::from_str::(&x).ok()) + profile.and_then(|x| serde_json::from_str::(&x).ok()) { - remaining_ids.retain(|x| profile.id != *x); + remaining_ids.retain(|x| profile.inner.id != *x); found_profiles.push(profile); continue; } @@ -277,13 +304,43 @@ impl ClientProfile { ) .await?; + let shared_profiles_links: DashMap> = + sqlx::query!( + " + SELECT id, shared_profile_id, created, expires + FROM shared_profiles_links spl + WHERE spl.shared_profile_id = ANY($1) + ", + &remaining_ids.iter().map(|x| x.0).collect::>() + ) + .fetch(&mut *exec) + .try_fold( + DashMap::new(), + |acc_links: DashMap>, m| { + let link = ClientProfileLink { + id: ClientProfileLinkId(m.id), + shared_profile_id: ClientProfileId(m.shared_profile_id), + created: m.created, + expires: m.expires, + }; + acc_links + .entry(ClientProfileId(m.shared_profile_id)) + .or_default() + .push(link); + async move { Ok(acc_links) } + }, + ) + .await?; + // One to many for shared_profiles to loaders, so can safely group by shared_profile_id - let db_profiles: Vec = sqlx::query!( + let db_profiles: Vec = sqlx::query!( r#" SELECT sp.id, sp.name, sp.owner_id, sp.icon_url, sp.created, sp.updated, sp.loader_id, l.loader, g.name as game_name, g.id as game_id, sp.metadata, - ARRAY_AGG(DISTINCT spu.user_id) filter (WHERE spu.user_id IS NOT NULL) as users + ARRAY_AGG(DISTINCT spu.user_id) filter (WHERE spu.user_id IS NOT NULL) as users, + ARRAY_AGG(DISTINCT spl.id) filter (WHERE spl.id IS NOT NULL) as links FROM shared_profiles sp + LEFT JOIN shared_profiles_links spl ON spl.shared_profile_id = sp.id LEFT JOIN loaders l ON l.id = sp.loader_id LEFT JOIN shared_profiles_users spu ON spu.shared_profile_id = sp.id INNER JOIN games g ON g.id = sp.game_id @@ -298,32 +355,41 @@ impl ClientProfile { let id = ClientProfileId(m.id); let versions = shared_profiles_mods.0.get(&id).map(|x| x.value().clone()).unwrap_or_default(); let files = shared_profiles_mods.1.get(&id).map(|x| x.value().clone()).unwrap_or_default(); + let links = shared_profiles_links.remove(&id).map(|x| x.1).unwrap_or_default(); let game_id = GameId(m.game_id); let metadata = serde_json::from_value::(m.metadata).unwrap_or(ClientProfileMetadata::Unknown); - ClientProfile { - id, - name: m.name, - icon_url: m.icon_url, - updated: m.updated, - created: m.created, - owner_id: UserId(m.owner_id), - game_id, - users: m.users.unwrap_or_default().into_iter().map(UserId).collect(), - loader_id: LoaderId(m.loader_id), - game_name: m.game_name, - metadata, - loader: m.loader, - versions, - overrides: files + QueryClientProfile { + inner: ClientProfile { + id, + name: m.name, + icon_url: m.icon_url, + updated: m.updated, + created: m.created, + owner_id: UserId(m.owner_id), + game_id, + users: m.users.unwrap_or_default().into_iter().map(UserId).collect(), + loader_id: LoaderId(m.loader_id), + game_name: m.game_name, + metadata, + loader: m.loader, + versions, + overrides: files + }, + links } })) }) - .try_collect::>() + .try_collect::>() .await?; for profile in db_profiles { redis - .set_serialized_to_json(CLIENT_PROFILES_NAMESPACE, profile.id.0, &profile, None) + .set_serialized_to_json( + CLIENT_PROFILES_NAMESPACE, + profile.inner.id.0, + &profile, + None, + ) .await?; found_profiles.push(profile); } diff --git a/src/models/v3/client/profile.rs b/src/models/v3/client/profile.rs index 45baac3c..006a8cba 100644 --- a/src/models/v3/client/profile.rs +++ b/src/models/v3/client/profile.rs @@ -1,11 +1,9 @@ -use std::path::PathBuf; - use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use crate::{ database, - models::ids::{Base62Id, UserId, VersionId}, + models::ids::{Base62Id, UserId}, }; /// The ID of a specific profile, encoded as base62 for usage in the API @@ -37,10 +35,6 @@ pub struct ClientProfile { /// The icon of the project. pub icon_url: Option, - // Users that are associated with this profile - // Hidden if the user is not the owner - pub users: Option>, - /// The loader pub loader: String, @@ -48,11 +42,11 @@ pub struct ClientProfile { #[serde(flatten)] pub game: ClientProfileMetadata, - /// Modrinth-associated versions - pub versions: Vec, - /// Overrides for this profile- only install paths are given, - /// hashes are looked up in the CDN by the client - pub override_install_paths: Vec, + // The following fields are hidden if the user is not the owner + /// The share links for this profile + pub share_links: Option>, + // Users that are associated with this profile + pub users: Option>, } #[derive(Serialize, Deserialize, Clone)] @@ -89,27 +83,27 @@ impl From for Clie impl ClientProfile { pub fn from( - profile: database::models::client_profile_item::ClientProfile, + profile: database::models::client_profile_item::QueryClientProfile, current_user_id: Option, ) -> Self { - let users = if Some(profile.owner_id) == current_user_id { - Some(profile.users.into_iter().map(|v| v.into()).collect()) - } else { - None + let mut users = None; + let mut share_links = None; + if Some(profile.inner.owner_id) == current_user_id { + users = Some(profile.inner.users.into_iter().map(|v| v.into()).collect()); + share_links = Some(profile.links.into_iter().map(|v| v.into()).collect()); }; Self { - id: profile.id.into(), - owner_id: profile.owner_id.into(), - name: profile.name, - created: profile.created, - updated: profile.updated, - icon_url: profile.icon_url, + id: profile.inner.id.into(), + owner_id: profile.inner.owner_id.into(), + name: profile.inner.name, + created: profile.inner.created, + updated: profile.inner.updated, + icon_url: profile.inner.icon_url, users, - loader: profile.loader, - game: profile.metadata.into(), - versions: profile.versions.into_iter().map(Into::into).collect(), - override_install_paths: profile.overrides.into_iter().map(|(_, v)| v).collect(), + loader: profile.inner.loader, + game: profile.inner.metadata.into(), + share_links, } } } diff --git a/src/routes/internal/client/profiles.rs b/src/routes/internal/client/profiles.rs index 2203ae8e..0994509d 100644 --- a/src/routes/internal/client/profiles.rs +++ b/src/routes/internal/client/profiles.rs @@ -39,26 +39,24 @@ pub fn config(cfg: &mut web::ServiceConfig) { web::scope("client") .route("profile", web::post().to(profile_create)) .route("check_token", web::get().to(profile_token_check)) + .service( + web::scope("share") + .route("{id}", web::get().to(profile_get_share_link)) + .route("{id}/accept", web::post().to(accept_share_link)) + .route("{id}/files", web::get().to(profile_share_files)), + ) .service( web::scope("profile") .route("{id}", web::get().to(profile_get)) .route("{id}", web::patch().to(profile_edit)) .route("{id}", web::delete().to(profile_delete)) + .route("{id}/files", web::get().to(profile_files)) .route("{id}/override", web::post().to(client_profile_add_override)) .route( "{id}/override", web::delete().to(client_profile_remove_overrides), ) - .route("{id}/share", web::get().to(profile_share)) - .route( - "{id}/share/{url_identifier}", - web::get().to(profile_link_get), - ) - .route( - "{id}/accept/{url_identifier}", - web::post().to(accept_share_link), - ) - .route("{id}/download", web::get().to(profile_download)) + .route("{id}/share", web::post().to(profile_share)) .route("{id}/icon", web::patch().to(profile_icon_edit)) .route("{id}/icon", web::delete().to(delete_profile_icon)), ), @@ -184,6 +182,7 @@ pub async fn profile_create( .await .map_err(|_| CreateError::InvalidInput("Could not fetch submitted version ids".to_string()))?; + println!("Filtered versions: {:?}", versions); let profile_builder_actual = client_profile_item::ClientProfile { id: profile_id, name: profile_create_data.name.clone(), @@ -204,8 +203,13 @@ pub async fn profile_create( profile_builder_actual.insert(&mut transaction).await?; transaction.commit().await?; + let profile = client_profile_item::QueryClientProfile { + inner: profile_builder, + links: Vec::new(), + }; + let profile = - models::client::profile::ClientProfile::from(profile_builder, Some(current_user.id.into())); + models::client::profile::ClientProfile::from(profile, Some(current_user.id.into())); Ok(HttpResponse::Ok().json(profile)) } @@ -300,6 +304,8 @@ pub struct EditClientProfile { pub versions: Option>, // You can remove users from your invite list here pub remove_users: Option>, + // You can remove share links here (by id) + pub remove_links: Option>, // As these fields affect metadata but do not yet use the 'loader_fields' system, // we simply list them here and compare them to the existing metadata. @@ -338,13 +344,13 @@ pub async fn profile_edit( .await?; if let Some(data) = profile_data { - if data.owner_id == user_option.1.id.into() { + if data.inner.owner_id == user_option.1.id.into() { // Edit the profile if let Some(name) = edit_data.name { sqlx::query!( "UPDATE shared_profiles SET name = $1 WHERE id = $2", name, - data.id.0 + data.inner.id.0 ) .execute(&mut *transaction) .await?; @@ -361,7 +367,7 @@ pub async fn profile_edit( sqlx::query!( "UPDATE shared_profiles SET loader_id = $1 WHERE id = $2", loader_id.0, - data.id.0 + data.inner.id.0 ) .execute(&mut *transaction) .await?; @@ -390,7 +396,7 @@ pub async fn profile_edit( // Remove all shared profile mods of this profile where version_id is set sqlx::query!( "DELETE FROM shared_profiles_mods WHERE shared_profile_id = $1 AND version_id IS NOT NULL", - data.id.0 + data.inner.id.0 ) .execute(&mut *transaction) .await?; @@ -399,7 +405,7 @@ pub async fn profile_edit( for v in versions { sqlx::query!( "INSERT INTO shared_profiles_mods (shared_profile_id, version_id) VALUES ($1, $2)", - data.id.0, + data.inner.id.0, v.0 ) .execute(&mut *transaction) @@ -413,7 +419,7 @@ pub async fn profile_edit( SET updated = NOW() WHERE id = $1 ", - data.id.0, + data.inner.id.0, ) .execute(&mut *transaction) .await?; @@ -423,7 +429,7 @@ pub async fn profile_edit( // Remove user from list sqlx::query!( "DELETE FROM shared_profiles_users WHERE shared_profile_id = $1 AND user_id = $2", - data.id.0 as i64, + data.inner.id.0 as i64, user.0 as i64 ) .execute(&mut *transaction) @@ -431,9 +437,24 @@ pub async fn profile_edit( } } + if let Some(remove_links) = edit_data.remove_links { + println!("remove links: {:?}", remove_links); + for link in remove_links { + println!("Removing link: {:?}", link); + // Remove link from list + sqlx::query!( + "DELETE FROM shared_profiles_links WHERE shared_profile_id = $1 AND id = $2", + data.inner.id.0 as i64, + link.0 as i64 + ) + .execute(&mut *transaction) + .await?; + } + } + // Edit the metadata fields if edit_data.loader_version.is_some() || edit_data.game_version.is_some() { - let mut metadata = data.metadata.clone(); + let mut metadata = data.inner.metadata.clone(); match &mut metadata { client_profile_item::ClientProfileMetadata::Minecraft { @@ -474,14 +495,14 @@ pub async fn profile_edit( sqlx::query!( "UPDATE shared_profiles SET metadata = $1 WHERE id = $2", serde_json::to_value(metadata)?, - data.id.0 + data.inner.id.0 ) .execute(&mut *transaction) .await?; } transaction.commit().await?; - client_profile_item::ClientProfile::clear_cache(data.id, &redis).await?; + client_profile_item::ClientProfile::clear_cache(data.inner.id, &redis).await?; return Ok(HttpResponse::NoContent().finish()); } else { return Err(ApiError::CustomAuthentication( @@ -518,23 +539,23 @@ pub async fn profile_delete( let profile_data = database::models::client_profile_item::ClientProfile::get(id, &**pool, &redis).await?; if let Some(data) = profile_data { - if data.owner_id == user_option.1.id.into() { + if data.inner.owner_id == user_option.1.id.into() { let mut transaction = pool.begin().await?; let deleted_hashes = database::models::client_profile_item::ClientProfile::remove( - data.id, + data.inner.id, &mut transaction, &redis, ) .await?; transaction.commit().await?; - client_profile_item::ClientProfile::clear_cache(data.id, &redis).await?; + client_profile_item::ClientProfile::clear_cache(data.inner.id, &redis).await?; // Delete the files from the CDN if they are no longer used by any profile delete_unused_files_from_host(deleted_hashes, &pool, &file_host).await?; return Ok(HttpResponse::NoContent().finish()); - } else if data.users.contains(&user_option.1.id.into()) { + } else if data.inner.users.contains(&user_option.1.id.into()) { // We know it exists, but still can't delete it return Err(ApiError::CustomAuthentication( "You are not the owner of this profile".to_string(), @@ -574,7 +595,7 @@ pub async fn profile_share( database::models::client_profile_item::ClientProfile::get(id, &**pool, &redis).await?; if let Some(data) = profile_data { - if data.owner_id == user_option.1.id.into() { + if data.inner.owner_id == user_option.1.id.into() { // Generate a share link identifier let _identifier = ChaCha20Rng::from_entropy() .sample_iter(&Alphanumeric) @@ -588,30 +609,30 @@ pub async fn profile_share( let link = database::models::client_profile_item::ClientProfileLink { id: profile_link_id, - shared_profile_id: data.id, + shared_profile_id: data.inner.id, created: Utc::now(), expires: Utc::now() + chrono::Duration::days(7), }; link.insert(&mut transaction).await?; transaction.commit().await?; - client_profile_item::ClientProfile::clear_cache(data.id, &redis).await?; + client_profile_item::ClientProfile::clear_cache(data.inner.id, &redis).await?; return Ok(HttpResponse::Ok().json(ClientProfileShareLink::from(link))); } } Err(ApiError::NotFound) } -// See the status of a link to a profile by its id -// This is used by the to check if the link is expired, etc. -pub async fn profile_link_get( +// Get a profile's basic information by its share link id +pub async fn profile_get_share_link( req: HttpRequest, - info: web::Path<(String, ClientProfileLinkId)>, + info: web::Path<(ClientProfileLinkId,)>, pool: web::Data, redis: web::Data, session_queue: web::Data, ) -> Result { - let url_identifier = info.into_inner().1; - // Must be logged in to check + let url_identifier = info.into_inner().0; + + // Must be logged let user_option = get_user_from_headers( &req, &**pool, @@ -619,9 +640,10 @@ pub async fn profile_link_get( &session_queue, None, // No scopes required to read your own links ) - .await?; + .await? + .1; - // Confirm this is our project, then if so, share + // Fetch the profile information of the desired client profile let link_data = database::models::client_profile_item::ClientProfileLink::get( url_identifier.into(), &**pool, @@ -637,12 +659,8 @@ pub async fn profile_link_get( .await? .ok_or_else(|| ApiError::NotFound)?; - // Only view link meta information if the user is the owner of the profile - if data.owner_id == user_option.1.id.into() { - Ok(HttpResponse::Ok().json(ClientProfileShareLink::from(link_data))) - } else { - Err(ApiError::NotFound) - } + // Return the profile information + Ok(HttpResponse::Ok().json(ClientProfile::from(data, Some(user_option.id.into())))) } // Accept a share link to a profile @@ -650,12 +668,12 @@ pub async fn profile_link_get( // TODO: With above change, this is the API link that is translated from a modrinth:// link by the launcher, which would then download it pub async fn accept_share_link( req: HttpRequest, - info: web::Path<(ClientProfileId, ClientProfileLinkId)>, + info: web::Path<(ClientProfileLinkId,)>, pool: web::Data, redis: web::Data, session_queue: web::Data, ) -> Result { - let (profile_id, url_identifier) = info.into_inner(); + let url_identifier = info.into_inner().0; // Must be logged in to accept let user_option = get_user_from_headers( @@ -675,11 +693,6 @@ pub async fn accept_share_link( .await? .ok_or_else(|| ApiError::NotFound)?; - // Confirm it matches the profile id - if link_data.shared_profile_id != profile_id.into() { - return Err(ApiError::NotFound); - } - let data = database::models::client_profile_item::ClientProfile::get( link_data.shared_profile_id, &**pool, @@ -689,14 +702,19 @@ pub async fn accept_share_link( .ok_or_else(|| ApiError::NotFound)?; // Confirm this is not our profile - if data.owner_id == user_option.1.id.into() { + if data.inner.owner_id == user_option.1.id.into() { return Err(ApiError::InvalidInput( "You cannot accept your own share link".to_string(), )); } // Confirm we are not already on the team - if data.users.iter().any(|x| *x == user_option.1.id.into()) { + if data + .inner + .users + .iter() + .any(|x| *x == user_option.1.id.into()) + { return Err(ApiError::InvalidInput( "You are already on this profile's team".to_string(), )); @@ -705,17 +723,17 @@ pub async fn accept_share_link( // Add the user to the team sqlx::query!( "INSERT INTO shared_profiles_users (shared_profile_id, user_id) VALUES ($1, $2)", - data.id.0 as i64, + data.inner.id.0 as i64, user_option.1.id.0 as i64 ) .execute(&**pool) .await?; - client_profile_item::ClientProfile::clear_cache(data.id, &redis).await?; + client_profile_item::ClientProfile::clear_cache(data.inner.id, &redis).await?; Ok(HttpResponse::NoContent().finish()) } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, PartialEq, Eq, Hash, Clone, Debug)] pub struct ProfileDownload { // Version ids for modrinth-hosted versions pub version_ids: Vec, @@ -725,9 +743,10 @@ pub struct ProfileDownload { pub override_cdns: Vec<(String, PathBuf)>, } -// Download a client profile +// Download a client profile (gets files) +// This one can use profile id, so fields can be accessed if no share link is available // Only the owner of the profile or an invited user can download -pub async fn profile_download( +pub async fn profile_files( req: HttpRequest, info: web::Path<(ClientProfileId,)>, pool: web::Data, @@ -759,20 +778,83 @@ pub async fn profile_download( }; // Check if this user is on the profile user list - if !profile.users.contains(&user_option.1.id.into()) { + if !profile.inner.users.contains(&user_option.1.id.into()) { + return Err(ApiError::CustomAuthentication( + "You are not on this profile's team".to_string(), + )); + } + + let override_cdns = profile + .inner + .overrides + .into_iter() + .map(|x| (format!("{}/custom_files/{}", cdn_url, x.0), x.1)) + .collect::>(); + + Ok(HttpResponse::Ok().json(ProfileDownload { + version_ids: profile.inner.versions.iter().map(|x| (*x).into()).collect(), + override_cdns, + })) +} + +// Download a client profile (gets files) +// This one uses the share id, so fields can be accessed if you don't have the profile id directly +// Only the owner of the profile or an invited user can download +pub async fn profile_share_files( + req: HttpRequest, + info: web::Path<(ClientProfileLinkId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let cdn_url = dotenvy::var("CDN_URL")?; + let share_link_id = info.into_inner().0; + + // Must be logged in to download + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::CLIENT_PROFILE_DOWNLOAD]), + ) + .await?; + + // Fetch client profile from link id + let link_data = database::models::client_profile_item::ClientProfileLink::get( + share_link_id.into(), + &**pool, + ) + .await? + .ok_or_else(|| ApiError::NotFound)?; + + // Fetch the profile information of the desired client profile + let Some(profile) = database::models::client_profile_item::ClientProfile::get( + link_data.shared_profile_id, + &**pool, + &redis, + ) + .await? + else { + return Err(ApiError::NotFound); + }; + + // Check if this user is on the profile user list + if !profile.inner.users.contains(&user_option.1.id.into()) { return Err(ApiError::CustomAuthentication( "You are not on this profile's team".to_string(), )); } let override_cdns = profile + .inner .overrides .into_iter() .map(|x| (format!("{}/custom_files/{}", cdn_url, x.0), x.1)) .collect::>(); Ok(HttpResponse::Ok().json(ProfileDownload { - version_ids: profile.versions.iter().map(|x| (*x).into()).collect(), + version_ids: profile.inner.versions.iter().map(|x| (*x).into()).collect(), override_cdns, })) } @@ -821,7 +903,7 @@ pub async fn profile_token_check( let all_allowed_urls = profiles .into_iter() - .flat_map(|x| x.overrides.into_iter().map(|x| x.0)) + .flat_map(|x| x.inner.overrides.into_iter().map(|x| x.0)) .collect::>(); // Check the token is valid for the requested file @@ -876,13 +958,13 @@ pub async fn profile_icon_edit( ApiError::InvalidInput("The specified profile does not exist!".to_string()) })?; - if !user.role.is_mod() && profile_item.owner_id != user.id.into() { + if !user.role.is_mod() && profile_item.inner.owner_id != user.id.into() { return Err(ApiError::CustomAuthentication( "You don't have permission to edit this profile's icon.".to_string(), )); } - if let Some(icon) = profile_item.icon_url { + if let Some(icon) = profile_item.inner.icon_url { let name = icon.split(&format!("{cdn_url}/")).nth(1); if let Some(icon_path) = name { @@ -896,7 +978,7 @@ pub async fn profile_icon_edit( let color = crate::util::img::get_color_from_img(&bytes)?; let hash = format!("{:x}", sha2::Sha512::digest(&bytes)); - let id: ClientProfileId = profile_item.id.into(); + let id: ClientProfileId = profile_item.inner.id.into(); let upload_data = file_host .upload_file( content_type, @@ -915,14 +997,17 @@ pub async fn profile_icon_edit( ", format!("{}/{}", cdn_url, upload_data.file_name), color.map(|x| x as i32), - profile_item.id as database::models::ids::ClientProfileId, + profile_item.inner.id as database::models::ids::ClientProfileId, ) .execute(&mut *transaction) .await?; transaction.commit().await?; - database::models::client_profile_item::ClientProfile::clear_cache(profile_item.id, &redis) - .await?; + database::models::client_profile_item::ClientProfile::clear_cache( + profile_item.inner.id, + &redis, + ) + .await?; Ok(HttpResponse::NoContent().body("")) } else { @@ -959,14 +1044,14 @@ pub async fn delete_profile_icon( ApiError::InvalidInput("The specified profile does not exist!".to_string()) })?; - if !user.role.is_mod() && profile_item.owner_id != user.id.into() { + if !user.role.is_mod() && profile_item.inner.owner_id != user.id.into() { return Err(ApiError::CustomAuthentication( "You don't have permission to edit this profile's icon.".to_string(), )); } let cdn_url = dotenvy::var("CDN_URL")?; - if let Some(icon) = profile_item.icon_url { + if let Some(icon) = profile_item.inner.icon_url { let name = icon.split(&format!("{cdn_url}/")).nth(1); if let Some(icon_path) = name { @@ -982,15 +1067,18 @@ pub async fn delete_profile_icon( SET icon_url = NULL, color = NULL WHERE (id = $1) ", - profile_item.id as database::models::ids::ClientProfileId, + profile_item.inner.id as database::models::ids::ClientProfileId, ) .execute(&mut *transaction) .await?; transaction.commit().await?; - database::models::client_profile_item::ClientProfile::clear_cache(profile_item.id, &redis) - .await?; + database::models::client_profile_item::ClientProfile::clear_cache( + profile_item.inner.id, + &redis, + ) + .await?; Ok(HttpResponse::NoContent().body("")) } @@ -1039,7 +1127,7 @@ pub async fn client_profile_add_override( CreateError::InvalidInput("The specified profile does not exist!".to_string()) })?; - if !user.role.is_mod() && profile_item.owner_id != user.id.into() { + if !user.role.is_mod() && profile_item.inner.owner_id != user.id.into() { return Err(CreateError::CustomAuthenticationError( "You don't have permission to add overrides.".to_string(), )); @@ -1140,7 +1228,7 @@ pub async fn client_profile_add_override( let (ids, hashes, install_paths): (Vec<_>, Vec<_>, Vec<_>) = uploaded_files .into_iter() - .map(|f| (profile_item.id.0, f.hash, f.install_path)) + .map(|f| (profile_item.inner.id.0, f.hash, f.install_path)) .multiunzip(); sqlx::query!( @@ -1162,15 +1250,18 @@ pub async fn client_profile_add_override( SET updated = NOW() WHERE id = $1 ", - profile_item.id.0, + profile_item.inner.id.0, ) .execute(&mut *transaction) .await?; transaction.commit().await?; - database::models::client_profile_item::ClientProfile::clear_cache(profile_item.id, &redis) - .await?; + database::models::client_profile_item::ClientProfile::clear_cache( + profile_item.inner.id, + &redis, + ) + .await?; Ok(HttpResponse::NoContent().body("")) } @@ -1209,11 +1300,9 @@ pub async fn client_profile_remove_overrides( &redis, ) .await? - .ok_or_else(|| { - ApiError::InvalidInput("The specified profile does not exist!".to_string()) - })?; + .ok_or_else(|| ApiError::InvalidInput("The specified profile does not exist!".to_string()))?; - if !user.role.is_mod() && profile_item.owner_id != user.id.into() { + if !user.role.is_mod() && profile_item.inner.owner_id != user.id.into() { return Err(ApiError::CustomAuthentication( "You don't have permission to remove overrides.".to_string(), )); @@ -1223,6 +1312,7 @@ pub async fn client_profile_remove_overrides( let delete_install_paths = data.install_paths.clone().unwrap_or_default(); let overrides = profile_item + .inner .overrides .into_iter() .filter(|(hash, path)| delete_hashes.contains(hash) || delete_install_paths.contains(path)) @@ -1241,7 +1331,7 @@ pub async fn client_profile_remove_overrides( WHERE (shared_profile_id = $1 AND (file_hash = ANY($2::text[]) OR install_path = ANY($3::text[]))) RETURNING file_hash ", - profile_item.id.0, + profile_item.inner.id.0, &delete_hashes[..], &delete_install_paths[..], ) @@ -1255,15 +1345,18 @@ pub async fn client_profile_remove_overrides( SET updated = NOW() WHERE id = $1 ", - profile_item.id.0, + profile_item.inner.id.0, ) .execute(&mut *transaction) .await?; transaction.commit().await?; - database::models::client_profile_item::ClientProfile::clear_cache(profile_item.id, &redis) - .await?; + database::models::client_profile_item::ClientProfile::clear_cache( + profile_item.inner.id, + &redis, + ) + .await?; // Delete the files from the CDN if they are no longer used by any profile delete_unused_files_from_host(deleted_hashes, &pool, &file_host).await?; @@ -1271,10 +1364,9 @@ pub async fn client_profile_remove_overrides( Ok(HttpResponse::NoContent().body("")) } - // For a list of deleted hashes, delete the files from the CDN if they are no longer used by any profile async fn delete_unused_files_from_host( - deleted_hashes : Vec, + deleted_hashes: Vec, pool: &PgPool, file_host: &Arc, ) -> Result<(), ApiError> { @@ -1286,7 +1378,7 @@ async fn delete_unused_files_from_host( ", &deleted_hashes[..], ) - .fetch_all(&*pool) + .fetch_all(pool) .await? .into_iter() .filter_map(|x| x.file_hash) @@ -1309,4 +1401,4 @@ async fn delete_unused_files_from_host( } Ok(()) -} \ No newline at end of file +} diff --git a/tests/common/api_v3/client_profile.rs b/tests/common/api_v3/client_profile.rs index c8579a81..27c4f1fc 100644 --- a/tests/common/api_v3/client_profile.rs +++ b/tests/common/api_v3/client_profile.rs @@ -73,6 +73,7 @@ impl ApiV3 { loader_version: Option<&str>, versions: Option>, remove_users: Option>, + remove_links: Option>, pat: Option<&str>, ) -> ServiceResponse { let req = test::TestRequest::patch() @@ -83,7 +84,8 @@ impl ApiV3 { "loader": loader, "loader_version": loader_version, "versions": versions, - "remove_users": remove_users + "remove_users": remove_users, + "remove_links": remove_links })) .to_request(); self.call(req).await @@ -200,7 +202,7 @@ impl ApiV3 { id: &str, pat: Option<&str>, ) -> ServiceResponse { - let req = TestRequest::get() + let req = TestRequest::post() .uri(&format!("/_internal/client/profile/{}/share", id)) .append_pat(pat) .to_request(); @@ -217,73 +219,67 @@ impl ApiV3 { test::read_body_json(resp).await } - pub async fn get_client_profile_share_link( + pub async fn accept_client_profile_share_link( &self, - profile_id: &str, url_identifier: &str, pat: Option<&str>, ) -> ServiceResponse { - let req = TestRequest::get() + let req = TestRequest::post() .uri(&format!( - "/_internal/client/profile/{}/share/{}", - profile_id, url_identifier + "/_internal/client/share/{}/accept", + url_identifier )) .append_pat(pat) .to_request(); self.call(req).await } - pub async fn get_client_profile_share_link_deserialized( + // Get links and token + pub async fn download_client_profile_from_profile_id( &self, profile_id: &str, - url_identifier: &str, pat: Option<&str>, - ) -> ClientProfileShareLink { - let resp = self - .get_client_profile_share_link(profile_id, url_identifier, pat) - .await; - assert_status!(&resp, StatusCode::OK); - test::read_body_json(resp).await + ) -> ServiceResponse { + let req = TestRequest::get() + .uri(&format!("/_internal/client/profile/{}/files", profile_id)) + .append_pat(pat) + .to_request(); + self.call(req).await } - pub async fn accept_client_profile_share_link( + pub async fn download_client_profile_from_profile_id_deserialized( &self, profile_id: &str, - url_identifier: &str, pat: Option<&str>, - ) -> ServiceResponse { - let req = TestRequest::post() - .uri(&format!( - "/_internal/client/profile/{}/accept/{}", - profile_id, url_identifier - )) - .append_pat(pat) - .to_request(); - self.call(req).await + ) -> ProfileDownload { + let resp = self + .download_client_profile_from_profile_id(profile_id, pat) + .await; + assert_status!(&resp, StatusCode::OK); + test::read_body_json(resp).await } // Get links and token - pub async fn download_client_profile( + pub async fn download_client_profile_from_link_id( &self, - profile_id: &str, + link_id: &str, pat: Option<&str>, ) -> ServiceResponse { let req = TestRequest::get() - .uri(&format!( - "/_internal/client/profile/{}/download", - profile_id - )) + .uri(&format!("/_internal/client/share/{}/files", link_id)) .append_pat(pat) .to_request(); self.call(req).await } - pub async fn download_client_profile_deserialized( + pub async fn download_client_profile_from_link_id_deserialized( &self, - profile_id: &str, + link_id: &str, pat: Option<&str>, ) -> ProfileDownload { - let resp = self.download_client_profile(profile_id, pat).await; + let resp = self + .download_client_profile_from_link_id(link_id, pat) + .await; assert_status!(&resp, StatusCode::OK); test::read_body_json(resp).await } diff --git a/tests/profiles.rs b/tests/profiles.rs index 71c2d59e..ed37c103 100644 --- a/tests/profiles.rs +++ b/tests/profiles.rs @@ -25,7 +25,6 @@ async fn create_modify_profile() { // Check that the properties are correct let api = &test_env.api; let alpha_version_id = test_env.dummy.project_alpha.version_id.to_string(); - let alpha_version_id_parsed = test_env.dummy.project_alpha.version_id_parsed; // Attempt to create a simple profile with invalid data, these should fail. // - fake loader @@ -92,7 +91,7 @@ async fn create_modify_profile() { assert_eq!(profile.loader, "fabric"); assert_eq!(loader_version, "1.0.0"); assert_eq!(game_version, "1.20.1"); - assert_eq!(profile.versions, vec![]); + assert_eq!(profile.share_links.unwrap().len(), 0); // No links yet assert_eq!(profile.icon_url, None); // Modify the profile illegally in the same ways @@ -104,6 +103,7 @@ async fn create_modify_profile() { None, None, None, + None, USER_USER_PAT, ) .await; @@ -130,6 +130,7 @@ async fn create_modify_profile() { None, Some(vec!["unparseable-version"]), None, + None, USER_USER_PAT, ) .await; @@ -144,6 +145,7 @@ async fn create_modify_profile() { None, None, None, + None, FRIEND_USER_PAT, ) .await; @@ -164,7 +166,7 @@ async fn create_modify_profile() { }; assert_eq!(loader_version, "1.0.0"); assert_eq!(game_version, "1.20.1"); - assert_eq!(profile.versions, vec![]); + assert_eq!(profile.share_links.unwrap().len(), 0); assert_eq!(profile.icon_url, None); assert_eq!(profile.updated, updated); @@ -177,6 +179,7 @@ async fn create_modify_profile() { Some("1.0.1"), Some(vec![&alpha_version_id]), None, + None, USER_USER_PAT, ) .await; @@ -197,7 +200,6 @@ async fn create_modify_profile() { }; assert_eq!(loader_version, "1.0.1"); assert_eq!(game_version, "1.20.1"); - assert_eq!(profile.versions, vec![alpha_version_id_parsed]); assert_eq!(profile.icon_url, None); assert!(profile.updated > updated); let updated = profile.updated; @@ -211,6 +213,7 @@ async fn create_modify_profile() { Some("1.0.0"), Some(vec![]), None, + None, USER_USER_PAT, ) .await; @@ -232,7 +235,6 @@ async fn create_modify_profile() { }; assert_eq!(loader_version, "1.0.0"); assert_eq!(game_version, "1.20.1"); - assert_eq!(profile.versions, vec![]); assert_eq!(profile.icon_url, None); assert!(profile.updated > updated); }) @@ -251,13 +253,21 @@ async fn accept_share_link() { .create_client_profile("test", "fabric", "1.0.0", "1.20.1", vec![], USER_USER_PAT) .await; assert_status!(&profile, StatusCode::OK); - let profile: ClientProfile = test::read_body_json(profile).await; - let id = profile.id.to_string(); + let id = test::read_body_json::(profile) + .await + .id + .to_string(); + + // get the profile + let profile = api + .get_client_profile_deserialized(&id, USER_USER_PAT) + .await; + assert_eq!(profile.share_links.unwrap().len(), 0); let users: Vec = profile.users.unwrap(); assert_eq!(users.len(), 1); assert_eq!(users[0].0, USER_USER_ID_PARSED as u64); - // Friend can't see the profile user yet, but can see the profile + // Friend can't see the profile users, links, versions, install paths yet, but can see the profile let profile = api .get_client_profile_deserialized(&id, FRIEND_USER_PAT) .await; @@ -268,10 +278,16 @@ async fn accept_share_link() { .generate_client_profile_share_link_deserialized(&id, USER_USER_PAT) .await; + // Get profile again + let profile = api + .get_client_profile_deserialized(&id, USER_USER_PAT) + .await; + assert_eq!(profile.share_links.unwrap().len(), 1); // Now has a share link + // Link is an 'accept' link, when visited using any user token using POST, it should add the user to the profile // As 'friend', accept the share link let resp = api - .accept_client_profile_share_link(&id, &share_link.id.to_string(), FRIEND_USER_PAT) + .accept_client_profile_share_link(&share_link.id.to_string(), FRIEND_USER_PAT) .await; assert_status!(&resp, StatusCode::NO_CONTENT); @@ -296,7 +312,7 @@ async fn accept_share_link() { ]; for (i, pat) in dummy_user_pats.iter().enumerate().take(4 + 1) { let resp = api - .accept_client_profile_share_link(&id, &share_link.id.to_string(), *pat) + .accept_client_profile_share_link(&share_link.id.to_string(), *pat) .await; if i == 0 || i == 1 { assert_status!(&resp, StatusCode::BAD_REQUEST); @@ -304,6 +320,27 @@ async fn accept_share_link() { assert_status!(&resp, StatusCode::NO_CONTENT); } } + + // As user, remove share link + let resp = api + .edit_client_profile( + &id, + None, + None, + None, + None, + None, + Some(vec![&share_link.id.to_string()]), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // Confirm share link is gone + let profile = api + .get_client_profile_deserialized(&id, USER_USER_PAT) + .await; + assert_eq!(profile.share_links.unwrap().len(), 0); }) .await; } @@ -344,18 +381,34 @@ async fn delete_profile() { .await; assert_status!(&resp, StatusCode::NO_CONTENT); - // Invite a friend to the profile and accept it + // Invite a friend to the profile let share_link = api .generate_client_profile_share_link_deserialized(&id, USER_USER_PAT) .await; + + // As friend, try to get the download links for the profile by both profile id and share link id + // Not invited yet, should fail + let resp = api + .download_client_profile_from_profile_id(&id, FRIEND_USER_PAT) + .await; + assert_status!(&resp, StatusCode::UNAUTHORIZED); + let resp = api + .download_client_profile_from_link_id(&share_link.id.to_string(), FRIEND_USER_PAT) + .await; + assert_status!(&resp, StatusCode::UNAUTHORIZED); + + // Accept let resp = api - .accept_client_profile_share_link(&id, &share_link.id.to_string(), FRIEND_USER_PAT) + .accept_client_profile_share_link(&share_link.id.to_string(), FRIEND_USER_PAT) .await; assert_status!(&resp, StatusCode::NO_CONTENT); - // Get a token as the friend + // Get a token as the friend, from the share link id let token = api - .download_client_profile_deserialized(&id, FRIEND_USER_PAT) + .download_client_profile_from_link_id_deserialized( + &share_link.id.to_string(), + FRIEND_USER_PAT, + ) .await; // Confirm it works @@ -416,12 +469,16 @@ async fn download_profile() { assert_status!(&resp, StatusCode::NO_CONTENT); // As 'user', try to generate a download link for the profile - let resp = api.download_client_profile(&id, USER_USER_PAT).await; + let resp = api + .download_client_profile_from_profile_id(&id, USER_USER_PAT) + .await; assert_status!(&resp, StatusCode::OK); // As 'friend', try to get the download links for the profile // Not invited yet, should fail - let resp = api.download_client_profile(&id, FRIEND_USER_PAT).await; + let resp = api + .download_client_profile_from_profile_id(&id, FRIEND_USER_PAT) + .await; assert_status!(&resp, StatusCode::UNAUTHORIZED); // As 'user', try to generate a share link for the profile, and accept it as 'friend' @@ -429,18 +486,27 @@ async fn download_profile() { .generate_client_profile_share_link_deserialized(&id, USER_USER_PAT) .await; let resp = api - .accept_client_profile_share_link(&id, &share_link.id.to_string(), FRIEND_USER_PAT) + .accept_client_profile_share_link(&share_link.id.to_string(), FRIEND_USER_PAT) .await; assert_status!(&resp, StatusCode::NO_CONTENT); // As 'friend', try to get the download links for the profile // Should succeed let mut download = api - .download_client_profile_deserialized(&id, FRIEND_USER_PAT) + .download_client_profile_from_link_id_deserialized( + &share_link.id.to_string(), + FRIEND_USER_PAT, + ) + .await; + let download_clone = api + .download_client_profile_from_profile_id_deserialized(&id, FRIEND_USER_PAT) .await; + assert_eq!(download, download_clone); // But enemy should fail - let resp = api.download_client_profile(&id, ENEMY_USER_PAT).await; + let resp = api + .download_client_profile_from_link_id(&share_link.id.to_string(), ENEMY_USER_PAT) + .await; assert_status!(&resp, StatusCode::UNAUTHORIZED); // Download url should be: @@ -498,6 +564,7 @@ async fn download_profile() { None, None, Some(vec![FRIEND_USER_ID]), + None, USER_USER_PAT, ) .await; @@ -510,7 +577,9 @@ async fn download_profile() { assert_eq!(profile.users.unwrap().len(), 1); // Confirm friend can no longer download the profile - let resp = api.download_client_profile(&id, FRIEND_USER_PAT).await; + let resp = api + .download_client_profile_from_link_id(&share_link.id.to_string(), FRIEND_USER_PAT) + .await; assert_status!(&resp, StatusCode::UNAUTHORIZED); // Confirm token invalidation @@ -521,7 +590,10 @@ async fn download_profile() { // Confirm user can still download the profile let resp = api - .download_client_profile_deserialized(&id, USER_USER_PAT) + .download_client_profile_from_link_id_deserialized( + &share_link.id.to_string(), + USER_USER_PAT, + ) .await; assert_eq!(resp.override_cdns.len(), 1); }) @@ -595,6 +667,7 @@ async fn add_remove_profile_versions() { None, Some(vec![&alpha_version_id]), None, + None, USER_USER_PAT, ) .await; @@ -627,20 +700,32 @@ async fn add_remove_profile_versions() { assert_status!(&resp, StatusCode::NO_CONTENT); // Get the profile and check the versions - let profile = api - .get_client_profile_deserialized(&profile.id.to_string(), USER_USER_PAT) + let profile_downloads = api + .download_client_profile_from_profile_id_deserialized( + &profile.id.to_string(), + USER_USER_PAT, + ) .await; assert_eq!( - profile.versions, + profile_downloads.version_ids, vec![test_env.dummy.project_alpha.version_id_parsed] ); assert_eq!( - profile.override_install_paths, + profile_downloads + .override_cdns + .into_iter() + .map(|(_, path)| path) + .collect::>(), vec![ PathBuf::from("mods/test.jar"), PathBuf::from("mods/test_different.jar") ] ); + + // Get profile again to confirm update + let profile = api + .get_client_profile_deserialized(&profile.id.to_string(), USER_USER_PAT) + .await; assert!(profile.updated > updated); let updated = profile.updated; @@ -667,10 +752,15 @@ async fn add_remove_profile_versions() { // Get the profile and check the versions let profile_enemy = api - .get_client_profile_deserialized(&id_enemy, ENEMY_USER_PAT) + .download_client_profile_from_profile_id_deserialized(&id_enemy, ENEMY_USER_PAT) .await; + assert_eq!( - profile_enemy.override_install_paths, + profile_enemy + .override_cdns + .into_iter() + .map(|(_, path)| path) + .collect::>(), vec![PathBuf::from("mods/test.jar")] ); @@ -687,21 +777,37 @@ async fn add_remove_profile_versions() { assert_status!(&resp, StatusCode::NO_CONTENT); // Should still exist in the enemy's profile, but not the user's - let profile_enemy = api - .get_client_profile_deserialized(&id_enemy, ENEMY_USER_PAT) + let profile_enemy_downloads = api + .download_client_profile_from_profile_id_deserialized(&id_enemy, ENEMY_USER_PAT) .await; assert_eq!( - profile_enemy.override_install_paths, + profile_enemy_downloads + .override_cdns + .into_iter() + .map(|(_, path)| path) + .collect::>(), vec![PathBuf::from("mods/test.jar")] ); - let profile = api - .get_client_profile_deserialized(&profile.id.to_string(), USER_USER_PAT) + let profile_downloads = api + .download_client_profile_from_profile_id_deserialized( + &profile.id.to_string(), + USER_USER_PAT, + ) .await; assert_eq!( - profile.override_install_paths, + profile_downloads + .override_cdns + .into_iter() + .map(|(_, path)| path) + .collect::>(), vec![PathBuf::from("mods/test_different.jar")] ); + + // Get profile again to confirm update + let profile = api + .get_client_profile_deserialized(&profile.id.to_string(), USER_USER_PAT) + .await; assert!(profile.updated > updated); let updated = profile.updated; @@ -737,11 +843,18 @@ async fn add_remove_profile_versions() { assert_status!(&resp, StatusCode::NO_CONTENT); // Allow failure to return success, it just doesn't delete anything // Confirm user still has it - let profile = api - .get_client_profile_deserialized(&profile.id.to_string(), USER_USER_PAT) + let profile_downloads = api + .download_client_profile_from_profile_id_deserialized( + &profile.id.to_string(), + USER_USER_PAT, + ) .await; assert_eq!( - profile.override_install_paths, + profile_downloads + .override_cdns + .into_iter() + .map(|(_, path)| path) + .collect::>(), vec![PathBuf::from("mods/test_different.jar")] ); @@ -765,10 +878,25 @@ async fn add_remove_profile_versions() { assert_status!(&resp, StatusCode::NO_CONTENT); // Confirm user no longer has it + let profile_downloads = api + .download_client_profile_from_profile_id_deserialized( + &profile.id.to_string(), + USER_USER_PAT, + ) + .await; + assert_eq!( + profile_downloads + .override_cdns + .into_iter() + .map(|(_, path)| path) + .collect::>(), + Vec::::new() + ); + + // Get profile again to confirm update let profile = api .get_client_profile_deserialized(&profile.id.to_string(), USER_USER_PAT) .await; - assert_eq!(profile.override_install_paths, Vec::::new()); assert!(profile.updated > updated); // In addition, delete "alpha_version_id" from the user's profile @@ -781,16 +909,20 @@ async fn add_remove_profile_versions() { None, Some(vec![]), None, + None, USER_USER_PAT, ) .await; assert_status!(&resp, StatusCode::NO_CONTENT); // Confirm user no longer has it - let profile = api - .get_client_profile_deserialized(&profile.id.to_string(), USER_USER_PAT) + let profile_downloads = api + .download_client_profile_from_profile_id_deserialized( + &profile.id.to_string(), + USER_USER_PAT, + ) .await; - assert_eq!(profile.versions, vec![]); + assert_eq!(profile_downloads.version_ids, vec![]); }) .await; } @@ -819,7 +951,13 @@ async fn hidden_versions_are_forbidden() { .await; assert_status!(&profile, StatusCode::OK); let profile: ClientProfile = test::read_body_json(profile).await; - assert_eq!(profile.versions, vec![alpha_version_id_parsed]); + let id = profile.id.to_string(); + + // Get the profile and check the versions + let profile_downloads = api + .download_client_profile_from_profile_id_deserialized(&id, FRIEND_USER_PAT) + .await; + assert_eq!(profile_downloads.version_ids, vec![alpha_version_id_parsed]); // Edit profile, as FRIEND, with beta version, which is not visible to FRIEND // This should fail @@ -831,6 +969,7 @@ async fn hidden_versions_are_forbidden() { None, Some(vec![&beta_version_id]), None, + None, FRIEND_USER_PAT, ) .await; @@ -838,10 +977,10 @@ async fn hidden_versions_are_forbidden() { // Get the profile and check the versions // Empty, because alpha is removed, and beta is not visible - let profile = api - .get_client_profile_deserialized(&profile.id.to_string(), FRIEND_USER_PAT) + let profile_downloads = api + .download_client_profile_from_profile_id_deserialized(&id, FRIEND_USER_PAT) .await; - assert_eq!(profile.versions, vec![]); + assert_eq!(profile_downloads.version_ids, vec![]); }) .await; } From e07b4e1a6d32c8174efb45c0a4cc1c2155b5752e Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Sat, 20 Jan 2024 09:21:04 -0800 Subject: [PATCH 19/25] revised routes --- src/models/v3/client/profile.rs | 6 +-- src/routes/internal/client/profiles.rs | 66 +----------------------- tests/common/api_v3/client_profile.rs | 50 +++++++++---------- tests/profiles.rs | 69 ++++++++++++++++++-------- 4 files changed, 78 insertions(+), 113 deletions(-) diff --git a/src/models/v3/client/profile.rs b/src/models/v3/client/profile.rs index 006a8cba..dd3fdb07 100644 --- a/src/models/v3/client/profile.rs +++ b/src/models/v3/client/profile.rs @@ -19,7 +19,7 @@ pub struct ClientProfileId(pub u64); pub struct ClientProfileLinkId(pub u64); /// A project returned from the API -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)] pub struct ClientProfile { /// The ID of the profile, encoded as a base62 string. pub id: ClientProfileId, @@ -49,7 +49,7 @@ pub struct ClientProfile { pub users: Option>, } -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)] #[serde(tag = "game")] pub enum ClientProfileMetadata { #[serde(rename = "minecraft-java")] @@ -108,7 +108,7 @@ impl ClientProfile { } } -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)] pub struct ClientProfileShareLink { pub id: ClientProfileLinkId, // The url identifier, encoded as base62 pub profile_id: ClientProfileId, diff --git a/src/routes/internal/client/profiles.rs b/src/routes/internal/client/profiles.rs index 0994509d..fc50b87d 100644 --- a/src/routes/internal/client/profiles.rs +++ b/src/routes/internal/client/profiles.rs @@ -42,8 +42,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { .service( web::scope("share") .route("{id}", web::get().to(profile_get_share_link)) - .route("{id}/accept", web::post().to(accept_share_link)) - .route("{id}/files", web::get().to(profile_share_files)), + .route("{id}/accept", web::post().to(accept_share_link)), ) .service( web::scope("profile") @@ -182,7 +181,6 @@ pub async fn profile_create( .await .map_err(|_| CreateError::InvalidInput("Could not fetch submitted version ids".to_string()))?; - println!("Filtered versions: {:?}", versions); let profile_builder_actual = client_profile_item::ClientProfile { id: profile_id, name: profile_create_data.name.clone(), @@ -797,68 +795,6 @@ pub async fn profile_files( })) } -// Download a client profile (gets files) -// This one uses the share id, so fields can be accessed if you don't have the profile id directly -// Only the owner of the profile or an invited user can download -pub async fn profile_share_files( - req: HttpRequest, - info: web::Path<(ClientProfileLinkId,)>, - pool: web::Data, - redis: web::Data, - session_queue: web::Data, -) -> Result { - let cdn_url = dotenvy::var("CDN_URL")?; - let share_link_id = info.into_inner().0; - - // Must be logged in to download - let user_option = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::CLIENT_PROFILE_DOWNLOAD]), - ) - .await?; - - // Fetch client profile from link id - let link_data = database::models::client_profile_item::ClientProfileLink::get( - share_link_id.into(), - &**pool, - ) - .await? - .ok_or_else(|| ApiError::NotFound)?; - - // Fetch the profile information of the desired client profile - let Some(profile) = database::models::client_profile_item::ClientProfile::get( - link_data.shared_profile_id, - &**pool, - &redis, - ) - .await? - else { - return Err(ApiError::NotFound); - }; - - // Check if this user is on the profile user list - if !profile.inner.users.contains(&user_option.1.id.into()) { - return Err(ApiError::CustomAuthentication( - "You are not on this profile's team".to_string(), - )); - } - - let override_cdns = profile - .inner - .overrides - .into_iter() - .map(|x| (format!("{}/custom_files/{}", cdn_url, x.0), x.1)) - .collect::>(); - - Ok(HttpResponse::Ok().json(ProfileDownload { - version_ids: profile.inner.versions.iter().map(|x| (*x).into()).collect(), - override_cdns, - })) -} - #[derive(Deserialize)] pub struct TokenUrl { pub url: String, // TODO: Could take a vec instead for mass checking- revisit after cloudflare worker is done diff --git a/tests/common/api_v3/client_profile.rs b/tests/common/api_v3/client_profile.rs index 27c4f1fc..5c555637 100644 --- a/tests/common/api_v3/client_profile.rs +++ b/tests/common/api_v3/client_profile.rs @@ -109,6 +109,31 @@ impl ApiV3 { test::read_body_json(resp).await } + // Like get_client_profile, but via a share id + pub async fn get_client_profile_from_share_link( + &self, + url_identifier: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = TestRequest::get() + .uri(&format!("/_internal/client/share/{}", url_identifier)) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + pub async fn get_client_profile_from_share_link_deserialized( + &self, + url_identifier: &str, + pat: Option<&str>, + ) -> ClientProfile { + let resp = self + .get_client_profile_from_share_link(url_identifier, pat) + .await; + assert_status!(&resp, StatusCode::OK); + test::read_body_json(resp).await + } + pub async fn delete_client_profile(&self, id: &str, pat: Option<&str>) -> ServiceResponse { let req = TestRequest::delete() .uri(&format!("/_internal/client/profile/{}", id)) @@ -259,31 +284,6 @@ impl ApiV3 { test::read_body_json(resp).await } - // Get links and token - pub async fn download_client_profile_from_link_id( - &self, - link_id: &str, - pat: Option<&str>, - ) -> ServiceResponse { - let req = TestRequest::get() - .uri(&format!("/_internal/client/share/{}/files", link_id)) - .append_pat(pat) - .to_request(); - self.call(req).await - } - - pub async fn download_client_profile_from_link_id_deserialized( - &self, - link_id: &str, - pat: Option<&str>, - ) -> ProfileDownload { - let resp = self - .download_client_profile_from_link_id(link_id, pat) - .await; - assert_status!(&resp, StatusCode::OK); - test::read_body_json(resp).await - } - pub async fn check_download_client_profile_token( &self, url: &str, // Full URL, the route will parse it diff --git a/tests/profiles.rs b/tests/profiles.rs index ed37c103..6142efb1 100644 --- a/tests/profiles.rs +++ b/tests/profiles.rs @@ -386,16 +386,12 @@ async fn delete_profile() { .generate_client_profile_share_link_deserialized(&id, USER_USER_PAT) .await; - // As friend, try to get the download links for the profile by both profile id and share link id + // As friend, try to get the download links for the profile // Not invited yet, should fail let resp = api .download_client_profile_from_profile_id(&id, FRIEND_USER_PAT) .await; assert_status!(&resp, StatusCode::UNAUTHORIZED); - let resp = api - .download_client_profile_from_link_id(&share_link.id.to_string(), FRIEND_USER_PAT) - .await; - assert_status!(&resp, StatusCode::UNAUTHORIZED); // Accept let resp = api @@ -403,10 +399,18 @@ async fn delete_profile() { .await; assert_status!(&resp, StatusCode::NO_CONTENT); + // Get profile from share link + let profile = api + .get_client_profile_from_share_link_deserialized( + &share_link.id.to_string(), + FRIEND_USER_PAT, + ) + .await; + // Get a token as the friend, from the share link id let token = api - .download_client_profile_from_link_id_deserialized( - &share_link.id.to_string(), + .download_client_profile_from_profile_id_deserialized( + &profile.id.to_string(), FRIEND_USER_PAT, ) .await; @@ -493,19 +497,12 @@ async fn download_profile() { // As 'friend', try to get the download links for the profile // Should succeed let mut download = api - .download_client_profile_from_link_id_deserialized( - &share_link.id.to_string(), - FRIEND_USER_PAT, - ) - .await; - let download_clone = api .download_client_profile_from_profile_id_deserialized(&id, FRIEND_USER_PAT) .await; - assert_eq!(download, download_clone); // But enemy should fail let resp = api - .download_client_profile_from_link_id(&share_link.id.to_string(), ENEMY_USER_PAT) + .download_client_profile_from_profile_id(&id, ENEMY_USER_PAT) .await; assert_status!(&resp, StatusCode::UNAUTHORIZED); @@ -578,7 +575,7 @@ async fn download_profile() { // Confirm friend can no longer download the profile let resp = api - .download_client_profile_from_link_id(&share_link.id.to_string(), FRIEND_USER_PAT) + .download_client_profile_from_profile_id(&id, FRIEND_USER_PAT) .await; assert_status!(&resp, StatusCode::UNAUTHORIZED); @@ -590,10 +587,7 @@ async fn download_profile() { // Confirm user can still download the profile let resp = api - .download_client_profile_from_link_id_deserialized( - &share_link.id.to_string(), - USER_USER_PAT, - ) + .download_client_profile_from_profile_id_deserialized(&id, USER_USER_PAT) .await; assert_eq!(resp.override_cdns.len(), 1); }) @@ -927,6 +921,41 @@ async fn add_remove_profile_versions() { .await; } +// Profile gotten from share link vs profile gotten from profile id should be the same +#[actix_rt::test] +async fn share_link_profile_same_as_profile_id_profile() { + with_test_environment(None, |test_env: TestEnvironment| async move { + // Get download links for a created profile (including failure), create a share link, and create the correct number of tokens based on that + // They should expire after a time + let api = &test_env.api; + + // Create a simple profile + let profile = api + .create_client_profile("test", "fabric", "1.0.0", "1.20.1", vec![], USER_USER_PAT) + .await; + assert_status!(&profile, StatusCode::OK); + let profile: ClientProfile = test::read_body_json(profile).await; + let id = profile.id.to_string(); + + // Create a share link for the profile + let share_link = api + .generate_client_profile_share_link_deserialized(&id, USER_USER_PAT) + .await; + + // Get the profile from the share link + for pat in [USER_USER_PAT, FRIEND_USER_PAT].iter() { + let profile_from_share_link = api + .get_client_profile_from_share_link_deserialized(&share_link.id.to_string(), *pat) + .await; + + let profile_from_profile_id = api.get_client_profile_deserialized(&id, *pat).await; + + assert_eq!(profile_from_share_link, profile_from_profile_id); + } + }) + .await; +} + // Cannot add versions you do not have visibility access to #[actix_rt::test] async fn hidden_versions_are_forbidden() { From fd259913a702ce113e088b5bd68c34a154a301cf Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Sat, 20 Jan 2024 17:25:21 -0800 Subject: [PATCH 20/25] partial conversion to file system --- migrations/20231226012200_shared_modpacks.sql | 45 +-- src/database/models/client_profile_item.rs | 187 +++++++++--- src/database/models/file_item.rs | 212 ++++++++++++++ src/database/models/mod.rs | 1 + src/database/models/version_item.rs | 111 ++------ src/routes/internal/admin.rs | 6 +- src/routes/internal/client/profiles.rs | 267 ++++++++++++------ src/routes/maven.rs | 4 +- src/routes/v3/statistics.rs | 3 +- src/routes/v3/version_creation.rs | 13 +- src/routes/v3/versions.rs | 7 +- tests/profiles.rs | 7 +- 12 files changed, 622 insertions(+), 241 deletions(-) create mode 100644 src/database/models/file_item.rs diff --git a/migrations/20231226012200_shared_modpacks.sql b/migrations/20231226012200_shared_modpacks.sql index d47de19a..87cd95ba 100644 --- a/migrations/20231226012200_shared_modpacks.sql +++ b/migrations/20231226012200_shared_modpacks.sql @@ -13,22 +13,6 @@ CREATE TABLE shared_profiles ( game_id int NOT NULL REFERENCES games(id) ); -CREATE TABLE shared_profiles_mods ( - shared_profile_id bigint NOT NULL REFERENCES shared_profiles(id), - - -- for versions we have hosted - version_id bigint NULL REFERENCES versions(id), -- for versions - - -- for cdn links to files we host directly - file_hash varchar(255) NULL, - install_path varchar(255) NULL, - - CHECK ( - (version_id IS NOT NULL AND file_hash IS NULL AND install_path IS NULL) OR - (version_id IS NULL AND file_hash IS NOT NULL AND install_path IS NOT NULL) - ) -); - CREATE TABLE shared_profiles_links ( id bigint PRIMARY KEY, -- id of the shared profile link (doubles as the link identifier) shared_profile_id bigint NOT NULL REFERENCES shared_profiles(id), @@ -41,3 +25,32 @@ CREATE TABLE shared_profiles_users ( user_id bigint NOT NULL REFERENCES users(id), CONSTRAINT shared_profiles_users_unique UNIQUE (shared_profile_id, user_id) ); + +-- Together, the following two tables comprise the list of files that are part of a shared profile. +-- for versions we have hosted +CREATE TABLE shared_profiles_versions ( + shared_profile_id bigint NOT NULL REFERENCES shared_profiles(id), + version_id bigint NULL REFERENCES versions(id) -- for versions +); + +-- for files we host directly +CREATE TABLE shared_profiles_files ( + shared_profile_id bigint NOT NULL REFERENCES shared_profiles(id), + file_id bigint NOT NULL REFERENCES files(id), + install_path varchar(255) NOT NULL +); + +-- Now that files do not necessarily have a version, we create a table to store them +CREATE TABLE versions_files ( + version_id bigint NOT NULL REFERENCES versions(id), + is_primary boolean NOT NULL DEFAULT false, + file_id bigint NOT NULL REFERENCES files(id) +); + +-- Populate with the previously named 'version_id' column of the files table +INSERT INTO versions_files (version_id, file_id, is_primary) +SELECT version_id, id, is_primary FROM files; + +-- Drop the version_id and is_primary columns from the files table +ALTER TABLE files DROP COLUMN version_id; +ALTER TABLE files DROP COLUMN is_primary; diff --git a/src/database/models/client_profile_item.rs b/src/database/models/client_profile_item.rs index ef696948..58ef751a 100644 --- a/src/database/models/client_profile_item.rs +++ b/src/database/models/client_profile_item.rs @@ -1,10 +1,11 @@ +use std::collections::HashMap; use std::path::PathBuf; -use super::ids::*; -use crate::database::models::DatabaseError; +use super::{file_item, ids::*}; +use crate::{database::models::DatabaseError, models::projects::FileType}; use crate::database::redis::RedisPool; use chrono::{DateTime, Utc}; -use dashmap::DashMap; +use dashmap::{DashMap, DashSet}; use futures::TryStreamExt; use serde::{Deserialize, Serialize}; @@ -33,13 +34,24 @@ pub struct ClientProfile { pub loader: String, pub versions: Vec, - pub overrides: Vec, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct QueryClientProfile { pub inner: ClientProfile, pub links: Vec, + pub override_files: Vec, +} + +#[derive(Clone, Deserialize, Serialize, PartialEq, Eq, Debug)] +pub struct QueryClientProfileFile { + pub id: FileId, + pub url: String, + pub filename: String, + pub hashes: HashMap, + pub install_path: PathBuf, + pub size: u32, + pub file_type: Option, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -110,7 +122,7 @@ impl ClientProfile { for version_id in &self.versions { sqlx::query!( " - INSERT INTO shared_profiles_mods ( + INSERT INTO shared_profiles_versions ( shared_profile_id, version_id ) VALUES ( @@ -155,21 +167,36 @@ impl ClientProfile { .execute(&mut **transaction) .await?; + // Deletes attached versions + sqlx::query!( + " + DELETE FROM shared_profiles_versions + WHERE shared_profile_id = $1 + ", + id as ClientProfileId, + ) + .execute(&mut **transaction) + .await?; + // Deletes attached files- we return the hashes so we can delete them from the file host if needed - let deleted_hashes = sqlx::query!( + let deleted_ids = sqlx::query!( " - DELETE FROM shared_profiles_mods + DELETE FROM shared_profiles_files WHERE shared_profile_id = $1 - RETURNING file_hash + RETURNING file_id ", id as ClientProfileId, ) .fetch_all(&mut **transaction) .await? .into_iter() - .filter_map(|x| x.file_hash) + .map(|x| FileId(x.file_id)) .collect::>(); + // Check if any versions_files or shared_profiles_files still reference the file- these files should not be deleted + // Delete the files that are not referenced + let removed_hashes = file_item::remove_unreferenced_files(deleted_ids, transaction).await?; + sqlx::query!( " DELETE FROM shared_profiles_links @@ -192,7 +219,7 @@ impl ClientProfile { ClientProfile::clear_cache(id, redis).await?; - Ok(deleted_hashes) + Ok(removed_hashes) } pub async fn get<'a, 'b, E>( @@ -266,42 +293,101 @@ impl ClientProfile { } if !remaining_ids.is_empty() { - type AttachedProjectsMap = ( - DashMap>, - DashMap>, - ); - let shared_profiles_mods: AttachedProjectsMap = sqlx::query!( + + let shared_profiles_versions: DashMap> = sqlx::query!( " - SELECT shared_profile_id, version_id, file_hash, install_path - FROM shared_profiles_mods spm - WHERE spm.shared_profile_id = ANY($1) + SELECT shared_profile_id, version_id + FROM shared_profiles_versions spv + WHERE spv.shared_profile_id = ANY($1) ", &remaining_ids.iter().map(|x| x.0).collect::>() ) .fetch(&mut *exec) .try_fold( - (DashMap::new(), DashMap::new()), - |(acc_versions, acc_overrides): AttachedProjectsMap, m| { + DashMap::new(), + |acc: DashMap>, m| { let version_id = m.version_id.map(VersionId); - let file_hash = m.file_hash; - let install_path = m.install_path; if let Some(version_id) = version_id { - acc_versions - .entry(ClientProfileId(m.shared_profile_id)) + acc.entry(ClientProfileId(m.shared_profile_id)) .or_default() .push(version_id); } + async move { Ok(acc) } + }, + ) + .await?; - if let (Some(install_path), Some(file_hash)) = (install_path, file_hash) { - acc_overrides - .entry(ClientProfileId(m.shared_profile_id)) - .or_default() - .push((file_hash, PathBuf::from(install_path))); - } + #[derive(Deserialize)] + struct Hash { + pub file_id: FileId, + pub algorithm: String, + pub hash: String, + } - async move { Ok((acc_versions, acc_overrides)) } - }, + #[derive(Deserialize)] + struct File { + pub id: FileId, + pub url: String, + pub filename: String, + pub install_path: PathBuf, + pub size: u32, + pub file_type: Option, + } + + let file_ids = DashSet::new(); + let reverse_file_map = DashMap::new(); + let files : DashMap> = sqlx::query!( + " + SELECT DISTINCT shared_profile_id, f.id, f.url, f.filename, spf.install_path, f.size, f.file_type + FROM files f + INNER JOIN shared_profiles_files spf ON spf.file_id = f.id + WHERE spf.shared_profile_id = ANY($1) + ", + &remaining_ids.iter().map(|x| x.0).collect::>() + ).fetch(&mut *exec) + .try_fold(DashMap::new(), |acc : DashMap>, m| { + let file = File { + id: FileId(m.id), + url: m.url, + filename: m.filename, + install_path: m.install_path.into(), + size: m.size as u32, + file_type: m.file_type.map(|x| FileType::from_string(&x)), + }; + + file_ids.insert(FileId(m.id)); + reverse_file_map.insert(FileId(m.id), ClientProfileId(m.shared_profile_id)); + + acc.entry(ClientProfileId(m.shared_profile_id)) + .or_default() + .push(file); + async move { Ok(acc) } + } + ).await?; + + let hashes: DashMap> = sqlx::query!( + " + SELECT DISTINCT file_id, algorithm, encode(hash, 'escape') hash + FROM hashes + WHERE file_id = ANY($1) + ", + &file_ids.iter().map(|x| x.0).collect::>() ) + .fetch(&mut *exec) + .try_fold(DashMap::new(), |acc: DashMap>, m| { + if let Some(found_hash) = m.hash { + let hash = Hash { + file_id: FileId(m.file_id), + algorithm: m.algorithm, + hash: found_hash, + }; + + if let Some(profile_id) = reverse_file_map.get(&FileId(m.file_id)) { + acc.entry(*profile_id).or_default().push(hash); + } + } + async move { Ok(acc) } + }) .await?; let shared_profiles_links: DashMap> = @@ -353,11 +439,40 @@ impl ClientProfile { .try_filter_map(|e| async { Ok(e.right().map(|m| { let id = ClientProfileId(m.id); - let versions = shared_profiles_mods.0.get(&id).map(|x| x.value().clone()).unwrap_or_default(); - let files = shared_profiles_mods.1.get(&id).map(|x| x.value().clone()).unwrap_or_default(); + let versions = shared_profiles_versions + .remove(&id) + .map(|(_, x)| x) + .unwrap_or_default(); + let files = files.remove(&id).map(|(_,x)| x).unwrap_or_default(); + let hashes = hashes.remove(&id).map(|x|x.1).unwrap_or_default(); + let links = shared_profiles_links.remove(&id).map(|x| x.1).unwrap_or_default(); let game_id = GameId(m.game_id); let metadata = serde_json::from_value::(m.metadata).unwrap_or(ClientProfileMetadata::Unknown); + + let mut files = files.into_iter().map(|x| { + let mut file_hashes = HashMap::new(); + + for hash in hashes.iter() { + if hash.file_id == x.id { + file_hashes.insert( + hash.algorithm.clone(), + hash.hash.clone(), + ); + } + } + + QueryClientProfileFile { + id: x.id, + url: x.url.clone(), + filename: x.filename.clone(), + hashes: file_hashes, + install_path: x.install_path, + size: x.size, + file_type: x.file_type, + } + }).collect::>(); + QueryClientProfile { inner: ClientProfile { id, @@ -373,9 +488,9 @@ impl ClientProfile { metadata, loader: m.loader, versions, - overrides: files }, - links + links, + override_files: files, } })) }) diff --git a/src/database/models/file_item.rs b/src/database/models/file_item.rs new file mode 100644 index 00000000..80315704 --- /dev/null +++ b/src/database/models/file_item.rs @@ -0,0 +1,212 @@ +use std::path::PathBuf; + +use crate::{database::{models::VersionId, redis::RedisPool}, models::projects::FileType}; + +use super::{generate_file_id, ClientProfileId, DatabaseError, FileId}; + + +#[derive(Clone, Debug)] +pub struct VersionFileBuilder { + pub url: String, + pub filename: String, + pub hashes: Vec, + pub primary: bool, + pub size: u32, + pub file_type: Option, +} + +#[derive(Clone, Debug)] +pub struct HashBuilder { + pub algorithm: String, + pub hash: Vec, +} + +impl VersionFileBuilder { + pub async fn insert( + self, + version_id: VersionId, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result { + let file_id = generate_file_id(&mut *transaction).await?; + + sqlx::query!( + " + INSERT INTO files (id, url, filename, size, file_type) + VALUES ($1, $2, $3, $4, $5) + ", + file_id as FileId, + self.url, + self.filename, + self.size as i32, + self.file_type.map(|x| x.as_str()), + ) + .execute(&mut **transaction) + .await?; + + sqlx::query!( + " + INSERT INTO versions_files (version_id, file_id, is_primary) + VALUES ($1, $2, $3) + ", + version_id as VersionId, + file_id as FileId, + self.primary, + ) + .execute(&mut **transaction) + .await?; + + for hash in self.hashes { + sqlx::query!( + " + INSERT INTO hashes (file_id, algorithm, hash) + VALUES ($1, $2, $3) + ", + file_id as FileId, + hash.algorithm, + hash.hash, + ) + .execute(&mut **transaction) + .await?; + } + + Ok(file_id) + } +} + + +#[derive(Clone, Debug)] +pub struct ClientProfileFileBuilder { + pub url: String, + pub filename: String, + pub hashes: Vec, + pub install_path: PathBuf, + // Whether a new file should be generated or an existing one should be used + // If one is providded, that file will be connected to the profile instead of creating a new one + pub existing_file : Option, + pub size: u32, + pub file_type: Option, +} + +impl ClientProfileFileBuilder { + pub async fn insert( + self, + profile_id: ClientProfileId, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result { + let file_id = if let Some(file_id) = self.existing_file { + file_id + } else { + let file_id = generate_file_id(&mut *transaction).await?; + + sqlx::query!( + " + INSERT INTO files (id, url, filename, size, file_type) + VALUES ($1, $2, $3, $4, $5) + ", + file_id as FileId, + self.url, + self.filename, + self.size as i32, + self.file_type.map(|x| x.as_str()), + ) + .execute(&mut **transaction) + .await?; + + + for hash in self.hashes { + sqlx::query!( + " + INSERT INTO hashes (file_id, algorithm, hash) + VALUES ($1, $2, $3) + ", + file_id as FileId, + hash.algorithm, + hash.hash, + ) + .execute(&mut **transaction) + .await?; + } + + file_id + }; + + + sqlx::query!( + " + INSERT INTO shared_profiles_files (shared_profile_id, file_id, install_path) + VALUES ($1, $2, $3) + ", + profile_id as ClientProfileId, + file_id as FileId, + self.install_path.to_string_lossy().to_string(), + ) + .execute(&mut **transaction) + .await?; + + Ok(file_id) + } +} + +// Remove files that are not referenced by any versions_files or shared_profiles_files +// This is a separate function because it is used in multiple places +// Returns a list of hashes that were deleted, so they can be removed from the file host +pub async fn remove_unreferenced_files( + file_ids : Vec, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, +) -> Result, DatabaseError> { + let file_ids = file_ids.into_iter().map(|x| x.0).collect::>(); + + // Check if any versions_files or shared_profiles_files still reference the file- these files should not be deleted + let referenced_files = sqlx::query!( + " + SELECT f.id + FROM files f + LEFT JOIN versions_files vf ON vf.file_id = f.id + LEFT JOIN shared_profiles_files spf ON spf.file_id = f.id + WHERE f.id = ANY($1) AND (vf.version_id IS NOT NULL OR spf.shared_profile_id IS NOT NULL) + ", + &file_ids[..], + ) + .fetch_all(&mut **transaction) + .await? + .into_iter() + .filter_map(|x| x.id) + .collect::>(); + + // Filter out the referenced files + let file_ids = file_ids + .into_iter() + .filter(|x| !referenced_files.contains(x)) + .collect::>(); + + // Delete hashes for the files remaining + let hashes : Vec = sqlx::query!( + " + DELETE FROM hashes + WHERE EXISTS( + SELECT 1 FROM files WHERE + (files.id = ANY($1) AND hashes.file_id = files.id) + ) + RETURNING encode(hashes.hash, 'escape') hash + ", + &file_ids[..], + ) + .fetch_all(&mut **transaction) + .await? + .into_iter() + .filter_map(|x| x.hash) + .collect::>(); + + // Delete files remaining + sqlx::query!( + " + DELETE FROM files + WHERE files.id = ANY($1) + ", + &file_ids[..], + ) + .execute(&mut **transaction) + .await?; + + Ok(hashes) +} \ No newline at end of file diff --git a/src/database/models/mod.rs b/src/database/models/mod.rs index d1a6f847..729f572a 100644 --- a/src/database/models/mod.rs +++ b/src/database/models/mod.rs @@ -3,6 +3,7 @@ use thiserror::Error; pub mod categories; pub mod client_profile_item; pub mod collection_item; +pub mod file_item; pub mod flow_item; pub mod ids; pub mod image_item; diff --git a/src/database/models/version_item.rs b/src/database/models/version_item.rs index eeb6a965..db0be2c6 100644 --- a/src/database/models/version_item.rs +++ b/src/database/models/version_item.rs @@ -1,4 +1,5 @@ -use super::ids::*; +use super::file_item::VersionFileBuilder; +use super::{file_item, ids::*}; use super::loader_fields::VersionField; use super::DatabaseError; use crate::database::models::loader_fields::{ @@ -115,64 +116,6 @@ impl DependencyBuilder { } } -#[derive(Clone, Debug)] -pub struct VersionFileBuilder { - pub url: String, - pub filename: String, - pub hashes: Vec, - pub primary: bool, - pub size: u32, - pub file_type: Option, -} - -impl VersionFileBuilder { - pub async fn insert( - self, - version_id: VersionId, - transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, - ) -> Result { - let file_id = generate_file_id(&mut *transaction).await?; - - sqlx::query!( - " - INSERT INTO files (id, version_id, url, filename, is_primary, size, file_type) - VALUES ($1, $2, $3, $4, $5, $6, $7) - ", - file_id as FileId, - version_id as VersionId, - self.url, - self.filename, - self.primary, - self.size as i32, - self.file_type.map(|x| x.as_str()), - ) - .execute(&mut **transaction) - .await?; - - for hash in self.hashes { - sqlx::query!( - " - INSERT INTO hashes (file_id, algorithm, hash) - VALUES ($1, $2, $3) - ", - file_id as FileId, - hash.algorithm, - hash.hash, - ) - .execute(&mut **transaction) - .await?; - } - - Ok(file_id) - } -} - -#[derive(Clone, Debug)] -pub struct HashBuilder { - pub algorithm: String, - pub hash: Vec, -} - impl VersionBuilder { pub async fn insert( self, @@ -362,29 +305,23 @@ impl Version { .execute(&mut **transaction) .await?; - sqlx::query!( + let files = sqlx::query!( " - DELETE FROM hashes - WHERE EXISTS( - SELECT 1 FROM files WHERE - (files.version_id = $1) AND - (hashes.file_id = files.id) - ) - ", - id as VersionId - ) - .execute(&mut **transaction) - .await?; - - sqlx::query!( - " - DELETE FROM files - WHERE files.version_id = $1 + DELETE FROM versions_files + WHERE versions_files.version_id = $1 + RETURNING file_id ", id as VersionId, ) - .execute(&mut **transaction) - .await?; + .fetch_all(&mut **transaction) + .await? + .into_iter() + .map(|x| FileId(x.file_id)) + .collect::>(); + + // Check if any versions_files or shared_profiles_files still reference the file- these files should not be deleted + // Delete the files that are not referenced + file_item::remove_unreferenced_files(files, transaction).await?; // Sync dependencies @@ -658,9 +595,10 @@ impl Version { let reverse_file_map = DashMap::new(); let files : DashMap> = sqlx::query!( " - SELECT DISTINCT version_id, f.id, f.url, f.filename, f.is_primary, f.size, f.file_type + SELECT DISTINCT vf.version_id, f.id, f.url, f.filename, vf.is_primary, f.size, f.file_type FROM files f - WHERE f.version_id = ANY($1) + INNER JOIN versions_files vf ON vf.file_id = f.id + WHERE vf.version_id = ANY($1) ", &version_ids_parsed ).fetch(&mut *exec) @@ -793,7 +731,7 @@ impl Version { } } - QueryFile { + QueryVersionFile { id: x.id, url: x.url.clone(), filename: x.filename.clone(), @@ -904,13 +842,14 @@ impl Version { if !file_ids_parsed.is_empty() { let db_files: Vec = sqlx::query!( " - SELECT f.id, f.version_id, v.mod_id, f.url, f.filename, f.is_primary, f.size, f.file_type, + SELECT f.id, vf.version_id, v.mod_id, f.url, f.filename, vf.is_primary, f.size, f.file_type, JSONB_AGG(DISTINCT jsonb_build_object('algorithm', h.algorithm, 'hash', encode(h.hash, 'escape'))) filter (where h.hash is not null) hashes FROM files f - INNER JOIN versions v on v.id = f.version_id + INNER JOIN versions_files vf on vf.file_id = f.id + INNER JOIN versions v on v.id = vf.version_id INNER JOIN hashes h on h.file_id = f.id WHERE h.algorithm = $1 AND h.hash = ANY($2) - GROUP BY f.id, v.mod_id, v.date_published + GROUP BY f.id, v.mod_id, v.date_published, vf.version_id, vf.is_primary ORDER BY v.date_published ", algorithm, @@ -997,7 +936,7 @@ impl Version { pub struct QueryVersion { pub inner: Version, - pub files: Vec, + pub files: Vec, pub version_fields: Vec, pub loaders: Vec, pub project_types: Vec, @@ -1014,7 +953,7 @@ pub struct QueryDependency { } #[derive(Clone, Deserialize, Serialize, PartialEq, Eq)] -pub struct QueryFile { +pub struct QueryVersionFile { pub id: FileId, pub url: String, pub filename: String, diff --git a/src/routes/internal/admin.rs b/src/routes/internal/admin.rs index 07afe836..d082d108 100644 --- a/src/routes/internal/admin.rs +++ b/src/routes/internal/admin.rs @@ -66,8 +66,10 @@ pub async fn count_download( let (version_id, project_id) = if let Some(version) = sqlx::query!( " - SELECT v.id id, v.mod_id mod_id FROM files f - INNER JOIN versions v ON v.id = f.version_id + SELECT v.id id, v.mod_id mod_id + FROM files f + INNER JOIN versions_files vf ON vf.file_id = f.id + INNER JOIN versions v ON v.id = vf.version_id WHERE f.url = $1 ", download_body.url, diff --git a/src/routes/internal/client/profiles.rs b/src/routes/internal/client/profiles.rs index fc50b87d..a301bb6a 100644 --- a/src/routes/internal/client/profiles.rs +++ b/src/routes/internal/client/profiles.rs @@ -1,8 +1,9 @@ use crate::auth::checks::filter_visible_version_ids; use crate::auth::{get_user_from_headers, AuthenticationError}; +use crate::database::models::file_item::ClientProfileFileBuilder; use crate::database::models::legacy_loader_fields::MinecraftGameVersion; use crate::database::models::{ - client_profile_item, generate_client_profile_id, generate_client_profile_link_id, version_item, + client_profile_item, file_item, generate_client_profile_id, generate_client_profile_link_id, version_item, FileId }; use crate::database::redis::RedisPool; use crate::file_hosting::FileHost; @@ -12,8 +13,11 @@ use crate::models::client::profile::{ use crate::models::ids::base62_impl::parse_base62; use crate::models::ids::{UserId, VersionId}; use crate::models::pats::Scopes; +use crate::models::projects::FileType; use crate::queue::session::AuthQueue; -use crate::routes::v3::project_creation::CreateError; +use crate::routes::v3::project_creation::{CreateError, UploadedFile}; +use crate::routes::v3::version_creation::get_name_ext; +use crate::routes::v3::version_file::default_algorithm_from_hashes; use crate::routes::ApiError; use crate::util::routes::{read_from_field, read_from_payload}; use crate::util::validate::validation_errors_to_string; @@ -195,7 +199,6 @@ pub async fn profile_create( loader: profile_create_data.loader, users: vec![current_user.id.into()], versions, - overrides: Vec::new(), }; let profile_builder = profile_builder_actual.clone(); profile_builder_actual.insert(&mut transaction).await?; @@ -204,6 +207,7 @@ pub async fn profile_create( let profile = client_profile_item::QueryClientProfile { inner: profile_builder, links: Vec::new(), + override_files: Vec::new(), }; let profile = @@ -391,9 +395,9 @@ pub async fn profile_edit( ApiError::InvalidInput("Could not fetch submitted version ids".to_string()) })?; - // Remove all shared profile mods of this profile where version_id is set + // Remove all shared profile versions of this profile sqlx::query!( - "DELETE FROM shared_profiles_mods WHERE shared_profile_id = $1 AND version_id IS NOT NULL", + "DELETE FROM shared_profiles_versions WHERE shared_profile_id = $1", data.inner.id.0 ) .execute(&mut *transaction) @@ -402,7 +406,7 @@ pub async fn profile_edit( // Insert all new shared profile mods for v in versions { sqlx::query!( - "INSERT INTO shared_profiles_mods (shared_profile_id, version_id) VALUES ($1, $2)", + "INSERT INTO shared_profiles_versions (shared_profile_id, version_id) VALUES ($1, $2)", data.inner.id.0, v.0 ) @@ -783,10 +787,9 @@ pub async fn profile_files( } let override_cdns = profile - .inner - .overrides + .override_files .into_iter() - .map(|x| (format!("{}/custom_files/{}", cdn_url, x.0), x.1)) + .map(|x| (x.url, x.install_path)) .collect::>(); Ok(HttpResponse::Ok().json(ProfileDownload { @@ -839,16 +842,17 @@ pub async fn profile_token_check( let all_allowed_urls = profiles .into_iter() - .flat_map(|x| x.inner.overrides.into_iter().map(|x| x.0)) + .flat_map(|x| + x.override_files + .into_iter().map(|x| x.url)) .collect::>(); + println!("All allowed urls: {:?}", all_allowed_urls); + // Check the token is valid for the requested file - let file_url_hash = file_url - .split(&format!("{cdn_url}/custom_files/")) - .nth(1) - .ok_or_else(|| ApiError::Authentication(AuthenticationError::InvalidAuthMethod))?; + let valid = all_allowed_urls.iter().any(|x| x == &file_url); - let valid = all_allowed_urls.iter().any(|x| x == file_url_hash); + println!("Valid: {:?}", valid); if !valid { Err(ApiError::Authentication( AuthenticationError::InvalidAuthMethod, @@ -1102,6 +1106,9 @@ pub async fn client_profile_add_override( } }; + let mut client_profile_files = Vec::new(); + let mut transaction = pool.begin().await?; + while let Some(item) = payload.next().await { let mut field: Field = item?; if error.is_some() { @@ -1109,44 +1116,10 @@ pub async fn client_profile_add_override( } let result = async { let content_disposition = field.content_disposition().clone(); - let content_type = field - .content_type() - .map(|x| x.essence_str()) - .unwrap_or_else(|| "application/octet-stream") - .to_string(); - // Allow any content type - let name = content_disposition.get_name().ok_or_else(|| { - CreateError::InvalidInput(String::from("Upload must have a name")) - })?; - - let data = read_from_field( - &mut field, 500 * (1 << 20), - "Project file exceeds the maximum of 500MiB. Contact a moderator or admin to request permission to upload larger files." - ).await?; - - let install_path = files - .iter() - .find(|x| x.file_name == name) - .ok_or_else(|| { - CreateError::InvalidInput(format!( - "No matching file name in `data` for file '{}'", - name - )) - })? - .install_path - .clone(); + let cdn_url = dotenvy::var("CDN_URL")?; - let hash = format!("{:x}", sha2::Sha512::digest(&data)); - - file_host - .upload_file( - &content_type, - &format!("custom_files/{hash}"), - data.freeze(), - ) - .await?; - - uploaded_files.push(UploadedFile { install_path, hash }); + // Upload file to CDN and get hash + upload_file(&mut field, &files, &***file_host, &content_disposition, &cdn_url, None, &mut client_profile_files, &mut uploaded_files, &mut transaction).await?; Ok(()) } .await; @@ -1160,24 +1133,9 @@ pub async fn client_profile_add_override( return Err(error); } - let mut transaction = pool.begin().await?; - - let (ids, hashes, install_paths): (Vec<_>, Vec<_>, Vec<_>) = uploaded_files - .into_iter() - .map(|f| (profile_item.inner.id.0, f.hash, f.install_path)) - .multiunzip(); - - sqlx::query!( - " - INSERT INTO shared_profiles_mods (shared_profile_id, file_hash, install_path) - SELECT * FROM UNNEST($1::bigint[], $2::text[], $3::text[]) - ", - &ids[..], - &hashes[..], - &install_paths[..], - ) - .execute(&mut *transaction) - .await?; + for file in client_profile_files { + file.insert(profile_item.inner.id, &mut transaction).await?; + } // Set updated sqlx::query!( @@ -1206,6 +1164,7 @@ pub async fn client_profile_add_override( pub struct RemoveOverrides { // Either will work, or some combination, to identify the overrides to remove pub install_paths: Option>, + pub algorithm: Option, pub hashes: Option>, } @@ -1229,6 +1188,7 @@ pub async fn client_profile_remove_overrides( .await? .1; + // Check if this is our profile let profile_item = database::models::client_profile_item::ClientProfile::get( client_id.into(), @@ -1243,36 +1203,59 @@ pub async fn client_profile_remove_overrides( "You don't have permission to remove overrides.".to_string(), )); } - + let delete_hashes = data.hashes.clone().unwrap_or_default(); + let algorithm = data.algorithm.clone().unwrap_or_else(|| default_algorithm_from_hashes(&delete_hashes)); let delete_install_paths = data.install_paths.clone().unwrap_or_default(); + // TODO: ensure tested well let overrides = profile_item - .inner - .overrides + .override_files .into_iter() - .filter(|(hash, path)| delete_hashes.contains(hash) || delete_install_paths.contains(path)) + .map(|x| (x.hashes, x.install_path)) + .map(|(hashes, install_paths)| + (hashes.get(&algorithm).map(|x| x.clone()), install_paths) + ) + .filter(|(hash, path)| hash.as_ref().map(|h| delete_hashes.contains(&h)).unwrap_or(false) || delete_install_paths.contains(path)) .collect::>(); - let delete_hashes = overrides.iter().map(|x| x.0.clone()).collect::>(); + let delete_hashes = overrides.iter().filter_map(|x| x.0.as_ref().map(|x| x.as_bytes().to_vec())).collect::>(); let delete_install_paths = overrides .iter() .map(|x| x.1.to_string_lossy().to_string()) .collect::>(); let mut transaction = pool.begin().await?; - let deleted_hashes = sqlx::query!( + + let files_to_delete = sqlx::query!( " - DELETE FROM shared_profiles_mods - WHERE (shared_profile_id = $1 AND (file_hash = ANY($2::text[]) OR install_path = ANY($3::text[]))) - RETURNING file_hash + SELECT spf.file_id + FROM shared_profiles_files spf + INNER JOIN files f ON f.id = spf.file_id + INNER JOIN hashes h ON h.file_id = f.id + WHERE (shared_profile_id = $1 AND (h.hash = ANY($2) OR install_path = ANY($3::text[]))) ", profile_item.inner.id.0, &delete_hashes[..], &delete_install_paths[..], ) .fetch_all(&mut *transaction) - .await?.into_iter().filter_map(|x| x.file_hash).collect::>(); + .await?.into_iter().map(|x| FileId(x.file_id)).collect::>(); + + sqlx::query!( + " + DELETE FROM shared_profiles_files + WHERE file_id = ANY($1::bigint[]) AND shared_profile_id = $2 + ", + &files_to_delete.iter().map(|x| x.0).collect::>()[..], + profile_item.inner.id.0, + ) + .execute(&mut *transaction) + .await?; + + // Check if any versions_files or shared_profiles_files still reference the file- these files should not be deleted + // Delete the files that are not referenced + let deleted_hashes = file_item::remove_unreferenced_files(files_to_delete, &mut transaction).await?; // Set updated sqlx::query!( @@ -1306,18 +1289,23 @@ async fn delete_unused_files_from_host( pool: &PgPool, file_host: &Arc, ) -> Result<(), ApiError> { - // Get all hashes that are still used by any profile + + // Confirm hashes no longer exist in any profile (for sureness) + let deleted_hashes_bytes = deleted_hashes + .iter() + .map(|x| x.as_bytes().to_vec()) + .collect::>(); let still_existing_hashes = sqlx::query!( " - SELECT file_hash FROM shared_profiles_mods - WHERE file_hash = ANY($1::text[]) - ", - &deleted_hashes[..], + SELECT encode(hash, 'escape') hash FROM hashes + WHERE hash = ANY($1) + ", + &deleted_hashes_bytes ) - .fetch_all(pool) + .fetch_all(&*pool) .await? .into_iter() - .filter_map(|x| x.file_hash) + .filter_map(|x| x.hash) .collect::>(); // We want to delete files from the server that are no longer used by any profile @@ -1338,3 +1326,108 @@ async fn delete_unused_files_from_host( Ok(()) } + +// This function is used for adding an override file to a pack +#[allow(clippy::too_many_arguments)] +pub async fn upload_file( + field: &mut Field, + multipart_files: &Vec, + file_host: &dyn FileHost, + content_disposition: &actix_web::http::header::ContentDisposition, + cdn_url: &str, + file_type: Option, + client_profile_files: &mut Vec, + uploaded_files: &mut Vec, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, +) -> Result<(), CreateError> { + let (file_name, file_extension) = get_name_ext(content_disposition)?; + + if file_name.contains('/') { + return Err(CreateError::InvalidInput( + "File names must not contain slashes!".to_string(), + )); + } + + let content_type = crate::util::ext::project_file_type(file_extension) + .ok_or_else(|| CreateError::InvalidFileType(file_extension.to_string()))?; + + let data = read_from_field( + field, 500 * (1 << 20), + "Project file exceeds the maximum of 500MiB. Contact a moderator or admin to request permission to upload larger files." + ).await?; + + let name = content_disposition.get_name().ok_or_else(|| { + CreateError::InvalidInput(String::from("Upload must have a name")) + })?; + + let install_path = multipart_files + .iter() + .find(|x| x.file_name == name) + .ok_or_else(|| { + CreateError::InvalidInput(format!( + "No matching file name in `data` for file '{}'", + name + )) + })? + .install_path + .clone(); + + let hash = format!("{:x}", sha2::Sha512::digest(&data)); + + // Allow uploading the same file multiple times, but + // we'll connect them to the same file in the database/CDN + let existing_file = sqlx::query!( + " + SELECT f.id + FROM hashes h + INNER JOIN files f ON f.id = h.file_id + WHERE h.algorithm = $2 AND h.hash = $1 + ", + hash.as_bytes(), + "sha1", + ) + .fetch_optional(&mut **transaction) + .await? + .map(|x| FileId(x.id)); + + let data = data.freeze(); + let file_path = format!("custom_files/{hash}"); + + let upload_data = file_host + .upload_file(content_type, &file_path, data) + .await?; + + let sha1_bytes = upload_data.content_sha1.into_bytes(); + let sha512_bytes = upload_data.content_sha512.into_bytes(); + + + client_profile_files.push(ClientProfileFileBuilder { + filename: file_name.to_string(), + url: format!("{}/{}", cdn_url, upload_data.file_name), + hashes: vec![ + file_item::HashBuilder { + algorithm: "sha1".to_string(), + // This is an invalid cast - the database expects the hash's + // bytes, but this is the string version. + hash: sha1_bytes, + }, + file_item::HashBuilder { + algorithm: "sha512".to_string(), + // This is an invalid cast - the database expects the hash's + // bytes, but this is the string version. + hash: sha512_bytes, + }, + ], + install_path: install_path.into(), + size: upload_data.content_length, + existing_file, + file_type, + }); + + uploaded_files.push(UploadedFile { + file_id: upload_data.file_id, + file_name: file_path, + }); + + Ok(()) +} diff --git a/src/routes/maven.rs b/src/routes/maven.rs index aeb9bb88..da3b5907 100644 --- a/src/routes/maven.rs +++ b/src/routes/maven.rs @@ -2,7 +2,7 @@ use crate::auth::checks::{is_visible_project, is_visible_version}; use crate::database::models::legacy_loader_fields::MinecraftGameVersion; use crate::database::models::loader_fields::Loader; use crate::database::models::project_item::QueryProject; -use crate::database::models::version_item::{QueryFile, QueryVersion}; +use crate::database::models::version_item::{QueryVersionFile, QueryVersion}; use crate::database::redis::RedisPool; use crate::models::pats::Scopes; use crate::models::projects::{ProjectId, VersionId}; @@ -230,7 +230,7 @@ fn find_file<'a>( vcoords: &str, version: &'a QueryVersion, file: &str, -) -> Option<&'a QueryFile> { +) -> Option<&'a QueryVersionFile> { if let Some(selected_file) = version.files.iter().find(|x| x.filename == file) { return Some(selected_file); } diff --git a/src/routes/v3/statistics.rs b/src/routes/v3/statistics.rs index c6c24e1a..ce899223 100644 --- a/src/routes/v3/statistics.rs +++ b/src/routes/v3/statistics.rs @@ -66,7 +66,8 @@ pub async fn get_stats(pool: web::Data) -> Result>(), - vec![ + .collect::>(), + [ PathBuf::from("mods/test.jar"), PathBuf::from("mods/test_different.jar") - ] + ].iter().cloned().collect::>() ); // Get profile again to confirm update From 5f76c913b20904aaf4c1ea43e668246f088e0b4e Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Sun, 21 Jan 2024 00:16:05 -0800 Subject: [PATCH 21/25] Fixed test --- src/routes/v3/version_file.rs | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/src/routes/v3/version_file.rs b/src/routes/v3/version_file.rs index 8d3885c8..e8da99a2 100644 --- a/src/routes/v3/version_file.rs +++ b/src/routes/v3/version_file.rs @@ -1,6 +1,7 @@ use super::ApiError; use crate::auth::checks::{filter_visible_versions, is_visible_version}; use crate::auth::{filter_visible_projects, get_user_from_headers}; +use crate::database::models::file_item; use crate::database::redis::RedisPool; use crate::models::ids::VersionId; use crate::models::pats::Scopes; @@ -620,23 +621,17 @@ pub async fn delete_file( sqlx::query!( " - DELETE FROM hashes - WHERE file_id = $1 - ", - row.id.0 - ) - .execute(&mut *transaction) - .await?; - - sqlx::query!( - " - DELETE FROM files - WHERE files.id = $1 - ", - row.id.0, + DELETE FROM versions_files + WHERE file_id = $1 + ", + &row.id.0 ) .execute(&mut *transaction) .await?; + + // Check if any versions_files or shared_profiles_files still reference the file- these files should not be deleted + // Delete the files that are not referenced + file_item::remove_unreferenced_files(vec![row.id], &mut transaction).await?; transaction.commit().await?; From 64f977e3952e367b1603ad0e8e31f2e04a6bbdd2 Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Tue, 23 Jan 2024 19:29:03 -0800 Subject: [PATCH 22/25] profiles system now uses 'files' --- ...bb3d1d6917ae1bab7981db40e3cec5a962fee.json | 14 - ...0dc07c95361aaa2aa0051628b566b99d15b2c.json | 14 + ...3bbb751fb2bdc22d1a5368f61cc7827586840.json | 20 - ...ae5fe6371d397ecee62cb68607f608a772af0.json | 16 + ...0efaa3d8ad9f3e014b9438ab98ca75356eaf.json} | 7 +- ...cdd8e36ef1b07f9970ac4a4b8b82db3b42b9.json} | 4 +- ...d9f506c076ea43212fe02321d3d7b192eebea.json | 15 + ...92e94684f1a3e7e13bfc3fc5c39f095375a61.json | 22 - ...f8dfc2d10d07e5abc26e6849f2506b8013867.json | 14 - ...0142526fffe9d62a7961f0faf35157f9ecff0.json | 23 + ...b94b998b4946191e7e32c474faf426162bd35.json | 16 - ...ee01157fb2de23228f89594b19f507e9f732c.json | 24 + ...740db29aa86018e16cbd685032f35b2f86fa3.json | 40 ++ ...71a9d80301cbca4073c0c6106cc32f9478993.json | 22 + ...e9089e641eee5bb5d9dac06e0531e9c3853d9.json | 22 + ...a37ee1ce9988070d50dce3708d5de1079340b.json | 14 + ...899877e01abd792fba8bf7c181261fbab49a.json} | 4 +- ...349d6ff14d72a43587e7cd984a3c0dd9d0c3f.json | 15 + ...4ee5712e69ad609a030f207918c2e08b34c54.json | 22 + ...0370711b1e698a900a84cba944284c244b5e.json} | 4 +- ...e842d092b0604585567eb48cc40e7d44f4a7f.json | 24 + ...03f39eef0a34c97f97a939c14fb8fe349f7eb.json | 14 - ...d9e470d38cc13114b18a6c02507787502dfe.json} | 16 +- ...448b20740bf04646701766e1b282b423896a4.json | 22 + ...611a0fa4ef366d5c8a91d9fb9a1360ea04d46.json | 24 - ...63f1d432717ec7a0fb9ab29e01fcb061b3afc.json | 14 - ...486b2ee68732db8ebb11198a0c97ee59d2555.json | 15 + ...b812287b09b71546c2b7864fbacc3c91e5dfe.json | 14 + ...870a2801090a0568de1e48bbc69cfaf019b6.json} | 4 +- ...4a5bac87d0018af1a68184dbc3c048ee6c8b.json} | 4 +- ...b7cc06e93a598b788dfaf1f05d735132fea27.json | 22 + ...53a44c06006a6618880e191e8110cd0fc16ba.json | 16 + ...73f498564809f66b08e3ca63b4d4db32ebfa3.json | 15 + ...b5e6c135c673cc99a6feab16ae7f2017200f5.json | 24 - ...787ebecfe6402a80691ee7e66e0ddb88d8628.json | 18 + ...09f2f694a20ea984655debb1bda2ec7958eaa.json | 28 ++ ...da07860e7f276b261d8f2cebb74e73b094970.json | 14 - ...624d1c670a82502373ef9b3b08c584605d1ca.json | 14 + ...b2b874e1ccbe696d1ad1d918adcf571cb05cd.json | 58 +++ ...6e4082557ad5d627fba569c7f6aeca235d9ba.json | 14 + ...bdc82e2251596bcf85aea52e8def343e423b8.json | 16 - ...ebdc6e7cc996c3a03b2cbacc48b3ea09ef35.json} | 26 +- ...fbc9023eff17d78ef35786a6138c58001ddb2.json | 15 + ...03be0466214da1e157f6bc577b76dbef7df86.json | 14 - ...97f1f993d084059f2d6ce61e9bfb2364fa96e.json | 14 + ...f436b87acc79fc56513f1c5c4c99e9b9cb283.json | 16 + ...27cccb687d0e2c31b76d49b03aa364d099d42.json | 14 - ...92a502d56763d737d0c9927b8ae4dcbbabf24.json | 29 ++ ...80d4e0d7f3c3d81312d9d28a8c3c8213ad54b.json | 14 - ...668dcf0703d987fc1dae89c1b97badb74f73b.json | 22 + ...52fb5320e093d09a42ebcb5b44fdad0860cde.json | 28 ++ migrations/20231226012200_shared_modpacks.sql | 3 + src/database/models/client_profile_item.rs | 36 +- src/database/models/file_item.rs | 219 ++++++-- src/database/models/version_item.rs | 7 +- src/routes/internal/client/profiles.rs | 141 ++++-- src/routes/maven.rs | 2 +- src/routes/mod.rs | 19 + src/routes/v3/projects.rs | 24 +- src/routes/v3/version_creation.rs | 85 +++- src/routes/v3/version_file.rs | 2 +- tests/profiles.rs | 471 +++++++++++++++++- tests/project.rs | 18 +- tests/scopes.rs | 23 + tests/v2/project.rs | 15 +- tests/v2/version.rs | 4 +- tests/version.rs | 143 +++++- 67 files changed, 1702 insertions(+), 424 deletions(-) delete mode 100644 .sqlx/query-027071c4d5206a781f650d40dcbbb3d1d6917ae1bab7981db40e3cec5a962fee.json create mode 100644 .sqlx/query-0ac5ed5fc0b22aeb5e3b72b81b70dc07c95361aaa2aa0051628b566b99d15b2c.json delete mode 100644 .sqlx/query-0c2addb0d7a87fa558821ff8e943bbb751fb2bdc22d1a5368f61cc7827586840.json create mode 100644 .sqlx/query-0e9c50b8a7a9ef1155c1d893979ae5fe6371d397ecee62cb68607f608a772af0.json rename .sqlx/{query-0b79ae3825e05ae07058a0a9d02fb0bd68ce37f3c7cf0356d565c23520988816.json => query-0fa92183a6de18722c9ba740a84c0efaa3d8ad9f3e014b9438ab98ca75356eaf.json} (55%) rename .sqlx/{query-11b484208e8a2344a20a05a5427158d139e953eb3456c1fe76d9423cfa7ec631.json => query-10a892bfd4e988b5491ca631d36fcdd8e36ef1b07f9970ac4a4b8b82db3b42b9.json} (50%) create mode 100644 .sqlx/query-11239992f6177b638bcf48d416cd9f506c076ea43212fe02321d3d7b192eebea.json delete mode 100644 .sqlx/query-156566dd89830c1d24d07698c5c92e94684f1a3e7e13bfc3fc5c39f095375a61.json delete mode 100644 .sqlx/query-1fb64651d324c7df04b28a9dc8ff8dfc2d10d07e5abc26e6849f2506b8013867.json create mode 100644 .sqlx/query-275c46aac425f17b2bf621be8000142526fffe9d62a7961f0faf35157f9ecff0.json delete mode 100644 .sqlx/query-2b59e48fddcf2b59a1f1f62dce3b94b998b4946191e7e32c474faf426162bd35.json create mode 100644 .sqlx/query-2d5ad68ad98955b055d5a81472bee01157fb2de23228f89594b19f507e9f732c.json create mode 100644 .sqlx/query-2eadf8f73266ce5fb700ab4fef5740db29aa86018e16cbd685032f35b2f86fa3.json create mode 100644 .sqlx/query-2f02ab522bd717fe13e2584595f71a9d80301cbca4073c0c6106cc32f9478993.json create mode 100644 .sqlx/query-3e824afee6d0c7e6741fef0cccbe9089e641eee5bb5d9dac06e0531e9c3853d9.json create mode 100644 .sqlx/query-40992ad8967d190f4b584e3092ea37ee1ce9988070d50dce3708d5de1079340b.json rename .sqlx/{query-155361716f9d697c0d961b7bbad30e70698a8e5c9ceaa03b2091e058b58fb938.json => query-417950f7e332b0d06a2d75693451899877e01abd792fba8bf7c181261fbab49a.json} (52%) create mode 100644 .sqlx/query-44e08e05cde8d5b27e6c45482bf349d6ff14d72a43587e7cd984a3c0dd9d0c3f.json create mode 100644 .sqlx/query-4d784747f424af21136ea82dcf54ee5712e69ad609a030f207918c2e08b34c54.json rename .sqlx/{query-e72736bb7fca4df41cf34186b1edf04d6b4d496971aaf87ed1a88e7d64eab823.json => query-50e1f713ee4a1d424a92c950fb3d0370711b1e698a900a84cba944284c244b5e.json} (71%) create mode 100644 .sqlx/query-56fa5de99008f7c2941e69451c1e842d092b0604585567eb48cc40e7d44f4a7f.json delete mode 100644 .sqlx/query-5c4262689205aafdd97a74bee0003f39eef0a34c97f97a939c14fb8fe349f7eb.json rename .sqlx/{query-196bbbebb943163d6c6fad254a4fb177b339f6d83e7053102d71edc3c4df72a3.json => query-5ff2116335c8899784787dfccba9d9e470d38cc13114b18a6c02507787502dfe.json} (50%) create mode 100644 .sqlx/query-6638b1abe406e55a6b92ea19055448b20740bf04646701766e1b282b423896a4.json delete mode 100644 .sqlx/query-6c8b8a2f11c0b4e7a5973547fe1611a0fa4ef366d5c8a91d9fb9a1360ea04d46.json delete mode 100644 .sqlx/query-6d883ea05aead20f571a0f63bfd63f1d432717ec7a0fb9ab29e01fcb061b3afc.json create mode 100644 .sqlx/query-6fcd4595999ef05fb09ef56c639486b2ee68732db8ebb11198a0c97ee59d2555.json create mode 100644 .sqlx/query-8742663fff60d3c2e03844f9330b812287b09b71546c2b7864fbacc3c91e5dfe.json rename .sqlx/{query-a1ba3b5cc50b1eb24f5529e06be1439f4a313c4ea8845c2733db752e53f5ae1c.json => query-8a12a47c8e17fef339affb7b3c43870a2801090a0568de1e48bbc69cfaf019b6.json} (55%) rename .sqlx/{query-cfcc6970c0b469c4afd37bedfd386def7980f6b7006030d4783723861d0e3a38.json => query-a1b6261d66d6be09c275029047e54a5bac87d0018af1a68184dbc3c048ee6c8b.json} (70%) create mode 100644 .sqlx/query-a6f13c45001aed03871bcd50a76b7cc06e93a598b788dfaf1f05d735132fea27.json create mode 100644 .sqlx/query-ab7b439a8364871fcdb552736bd53a44c06006a6618880e191e8110cd0fc16ba.json create mode 100644 .sqlx/query-ae06d55081b25a05297d8468dc473f498564809f66b08e3ca63b4d4db32ebfa3.json delete mode 100644 .sqlx/query-aeae06e0779379838d3ef86fdfbb5e6c135c673cc99a6feab16ae7f2017200f5.json create mode 100644 .sqlx/query-aeb70d4d015d79e73f38c782cbc787ebecfe6402a80691ee7e66e0ddb88d8628.json create mode 100644 .sqlx/query-b0a13889f62d3056ad94b3893f709f2f694a20ea984655debb1bda2ec7958eaa.json delete mode 100644 .sqlx/query-b903ac4e686ef85ba28d698c668da07860e7f276b261d8f2cebb74e73b094970.json create mode 100644 .sqlx/query-c999ad4fcc54919a7b83bfad7c4624d1c670a82502373ef9b3b08c584605d1ca.json create mode 100644 .sqlx/query-ca0dcd1c64488eda289f7344d2bb2b874e1ccbe696d1ad1d918adcf571cb05cd.json create mode 100644 .sqlx/query-cb151564dec209f0ef5cf95f3156e4082557ad5d627fba569c7f6aeca235d9ba.json delete mode 100644 .sqlx/query-cb57ae673f1a7e50cc319efddb9bdc82e2251596bcf85aea52e8def343e423b8.json rename .sqlx/{query-477c02c4dd36d372683be4e98af1b1b68a23c7cd75677a4c095a406cdb07a7fc.json => query-ccfc8969a13b6c7f7939560ea299ebdc6e7cc996c3a03b2cbacc48b3ea09ef35.json} (63%) create mode 100644 .sqlx/query-cd6ac1cb990bccdde0257d1b501fbc9023eff17d78ef35786a6138c58001ddb2.json delete mode 100644 .sqlx/query-cdd7f8f95c308d9474e214d584c03be0466214da1e157f6bc577b76dbef7df86.json create mode 100644 .sqlx/query-ce10bf641f6fbc38e5acbd76d3097f1f993d084059f2d6ce61e9bfb2364fa96e.json create mode 100644 .sqlx/query-d67e6c185460a17b65c0dc01be0f436b87acc79fc56513f1c5c4c99e9b9cb283.json delete mode 100644 .sqlx/query-d8b4e7e382c77a05395124d5a6a27cccb687d0e2c31b76d49b03aa364d099d42.json create mode 100644 .sqlx/query-dd8a0e5976094bc3285326dd78f92a502d56763d737d0c9927b8ae4dcbbabf24.json delete mode 100644 .sqlx/query-e3cc1fd070b97c4cc36bdb2f33080d4e0d7f3c3d81312d9d28a8c3c8213ad54b.json create mode 100644 .sqlx/query-e539388f748db3cbda83b27df86668dcf0703d987fc1dae89c1b97badb74f73b.json create mode 100644 .sqlx/query-f6395015c3287dbfebcf9f3e0e852fb5320e093d09a42ebcb5b44fdad0860cde.json diff --git a/.sqlx/query-027071c4d5206a781f650d40dcbbb3d1d6917ae1bab7981db40e3cec5a962fee.json b/.sqlx/query-027071c4d5206a781f650d40dcbbb3d1d6917ae1bab7981db40e3cec5a962fee.json deleted file mode 100644 index 51cb9e48..00000000 --- a/.sqlx/query-027071c4d5206a781f650d40dcbbb3d1d6917ae1bab7981db40e3cec5a962fee.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM shared_profiles_mods WHERE shared_profile_id = $1 AND version_id IS NOT NULL", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [] - }, - "hash": "027071c4d5206a781f650d40dcbbb3d1d6917ae1bab7981db40e3cec5a962fee" -} diff --git a/.sqlx/query-0ac5ed5fc0b22aeb5e3b72b81b70dc07c95361aaa2aa0051628b566b99d15b2c.json b/.sqlx/query-0ac5ed5fc0b22aeb5e3b72b81b70dc07c95361aaa2aa0051628b566b99d15b2c.json new file mode 100644 index 00000000..5e91f1f7 --- /dev/null +++ b/.sqlx/query-0ac5ed5fc0b22aeb5e3b72b81b70dc07c95361aaa2aa0051628b566b99d15b2c.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM shared_profiles_versions\n WHERE shared_profile_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "0ac5ed5fc0b22aeb5e3b72b81b70dc07c95361aaa2aa0051628b566b99d15b2c" +} diff --git a/.sqlx/query-0c2addb0d7a87fa558821ff8e943bbb751fb2bdc22d1a5368f61cc7827586840.json b/.sqlx/query-0c2addb0d7a87fa558821ff8e943bbb751fb2bdc22d1a5368f61cc7827586840.json deleted file mode 100644 index 667bdcbf..00000000 --- a/.sqlx/query-0c2addb0d7a87fa558821ff8e943bbb751fb2bdc22d1a5368f61cc7827586840.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO files (id, version_id, url, filename, is_primary, size, file_type)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Int8", - "Varchar", - "Varchar", - "Bool", - "Int4", - "Varchar" - ] - }, - "nullable": [] - }, - "hash": "0c2addb0d7a87fa558821ff8e943bbb751fb2bdc22d1a5368f61cc7827586840" -} diff --git a/.sqlx/query-0e9c50b8a7a9ef1155c1d893979ae5fe6371d397ecee62cb68607f608a772af0.json b/.sqlx/query-0e9c50b8a7a9ef1155c1d893979ae5fe6371d397ecee62cb68607f608a772af0.json new file mode 100644 index 00000000..a548fffb --- /dev/null +++ b/.sqlx/query-0e9c50b8a7a9ef1155c1d893979ae5fe6371d397ecee62cb68607f608a772af0.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO versions_files (version_id, file_id, is_primary)\n VALUES ($1, $2, $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "0e9c50b8a7a9ef1155c1d893979ae5fe6371d397ecee62cb68607f608a772af0" +} diff --git a/.sqlx/query-0b79ae3825e05ae07058a0a9d02fb0bd68ce37f3c7cf0356d565c23520988816.json b/.sqlx/query-0fa92183a6de18722c9ba740a84c0efaa3d8ad9f3e014b9438ab98ca75356eaf.json similarity index 55% rename from .sqlx/query-0b79ae3825e05ae07058a0a9d02fb0bd68ce37f3c7cf0356d565c23520988816.json rename to .sqlx/query-0fa92183a6de18722c9ba740a84c0efaa3d8ad9f3e014b9438ab98ca75356eaf.json index 6d206d58..4230e3fc 100644 --- a/.sqlx/query-0b79ae3825e05ae07058a0a9d02fb0bd68ce37f3c7cf0356d565c23520988816.json +++ b/.sqlx/query-0fa92183a6de18722c9ba740a84c0efaa3d8ad9f3e014b9438ab98ca75356eaf.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT f.id, f.version_id, v.mod_id, f.url, f.filename, f.is_primary, f.size, f.file_type,\n JSONB_AGG(DISTINCT jsonb_build_object('algorithm', h.algorithm, 'hash', encode(h.hash, 'escape'))) filter (where h.hash is not null) hashes\n FROM files f\n INNER JOIN versions v on v.id = f.version_id\n INNER JOIN hashes h on h.file_id = f.id\n WHERE h.algorithm = $1 AND h.hash = ANY($2)\n GROUP BY f.id, v.mod_id, v.date_published\n ORDER BY v.date_published\n ", + "query": "\n SELECT f.id, vf.version_id, v.mod_id, f.url, f.filename, vf.is_primary, f.size, f.file_type,\n JSONB_AGG(DISTINCT jsonb_build_object('algorithm', h.algorithm, 'hash', encode(h.hash, 'escape'))) filter (where h.hash is not null) hashes\n FROM files f\n INNER JOIN versions_files vf on vf.file_id = f.id\n INNER JOIN versions v on v.id = vf.version_id\n INNER JOIN mods m on m.id = v.mod_id AND m.status = ANY($3)\n INNER JOIN hashes h on h.file_id = f.id\n WHERE h.algorithm = $1 AND h.hash = ANY($2)\n GROUP BY f.id, v.mod_id, v.date_published, vf.version_id, vf.is_primary\n ORDER BY v.date_published\n ", "describe": { "columns": [ { @@ -52,7 +52,8 @@ "parameters": { "Left": [ "Text", - "ByteaArray" + "ByteaArray", + "TextArray" ] }, "nullable": [ @@ -67,5 +68,5 @@ null ] }, - "hash": "0b79ae3825e05ae07058a0a9d02fb0bd68ce37f3c7cf0356d565c23520988816" + "hash": "0fa92183a6de18722c9ba740a84c0efaa3d8ad9f3e014b9438ab98ca75356eaf" } diff --git a/.sqlx/query-11b484208e8a2344a20a05a5427158d139e953eb3456c1fe76d9423cfa7ec631.json b/.sqlx/query-10a892bfd4e988b5491ca631d36fcdd8e36ef1b07f9970ac4a4b8b82db3b42b9.json similarity index 50% rename from .sqlx/query-11b484208e8a2344a20a05a5427158d139e953eb3456c1fe76d9423cfa7ec631.json rename to .sqlx/query-10a892bfd4e988b5491ca631d36fcdd8e36ef1b07f9970ac4a4b8b82db3b42b9.json index 1d8cf3ef..9f6c9a16 100644 --- a/.sqlx/query-11b484208e8a2344a20a05a5427158d139e953eb3456c1fe76d9423cfa7ec631.json +++ b/.sqlx/query-10a892bfd4e988b5491ca631d36fcdd8e36ef1b07f9970ac4a4b8b82db3b42b9.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO shared_profiles_mods (shared_profile_id, version_id) VALUES ($1, $2)", + "query": "DELETE FROM shared_profiles_links WHERE shared_profile_id = $1 AND id = $2", "describe": { "columns": [], "parameters": { @@ -11,5 +11,5 @@ }, "nullable": [] }, - "hash": "11b484208e8a2344a20a05a5427158d139e953eb3456c1fe76d9423cfa7ec631" + "hash": "10a892bfd4e988b5491ca631d36fcdd8e36ef1b07f9970ac4a4b8b82db3b42b9" } diff --git a/.sqlx/query-11239992f6177b638bcf48d416cd9f506c076ea43212fe02321d3d7b192eebea.json b/.sqlx/query-11239992f6177b638bcf48d416cd9f506c076ea43212fe02321d3d7b192eebea.json new file mode 100644 index 00000000..51354432 --- /dev/null +++ b/.sqlx/query-11239992f6177b638bcf48d416cd9f506c076ea43212fe02321d3d7b192eebea.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO shared_profiles_versions (shared_profile_id, version_id)\n SELECT * FROM UNNEST($1::bigint[], $2::bigint[])\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8Array", + "Int8Array" + ] + }, + "nullable": [] + }, + "hash": "11239992f6177b638bcf48d416cd9f506c076ea43212fe02321d3d7b192eebea" +} diff --git a/.sqlx/query-156566dd89830c1d24d07698c5c92e94684f1a3e7e13bfc3fc5c39f095375a61.json b/.sqlx/query-156566dd89830c1d24d07698c5c92e94684f1a3e7e13bfc3fc5c39f095375a61.json deleted file mode 100644 index d909e173..00000000 --- a/.sqlx/query-156566dd89830c1d24d07698c5c92e94684f1a3e7e13bfc3fc5c39f095375a61.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT file_hash FROM shared_profiles_mods\n WHERE file_hash = ANY($1::text[])\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "file_hash", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "TextArray" - ] - }, - "nullable": [ - true - ] - }, - "hash": "156566dd89830c1d24d07698c5c92e94684f1a3e7e13bfc3fc5c39f095375a61" -} diff --git a/.sqlx/query-1fb64651d324c7df04b28a9dc8ff8dfc2d10d07e5abc26e6849f2506b8013867.json b/.sqlx/query-1fb64651d324c7df04b28a9dc8ff8dfc2d10d07e5abc26e6849f2506b8013867.json deleted file mode 100644 index 6b059f87..00000000 --- a/.sqlx/query-1fb64651d324c7df04b28a9dc8ff8dfc2d10d07e5abc26e6849f2506b8013867.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n DELETE FROM shared_profiles_mods\n WHERE shared_profile_id = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [] - }, - "hash": "1fb64651d324c7df04b28a9dc8ff8dfc2d10d07e5abc26e6849f2506b8013867" -} diff --git a/.sqlx/query-275c46aac425f17b2bf621be8000142526fffe9d62a7961f0faf35157f9ecff0.json b/.sqlx/query-275c46aac425f17b2bf621be8000142526fffe9d62a7961f0faf35157f9ecff0.json new file mode 100644 index 00000000..e5b80bc7 --- /dev/null +++ b/.sqlx/query-275c46aac425f17b2bf621be8000142526fffe9d62a7961f0faf35157f9ecff0.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT f.id\n FROM hashes h\n INNER JOIN files f ON f.id = h.file_id\n WHERE h.algorithm = $2 AND h.hash = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Bytea", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "275c46aac425f17b2bf621be8000142526fffe9d62a7961f0faf35157f9ecff0" +} diff --git a/.sqlx/query-2b59e48fddcf2b59a1f1f62dce3b94b998b4946191e7e32c474faf426162bd35.json b/.sqlx/query-2b59e48fddcf2b59a1f1f62dce3b94b998b4946191e7e32c474faf426162bd35.json deleted file mode 100644 index 8661d6b2..00000000 --- a/.sqlx/query-2b59e48fddcf2b59a1f1f62dce3b94b998b4946191e7e32c474faf426162bd35.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO shared_profiles_mods (shared_profile_id, file_hash, install_path)\n SELECT * FROM UNNEST($1::bigint[], $2::text[], $3::text[])\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8Array", - "TextArray", - "TextArray" - ] - }, - "nullable": [] - }, - "hash": "2b59e48fddcf2b59a1f1f62dce3b94b998b4946191e7e32c474faf426162bd35" -} diff --git a/.sqlx/query-2d5ad68ad98955b055d5a81472bee01157fb2de23228f89594b19f507e9f732c.json b/.sqlx/query-2d5ad68ad98955b055d5a81472bee01157fb2de23228f89594b19f507e9f732c.json new file mode 100644 index 00000000..9e26f8d0 --- /dev/null +++ b/.sqlx/query-2d5ad68ad98955b055d5a81472bee01157fb2de23228f89594b19f507e9f732c.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT spf.file_id \n FROM shared_profiles_files spf\n INNER JOIN files f ON f.id = spf.file_id\n INNER JOIN hashes h ON h.file_id = f.id\n WHERE (shared_profile_id = $1 AND (h.hash = ANY($2) OR install_path = ANY($3::text[])))\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "file_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "ByteaArray", + "TextArray" + ] + }, + "nullable": [ + false + ] + }, + "hash": "2d5ad68ad98955b055d5a81472bee01157fb2de23228f89594b19f507e9f732c" +} diff --git a/.sqlx/query-2eadf8f73266ce5fb700ab4fef5740db29aa86018e16cbd685032f35b2f86fa3.json b/.sqlx/query-2eadf8f73266ce5fb700ab4fef5740db29aa86018e16cbd685032f35b2f86fa3.json new file mode 100644 index 00000000..f15e594e --- /dev/null +++ b/.sqlx/query-2eadf8f73266ce5fb700ab4fef5740db29aa86018e16cbd685032f35b2f86fa3.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, shared_profile_id, created, expires\n FROM shared_profiles_links spl\n WHERE spl.shared_profile_id = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "shared_profile_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "expires", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "2eadf8f73266ce5fb700ab4fef5740db29aa86018e16cbd685032f35b2f86fa3" +} diff --git a/.sqlx/query-2f02ab522bd717fe13e2584595f71a9d80301cbca4073c0c6106cc32f9478993.json b/.sqlx/query-2f02ab522bd717fe13e2584595f71a9d80301cbca4073c0c6106cc32f9478993.json new file mode 100644 index 00000000..e1f5f6d9 --- /dev/null +++ b/.sqlx/query-2f02ab522bd717fe13e2584595f71a9d80301cbca4073c0c6106cc32f9478993.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM shared_profiles_files\n WHERE shared_profile_id = $1\n RETURNING file_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "file_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "2f02ab522bd717fe13e2584595f71a9d80301cbca4073c0c6106cc32f9478993" +} diff --git a/.sqlx/query-3e824afee6d0c7e6741fef0cccbe9089e641eee5bb5d9dac06e0531e9c3853d9.json b/.sqlx/query-3e824afee6d0c7e6741fef0cccbe9089e641eee5bb5d9dac06e0531e9c3853d9.json new file mode 100644 index 00000000..3adee636 --- /dev/null +++ b/.sqlx/query-3e824afee6d0c7e6741fef0cccbe9089e641eee5bb5d9dac06e0531e9c3853d9.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM hashes\n WHERE EXISTS(\n SELECT 1 FROM files WHERE\n (files.id = ANY($1) AND hashes.file_id = files.id)\n )\n RETURNING encode(hashes.hash, 'escape') hash\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "hash", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + null + ] + }, + "hash": "3e824afee6d0c7e6741fef0cccbe9089e641eee5bb5d9dac06e0531e9c3853d9" +} diff --git a/.sqlx/query-40992ad8967d190f4b584e3092ea37ee1ce9988070d50dce3708d5de1079340b.json b/.sqlx/query-40992ad8967d190f4b584e3092ea37ee1ce9988070d50dce3708d5de1079340b.json new file mode 100644 index 00000000..d59b5c49 --- /dev/null +++ b/.sqlx/query-40992ad8967d190f4b584e3092ea37ee1ce9988070d50dce3708d5de1079340b.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM files\n WHERE files.id = ANY($1)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [] + }, + "hash": "40992ad8967d190f4b584e3092ea37ee1ce9988070d50dce3708d5de1079340b" +} diff --git a/.sqlx/query-155361716f9d697c0d961b7bbad30e70698a8e5c9ceaa03b2091e058b58fb938.json b/.sqlx/query-417950f7e332b0d06a2d75693451899877e01abd792fba8bf7c181261fbab49a.json similarity index 52% rename from .sqlx/query-155361716f9d697c0d961b7bbad30e70698a8e5c9ceaa03b2091e058b58fb938.json rename to .sqlx/query-417950f7e332b0d06a2d75693451899877e01abd792fba8bf7c181261fbab49a.json index 46917471..fa92d1aa 100644 --- a/.sqlx/query-155361716f9d697c0d961b7bbad30e70698a8e5c9ceaa03b2091e058b58fb938.json +++ b/.sqlx/query-417950f7e332b0d06a2d75693451899877e01abd792fba8bf7c181261fbab49a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT v.id id, v.mod_id mod_id FROM files f\n INNER JOIN versions v ON v.id = f.version_id\n WHERE f.url = $1\n ", + "query": "\n SELECT v.id id, v.mod_id mod_id \n FROM files f\n INNER JOIN versions_files vf ON vf.file_id = f.id\n INNER JOIN versions v ON v.id = vf.version_id\n WHERE f.url = $1\n ", "describe": { "columns": [ { @@ -24,5 +24,5 @@ false ] }, - "hash": "155361716f9d697c0d961b7bbad30e70698a8e5c9ceaa03b2091e058b58fb938" + "hash": "417950f7e332b0d06a2d75693451899877e01abd792fba8bf7c181261fbab49a" } diff --git a/.sqlx/query-44e08e05cde8d5b27e6c45482bf349d6ff14d72a43587e7cd984a3c0dd9d0c3f.json b/.sqlx/query-44e08e05cde8d5b27e6c45482bf349d6ff14d72a43587e7cd984a3c0dd9d0c3f.json new file mode 100644 index 00000000..b85ca9f3 --- /dev/null +++ b/.sqlx/query-44e08e05cde8d5b27e6c45482bf349d6ff14d72a43587e7cd984a3c0dd9d0c3f.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM shared_profiles_files\n WHERE file_id = ANY($1::bigint[]) AND shared_profile_id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8Array", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "44e08e05cde8d5b27e6c45482bf349d6ff14d72a43587e7cd984a3c0dd9d0c3f" +} diff --git a/.sqlx/query-4d784747f424af21136ea82dcf54ee5712e69ad609a030f207918c2e08b34c54.json b/.sqlx/query-4d784747f424af21136ea82dcf54ee5712e69ad609a030f207918c2e08b34c54.json new file mode 100644 index 00000000..b5531d03 --- /dev/null +++ b/.sqlx/query-4d784747f424af21136ea82dcf54ee5712e69ad609a030f207918c2e08b34c54.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT f.id\n FROM files f\n LEFT JOIN versions_files vf ON vf.file_id = f.id\n LEFT JOIN shared_profiles_files spf ON spf.file_id = f.id\n WHERE f.id = ANY($1) AND (vf.version_id IS NOT NULL OR spf.shared_profile_id IS NOT NULL)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + true + ] + }, + "hash": "4d784747f424af21136ea82dcf54ee5712e69ad609a030f207918c2e08b34c54" +} diff --git a/.sqlx/query-e72736bb7fca4df41cf34186b1edf04d6b4d496971aaf87ed1a88e7d64eab823.json b/.sqlx/query-50e1f713ee4a1d424a92c950fb3d0370711b1e698a900a84cba944284c244b5e.json similarity index 71% rename from .sqlx/query-e72736bb7fca4df41cf34186b1edf04d6b4d496971aaf87ed1a88e7d64eab823.json rename to .sqlx/query-50e1f713ee4a1d424a92c950fb3d0370711b1e698a900a84cba944284c244b5e.json index 20c4ed62..32e3783e 100644 --- a/.sqlx/query-e72736bb7fca4df41cf34186b1edf04d6b4d496971aaf87ed1a88e7d64eab823.json +++ b/.sqlx/query-50e1f713ee4a1d424a92c950fb3d0370711b1e698a900a84cba944284c244b5e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT DISTINCT version_id, f.id, f.url, f.filename, f.is_primary, f.size, f.file_type\n FROM files f\n WHERE f.version_id = ANY($1)\n ", + "query": "\n SELECT DISTINCT vf.version_id, f.id, f.url, f.filename, vf.is_primary, f.size, f.file_type\n FROM files f\n INNER JOIN versions_files vf ON vf.file_id = f.id\n WHERE vf.version_id = ANY($1)\n ", "describe": { "columns": [ { @@ -54,5 +54,5 @@ true ] }, - "hash": "e72736bb7fca4df41cf34186b1edf04d6b4d496971aaf87ed1a88e7d64eab823" + "hash": "50e1f713ee4a1d424a92c950fb3d0370711b1e698a900a84cba944284c244b5e" } diff --git a/.sqlx/query-56fa5de99008f7c2941e69451c1e842d092b0604585567eb48cc40e7d44f4a7f.json b/.sqlx/query-56fa5de99008f7c2941e69451c1e842d092b0604585567eb48cc40e7d44f4a7f.json new file mode 100644 index 00000000..9eac2a63 --- /dev/null +++ b/.sqlx/query-56fa5de99008f7c2941e69451c1e842d092b0604585567eb48cc40e7d44f4a7f.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT m.slug FROM hashes h\n INNER JOIN files f ON f.id = h.file_id\n INNER JOIN versions_files vf on vf.file_id = f.id\n INNER JOIN versions v ON v.id = vf.version_id\n INNER JOIN mods m ON m.id = v.mod_id AND m.status = ANY($3)\n WHERE h.algorithm = $2 AND h.hash = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "slug", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Bytea", + "Text", + "TextArray" + ] + }, + "nullable": [ + true + ] + }, + "hash": "56fa5de99008f7c2941e69451c1e842d092b0604585567eb48cc40e7d44f4a7f" +} diff --git a/.sqlx/query-5c4262689205aafdd97a74bee0003f39eef0a34c97f97a939c14fb8fe349f7eb.json b/.sqlx/query-5c4262689205aafdd97a74bee0003f39eef0a34c97f97a939c14fb8fe349f7eb.json deleted file mode 100644 index 4fe0c389..00000000 --- a/.sqlx/query-5c4262689205aafdd97a74bee0003f39eef0a34c97f97a939c14fb8fe349f7eb.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE files\n SET is_primary = TRUE\n WHERE (id = $1)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [] - }, - "hash": "5c4262689205aafdd97a74bee0003f39eef0a34c97f97a939c14fb8fe349f7eb" -} diff --git a/.sqlx/query-196bbbebb943163d6c6fad254a4fb177b339f6d83e7053102d71edc3c4df72a3.json b/.sqlx/query-5ff2116335c8899784787dfccba9d9e470d38cc13114b18a6c02507787502dfe.json similarity index 50% rename from .sqlx/query-196bbbebb943163d6c6fad254a4fb177b339f6d83e7053102d71edc3c4df72a3.json rename to .sqlx/query-5ff2116335c8899784787dfccba9d9e470d38cc13114b18a6c02507787502dfe.json index ab0cda5f..5a45fc7c 100644 --- a/.sqlx/query-196bbbebb943163d6c6fad254a4fb177b339f6d83e7053102d71edc3c4df72a3.json +++ b/.sqlx/query-5ff2116335c8899784787dfccba9d9e470d38cc13114b18a6c02507787502dfe.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT shared_profile_id, version_id, file_hash, install_path\n FROM shared_profiles_mods spm\n WHERE spm.shared_profile_id = ANY($1)\n ", + "query": "\n SELECT shared_profile_id, version_id\n FROM shared_profiles_versions spv\n WHERE spv.shared_profile_id = ANY($1)\n ", "describe": { "columns": [ { @@ -12,16 +12,6 @@ "ordinal": 1, "name": "version_id", "type_info": "Int8" - }, - { - "ordinal": 2, - "name": "file_hash", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "install_path", - "type_info": "Varchar" } ], "parameters": { @@ -31,10 +21,8 @@ }, "nullable": [ false, - true, - true, true ] }, - "hash": "196bbbebb943163d6c6fad254a4fb177b339f6d83e7053102d71edc3c4df72a3" + "hash": "5ff2116335c8899784787dfccba9d9e470d38cc13114b18a6c02507787502dfe" } diff --git a/.sqlx/query-6638b1abe406e55a6b92ea19055448b20740bf04646701766e1b282b423896a4.json b/.sqlx/query-6638b1abe406e55a6b92ea19055448b20740bf04646701766e1b282b423896a4.json new file mode 100644 index 00000000..8c14437e --- /dev/null +++ b/.sqlx/query-6638b1abe406e55a6b92ea19055448b20740bf04646701766e1b282b423896a4.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id FROM versions\n WHERE mod_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "6638b1abe406e55a6b92ea19055448b20740bf04646701766e1b282b423896a4" +} diff --git a/.sqlx/query-6c8b8a2f11c0b4e7a5973547fe1611a0fa4ef366d5c8a91d9fb9a1360ea04d46.json b/.sqlx/query-6c8b8a2f11c0b4e7a5973547fe1611a0fa4ef366d5c8a91d9fb9a1360ea04d46.json deleted file mode 100644 index 7833fda9..00000000 --- a/.sqlx/query-6c8b8a2f11c0b4e7a5973547fe1611a0fa4ef366d5c8a91d9fb9a1360ea04d46.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT EXISTS(SELECT 1 FROM hashes h\n INNER JOIN files f ON f.id = h.file_id\n INNER JOIN versions v ON v.id = f.version_id\n WHERE h.algorithm = $2 AND h.hash = $1 AND v.mod_id != $3)\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "exists", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Bytea", - "Text", - "Int8" - ] - }, - "nullable": [ - null - ] - }, - "hash": "6c8b8a2f11c0b4e7a5973547fe1611a0fa4ef366d5c8a91d9fb9a1360ea04d46" -} diff --git a/.sqlx/query-6d883ea05aead20f571a0f63bfd63f1d432717ec7a0fb9ab29e01fcb061b3afc.json b/.sqlx/query-6d883ea05aead20f571a0f63bfd63f1d432717ec7a0fb9ab29e01fcb061b3afc.json deleted file mode 100644 index 55a5015c..00000000 --- a/.sqlx/query-6d883ea05aead20f571a0f63bfd63f1d432717ec7a0fb9ab29e01fcb061b3afc.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE files\n SET is_primary = FALSE\n WHERE (version_id = $1)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [] - }, - "hash": "6d883ea05aead20f571a0f63bfd63f1d432717ec7a0fb9ab29e01fcb061b3afc" -} diff --git a/.sqlx/query-6fcd4595999ef05fb09ef56c639486b2ee68732db8ebb11198a0c97ee59d2555.json b/.sqlx/query-6fcd4595999ef05fb09ef56c639486b2ee68732db8ebb11198a0c97ee59d2555.json new file mode 100644 index 00000000..6d36b3d5 --- /dev/null +++ b/.sqlx/query-6fcd4595999ef05fb09ef56c639486b2ee68732db8ebb11198a0c97ee59d2555.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO shared_profiles_versions (shared_profile_id, version_id) VALUES ($1, $2)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "6fcd4595999ef05fb09ef56c639486b2ee68732db8ebb11198a0c97ee59d2555" +} diff --git a/.sqlx/query-8742663fff60d3c2e03844f9330b812287b09b71546c2b7864fbacc3c91e5dfe.json b/.sqlx/query-8742663fff60d3c2e03844f9330b812287b09b71546c2b7864fbacc3c91e5dfe.json new file mode 100644 index 00000000..90435003 --- /dev/null +++ b/.sqlx/query-8742663fff60d3c2e03844f9330b812287b09b71546c2b7864fbacc3c91e5dfe.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM shared_profiles_versions WHERE shared_profile_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "8742663fff60d3c2e03844f9330b812287b09b71546c2b7864fbacc3c91e5dfe" +} diff --git a/.sqlx/query-a1ba3b5cc50b1eb24f5529e06be1439f4a313c4ea8845c2733db752e53f5ae1c.json b/.sqlx/query-8a12a47c8e17fef339affb7b3c43870a2801090a0568de1e48bbc69cfaf019b6.json similarity index 55% rename from .sqlx/query-a1ba3b5cc50b1eb24f5529e06be1439f4a313c4ea8845c2733db752e53f5ae1c.json rename to .sqlx/query-8a12a47c8e17fef339affb7b3c43870a2801090a0568de1e48bbc69cfaf019b6.json index 3d018fc4..9d53535e 100644 --- a/.sqlx/query-a1ba3b5cc50b1eb24f5529e06be1439f4a313c4ea8845c2733db752e53f5ae1c.json +++ b/.sqlx/query-8a12a47c8e17fef339affb7b3c43870a2801090a0568de1e48bbc69cfaf019b6.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT COUNT(f.id) FROM files f\n INNER JOIN versions v on f.version_id = v.id AND v.status = ANY($2)\n INNER JOIN mods m on v.mod_id = m.id AND m.status = ANY($1)\n ", + "query": "\n SELECT COUNT(f.id) FROM files f\n INNER JOIN versions_files vf ON vf.file_id = f.id\n INNER JOIN versions v ON v.id = vf.version_id AND v.status = ANY($2)\n INNER JOIN mods m on v.mod_id = m.id AND m.status = ANY($1)\n ", "describe": { "columns": [ { @@ -19,5 +19,5 @@ null ] }, - "hash": "a1ba3b5cc50b1eb24f5529e06be1439f4a313c4ea8845c2733db752e53f5ae1c" + "hash": "8a12a47c8e17fef339affb7b3c43870a2801090a0568de1e48bbc69cfaf019b6" } diff --git a/.sqlx/query-cfcc6970c0b469c4afd37bedfd386def7980f6b7006030d4783723861d0e3a38.json b/.sqlx/query-a1b6261d66d6be09c275029047e54a5bac87d0018af1a68184dbc3c048ee6c8b.json similarity index 70% rename from .sqlx/query-cfcc6970c0b469c4afd37bedfd386def7980f6b7006030d4783723861d0e3a38.json rename to .sqlx/query-a1b6261d66d6be09c275029047e54a5bac87d0018af1a68184dbc3c048ee6c8b.json index 64c54a6e..2ec001f9 100644 --- a/.sqlx/query-cfcc6970c0b469c4afd37bedfd386def7980f6b7006030d4783723861d0e3a38.json +++ b/.sqlx/query-a1b6261d66d6be09c275029047e54a5bac87d0018af1a68184dbc3c048ee6c8b.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT v.id version_id, v.mod_id project_id, h.hash hash FROM hashes h\n INNER JOIN files f on h.file_id = f.id\n INNER JOIN versions v on f.version_id = v.id\n WHERE h.algorithm = 'sha1' AND h.hash = ANY($1)\n ", + "query": "\n SELECT v.id version_id, v.mod_id project_id, h.hash hash FROM hashes h\n INNER JOIN files f on h.file_id = f.id\n INNER JOIN versions_files vf on vf.file_id = f.id\n INNER JOIN versions v on v.id = vf.version_id\n WHERE h.algorithm = 'sha1' AND h.hash = ANY($1)\n ", "describe": { "columns": [ { @@ -30,5 +30,5 @@ false ] }, - "hash": "cfcc6970c0b469c4afd37bedfd386def7980f6b7006030d4783723861d0e3a38" + "hash": "a1b6261d66d6be09c275029047e54a5bac87d0018af1a68184dbc3c048ee6c8b" } diff --git a/.sqlx/query-a6f13c45001aed03871bcd50a76b7cc06e93a598b788dfaf1f05d735132fea27.json b/.sqlx/query-a6f13c45001aed03871bcd50a76b7cc06e93a598b788dfaf1f05d735132fea27.json new file mode 100644 index 00000000..f627750e --- /dev/null +++ b/.sqlx/query-a6f13c45001aed03871bcd50a76b7cc06e93a598b788dfaf1f05d735132fea27.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT encode(hash, 'escape') hash FROM hashes\n WHERE hash = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "hash", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "ByteaArray" + ] + }, + "nullable": [ + null + ] + }, + "hash": "a6f13c45001aed03871bcd50a76b7cc06e93a598b788dfaf1f05d735132fea27" +} diff --git a/.sqlx/query-ab7b439a8364871fcdb552736bd53a44c06006a6618880e191e8110cd0fc16ba.json b/.sqlx/query-ab7b439a8364871fcdb552736bd53a44c06006a6618880e191e8110cd0fc16ba.json new file mode 100644 index 00000000..2d54f1f1 --- /dev/null +++ b/.sqlx/query-ab7b439a8364871fcdb552736bd53a44c06006a6618880e191e8110cd0fc16ba.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO shared_profiles_files (shared_profile_id, file_id, install_path)\n VALUES ($1, $2, $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "ab7b439a8364871fcdb552736bd53a44c06006a6618880e191e8110cd0fc16ba" +} diff --git a/.sqlx/query-ae06d55081b25a05297d8468dc473f498564809f66b08e3ca63b4d4db32ebfa3.json b/.sqlx/query-ae06d55081b25a05297d8468dc473f498564809f66b08e3ca63b4d4db32ebfa3.json new file mode 100644 index 00000000..65b13328 --- /dev/null +++ b/.sqlx/query-ae06d55081b25a05297d8468dc473f498564809f66b08e3ca63b4d4db32ebfa3.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE versions_files\n SET is_primary = TRUE\n WHERE (file_id = $1 AND version_id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "ae06d55081b25a05297d8468dc473f498564809f66b08e3ca63b4d4db32ebfa3" +} diff --git a/.sqlx/query-aeae06e0779379838d3ef86fdfbb5e6c135c673cc99a6feab16ae7f2017200f5.json b/.sqlx/query-aeae06e0779379838d3ef86fdfbb5e6c135c673cc99a6feab16ae7f2017200f5.json deleted file mode 100644 index 767980e3..00000000 --- a/.sqlx/query-aeae06e0779379838d3ef86fdfbb5e6c135c673cc99a6feab16ae7f2017200f5.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n DELETE FROM shared_profiles_mods\n WHERE (shared_profile_id = $1 AND (file_hash = ANY($2::text[]) OR install_path = ANY($3::text[])))\n RETURNING file_hash\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "file_hash", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Int8", - "TextArray", - "TextArray" - ] - }, - "nullable": [ - true - ] - }, - "hash": "aeae06e0779379838d3ef86fdfbb5e6c135c673cc99a6feab16ae7f2017200f5" -} diff --git a/.sqlx/query-aeb70d4d015d79e73f38c782cbc787ebecfe6402a80691ee7e66e0ddb88d8628.json b/.sqlx/query-aeb70d4d015d79e73f38c782cbc787ebecfe6402a80691ee7e66e0ddb88d8628.json new file mode 100644 index 00000000..43d0c901 --- /dev/null +++ b/.sqlx/query-aeb70d4d015d79e73f38c782cbc787ebecfe6402a80691ee7e66e0ddb88d8628.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO files (id, url, filename, size, file_type)\n VALUES ($1, $2, $3, $4, $5)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Varchar", + "Varchar", + "Int4", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "aeb70d4d015d79e73f38c782cbc787ebecfe6402a80691ee7e66e0ddb88d8628" +} diff --git a/.sqlx/query-b0a13889f62d3056ad94b3893f709f2f694a20ea984655debb1bda2ec7958eaa.json b/.sqlx/query-b0a13889f62d3056ad94b3893f709f2f694a20ea984655debb1bda2ec7958eaa.json new file mode 100644 index 00000000..b83e371f --- /dev/null +++ b/.sqlx/query-b0a13889f62d3056ad94b3893f709f2f694a20ea984655debb1bda2ec7958eaa.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT version_id, file_id\n FROM versions_files\n WHERE version_id = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "version_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "file_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "b0a13889f62d3056ad94b3893f709f2f694a20ea984655debb1bda2ec7958eaa" +} diff --git a/.sqlx/query-b903ac4e686ef85ba28d698c668da07860e7f276b261d8f2cebb74e73b094970.json b/.sqlx/query-b903ac4e686ef85ba28d698c668da07860e7f276b261d8f2cebb74e73b094970.json deleted file mode 100644 index 8bb97239..00000000 --- a/.sqlx/query-b903ac4e686ef85ba28d698c668da07860e7f276b261d8f2cebb74e73b094970.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n DELETE FROM hashes\n WHERE EXISTS(\n SELECT 1 FROM files WHERE\n (files.version_id = $1) AND\n (hashes.file_id = files.id)\n )\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [] - }, - "hash": "b903ac4e686ef85ba28d698c668da07860e7f276b261d8f2cebb74e73b094970" -} diff --git a/.sqlx/query-c999ad4fcc54919a7b83bfad7c4624d1c670a82502373ef9b3b08c584605d1ca.json b/.sqlx/query-c999ad4fcc54919a7b83bfad7c4624d1c670a82502373ef9b3b08c584605d1ca.json new file mode 100644 index 00000000..8b5fbccb --- /dev/null +++ b/.sqlx/query-c999ad4fcc54919a7b83bfad7c4624d1c670a82502373ef9b3b08c584605d1ca.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE shared_profiles\n SET updated = NOW()\n WHERE id = ANY($1::bigint[])\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [] + }, + "hash": "c999ad4fcc54919a7b83bfad7c4624d1c670a82502373ef9b3b08c584605d1ca" +} diff --git a/.sqlx/query-ca0dcd1c64488eda289f7344d2bb2b874e1ccbe696d1ad1d918adcf571cb05cd.json b/.sqlx/query-ca0dcd1c64488eda289f7344d2bb2b874e1ccbe696d1ad1d918adcf571cb05cd.json new file mode 100644 index 00000000..73b2d7ed --- /dev/null +++ b/.sqlx/query-ca0dcd1c64488eda289f7344d2bb2b874e1ccbe696d1ad1d918adcf571cb05cd.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT DISTINCT shared_profile_id, f.id, f.url, f.filename, spf.install_path, f.size, f.file_type\n FROM files f\n INNER JOIN shared_profiles_files spf ON spf.file_id = f.id\n WHERE spf.shared_profile_id = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "shared_profile_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "url", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "filename", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "install_path", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "size", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "file_type", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "ca0dcd1c64488eda289f7344d2bb2b874e1ccbe696d1ad1d918adcf571cb05cd" +} diff --git a/.sqlx/query-cb151564dec209f0ef5cf95f3156e4082557ad5d627fba569c7f6aeca235d9ba.json b/.sqlx/query-cb151564dec209f0ef5cf95f3156e4082557ad5d627fba569c7f6aeca235d9ba.json new file mode 100644 index 00000000..a3a74558 --- /dev/null +++ b/.sqlx/query-cb151564dec209f0ef5cf95f3156e4082557ad5d627fba569c7f6aeca235d9ba.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM versions_files\n WHERE file_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "cb151564dec209f0ef5cf95f3156e4082557ad5d627fba569c7f6aeca235d9ba" +} diff --git a/.sqlx/query-cb57ae673f1a7e50cc319efddb9bdc82e2251596bcf85aea52e8def343e423b8.json b/.sqlx/query-cb57ae673f1a7e50cc319efddb9bdc82e2251596bcf85aea52e8def343e423b8.json deleted file mode 100644 index 2a441288..00000000 --- a/.sqlx/query-cb57ae673f1a7e50cc319efddb9bdc82e2251596bcf85aea52e8def343e423b8.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO hashes (file_id, algorithm, hash)\n VALUES ($1, $2, $3)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Varchar", - "Bytea" - ] - }, - "nullable": [] - }, - "hash": "cb57ae673f1a7e50cc319efddb9bdc82e2251596bcf85aea52e8def343e423b8" -} diff --git a/.sqlx/query-477c02c4dd36d372683be4e98af1b1b68a23c7cd75677a4c095a406cdb07a7fc.json b/.sqlx/query-ccfc8969a13b6c7f7939560ea299ebdc6e7cc996c3a03b2cbacc48b3ea09ef35.json similarity index 63% rename from .sqlx/query-477c02c4dd36d372683be4e98af1b1b68a23c7cd75677a4c095a406cdb07a7fc.json rename to .sqlx/query-ccfc8969a13b6c7f7939560ea299ebdc6e7cc996c3a03b2cbacc48b3ea09ef35.json index 850ddd3d..178f36d3 100644 --- a/.sqlx/query-477c02c4dd36d372683be4e98af1b1b68a23c7cd75677a4c095a406cdb07a7fc.json +++ b/.sqlx/query-ccfc8969a13b6c7f7939560ea299ebdc6e7cc996c3a03b2cbacc48b3ea09ef35.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT sp.id, sp.name, sp.owner_id, sp.icon_url, sp.created, sp.updated, sp.game_version_id, sp.loader_id,\n l.loader, g.name as game_name, g.id as game_id, sp.metadata,\n ARRAY_AGG(DISTINCT spu.user_id) filter (WHERE spu.user_id IS NOT NULL) as users\n FROM shared_profiles sp \n LEFT JOIN loaders l ON l.id = sp.loader_id\n LEFT JOIN shared_profiles_users spu ON spu.shared_profile_id = sp.id\n INNER JOIN games g ON g.id = sp.game_id\n LEFT JOIN loader_field_enum_values lfev ON sp.game_version_id = lfev.id\n WHERE sp.id = ANY($1)\n GROUP BY sp.id, l.id, g.id, lfev.id\n ", + "query": "\n SELECT sp.id, sp.name, sp.owner_id, sp.icon_url, sp.created, sp.updated, sp.loader_id,\n l.loader, g.name as game_name, g.id as game_id, sp.metadata,\n ARRAY_AGG(DISTINCT spu.user_id) filter (WHERE spu.user_id IS NOT NULL) as users,\n ARRAY_AGG(DISTINCT spl.id) filter (WHERE spl.id IS NOT NULL) as links\n FROM shared_profiles sp \n LEFT JOIN shared_profiles_links spl ON spl.shared_profile_id = sp.id\n LEFT JOIN loaders l ON l.id = sp.loader_id\n LEFT JOIN shared_profiles_users spu ON spu.shared_profile_id = sp.id\n INNER JOIN games g ON g.id = sp.game_id\n WHERE sp.id = ANY($1)\n GROUP BY sp.id, l.id, g.id\n ", "describe": { "columns": [ { @@ -35,38 +35,38 @@ }, { "ordinal": 6, - "name": "game_version_id", - "type_info": "Int4" - }, - { - "ordinal": 7, "name": "loader_id", "type_info": "Int4" }, { - "ordinal": 8, + "ordinal": 7, "name": "loader", "type_info": "Varchar" }, { - "ordinal": 9, + "ordinal": 8, "name": "game_name", "type_info": "Varchar" }, { - "ordinal": 10, + "ordinal": 9, "name": "game_id", "type_info": "Int4" }, { - "ordinal": 11, + "ordinal": 10, "name": "metadata", "type_info": "Jsonb" }, { - "ordinal": 12, + "ordinal": 11, "name": "users", "type_info": "Int8Array" + }, + { + "ordinal": 12, + "name": "links", + "type_info": "Int8Array" } ], "parameters": { @@ -81,14 +81,14 @@ true, false, false, - true, false, false, false, false, false, + null, null ] }, - "hash": "477c02c4dd36d372683be4e98af1b1b68a23c7cd75677a4c095a406cdb07a7fc" + "hash": "ccfc8969a13b6c7f7939560ea299ebdc6e7cc996c3a03b2cbacc48b3ea09ef35" } diff --git a/.sqlx/query-cd6ac1cb990bccdde0257d1b501fbc9023eff17d78ef35786a6138c58001ddb2.json b/.sqlx/query-cd6ac1cb990bccdde0257d1b501fbc9023eff17d78ef35786a6138c58001ddb2.json new file mode 100644 index 00000000..7252d462 --- /dev/null +++ b/.sqlx/query-cd6ac1cb990bccdde0257d1b501fbc9023eff17d78ef35786a6138c58001ddb2.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO shared_profiles_versions (\n shared_profile_id, version_id\n )\n VALUES (\n $1, $2\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "cd6ac1cb990bccdde0257d1b501fbc9023eff17d78ef35786a6138c58001ddb2" +} diff --git a/.sqlx/query-cdd7f8f95c308d9474e214d584c03be0466214da1e157f6bc577b76dbef7df86.json b/.sqlx/query-cdd7f8f95c308d9474e214d584c03be0466214da1e157f6bc577b76dbef7df86.json deleted file mode 100644 index 9edda84f..00000000 --- a/.sqlx/query-cdd7f8f95c308d9474e214d584c03be0466214da1e157f6bc577b76dbef7df86.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n DELETE FROM hashes\n WHERE file_id = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [] - }, - "hash": "cdd7f8f95c308d9474e214d584c03be0466214da1e157f6bc577b76dbef7df86" -} diff --git a/.sqlx/query-ce10bf641f6fbc38e5acbd76d3097f1f993d084059f2d6ce61e9bfb2364fa96e.json b/.sqlx/query-ce10bf641f6fbc38e5acbd76d3097f1f993d084059f2d6ce61e9bfb2364fa96e.json new file mode 100644 index 00000000..3326f107 --- /dev/null +++ b/.sqlx/query-ce10bf641f6fbc38e5acbd76d3097f1f993d084059f2d6ce61e9bfb2364fa96e.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE versions_files\n SET is_primary = FALSE\n WHERE (version_id = $1)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "ce10bf641f6fbc38e5acbd76d3097f1f993d084059f2d6ce61e9bfb2364fa96e" +} diff --git a/.sqlx/query-d67e6c185460a17b65c0dc01be0f436b87acc79fc56513f1c5c4c99e9b9cb283.json b/.sqlx/query-d67e6c185460a17b65c0dc01be0f436b87acc79fc56513f1c5c4c99e9b9cb283.json new file mode 100644 index 00000000..8cae6494 --- /dev/null +++ b/.sqlx/query-d67e6c185460a17b65c0dc01be0f436b87acc79fc56513f1c5c4c99e9b9cb283.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO hashes (file_id, algorithm, hash)\n VALUES ($1, $2, $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Varchar", + "Bytea" + ] + }, + "nullable": [] + }, + "hash": "d67e6c185460a17b65c0dc01be0f436b87acc79fc56513f1c5c4c99e9b9cb283" +} diff --git a/.sqlx/query-d8b4e7e382c77a05395124d5a6a27cccb687d0e2c31b76d49b03aa364d099d42.json b/.sqlx/query-d8b4e7e382c77a05395124d5a6a27cccb687d0e2c31b76d49b03aa364d099d42.json deleted file mode 100644 index 703fe4a1..00000000 --- a/.sqlx/query-d8b4e7e382c77a05395124d5a6a27cccb687d0e2c31b76d49b03aa364d099d42.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n DELETE FROM files\n WHERE files.version_id = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [] - }, - "hash": "d8b4e7e382c77a05395124d5a6a27cccb687d0e2c31b76d49b03aa364d099d42" -} diff --git a/.sqlx/query-dd8a0e5976094bc3285326dd78f92a502d56763d737d0c9927b8ae4dcbbabf24.json b/.sqlx/query-dd8a0e5976094bc3285326dd78f92a502d56763d737d0c9927b8ae4dcbbabf24.json new file mode 100644 index 00000000..db778f7b --- /dev/null +++ b/.sqlx/query-dd8a0e5976094bc3285326dd78f92a502d56763d737d0c9927b8ae4dcbbabf24.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT version_id, file_id\n FROM versions_files vf\n LEFT JOIN versions v ON v.id = vf.version_id\n LEFT JOIN mods m ON m.id = v.mod_id\n WHERE m.status = ANY($1) AND file_id = ANY($2::bigint[])\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "version_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "file_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "TextArray", + "Int8Array" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "dd8a0e5976094bc3285326dd78f92a502d56763d737d0c9927b8ae4dcbbabf24" +} diff --git a/.sqlx/query-e3cc1fd070b97c4cc36bdb2f33080d4e0d7f3c3d81312d9d28a8c3c8213ad54b.json b/.sqlx/query-e3cc1fd070b97c4cc36bdb2f33080d4e0d7f3c3d81312d9d28a8c3c8213ad54b.json deleted file mode 100644 index 241178a3..00000000 --- a/.sqlx/query-e3cc1fd070b97c4cc36bdb2f33080d4e0d7f3c3d81312d9d28a8c3c8213ad54b.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n DELETE FROM files\n WHERE files.id = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [] - }, - "hash": "e3cc1fd070b97c4cc36bdb2f33080d4e0d7f3c3d81312d9d28a8c3c8213ad54b" -} diff --git a/.sqlx/query-e539388f748db3cbda83b27df86668dcf0703d987fc1dae89c1b97badb74f73b.json b/.sqlx/query-e539388f748db3cbda83b27df86668dcf0703d987fc1dae89c1b97badb74f73b.json new file mode 100644 index 00000000..3e28a435 --- /dev/null +++ b/.sqlx/query-e539388f748db3cbda83b27df86668dcf0703d987fc1dae89c1b97badb74f73b.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM versions_files\n WHERE versions_files.version_id = $1\n RETURNING file_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "file_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "e539388f748db3cbda83b27df86668dcf0703d987fc1dae89c1b97badb74f73b" +} diff --git a/.sqlx/query-f6395015c3287dbfebcf9f3e0e852fb5320e093d09a42ebcb5b44fdad0860cde.json b/.sqlx/query-f6395015c3287dbfebcf9f3e0e852fb5320e093d09a42ebcb5b44fdad0860cde.json new file mode 100644 index 00000000..482d91c5 --- /dev/null +++ b/.sqlx/query-f6395015c3287dbfebcf9f3e0e852fb5320e093d09a42ebcb5b44fdad0860cde.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM shared_profiles_files\n WHERE file_id = ANY($1::bigint[])\n RETURNING shared_profile_id, file_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "shared_profile_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "file_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "f6395015c3287dbfebcf9f3e0e852fb5320e093d09a42ebcb5b44fdad0860cde" +} diff --git a/migrations/20231226012200_shared_modpacks.sql b/migrations/20231226012200_shared_modpacks.sql index 87cd95ba..7324520b 100644 --- a/migrations/20231226012200_shared_modpacks.sql +++ b/migrations/20231226012200_shared_modpacks.sql @@ -54,3 +54,6 @@ SELECT version_id, id, is_primary FROM files; -- Drop the version_id and is_primary columns from the files table ALTER TABLE files DROP COLUMN version_id; ALTER TABLE files DROP COLUMN is_primary; + +-- Adds a unique index based on the 'algorithm' and 'hash' pair on the hashes table +CREATE UNIQUE INDEX hashes_algorithm_hash_unique ON hashes (algorithm, hash); \ No newline at end of file diff --git a/src/database/models/client_profile_item.rs b/src/database/models/client_profile_item.rs index 58ef751a..0cd409cc 100644 --- a/src/database/models/client_profile_item.rs +++ b/src/database/models/client_profile_item.rs @@ -2,16 +2,13 @@ use std::collections::HashMap; use std::path::PathBuf; use super::{file_item, ids::*}; -use crate::{database::models::DatabaseError, models::projects::FileType}; use crate::database::redis::RedisPool; +use crate::{database::models::DatabaseError, models::projects::FileType}; use chrono::{DateTime, Utc}; use dashmap::{DashMap, DashSet}; use futures::TryStreamExt; use serde::{Deserialize, Serialize}; -// Hash and install path -type Override = (String, PathBuf); - pub const CLIENT_PROFILES_NAMESPACE: &str = "client_profiles"; #[derive(Clone, Debug, Serialize, Deserialize)] @@ -293,7 +290,6 @@ impl ClientProfile { } if !remaining_ids.is_empty() { - let shared_profiles_versions: DashMap> = sqlx::query!( " SELECT shared_profile_id, version_id @@ -374,20 +370,23 @@ impl ClientProfile { &file_ids.iter().map(|x| x.0).collect::>() ) .fetch(&mut *exec) - .try_fold(DashMap::new(), |acc: DashMap>, m| { - if let Some(found_hash) = m.hash { - let hash = Hash { - file_id: FileId(m.file_id), - algorithm: m.algorithm, - hash: found_hash, - }; + .try_fold( + DashMap::new(), + |acc: DashMap>, m| { + if let Some(found_hash) = m.hash { + let hash = Hash { + file_id: FileId(m.file_id), + algorithm: m.algorithm, + hash: found_hash, + }; - if let Some(profile_id) = reverse_file_map.get(&FileId(m.file_id)) { - acc.entry(*profile_id).or_default().push(hash); + if let Some(profile_id) = reverse_file_map.get(&FileId(m.file_id)) { + acc.entry(*profile_id).or_default().push(hash); + } } - } - async move { Ok(acc) } - }) + async move { Ok(acc) } + }, + ) .await?; let shared_profiles_links: DashMap> = @@ -449,8 +448,7 @@ impl ClientProfile { let links = shared_profiles_links.remove(&id).map(|x| x.1).unwrap_or_default(); let game_id = GameId(m.game_id); let metadata = serde_json::from_value::(m.metadata).unwrap_or(ClientProfileMetadata::Unknown); - - let mut files = files.into_iter().map(|x| { + let files = files.into_iter().map(|x| { let mut file_hashes = HashMap::new(); for hash in hashes.iter() { diff --git a/src/database/models/file_item.rs b/src/database/models/file_item.rs index 80315704..2bc090f8 100644 --- a/src/database/models/file_item.rs +++ b/src/database/models/file_item.rs @@ -1,9 +1,14 @@ -use std::path::PathBuf; +use std::{collections::HashMap, path::PathBuf}; -use crate::{database::{models::VersionId, redis::RedisPool}, models::projects::FileType}; +use itertools::Itertools; -use super::{generate_file_id, ClientProfileId, DatabaseError, FileId}; +use crate::{ + database::{models::VersionId, redis::RedisPool}, + models::{self, projects::FileType}, + routes::CommonError, +}; +use super::{client_profile_item, generate_file_id, ClientProfileId, DatabaseError, FileId}; #[derive(Clone, Debug)] pub struct VersionFileBuilder { @@ -11,6 +16,11 @@ pub struct VersionFileBuilder { pub filename: String, pub hashes: Vec, pub primary: bool, + // Whether a new file should be generated or an existing one should be used + // If one is provided, that file will be connected to the version instead of creating a new one + // This is used on rare allowable hash collisions, such as two unapproved versions + // No two approved versions should ever have the same file- this is enforced elsewhere + pub existing_file: Option, pub size: u32, pub file_type: Option, } @@ -27,21 +37,41 @@ impl VersionFileBuilder { version_id: VersionId, transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, ) -> Result { - let file_id = generate_file_id(&mut *transaction).await?; + let file_id = if let Some(file_id) = self.existing_file { + file_id + } else { + let file_id = generate_file_id(&mut *transaction).await?; - sqlx::query!( - " - INSERT INTO files (id, url, filename, size, file_type) - VALUES ($1, $2, $3, $4, $5) - ", - file_id as FileId, - self.url, - self.filename, - self.size as i32, - self.file_type.map(|x| x.as_str()), - ) - .execute(&mut **transaction) - .await?; + sqlx::query!( + " + INSERT INTO files (id, url, filename, size, file_type) + VALUES ($1, $2, $3, $4, $5) + ", + file_id as FileId, + self.url, + self.filename, + self.size as i32, + self.file_type.map(|x| x.as_str()), + ) + .execute(&mut **transaction) + .await?; + + for hash in self.hashes { + sqlx::query!( + " + INSERT INTO hashes (file_id, algorithm, hash) + VALUES ($1, $2, $3) + ", + file_id as FileId, + hash.algorithm, + hash.hash, + ) + .execute(&mut **transaction) + .await?; + } + + file_id + }; sqlx::query!( " @@ -55,25 +85,10 @@ impl VersionFileBuilder { .execute(&mut **transaction) .await?; - for hash in self.hashes { - sqlx::query!( - " - INSERT INTO hashes (file_id, algorithm, hash) - VALUES ($1, $2, $3) - ", - file_id as FileId, - hash.algorithm, - hash.hash, - ) - .execute(&mut **transaction) - .await?; - } - Ok(file_id) } } - #[derive(Clone, Debug)] pub struct ClientProfileFileBuilder { pub url: String, @@ -81,8 +96,8 @@ pub struct ClientProfileFileBuilder { pub hashes: Vec, pub install_path: PathBuf, // Whether a new file should be generated or an existing one should be used - // If one is providded, that file will be connected to the profile instead of creating a new one - pub existing_file : Option, + // If one is provided, that file will be connected to the profile instead of creating a new one + pub existing_file: Option, pub size: u32, pub file_type: Option, } @@ -111,8 +126,7 @@ impl ClientProfileFileBuilder { ) .execute(&mut **transaction) .await?; - - + for hash in self.hashes { sqlx::query!( " @@ -125,12 +139,11 @@ impl ClientProfileFileBuilder { ) .execute(&mut **transaction) .await?; - } + } file_id }; - sqlx::query!( " INSERT INTO shared_profiles_files (shared_profile_id, file_id, install_path) @@ -151,8 +164,8 @@ impl ClientProfileFileBuilder { // This is a separate function because it is used in multiple places // Returns a list of hashes that were deleted, so they can be removed from the file host pub async fn remove_unreferenced_files( - file_ids : Vec, - transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + file_ids: Vec, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, ) -> Result, DatabaseError> { let file_ids = file_ids.into_iter().map(|x| x.0).collect::>(); @@ -180,7 +193,7 @@ pub async fn remove_unreferenced_files( .collect::>(); // Delete hashes for the files remaining - let hashes : Vec = sqlx::query!( + let hashes: Vec = sqlx::query!( " DELETE FROM hashes WHERE EXISTS( @@ -207,6 +220,126 @@ pub async fn remove_unreferenced_files( ) .execute(&mut **transaction) .await?; - + Ok(hashes) -} \ No newline at end of file +} + +// Converts shared_profiles_files to shared_profiles_versions for cases of +// hash collisions for files that versions now 'own'. +// It also ensures that all files have at exactly one approved version- the one that was just approved. +// It returns a schema error if any file has multiple approved versions (reverting the transaction) +// (Before they are approved, uploaded files can have hash collections) +// This is a separate function because it is used in multiple places. +pub async fn convert_hash_collisions_to_versions( + approved_version_ids: &[VersionId], + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + redis: &RedisPool, +) -> Result<(), T> +where + T: CommonError + From + From, +{ + // First, get all file id associated with these versions + let file_ids: HashMap = sqlx::query!( + " + SELECT version_id, file_id + FROM versions_files + WHERE version_id = ANY($1) + ", + &approved_version_ids.iter().map(|x| x.0).collect::>()[..], + ) + .fetch_all(&mut **transaction) + .await? + .into_iter() + .map(|x| (FileId(x.file_id), VersionId(x.version_id))) + .collect(); + + // For each file, get all approved project's versions that have that file + let existing_approved_versions: HashMap> = sqlx::query!( + " + SELECT version_id, file_id + FROM versions_files vf + LEFT JOIN versions v ON v.id = vf.version_id + LEFT JOIN mods m ON m.id = v.mod_id + WHERE m.status = ANY($1) AND file_id = ANY($2::bigint[]) + ", + &*crate::models::projects::ProjectStatus::iterator() + .filter(|x| x.is_approved()) + .map(|x| x.to_string()) + .collect::>(), + &file_ids.keys().map(|x| x.0).collect::>()[..], + ) + .fetch_all(&mut **transaction) + .await? + .into_iter() + .map(|x| (FileId(x.file_id), VersionId(x.version_id))) + .into_group_map(); + + // Ensure that all files have at exactly one approved version- the one that was just approved + for (file_id, version_ids) in existing_approved_versions { + let Some(intended_version_id) = file_ids.get(&file_id) else { + continue; + }; + + if version_ids.len() != 1 || !version_ids.contains(intended_version_id) { + let versions: Vec = + version_ids.iter().map(|x| (*x).into()).collect(); + return Err(T::invalid_input(format!( + "File {} has existing or multiple approved versions: {}", + file_id.0, + versions.into_iter().join(", ") + ))); + } + } + + // Delete all shared_profiles_files that reference these files + let shared_profile_ids: Vec<(ClientProfileId, FileId)> = sqlx::query!( + " + DELETE FROM shared_profiles_files + WHERE file_id = ANY($1::bigint[]) + RETURNING shared_profile_id, file_id + ", + &file_ids.keys().map(|x| x.0).collect::>()[..], + ) + .fetch_all(&mut **transaction) + .await? + .into_iter() + .map(|x| (ClientProfileId(x.shared_profile_id), FileId(x.file_id))) + .collect(); + + // Add as versions + let versions_to_add: Vec<(ClientProfileId, VersionId)> = shared_profile_ids + .into_iter() + .filter_map(|(profile_id, file_id)| file_ids.get(&file_id).map(|x| (profile_id, *x))) + .collect(); + let (client_profile_ids, version_ids): (Vec<_>, Vec<_>) = + versions_to_add.iter().map(|x| (x.0 .0, x.1 .0)).unzip(); + sqlx::query!( + " + INSERT INTO shared_profiles_versions (shared_profile_id, version_id) + SELECT * FROM UNNEST($1::bigint[], $2::bigint[]) + ", + &client_profile_ids[..], + &version_ids[..], + ) + .execute(&mut **transaction) + .await?; + + // Set updated of all hit profiles + sqlx::query!( + " + UPDATE shared_profiles + SET updated = NOW() + WHERE id = ANY($1::bigint[]) + ", + &client_profile_ids[..], + ) + .execute(&mut **transaction) + .await?; + + // Clear cache of all hit profiles + for profile_id in client_profile_ids { + client_profile_item::ClientProfile::clear_cache(ClientProfileId(profile_id), redis).await?; + } + + Ok(()) +} diff --git a/src/database/models/version_item.rs b/src/database/models/version_item.rs index db0be2c6..35657cee 100644 --- a/src/database/models/version_item.rs +++ b/src/database/models/version_item.rs @@ -1,7 +1,7 @@ use super::file_item::VersionFileBuilder; -use super::{file_item, ids::*}; use super::loader_fields::VersionField; use super::DatabaseError; +use super::{file_item, ids::*}; use crate::database::models::loader_fields::{ QueryLoaderField, QueryLoaderFieldEnumValue, QueryVersionField, }; @@ -847,6 +847,7 @@ impl Version { FROM files f INNER JOIN versions_files vf on vf.file_id = f.id INNER JOIN versions v on v.id = vf.version_id + INNER JOIN mods m on m.id = v.mod_id AND m.status = ANY($3) INNER JOIN hashes h on h.file_id = f.id WHERE h.algorithm = $1 AND h.hash = ANY($2) GROUP BY f.id, v.mod_id, v.date_published, vf.version_id, vf.is_primary @@ -854,6 +855,10 @@ impl Version { ", algorithm, &file_ids_parsed.into_iter().map(|x| x.as_bytes().to_vec()).collect::>(), + &*crate::models::projects::ProjectStatus::iterator() + .filter(|x| x.is_approved()) + .map(|x| x.to_string()) + .collect::>(), ) .fetch_many(executor) .try_filter_map(|e| async { diff --git a/src/routes/internal/client/profiles.rs b/src/routes/internal/client/profiles.rs index a301bb6a..f1d1a03f 100644 --- a/src/routes/internal/client/profiles.rs +++ b/src/routes/internal/client/profiles.rs @@ -3,7 +3,8 @@ use crate::auth::{get_user_from_headers, AuthenticationError}; use crate::database::models::file_item::ClientProfileFileBuilder; use crate::database::models::legacy_loader_fields::MinecraftGameVersion; use crate::database::models::{ - client_profile_item, file_item, generate_client_profile_id, generate_client_profile_link_id, version_item, FileId + client_profile_item, file_item, generate_client_profile_id, generate_client_profile_link_id, + version_item, FileId, }; use crate::database::redis::RedisPool; use crate::file_hosting::FileHost; @@ -26,6 +27,7 @@ use actix_multipart::{Field, Multipart}; use actix_web::web::Data; use actix_web::{web, HttpRequest, HttpResponse}; use chrono::Utc; +use database::models::ids::ClientProfileId as DBClientProfileId; use futures::StreamExt; use itertools::Itertools; use rand::distributions::Alphanumeric; @@ -440,9 +442,7 @@ pub async fn profile_edit( } if let Some(remove_links) = edit_data.remove_links { - println!("remove links: {:?}", remove_links); for link in remove_links { - println!("Removing link: {:?}", link); // Remove link from list sqlx::query!( "DELETE FROM shared_profiles_links WHERE shared_profile_id = $1 AND id = $2", @@ -755,7 +755,6 @@ pub async fn profile_files( redis: web::Data, session_queue: web::Data, ) -> Result { - let cdn_url = dotenvy::var("CDN_URL")?; let profile_id = info.into_inner().0; // Must be logged in to download @@ -813,7 +812,6 @@ pub async fn profile_token_check( redis: web::Data, session_queue: web::Data, ) -> Result { - let cdn_url = dotenvy::var("CDN_URL")?; let file_url = file_url.into_inner().url; let user = get_user_from_headers( @@ -842,17 +840,12 @@ pub async fn profile_token_check( let all_allowed_urls = profiles .into_iter() - .flat_map(|x| - x.override_files - .into_iter().map(|x| x.url)) + .flat_map(|x| x.override_files.into_iter().map(|x| x.url)) .collect::>(); - println!("All allowed urls: {:?}", all_allowed_urls); - // Check the token is valid for the requested file let valid = all_allowed_urls.iter().any(|x| x == &file_url); - println!("Valid: {:?}", valid); if !valid { Err(ApiError::Authentication( AuthenticationError::InvalidAuthMethod, @@ -937,7 +930,7 @@ pub async fn profile_icon_edit( ", format!("{}/{}", cdn_url, upload_data.file_name), color.map(|x| x as i32), - profile_item.inner.id as database::models::ids::ClientProfileId, + profile_item.inner.id as DBClientProfileId, ) .execute(&mut *transaction) .await?; @@ -1007,7 +1000,7 @@ pub async fn delete_profile_icon( SET icon_url = NULL, color = NULL WHERE (id = $1) ", - profile_item.inner.id as database::models::ids::ClientProfileId, + profile_item.inner.id as DBClientProfileId, ) .execute(&mut *transaction) .await?; @@ -1030,7 +1023,7 @@ pub async fn delete_profile_icon( // install_path: String // The rest of the parts are files, and their install paths are matched to the install paths in the data field #[derive(Serialize, Deserialize)] -struct MultipartFile { +pub struct MultipartFile { pub file_name: String, pub install_path: String, } @@ -1073,11 +1066,6 @@ pub async fn client_profile_add_override( )); } - struct UploadedFile { - pub install_path: String, - pub hash: String, - } - let mut error = None; let mut uploaded_files = Vec::new(); @@ -1119,7 +1107,18 @@ pub async fn client_profile_add_override( let cdn_url = dotenvy::var("CDN_URL")?; // Upload file to CDN and get hash - upload_file(&mut field, &files, &***file_host, &content_disposition, &cdn_url, None, &mut client_profile_files, &mut uploaded_files, &mut transaction).await?; + upload_file( + &mut field, + &files, + &***file_host, + &content_disposition, + &cdn_url, + None, + &mut client_profile_files, + &mut uploaded_files, + &mut transaction, + ) + .await?; Ok(()) } .await; @@ -1188,7 +1187,6 @@ pub async fn client_profile_remove_overrides( .await? .1; - // Check if this is our profile let profile_item = database::models::client_profile_item::ClientProfile::get( client_id.into(), @@ -1203,9 +1201,12 @@ pub async fn client_profile_remove_overrides( "You don't have permission to remove overrides.".to_string(), )); } - + let delete_hashes = data.hashes.clone().unwrap_or_default(); - let algorithm = data.algorithm.clone().unwrap_or_else(|| default_algorithm_from_hashes(&delete_hashes)); + let algorithm = data + .algorithm + .clone() + .unwrap_or_else(|| default_algorithm_from_hashes(&delete_hashes)); let delete_install_paths = data.install_paths.clone().unwrap_or_default(); // TODO: ensure tested well @@ -1213,20 +1214,26 @@ pub async fn client_profile_remove_overrides( .override_files .into_iter() .map(|x| (x.hashes, x.install_path)) - .map(|(hashes, install_paths)| - (hashes.get(&algorithm).map(|x| x.clone()), install_paths) - ) - .filter(|(hash, path)| hash.as_ref().map(|h| delete_hashes.contains(&h)).unwrap_or(false) || delete_install_paths.contains(path)) + .map(|(hashes, install_paths)| (hashes.get(&algorithm).cloned(), install_paths)) + .filter(|(hash, path)| { + hash.as_ref() + .map(|h| delete_hashes.contains(h)) + .unwrap_or(false) + || delete_install_paths.contains(path) + }) .collect::>(); - let delete_hashes = overrides.iter().filter_map(|x| x.0.as_ref().map(|x| x.as_bytes().to_vec())).collect::>(); + let delete_hashes = overrides + .iter() + .filter_map(|x| x.0.as_ref().map(|x| x.as_bytes().to_vec())) + .collect::>(); let delete_install_paths = overrides .iter() .map(|x| x.1.to_string_lossy().to_string()) .collect::>(); let mut transaction = pool.begin().await?; - + let files_to_delete = sqlx::query!( " SELECT spf.file_id @@ -1240,7 +1247,10 @@ pub async fn client_profile_remove_overrides( &delete_install_paths[..], ) .fetch_all(&mut *transaction) - .await?.into_iter().map(|x| FileId(x.file_id)).collect::>(); + .await? + .into_iter() + .map(|x| FileId(x.file_id)) + .collect::>(); sqlx::query!( " @@ -1255,7 +1265,8 @@ pub async fn client_profile_remove_overrides( // Check if any versions_files or shared_profiles_files still reference the file- these files should not be deleted // Delete the files that are not referenced - let deleted_hashes = file_item::remove_unreferenced_files(files_to_delete, &mut transaction).await?; + let deleted_hashes = + file_item::remove_unreferenced_files(files_to_delete, &mut transaction).await?; // Set updated sqlx::query!( @@ -1289,7 +1300,6 @@ async fn delete_unused_files_from_host( pool: &PgPool, file_host: &Arc, ) -> Result<(), ApiError> { - // Confirm hashes no longer exist in any profile (for sureness) let deleted_hashes_bytes = deleted_hashes .iter() @@ -1302,7 +1312,7 @@ async fn delete_unused_files_from_host( ", &deleted_hashes_bytes ) - .fetch_all(&*pool) + .fetch_all(pool) .await? .into_iter() .filter_map(|x| x.hash) @@ -1331,7 +1341,7 @@ async fn delete_unused_files_from_host( #[allow(clippy::too_many_arguments)] pub async fn upload_file( field: &mut Field, - multipart_files: &Vec, + multipart_files: &[MultipartFile], file_host: &dyn FileHost, content_disposition: &actix_web::http::header::ContentDisposition, cdn_url: &str, @@ -1356,25 +1366,53 @@ pub async fn upload_file( "Project file exceeds the maximum of 500MiB. Contact a moderator or admin to request permission to upload larger files." ).await?; - let name = content_disposition.get_name().ok_or_else(|| { - CreateError::InvalidInput(String::from("Upload must have a name")) - })?; + let name = content_disposition + .get_name() + .ok_or_else(|| CreateError::InvalidInput(String::from("Upload must have a name")))?; let install_path = multipart_files - .iter() - .find(|x| x.file_name == name) - .ok_or_else(|| { - CreateError::InvalidInput(format!( - "No matching file name in `data` for file '{}'", - name - )) - })? - .install_path - .clone(); + .iter() + .find(|x| x.file_name == name) + .ok_or_else(|| { + CreateError::InvalidInput(format!( + "No matching file name in `data` for file '{}'", + name + )) + })? + .install_path + .clone(); let hash = format!("{:x}", sha2::Sha512::digest(&data)); - // Allow uploading the same file multiple times, but + // First, check if this file already exists with an approved project's version + let existing_collision = sqlx::query!( + " + SELECT m.slug FROM hashes h + INNER JOIN files f ON f.id = h.file_id + INNER JOIN versions_files vf on vf.file_id = f.id + INNER JOIN versions v ON v.id = vf.version_id + INNER JOIN mods m ON m.id = v.mod_id AND m.status = ANY($3) + WHERE h.algorithm = $2 AND h.hash = $1 + ", + hash.as_bytes(), + "sha512", + &*crate::models::projects::ProjectStatus::iterator() + .filter(|x| x.is_approved()) + .map(|x| x.to_string()) + .collect::>(), + ) + .fetch_optional(&mut **transaction) + .await? + .and_then(|x| x.slug); + + if let Some(existing_collision) = existing_collision { + return Err(CreateError::InvalidInput(format!( + "Override file at path '{}' already exists in Modrinth for mod '{}'", + install_path, existing_collision + ))); + } + + // Allow uploading the same file multiple times, if none are for approved versions, but // we'll connect them to the same file in the database/CDN let existing_file = sqlx::query!( " @@ -1384,7 +1422,7 @@ pub async fn upload_file( WHERE h.algorithm = $2 AND h.hash = $1 ", hash.as_bytes(), - "sha1", + "sha512", ) .fetch_optional(&mut **transaction) .await? @@ -1400,7 +1438,6 @@ pub async fn upload_file( let sha1_bytes = upload_data.content_sha1.into_bytes(); let sha512_bytes = upload_data.content_sha512.into_bytes(); - client_profile_files.push(ClientProfileFileBuilder { filename: file_name.to_string(), url: format!("{}/{}", cdn_url, upload_data.file_name), @@ -1424,10 +1461,10 @@ pub async fn upload_file( file_type, }); - uploaded_files.push(UploadedFile { + uploaded_files.push(UploadedFile { file_id: upload_data.file_id, file_name: file_path, - }); + }); Ok(()) } diff --git a/src/routes/maven.rs b/src/routes/maven.rs index da3b5907..ee86c2d9 100644 --- a/src/routes/maven.rs +++ b/src/routes/maven.rs @@ -2,7 +2,7 @@ use crate::auth::checks::{is_visible_project, is_visible_version}; use crate::database::models::legacy_loader_fields::MinecraftGameVersion; use crate::database::models::loader_fields::Loader; use crate::database::models::project_item::QueryProject; -use crate::database::models::version_item::{QueryVersionFile, QueryVersion}; +use crate::database::models::version_item::{QueryVersion, QueryVersionFile}; use crate::database::redis::RedisPool; use crate::models::pats::Scopes; use crate::models::projects::{ProjectId, VersionId}; diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 18581eaa..3c250144 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -21,6 +21,7 @@ mod not_found; mod updates; pub use self::not_found::not_found; +use self::v3::project_creation::CreateError; pub fn root_config(cfg: &mut web::ServiceConfig) { cfg.service( @@ -187,3 +188,21 @@ impl actix_web::ResponseError for ApiError { }) } } + +// A simple trait that allows helper functions to return ApiError or CreateError, +// while keeping the ability to generate InvalidInput(String) +pub trait CommonError { + fn invalid_input(s: String) -> Self; +} + +impl CommonError for ApiError { + fn invalid_input(s: String) -> Self { + ApiError::InvalidInput(s) + } +} + +impl CommonError for CreateError { + fn invalid_input(s: String) -> Self { + CreateError::InvalidInput(s) + } +} diff --git a/src/routes/v3/projects.rs b/src/routes/v3/projects.rs index c4f82ca7..5b139a9e 100644 --- a/src/routes/v3/projects.rs +++ b/src/routes/v3/projects.rs @@ -6,7 +6,7 @@ use crate::auth::{filter_visible_projects, get_user_from_headers}; use crate::database::models::notification_item::NotificationBuilder; use crate::database::models::project_item::{GalleryItem, ModCategory}; use crate::database::models::thread_item::ThreadMessageBuilder; -use crate::database::models::{ids as db_ids, image_item, TeamMember}; +use crate::database::models::{file_item, ids as db_ids, image_item, TeamMember}; use crate::database::redis::RedisPool; use crate::database::{self, models as db_models}; use crate::file_hosting::FileHost; @@ -375,6 +375,28 @@ pub async fn project_edit( ) .execute(&mut *transaction) .await?; + + // On approval, all versions become unique 'owners' of their files + // Get them directly rather than use project_item.versions (which is missing hidden versions) + // TODO: this can be simplified when .versions field on the Project struct is revised + let version_ids = sqlx::query!( + " + SELECT id FROM versions + WHERE mod_id = $1 + ", + id as db_ids::ProjectId, + ) + .fetch_many(&mut *transaction) + .try_filter_map(|e| async { Ok(e.right().map(|c| db_ids::VersionId(c.id))) }) + .try_collect::>() + .await?; + + file_item::convert_hash_collisions_to_versions::( + &version_ids, + &mut transaction, + &redis, + ) + .await?; } if status.is_searchable() && !project_item.inner.webhook_sent { if let Ok(webhook_url) = dotenvy::var("PUBLIC_DISCORD_WEBHOOK") { diff --git a/src/routes/v3/version_creation.rs b/src/routes/v3/version_creation.rs index e67922d6..c306255b 100644 --- a/src/routes/v3/version_creation.rs +++ b/src/routes/v3/version_creation.rs @@ -1,12 +1,10 @@ use super::project_creation::{CreateError, UploadedFile}; use crate::auth::get_user_from_headers; -use crate::database::models::file_item::VersionFileBuilder; +use crate::database::models::file_item::{self, VersionFileBuilder}; use crate::database::models::loader_fields::{LoaderField, LoaderFieldEnumValue, VersionField}; use crate::database::models::notification_item::NotificationBuilder; -use crate::database::models::version_item::{ - DependencyBuilder, VersionBuilder, -}; -use crate::database::models::{self, image_item, Organization}; +use crate::database::models::version_item::{DependencyBuilder, VersionBuilder}; +use crate::database::models::{self, image_item, FileId, Organization}; use crate::database::redis::RedisPool; use crate::file_hosting::FileHost; use crate::models::images::{Image, ImageContext, ImageId}; @@ -413,8 +411,9 @@ async fn version_create_inner( acc }); + let version_id = builder.version_id; let response = Version { - id: builder.version_id.into(), + id: version_id.into(), project_id: builder.project_id.into(), author_id: user.id, featured: builder.featured, @@ -497,6 +496,21 @@ async fn version_create_inner( } } + // On version creation in to approved project, the version become unique 'owners' of its files + let project = models::Project::get_id(project_id, &mut **transaction, redis) + .await? + .ok_or_else(|| { + CreateError::InvalidInput("An invalid project id was supplied".to_string()) + })?; + if project.inner.status.is_approved() { + file_item::convert_hash_collisions_to_versions::( + &[version_id], + &mut *transaction, + redis, + ) + .await?; + } + models::Project::clear_cache(project_id, None, Some(true), redis).await?; Ok(HttpResponse::Ok().json(response)) @@ -735,6 +749,21 @@ async fn upload_file_to_version_inner( } } + // On upload to approved project, the version become unique 'owner' of the file + let project = models::Project::get_id(version.inner.project_id, &mut **transaction, &redis) + .await? + .ok_or_else(|| { + CreateError::InvalidInput("An invalid project id was supplied".to_string()) + })?; + if project.inner.status.is_approved() { + file_item::convert_hash_collisions_to_versions::( + &[version_id], + &mut *transaction, + &redis, + ) + .await?; + } + // Clear version cache models::Version::clear_cache(&version, &redis).await?; @@ -780,29 +809,50 @@ pub async fn upload_file( ).await?; let hash = sha1::Sha1::from(&data).hexdigest(); - let exists = sqlx::query!( + // First, check if this file already exists with an approved project's version + let existing_collision = sqlx::query!( " - SELECT EXISTS(SELECT 1 FROM hashes h + SELECT m.slug FROM hashes h INNER JOIN files f ON f.id = h.file_id INNER JOIN versions_files vf on vf.file_id = f.id INNER JOIN versions v ON v.id = vf.version_id - WHERE h.algorithm = $2 AND h.hash = $1 AND v.mod_id != $3) + INNER JOIN mods m ON m.id = v.mod_id AND m.status = ANY($3) + WHERE h.algorithm = $2 AND h.hash = $1 ", hash.as_bytes(), "sha1", - project_id.0 as i64 + &*crate::models::projects::ProjectStatus::iterator() + .filter(|x| x.is_approved()) + .map(|x| x.to_string()) + .collect::>(), ) - .fetch_one(&mut **transaction) + .fetch_optional(&mut **transaction) .await? - .exists - .unwrap_or(false); + .and_then(|x| x.slug); - if exists { - return Err(CreateError::InvalidInput( - "Duplicate files are not allowed to be uploaded to Modrinth!".to_string(), - )); + if let Some(existing_collision) = existing_collision { + return Err(CreateError::InvalidInput(format!( + "File '{}' already exists in Modrinth for mod '{}' by hash.", + file_name, existing_collision + ))); } + // Allow uploading the same file multiple times, if none are for approved versions, but + // we'll connect them to the same file in the database/CDN + let existing_file = sqlx::query!( + " + SELECT f.id + FROM hashes h + INNER JOIN files f ON f.id = h.file_id + WHERE h.algorithm = $2 AND h.hash = $1 + ", + hash.as_bytes(), + "sha1", + ) + .fetch_optional(&mut **transaction) + .await? + .map(|x| FileId(x.id)); + let validation_result = validate_file( data.clone().into(), file_extension.to_string(), @@ -917,6 +967,7 @@ pub async fn upload_file( version_files.push(VersionFileBuilder { filename: file_name.to_string(), url: format!("{cdn_url}/{file_path_encode}"), + existing_file, hashes: vec![ models::file_item::HashBuilder { algorithm: "sha1".to_string(), diff --git a/src/routes/v3/version_file.rs b/src/routes/v3/version_file.rs index e8da99a2..65773e2e 100644 --- a/src/routes/v3/version_file.rs +++ b/src/routes/v3/version_file.rs @@ -628,7 +628,7 @@ pub async fn delete_file( ) .execute(&mut *transaction) .await?; - + // Check if any versions_files or shared_profiles_files still reference the file- these files should not be deleted // Delete the files that are not referenced file_item::remove_unreferenced_files(vec![row.id], &mut transaction).await?; diff --git a/tests/profiles.rs b/tests/profiles.rs index 8b30c196..56193aca 100644 --- a/tests/profiles.rs +++ b/tests/profiles.rs @@ -9,10 +9,14 @@ use common::environment::with_test_environment; use common::environment::TestEnvironment; use labrinth::models::client::profile::ClientProfile; use labrinth::models::client::profile::ClientProfileMetadata; +use labrinth::models::projects::Project; use labrinth::models::users::UserId; use sha2::Digest; +use crate::common::api_common::ApiProject; +use crate::common::api_common::ApiVersion; use crate::common::api_v3::client_profile::ClientProfileOverride; +use crate::common::api_v3::request_data::get_public_project_creation_data; use crate::common::dummy_data::DummyImage; use crate::common::dummy_data::TestFile; @@ -714,7 +718,10 @@ async fn add_remove_profile_versions() { [ PathBuf::from("mods/test.jar"), PathBuf::from("mods/test_different.jar") - ].iter().cloned().collect::>() + ] + .iter() + .cloned() + .collect::>() ); // Get profile again to confirm update @@ -1015,5 +1022,463 @@ async fn hidden_versions_are_forbidden() { .await; } -// try all file system related thinghs -// go thru all the stuff in the linear issue +#[actix_rt::test] +async fn verison_file_hash_collisions_with_shared_profiles() { + // Test setup and dummy data + with_test_environment(None, |test_env: TestEnvironment| async move { + let api = &test_env.api; + + let test_file_hash_xxx = TestFile::build_random_jar(); + let test_file_hash_yyy = TestFile::build_random_jar(); + let test_file_hash_zzz = TestFile::build_random_jar(); + + // Define some comparison projects/profiles that already have these files + // unapproved project has xxx + let creation_data = + get_public_project_creation_data("unapproved", Some(test_file_hash_xxx.clone()), None); + let unapproved_project = api.create_project(creation_data, USER_USER_PAT).await; + assert_status!(&unapproved_project, StatusCode::OK); + + // approved project has yyy + let creation_data = + get_public_project_creation_data("approved", Some(test_file_hash_yyy.clone()), None); + let approved_project = api.create_project(creation_data, USER_USER_PAT).await; + assert_status!(&approved_project, StatusCode::OK); + + // Approve as a moderator. + let resp = api + .edit_project( + "approved", + serde_json::json!({"status": "approved"}), + MOD_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // shared profile has zzz + let existing_profile = api + .create_client_profile( + "existing", + "fabric", + "1.0.0", + "1.20.1", + vec![], + USER_USER_PAT, + ) + .await; + assert_status!(&existing_profile, StatusCode::OK); + let existing_profile: ClientProfile = test::read_body_json(existing_profile).await; + let resp = api + .add_client_profile_overrides( + &existing_profile.id.to_string(), + vec![ClientProfileOverride::new( + test_file_hash_zzz.clone(), + "mods/test0.jar", + )], + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + let test_data = get_public_project_creation_data("test", None, None); + let test_project = api.create_project(test_data, USER_USER_PAT).await; + assert_status!(&test_project, StatusCode::OK); + let project = test::read_body_json::(test_project).await; + + let test_profile = api + .create_client_profile("test", "fabric", "1.0.0", "1.20.1", vec![], USER_USER_PAT) + .await; + assert_status!(&test_profile, StatusCode::OK); + let test_profile: ClientProfile = test::read_body_json(test_profile).await; + + // 1. Existing unapproved version file, and we upload a version file with the same hash + // -> Should succeed- OK to have two unapproved version files with the same hash + let test_version = api + .add_public_version( + project.id, + "1.0.0", + test_file_hash_xxx.clone(), + None, + None, + USER_USER_PAT, + ) + .await; + assert_status!(&test_version, StatusCode::OK); + + // 2. Existing approved version file, and we upload a version file with the same hash + // -> Should fail, cannot have two approved version files with the same hash + let test_version = api + .add_public_version( + project.id, + "1.0.1", + test_file_hash_yyy.clone(), + None, + None, + USER_USER_PAT, + ) + .await; + assert_status!(&test_version, StatusCode::BAD_REQUEST); + + // 3. Existing unapproved version file, and we upload a shared profile override file + // -> Should succeed- OK, but they should attach to the same file id + let resp = api + .add_client_profile_overrides( + &test_profile.id.to_string(), + vec![ClientProfileOverride::new( + test_file_hash_xxx.clone(), + "mods/test1.jar", + )], + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + let resp = api + .delete_client_profile_overrides( + &test_profile.id.to_string(), + None, + Some(&[&"mods/test1.jar"]), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // 4. Existing approved version file, and we upload a shared profile override file + // -> Should fail, tell user to attach as version instead of an override + let resp = api + .add_client_profile_overrides( + &test_profile.id.to_string(), + vec![ClientProfileOverride::new( + test_file_hash_yyy.clone(), + "mods/test2.jar", + )], + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + + // 5. Existing shared profile override file, and we upload a shared profile override file + // -> Should suceced, and they should attach to the same file id + let resp = api + .add_client_profile_overrides( + &test_profile.id.to_string(), + vec![ClientProfileOverride::new( + test_file_hash_zzz.clone(), + "mods/test3.jar", + )], + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // 6. Existing shared profile override file, and we upload a version file (as of yet unapproved) + // -> Should succeed, and they should attach to the same file id + // difficulty comes in on approval, which is tested in 'version_file_hash_collisions_approving' + let test_version = api + .add_public_version( + project.id, + "1.0.2", + test_file_hash_zzz.clone(), + None, + None, + USER_USER_PAT, + ) + .await; + assert_status!(&test_version, StatusCode::OK); + }) + .await; +} + +#[actix_rt::test] +async fn version_file_hash_collisions_approving() { + // Test setup and dummy data + with_test_environment(None, |test_env: TestEnvironment| async move { + let api = &test_env.api; + let test_file_hash_xxx = TestFile::build_random_jar(); + let test_file_hash_yyy = TestFile::build_random_jar(); + + // Set up four projects with colliding hashes + // A: unapproved version file with XXX hash + // B: unapproved version file with XXX hash + // C: unapproved version file with YYY hash + // C: approved project with no versions (but will contain YYY hash) + + let unapproved_project_a = api + .create_project( + get_public_project_creation_data( + "unapproved_a", + Some(test_file_hash_xxx.clone()), + None, + ), + USER_USER_PAT, + ) + .await; + assert_status!(&unapproved_project_a, StatusCode::OK); + let unapproved_project_a = api + .get_project_deserialized("unapproved_a", USER_USER_PAT) + .await; + + let unapproved_project_b = api + .create_project( + get_public_project_creation_data( + "unapproved_b", + Some(test_file_hash_xxx.clone()), + None, + ), + USER_USER_PAT, + ) + .await; + assert_status!(&unapproved_project_b, StatusCode::OK); + let unapproved_project_b = api + .get_project_deserialized("unapproved_b", USER_USER_PAT) + .await; + + let unapproved_project_c = api + .create_project( + get_public_project_creation_data( + "unapproved_c", + Some(test_file_hash_yyy.clone()), + None, + ), + USER_USER_PAT, + ) + .await; + assert_status!(&unapproved_project_c, StatusCode::OK); + let unapproved_project_c = api + .get_project_deserialized("unapproved_c", USER_USER_PAT) + .await; + + let approved_project_d = api + .create_project( + get_public_project_creation_data("approved_d", None, None), + USER_USER_PAT, + ) + .await; + assert_status!(&approved_project_d, StatusCode::OK); + let approved_project_d = api + .get_project_deserialized("approved_d", USER_USER_PAT) + .await; + + // Approve as a moderator. + let resp = api + .edit_project( + &approved_project_d.id.to_string(), + serde_json::json!({"status": "approved"}), + MOD_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // 1. Approve one of the projects (A), should succeed + let resp = api + .edit_project( + &unapproved_project_a.id.to_string(), + serde_json::json!({"status": "approved"}), + MOD_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // 2. Approve the other project (B), should fail- hash collision! + let resp = api + .edit_project( + &unapproved_project_b.id.to_string(), + serde_json::json!({"status": "approved"}), + MOD_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + + // 3. Attempt to add a version with XXX to the approved project (D), should fail- hash collision! + let resp = api + .add_public_version( + approved_project_d.id, + "1.0.0", + test_file_hash_xxx.clone(), + None, + None, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + + // 4. Attempt to add a version with YYY to the approved project (D), should succeed + let resp = api + .add_public_version( + approved_project_d.id, + "1.0.0", + test_file_hash_yyy.clone(), + None, + None, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::OK); + + // 5. Approve the other project (C), should fail- hash collision! + let resp = api + .edit_project( + &unapproved_project_c.id.to_string(), + serde_json::json!({"status": "approved"}), + MOD_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + }) + .await; +} + +// Has some redundant testing with version_file_hash_collisions_approving, but tests the profile side of things +#[actix_rt::test] +async fn version_file_hash_collisions_approving_with_profile() { + // Test setup and dummy data + with_test_environment(None, |test_env: TestEnvironment| async move { + // Set up three projects with colliding hashes + // A: unapproved version file with XXX hash + // C: approved project with no versions (but will contain YYY hash) + // Also, set up a shared profile that contains an overrides with XXX hash and YYY hash + let api = &test_env.api; + let test_file_hash_xxx = TestFile::build_random_jar(); + let test_file_hash_yyy = TestFile::build_random_jar(); + + let unapproved_project_a = api + .create_project( + get_public_project_creation_data( + "unapproved_a", + Some(test_file_hash_xxx.clone()), + None, + ), + USER_USER_PAT, + ) + .await; + assert_status!(&unapproved_project_a, StatusCode::OK); + let unapproved_project_a = api + .get_project_deserialized("unapproved_a", USER_USER_PAT) + .await; + + let approved_project_c = api + .create_project( + get_public_project_creation_data("approved_c", None, None), + USER_USER_PAT, + ) + .await; + assert_status!(&approved_project_c, StatusCode::OK); + let approved_project_c = api + .get_project_deserialized("approved_c", USER_USER_PAT) + .await; + + // Approve as a moderator. + let resp = api + .edit_project( + "approved_c", + serde_json::json!({"status": "approved"}), + MOD_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + let existing_profile = api + .create_client_profile( + "existing", + "fabric", + "1.0.0", + "1.20.1", + vec![], + USER_USER_PAT, + ) + .await; + assert_status!(&existing_profile, StatusCode::OK); + let existing_profile: ClientProfile = test::read_body_json(existing_profile).await; + + // Attempt to add overrides for XXX and YYY to the shared profile, should succeed + let resp = api + .add_client_profile_overrides( + &existing_profile.id.to_string(), + vec![ + ClientProfileOverride::new(test_file_hash_xxx.clone(), "mods/test0.jar"), + ClientProfileOverride::new(test_file_hash_yyy.clone(), "mods/test1.jar"), + ], + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // Approve one of the projects (A), should succeed + let resp = api + .edit_project( + "unapproved_a", + serde_json::json!({"status": "approved"}), + MOD_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // Shared profile should have its XXX override file removed and converted to a version matching + let version_for_a = api + .get_version_deserialized(&unapproved_project_a.versions[0].to_string(), USER_USER_PAT) + .await; + let profile_downloads = api + .download_client_profile_from_profile_id_deserialized( + &existing_profile.id.to_string(), + USER_USER_PAT, + ) + .await; + assert_eq!( + profile_downloads + .override_cdns + .into_iter() + .map(|(_, path)| path) + .collect::>(), + [PathBuf::from("mods/test1.jar")] + .iter() + .cloned() + .collect::>() + ); + assert_eq!(profile_downloads.version_ids, vec![version_for_a.id]); + + // Attempt to add a version with YYY to the approved project (C), should succeed + let resp = api + .add_public_version( + approved_project_c.id, + "1.0.0", + test_file_hash_yyy.clone(), + None, + None, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::OK); + + // Get the profile again, should have a version now + let approved_project_c = api + .get_project_deserialized(&approved_project_c.slug.unwrap(), USER_USER_PAT) + .await; + + // Shared profile should have its YYY override file removed and converted to a version matching + let version_for_c = api + .get_version_deserialized(&approved_project_c.versions[0].to_string(), USER_USER_PAT) + .await; + let profile_downloads = api + .download_client_profile_from_profile_id_deserialized( + &existing_profile.id.to_string(), + USER_USER_PAT, + ) + .await; + assert_eq!( + profile_downloads + .override_cdns + .into_iter() + .map(|(_, path)| path) + .collect::>(), + HashSet::::new() + ); + assert_eq!( + profile_downloads.version_ids, + vec![version_for_a.id, version_for_c.id] + ); + }) + .await; +} + +// TODO: Should we allow multiple overrides at the same path? +// TODO: Potentially setup a filesystem test to ensure that the files are actually being uploaded to the CDN diff --git a/tests/project.rs b/tests/project.rs index 7ef74e3f..40666271 100644 --- a/tests/project.rs +++ b/tests/project.rs @@ -177,6 +177,18 @@ async fn test_add_remove_project() { assert!(project.versions.len() == 1); let uploaded_version_id = project.versions[0]; + // Approve the project, which 'claims' the hash + let resp = api + .edit_project( + "demo", + json!({ + "status": "approved", + }), + MOD_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + // Checks files to ensure they were uploaded and correctly identify the file let hash = sha1::Sha1::from(basic_mod_file.bytes()) .digest() @@ -191,7 +203,7 @@ async fn test_add_remove_project() { let resp = api .create_project( ProjectCreationRequestData { - slug: "demo".to_string(), + slug: "".to_string(), // Slug not needed at this point segment_data: vec![ json_diff_slug_file_segment.clone(), file_diff_name_segment.clone(), @@ -207,7 +219,7 @@ async fn test_add_remove_project() { let resp = api .create_project( ProjectCreationRequestData { - slug: "demo".to_string(), + slug: "".to_string(), // Slug not needed at this point segment_data: vec![ json_diff_file_segment.clone(), file_diff_name_content_segment.clone(), @@ -223,7 +235,7 @@ async fn test_add_remove_project() { let resp = api .create_project( ProjectCreationRequestData { - slug: "demo".to_string(), + slug: "".to_string(), // Slug not needed at this point segment_data: vec![ json_diff_slug_file_segment.clone(), file_diff_name_content_segment.clone(), diff --git a/tests/scopes.rs b/tests/scopes.rs index 7ebc637b..7d012837 100644 --- a/tests/scopes.rs +++ b/tests/scopes.rs @@ -379,6 +379,29 @@ pub async fn project_version_reads_scopes() { .await .unwrap(); + // As moderator, approve the project then set it to private + // We need to approve it to claim the hash for 'get_version_from_hash' and such to work. + // We end with private, and the version remains 'draft' later so we can test the scope. + let resp = test_env + .api + .edit_project( + beta_project_id, + json!({ "status": "approved" }), + MOD_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + let resp = test_env + .api + .edit_project( + beta_project_id, + json!({ "status": "private" }), + MOD_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + // Version reading // First, set version to hidden (which is when the scope is required to read it) let read_version = Scopes::VERSION_READ; diff --git a/tests/v2/project.rs b/tests/v2/project.rs index 1352ee1f..dcef8d2b 100644 --- a/tests/v2/project.rs +++ b/tests/v2/project.rs @@ -6,7 +6,8 @@ use crate::{ api_common::{ApiProject, ApiVersion, AppendsOptionalPat}, api_v2::{request_data::get_public_project_creation_data_json, ApiV2}, database::{ - generate_random_name, ADMIN_USER_PAT, FRIEND_USER_ID, FRIEND_USER_PAT, USER_USER_PAT, + generate_random_name, ADMIN_USER_PAT, FRIEND_USER_ID, FRIEND_USER_PAT, MOD_USER_PAT, + USER_USER_PAT, }, dummy_data::TestFile, environment::{with_test_environment, TestEnvironment}, @@ -174,6 +175,18 @@ async fn test_add_remove_project() { assert!(project.versions.len() == 1); let uploaded_version_id = project.versions[0]; + // Approve the project, which 'claims' the hash + let resp = api + .edit_project( + "demo", + json!({ + "status": "approved", + }), + MOD_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + // Checks files to ensure they were uploaded and correctly identify the file let hash = sha1::Sha1::from(basic_mod_file.bytes()) .digest() diff --git a/tests/v2/version.rs b/tests/v2/version.rs index c4eceea1..770f2ad4 100644 --- a/tests/v2/version.rs +++ b/tests/v2/version.rs @@ -145,7 +145,6 @@ async fn version_updates() { .. } = &test_env.dummy.project_alpha; let DummyProjectBeta { - version_id: beta_version_id, file_hash: beta_version_hash, .. } = &test_env.dummy.project_beta; @@ -164,12 +163,11 @@ async fn version_updates() { USER_USER_PAT, ) .await; - assert_eq!(versions.len(), 2); + assert_eq!(versions.len(), 1); // Beta version should not be returned, not approved yet and hasn't claimed hash assert_eq!( &versions[alpha_version_hash].id.to_string(), alpha_version_id ); - assert_eq!(&versions[beta_version_hash].id.to_string(), beta_version_id); // When there is only the one version, there should be no updates let version = api diff --git a/tests/version.rs b/tests/version.rs index de587831..a235f86a 100644 --- a/tests/version.rs +++ b/tests/version.rs @@ -6,6 +6,8 @@ use crate::common::dummy_data::{DummyProjectAlpha, DummyProjectBeta, TestFile}; use crate::common::get_json_val_str; use actix_http::StatusCode; use actix_web::test; +use common::api_common::ApiProject; +use common::api_v3::request_data::get_public_project_creation_data; use common::api_v3::ApiV3; use common::asserts::assert_common_version_ids; use common::database::USER_USER_PAT; @@ -14,7 +16,7 @@ use futures::StreamExt; use labrinth::database::models::version_item::VERSIONS_NAMESPACE; use labrinth::models::ids::base62_impl::parse_base62; use labrinth::models::projects::{ - Dependency, DependencyType, VersionId, VersionStatus, VersionType, + Dependency, DependencyType, Project, VersionId, VersionStatus, VersionType, }; use labrinth::routes::v3::version_file::FileUpdateData; use serde_json::json; @@ -81,6 +83,138 @@ async fn test_get_version() { .await; } +#[actix_rt::test] +async fn version_updates_non_public_is_nothing() { + // Test setup and dummy data + with_test_environment( + None, + |test_env: common::environment::TestEnvironment| async move { + // Confirm that hash-finding functions return nothing for versions attached to non-public projects + // This is a necessity because now hashes do not uniquely identify versions, and these functions expect being able to find a single version from a hash + // This is the case even if we are the owner of the project/versions + let api = &test_env.api; + + // First, create project gamma, which is private + let gamma_creation_data = get_public_project_creation_data("gamma", None, None); + let gamma_project = api.create_project(gamma_creation_data, USER_USER_PAT).await; + let gamma_project: Project = test::read_body_json(gamma_project).await; + + let mut sha1_hashes = vec![]; + // Create 5 versions, and we will add them to both beta and gamma + for i in 0..5 { + let file = TestFile::build_random_jar(); + let version = api + .add_public_version_deserialized( + gamma_project.id, + &format!("1.2.3.{}", i), + file.clone(), + None, + None, + USER_USER_PAT, + ) + .await; + + api.add_public_version_deserialized( + test_env.dummy.project_beta.project_id_parsed, + &format!("1.2.3.{}", i), + file.clone(), + None, + None, + USER_USER_PAT, + ) + .await; + + sha1_hashes.push(version.files[0].hashes["sha1"].clone()); + } + + for approved in [false, true] { + // get_version_from_hash + let resp = api + .get_version_from_hash(&sha1_hashes[0], "sha1", USER_USER_PAT) + .await; + if approved { + assert_status!(&resp, StatusCode::OK); + } else { + assert_status!(&resp, StatusCode::NOT_FOUND); + } + + // get_versions_from_hashes + let resp = api + .get_versions_from_hashes(&[&sha1_hashes[0]], "sha1", USER_USER_PAT) + .await; + assert_status!(&resp, StatusCode::OK); + let body: HashMap = test::read_body_json(resp).await; + if approved { + assert_eq!(body.len(), 1); + } else { + assert_eq!(body.len(), 0); + } + + // get_update_from_hash + let resp = api + .get_update_from_hash(&sha1_hashes[0], "sha1", None, None, None, USER_USER_PAT) + .await; + if approved { + assert_status!(&resp, StatusCode::OK); + } else { + assert_status!(&resp, StatusCode::NOT_FOUND); + } + + // update_files + let resp = api + .update_files( + "sha1", + vec![sha1_hashes[0].clone()], + None, + None, + None, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::OK); + let body: HashMap = test::read_body_json(resp).await; + if approved { + assert_eq!(body.len(), 1); + } else { + assert_eq!(body.len(), 0); + } + + // update_individual_files + let hashes = vec![FileUpdateData { + hash: sha1_hashes[0].clone(), + loaders: None, + loader_fields: None, + version_types: None, + }]; + let resp = api + .update_individual_files("sha1", hashes, USER_USER_PAT) + .await; + assert_status!(&resp, StatusCode::OK); + + let body: HashMap = test::read_body_json(resp).await; + if approved { + assert_eq!(body.len(), 1); + } else { + assert_eq!(body.len(), 0); + } + + // Now, make the project public for the next loop, and confirm that the functions work + if !approved { + api.edit_project( + &gamma_project.id.to_string(), + json!({ + "status": "approved", + }), + MOD_USER_PAT, + ) + .await; + } + } + }, + ) + .await; +} + #[actix_rt::test] async fn version_updates() { // Test setup and dummy data @@ -96,7 +230,6 @@ async fn version_updates() { .. } = &test_env.dummy.project_alpha; let DummyProjectBeta { - version_id: beta_version_id, file_hash: beta_version_hash, .. } = &test_env.dummy.project_beta; @@ -119,12 +252,11 @@ async fn version_updates() { USER_USER_PAT, ) .await; - assert_eq!(versions.len(), 2); + assert_eq!(versions.len(), 1); // Beta version should not be returned, not approved yet and hasn't claimed hash assert_eq!( &versions[alpha_version_hash].id.to_string(), alpha_version_id ); - assert_eq!(&versions[beta_version_hash].id.to_string(), beta_version_id); // When there is only the one version, there should be no updates let version = api @@ -182,11 +314,12 @@ async fn version_updates() { ] .iter() { + let file = TestFile::build_random_jar(); let version = api .add_public_version_deserialized( *alpha_project_id_parsed, version_number, - TestFile::build_random_jar(), + file.clone(), None, None, USER_USER_PAT, From 388e81a928b769caeb15f54b757d8df294f6022f Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Mon, 29 Jan 2024 16:00:41 -0800 Subject: [PATCH 23/25] adds user route --- src/routes/internal/client/profiles.rs | 39 ++++++++++++++++++ tests/common/api_v3/client_profile.rs | 17 ++++++++ tests/profiles.rs | 55 ++++++++++++++++++++++++++ 3 files changed, 111 insertions(+) diff --git a/src/routes/internal/client/profiles.rs b/src/routes/internal/client/profiles.rs index f1d1a03f..4ec9fe37 100644 --- a/src/routes/internal/client/profiles.rs +++ b/src/routes/internal/client/profiles.rs @@ -44,6 +44,8 @@ pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("client") .route("profile", web::post().to(profile_create)) + .route("profiles", web::get().to(profiles_get)) + .route("user", web::get().to(user_profiles_get)) .route("check_token", web::get().to(profile_token_check)) .service( web::scope("share") @@ -258,6 +260,43 @@ pub async fn profiles_get( Ok(HttpResponse::Ok().json(profiles)) } +// Get all a user's client profiles +pub async fn user_profiles_get( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user_id = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + None, // No scopes required to read your own links + ) + .await + .ok() + .map(|x| x.1.id.into()); + + let profile_ids = database::models::client_profile_item::ClientProfile::get_ids_for_user( + user_id.unwrap(), + &**pool, + ) + .await?; + let profiles_data = database::models::client_profile_item::ClientProfile::get_many( + &profile_ids, + &**pool, + &redis, + ) + .await?; + let profiles = profiles_data + .into_iter() + .map(|x| ClientProfile::from(x, user_id)) + .collect::>(); + + Ok(HttpResponse::Ok().json(profiles)) +} + // Get a client profile by its id pub async fn profile_get( req: HttpRequest, diff --git a/tests/common/api_v3/client_profile.rs b/tests/common/api_v3/client_profile.rs index 5c555637..164f2ddd 100644 --- a/tests/common/api_v3/client_profile.rs +++ b/tests/common/api_v3/client_profile.rs @@ -134,6 +134,23 @@ impl ApiV3 { test::read_body_json(resp).await } + pub async fn get_user_client_profiles(&self, pat: Option<&str>) -> ServiceResponse { + let req = TestRequest::get() + .uri("/_internal/client/user") + .append_pat(pat) + .to_request(); + self.call(req).await + } + + pub async fn get_user_client_profiles_deserialized( + &self, + pat: Option<&str>, + ) -> Vec { + let resp = self.get_user_client_profiles(pat).await; + assert_status!(&resp, StatusCode::OK); + test::read_body_json(resp).await + } + pub async fn delete_client_profile(&self, id: &str, pat: Option<&str>) -> ServiceResponse { let req = TestRequest::delete() .uri(&format!("/_internal/client/profile/{}", id)) diff --git a/tests/profiles.rs b/tests/profiles.rs index 56193aca..a8b114b2 100644 --- a/tests/profiles.rs +++ b/tests/profiles.rs @@ -272,12 +272,26 @@ async fn accept_share_link() { assert_eq!(users.len(), 1); assert_eq!(users[0].0, USER_USER_ID_PARSED as u64); + // Getting user's profiles should return the profile + let profiles = api + .get_user_client_profiles_deserialized(USER_USER_PAT) + .await; + assert_eq!(profiles.len(), 1); + assert_eq!(profiles[0].id.to_string(), id); + assert_eq!(profiles[0].owner_id.to_string(), USER_USER_ID); + // Friend can't see the profile users, links, versions, install paths yet, but can see the profile let profile = api .get_client_profile_deserialized(&id, FRIEND_USER_PAT) .await; assert_eq!(profile.users, None); + // Getting friend's profiles should not return the profile + let profiles = api + .get_user_client_profiles_deserialized(FRIEND_USER_PAT) + .await; + assert_eq!(profiles.len(), 0); + // As 'user', try to generate a download link for the profile let share_link = api .generate_client_profile_share_link_deserialized(&id, USER_USER_PAT) @@ -306,6 +320,12 @@ async fn accept_share_link() { assert_eq!(users[0].0, USER_USER_ID_PARSED as u64); assert_eq!(users[1].0, FRIEND_USER_ID_PARSED as u64); + // Getting friend's profiles should return the profile + let profiles = api + .get_user_client_profiles_deserialized(FRIEND_USER_PAT) + .await; + assert_eq!(profiles.len(), 1); + // Add all of test dummy users until we hit the limit let dummy_user_pats = [ USER_USER_PAT, // Fails because owner (and already added) @@ -346,6 +366,41 @@ async fn accept_share_link() { .get_client_profile_deserialized(&id, USER_USER_PAT) .await; assert_eq!(profile.share_links.unwrap().len(), 0); + + // Friend still has the profile + let profiles = api + .get_user_client_profiles_deserialized(USER_USER_PAT) + .await; + assert_eq!(profiles.len(), 1); + let profiles = api + .get_user_client_profiles_deserialized(FRIEND_USER_PAT) + .await; + assert_eq!(profiles.len(), 1); + + // Remove friend + let resp = api + .edit_client_profile( + &id, + None, + None, + None, + None, + Some(vec![FRIEND_USER_ID]), + None, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // Confirm friend is no longer on the profile + let profiles = api + .get_user_client_profiles_deserialized(USER_USER_PAT) + .await; + assert_eq!(profiles.len(), 1); + let profiles = api + .get_user_client_profiles_deserialized(FRIEND_USER_PAT) + .await; + assert_eq!(profiles.len(), 0); }) .await; } From 4a8bcbfb8ea3b2e0999c7831659532c96ef37542 Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Mon, 29 Jan 2024 17:29:37 -0800 Subject: [PATCH 24/25] temporary testing reversion --- migrations/20231226012200_shared_modpacks.sql | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/migrations/20231226012200_shared_modpacks.sql b/migrations/20231226012200_shared_modpacks.sql index 7324520b..b40883cc 100644 --- a/migrations/20231226012200_shared_modpacks.sql +++ b/migrations/20231226012200_shared_modpacks.sql @@ -49,11 +49,16 @@ CREATE TABLE versions_files ( -- Populate with the previously named 'version_id' column of the files table INSERT INTO versions_files (version_id, file_id, is_primary) -SELECT version_id, id, is_primary FROM files; +-- NOTE: Temporarily disabled due to unexpected data issue with staging data. Should be enabled before merging, and the issue should be resolved. +--SELECT version_id, id, is_primary FROM files; +SELECT v.id, f.id, is_primary FROM files f LEFT JOIN versions v ON f.version_id = v.id WHERE v.id is not null; + -- Drop the version_id and is_primary columns from the files table ALTER TABLE files DROP COLUMN version_id; ALTER TABLE files DROP COLUMN is_primary; -- Adds a unique index based on the 'algorithm' and 'hash' pair on the hashes table -CREATE UNIQUE INDEX hashes_algorithm_hash_unique ON hashes (algorithm, hash); \ No newline at end of file +-- NOTE: Temporarily disabled due to unexpected data issue with staging data. Should be enabled before merging, and the issue should be resolved. +-- In essence, there are hash collisions where there shouldn't be- entire file duplicates where the file url, version_id, etc, all are the same except for the file_id. +-- CREATE UNIQUE INDEX hashes_algorithm_hash_unique ON hashes (algorithm, hash); \ No newline at end of file From 7e3199dd46e296eb8cc1e5ae5f15833fb02b4144 Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Tue, 30 Jan 2024 11:13:14 -0800 Subject: [PATCH 25/25] adds hashes to return --- src/routes/internal/client/profiles.rs | 18 +++++++++++++++--- tests/profiles.rs | 22 +++++++++++----------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/src/routes/internal/client/profiles.rs b/src/routes/internal/client/profiles.rs index 4ec9fe37..e8815ca2 100644 --- a/src/routes/internal/client/profiles.rs +++ b/src/routes/internal/client/profiles.rs @@ -36,6 +36,7 @@ use rand_chacha::ChaCha20Rng; use serde::{Deserialize, Serialize}; use sha2::Digest; use sqlx::PgPool; +use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use validator::Validate; @@ -774,14 +775,21 @@ pub async fn accept_share_link( Ok(HttpResponse::NoContent().finish()) } -#[derive(Serialize, Deserialize, PartialEq, Eq, Hash, Clone, Debug)] +#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug)] pub struct ProfileDownload { // Version ids for modrinth-hosted versions pub version_ids: Vec, // The override cdns for the profile: // (cdn url, install path) - pub override_cdns: Vec<(String, PathBuf)>, + pub override_cdns: Vec, +} + +#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug)] +pub struct ProfileOverride { + pub url: String, + pub install_path: PathBuf, + pub hashes: HashMap, } // Download a client profile (gets files) @@ -827,7 +835,11 @@ pub async fn profile_files( let override_cdns = profile .override_files .into_iter() - .map(|x| (x.url, x.install_path)) + .map(|x| ProfileOverride { + url: x.url, + install_path: x.install_path, + hashes: x.hashes, + }) .collect::>(); Ok(HttpResponse::Ok().json(ProfileDownload { diff --git a/tests/profiles.rs b/tests/profiles.rs index a8b114b2..d872a5ac 100644 --- a/tests/profiles.rs +++ b/tests/profiles.rs @@ -477,7 +477,7 @@ async fn delete_profile() { // Confirm it works let resp = api - .check_download_client_profile_token(&token.override_cdns[0].0, FRIEND_USER_PAT) + .check_download_client_profile_token(&token.override_cdns[0].url, FRIEND_USER_PAT) .await; assert_status!(&resp, StatusCode::OK); @@ -497,7 +497,7 @@ async fn delete_profile() { // Confirm the token is gone let resp = api - .check_download_client_profile_token(&token.override_cdns[0].0, FRIEND_USER_PAT) + .check_download_client_profile_token(&token.override_cdns[0].url, FRIEND_USER_PAT) .await; assert_status!(&resp, StatusCode::UNAUTHORIZED); }) @@ -571,7 +571,7 @@ async fn download_profile() { // "custom_files" // - hash assert_eq!(download.override_cdns.len(), 1); - let override_file_url = download.override_cdns.remove(0).0; + let override_file_url = download.override_cdns.remove(0).url; let hash = format!("{:x}", sha2::Sha512::digest(&TestFile::BasicMod.bytes())); assert_eq!( override_file_url, @@ -768,7 +768,7 @@ async fn add_remove_profile_versions() { profile_downloads .override_cdns .into_iter() - .map(|(_, path)| path) + .map(|x| x.install_path) .collect::>(), [ PathBuf::from("mods/test.jar"), @@ -816,7 +816,7 @@ async fn add_remove_profile_versions() { profile_enemy .override_cdns .into_iter() - .map(|(_, path)| path) + .map(|x| x.install_path) .collect::>(), vec![PathBuf::from("mods/test.jar")] ); @@ -841,7 +841,7 @@ async fn add_remove_profile_versions() { profile_enemy_downloads .override_cdns .into_iter() - .map(|(_, path)| path) + .map(|x| x.install_path) .collect::>(), vec![PathBuf::from("mods/test.jar")] ); @@ -856,7 +856,7 @@ async fn add_remove_profile_versions() { profile_downloads .override_cdns .into_iter() - .map(|(_, path)| path) + .map(|x| x.install_path) .collect::>(), vec![PathBuf::from("mods/test_different.jar")] ); @@ -910,7 +910,7 @@ async fn add_remove_profile_versions() { profile_downloads .override_cdns .into_iter() - .map(|(_, path)| path) + .map(|x| x.install_path) .collect::>(), vec![PathBuf::from("mods/test_different.jar")] ); @@ -945,7 +945,7 @@ async fn add_remove_profile_versions() { profile_downloads .override_cdns .into_iter() - .map(|(_, path)| path) + .map(|x| x.install_path) .collect::>(), Vec::::new() ); @@ -1482,7 +1482,7 @@ async fn version_file_hash_collisions_approving_with_profile() { profile_downloads .override_cdns .into_iter() - .map(|(_, path)| path) + .map(|x| x.install_path) .collect::>(), [PathBuf::from("mods/test1.jar")] .iter() @@ -1523,7 +1523,7 @@ async fn version_file_hash_collisions_approving_with_profile() { profile_downloads .override_cdns .into_iter() - .map(|(_, path)| path) + .map(|x| x.install_path) .collect::>(), HashSet::::new() );