diff --git a/packages/app-lib/migrations/20250413162050_skin-selector.sql b/packages/app-lib/migrations/20250413162050_skin-selector.sql index 5829ee33d..f76e667b4 100644 --- a/packages/app-lib/migrations/20250413162050_skin-selector.sql +++ b/packages/app-lib/migrations/20250413162050_skin-selector.sql @@ -15,7 +15,9 @@ CREATE TABLE custom_minecraft_skins ( PRIMARY KEY (minecraft_user_uuid, texture_key, variant, cape_id), FOREIGN KEY (minecraft_user_uuid) REFERENCES minecraft_users(uuid) - ON DELETE CASCADE ON UPDATE CASCADE + ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY (texture_key) REFERENCES custom_minecraft_skin_textures(texture_key) + ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED ); CREATE TABLE custom_minecraft_skin_textures ( @@ -25,16 +27,6 @@ CREATE TABLE custom_minecraft_skin_textures ( PRIMARY KEY (texture_key) ); --- Use triggers to emulate partial cascading foreign key constraints on the custom_minecraft_skin_textures table - -CREATE TRIGGER custom_minecraft_skin_texture_insertion_validation - BEFORE INSERT ON custom_minecraft_skin_textures FOR EACH ROW - BEGIN - SELECT CASE WHEN NOT EXISTS ( - SELECT 1 FROM custom_minecraft_skins WHERE texture_key = NEW.texture_key - ) THEN RAISE(ABORT, 'Missing custom skin for the specified skin texture key') END; - END; - CREATE TRIGGER custom_minecraft_skin_texture_delete_cleanup AFTER DELETE ON custom_minecraft_skins FOR EACH ROW BEGIN @@ -42,9 +34,3 @@ CREATE TRIGGER custom_minecraft_skin_texture_delete_cleanup SELECT texture_key FROM custom_minecraft_skins ); END; - -CREATE TRIGGER custom_minecraft_skin_texture_update_cleanup - AFTER UPDATE OF texture_key ON custom_minecraft_skins FOR EACH ROW - BEGIN - UPDATE custom_minecraft_skin_textures SET texture_key = NEW.texture_key WHERE texture_key = OLD.texture_key; - END; diff --git a/packages/app-lib/src/api/minecraft_skins.rs b/packages/app-lib/src/api/minecraft_skins.rs index bb2646ae9..8a759d39d 100644 --- a/packages/app-lib/src/api/minecraft_skins.rs +++ b/packages/app-lib/src/api/minecraft_skins.rs @@ -262,6 +262,11 @@ pub async fn add_and_equip_custom_skin( variant: MinecraftSkinVariant, cape_override: Option, ) -> crate::Result<()> { + let (skin_width, skin_height) = png_dimensions(&texture_blob)?; + if skin_width != 64 || ![32, 64].contains(&skin_height) { + return Err(ErrorKind::InvalidSkinTexture)?; + } + let cape_override = cape_override.map(|cape| cape.id); let state = State::get().await?; @@ -271,7 +276,7 @@ pub async fn add_and_equip_custom_skin( // We have to equip the skin first, as it's the Mojang API backend who knows // how to compute the texture key we require, which we can then read from the - // updated player profile. This also ensures the skin data is indeed valid + // updated player profile mojang_api::MinecraftSkinOperation::equip( &selected_credentials, stream::iter([Ok::<_, String>(Bytes::clone(&texture_blob))]), @@ -479,13 +484,15 @@ async fn sync_cape( Ok(()) } -fn texture_blob_to_data_url(texture_blob: Option>) -> Arc { - let data = texture_blob.map_or( +fn texture_blob_to_data_url(texture_blob: Vec) -> Arc { + let data = if is_png(&texture_blob) { + Cow::Owned(texture_blob) + } else { + // Fall back to a placeholder texture if the DB somehow contains corrupt data Cow::Borrowed( &include_bytes!("minecraft_skins/assets/default/MissingNo.png")[..], - ), - Cow::Owned, - ); + ) + }; Url::parse(&format!( "data:image/png;base64,{}", @@ -494,3 +501,39 @@ fn texture_blob_to_data_url(texture_blob: Option>) -> Arc { .unwrap() .into() } + +fn is_png(data: &[u8]) -> bool { + /// The initial 8 bytes of a PNG file, used to identify it as such. + /// + /// Reference: + const PNG_SIGNATURE: &[u8] = + &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + + data.starts_with(PNG_SIGNATURE) +} + +fn png_dimensions(data: &[u8]) -> crate::Result<(u32, u32)> { + if !is_png(data) { + Err(ErrorKind::InvalidPng)?; + } + + // Read the width and height fields from the IHDR chunk, which the + // PNG specification mandates to be the first in the file, just after + // the 8 signature bytes. See: + // https://www.w3.org/TR/png-3/#5DataRep + // https://www.w3.org/TR/png-3/#11IHDR + let width = u32::from_be_bytes( + data.get(16..20) + .ok_or(ErrorKind::InvalidPng)? + .try_into() + .unwrap(), + ); + let height = u32::from_be_bytes( + data.get(20..24) + .ok_or(ErrorKind::InvalidPng)? + .try_into() + .unwrap(), + ); + + Ok((width, height)) +} diff --git a/packages/app-lib/src/error.rs b/packages/app-lib/src/error.rs index abb22a0db..d38c10c3f 100644 --- a/packages/app-lib/src/error.rs +++ b/packages/app-lib/src/error.rs @@ -137,6 +137,14 @@ pub enum ErrorKind { #[error("Invalid data URL: {0}")] InvalidDataUrlBase64(#[from] data_url::forgiving_base64::InvalidBase64), + + #[error("Invalid PNG")] + InvalidPng, + + #[error( + "A skin texture must have a dimension of either 64x64 or 64x32 pixels" + )] + InvalidSkinTexture, } #[derive(Debug)] diff --git a/packages/app-lib/src/state/minecraft_skins/mod.rs b/packages/app-lib/src/state/minecraft_skins/mod.rs index ee65f0344..47c64f1a2 100644 --- a/packages/app-lib/src/state/minecraft_skins/mod.rs +++ b/packages/app-lib/src/state/minecraft_skins/mod.rs @@ -150,12 +150,12 @@ impl CustomMinecraftSkin { pub async fn texture_blob( &self, db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>, - ) -> crate::Result>> { + ) -> crate::Result> { Ok(sqlx::query_scalar!( "SELECT texture FROM custom_minecraft_skin_textures WHERE texture_key = ?", self.texture_key ) - .fetch_optional(&mut *db.acquire().await?) + .fetch_one(&mut *db.acquire().await?) .await?) }