enh(app/skin-selector): better DB intension through deferred FKs, further PNG validations

This commit is contained in:
Alejandro González 2025-04-17 21:23:59 +02:00
parent e189219407
commit a8226131d5
No known key found for this signature in database
4 changed files with 62 additions and 25 deletions

View File

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

View File

@ -262,6 +262,11 @@ pub async fn add_and_equip_custom_skin(
variant: MinecraftSkinVariant,
cape_override: Option<Cape>,
) -> 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<Vec<u8>>) -> Arc<Url> {
let data = texture_blob.map_or(
fn texture_blob_to_data_url(texture_blob: Vec<u8>) -> Arc<Url> {
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<Vec<u8>>) -> Arc<Url> {
.unwrap()
.into()
}
fn is_png(data: &[u8]) -> bool {
/// The initial 8 bytes of a PNG file, used to identify it as such.
///
/// Reference: <https://www.w3.org/TR/png-3/#3PNGsignature>
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))
}

View File

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

View File

@ -150,12 +150,12 @@ impl CustomMinecraftSkin {
pub async fn texture_blob(
&self,
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
) -> crate::Result<Option<Vec<u8>>> {
) -> crate::Result<Vec<u8>> {
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?)
}