Skip to content

Commit 1213562

Browse files
committed
enh(app/skin-selector): better DB intension through deferred FKs, further PNG validations
1 parent 12849ed commit 1213562

File tree

4 files changed

+62
-25
lines changed

4 files changed

+62
-25
lines changed

packages/app-lib/migrations/20250413162050_skin-selector.sql

+3-17
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ CREATE TABLE custom_minecraft_skins (
1515

1616
PRIMARY KEY (minecraft_user_uuid, texture_key, variant, cape_id),
1717
FOREIGN KEY (minecraft_user_uuid) REFERENCES minecraft_users(uuid)
18-
ON DELETE CASCADE ON UPDATE CASCADE
18+
ON DELETE CASCADE ON UPDATE CASCADE,
19+
FOREIGN KEY (texture_key) REFERENCES custom_minecraft_skin_textures(texture_key)
20+
ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED
1921
);
2022

2123
CREATE TABLE custom_minecraft_skin_textures (
@@ -25,26 +27,10 @@ CREATE TABLE custom_minecraft_skin_textures (
2527
PRIMARY KEY (texture_key)
2628
);
2729

28-
-- Use triggers to emulate partial cascading foreign key constraints on the custom_minecraft_skin_textures table
29-
30-
CREATE TRIGGER custom_minecraft_skin_texture_insertion_validation
31-
BEFORE INSERT ON custom_minecraft_skin_textures FOR EACH ROW
32-
BEGIN
33-
SELECT CASE WHEN NOT EXISTS (
34-
SELECT 1 FROM custom_minecraft_skins WHERE texture_key = NEW.texture_key
35-
) THEN RAISE(ABORT, 'Missing custom skin for the specified skin texture key') END;
36-
END;
37-
3830
CREATE TRIGGER custom_minecraft_skin_texture_delete_cleanup
3931
AFTER DELETE ON custom_minecraft_skins FOR EACH ROW
4032
BEGIN
4133
DELETE FROM custom_minecraft_skin_textures WHERE texture_key NOT IN (
4234
SELECT texture_key FROM custom_minecraft_skins
4335
);
4436
END;
45-
46-
CREATE TRIGGER custom_minecraft_skin_texture_update_cleanup
47-
AFTER UPDATE OF texture_key ON custom_minecraft_skins FOR EACH ROW
48-
BEGIN
49-
UPDATE custom_minecraft_skin_textures SET texture_key = NEW.texture_key WHERE texture_key = OLD.texture_key;
50-
END;

packages/app-lib/src/api/minecraft_skins.rs

+49-6
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,11 @@ pub async fn add_and_equip_custom_skin(
262262
variant: MinecraftSkinVariant,
263263
cape_override: Option<Cape>,
264264
) -> crate::Result<()> {
265+
let (skin_width, skin_height) = png_dimensions(&texture_blob)?;
266+
if skin_width != 64 || ![32, 64].contains(&skin_height) {
267+
return Err(ErrorKind::InvalidSkinTexture)?;
268+
}
269+
265270
let cape_override = cape_override.map(|cape| cape.id);
266271
let state = State::get().await?;
267272

@@ -271,7 +276,7 @@ pub async fn add_and_equip_custom_skin(
271276

272277
// We have to equip the skin first, as it's the Mojang API backend who knows
273278
// how to compute the texture key we require, which we can then read from the
274-
// updated player profile. This also ensures the skin data is indeed valid
279+
// updated player profile
275280
mojang_api::MinecraftSkinOperation::equip(
276281
&selected_credentials,
277282
stream::iter([Ok::<_, String>(Bytes::clone(&texture_blob))]),
@@ -479,13 +484,15 @@ async fn sync_cape(
479484
Ok(())
480485
}
481486

482-
fn texture_blob_to_data_url(texture_blob: Option<Vec<u8>>) -> Arc<Url> {
483-
let data = texture_blob.map_or(
487+
fn texture_blob_to_data_url(texture_blob: Vec<u8>) -> Arc<Url> {
488+
let data = if is_png(&texture_blob) {
489+
Cow::Owned(texture_blob)
490+
} else {
491+
// Fall back to a placeholder texture if the DB somehow contains corrupt data
484492
Cow::Borrowed(
485493
&include_bytes!("minecraft_skins/assets/default/MissingNo.png")[..],
486-
),
487-
Cow::Owned,
488-
);
494+
)
495+
};
489496

490497
Url::parse(&format!(
491498
"data:image/png;base64,{}",
@@ -494,3 +501,39 @@ fn texture_blob_to_data_url(texture_blob: Option<Vec<u8>>) -> Arc<Url> {
494501
.unwrap()
495502
.into()
496503
}
504+
505+
fn is_png(data: &[u8]) -> bool {
506+
/// The initial 8 bytes of a PNG file, used to identify it as such.
507+
///
508+
/// Reference: <https://www.w3.org/TR/png-3/#3PNGsignature>
509+
const PNG_SIGNATURE: &[u8] =
510+
&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
511+
512+
data.starts_with(PNG_SIGNATURE)
513+
}
514+
515+
fn png_dimensions(data: &[u8]) -> crate::Result<(u32, u32)> {
516+
if !is_png(data) {
517+
Err(ErrorKind::InvalidPng)?;
518+
}
519+
520+
// Read the width and height fields from the IHDR chunk, which the
521+
// PNG specification mandates to be the first in the file, just after
522+
// the 8 signature bytes. See:
523+
// https://www.w3.org/TR/png-3/#5DataRep
524+
// https://www.w3.org/TR/png-3/#11IHDR
525+
let width = u32::from_be_bytes(
526+
data.get(16..20)
527+
.ok_or(ErrorKind::InvalidPng)?
528+
.try_into()
529+
.unwrap(),
530+
);
531+
let height = u32::from_be_bytes(
532+
data.get(20..24)
533+
.ok_or(ErrorKind::InvalidPng)?
534+
.try_into()
535+
.unwrap(),
536+
);
537+
538+
Ok((width, height))
539+
}

packages/app-lib/src/error.rs

+8
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,14 @@ pub enum ErrorKind {
137137

138138
#[error("Invalid data URL: {0}")]
139139
InvalidDataUrlBase64(#[from] data_url::forgiving_base64::InvalidBase64),
140+
141+
#[error("Invalid PNG")]
142+
InvalidPng,
143+
144+
#[error(
145+
"A skin texture must have a dimension of either 64x64 or 64x32 pixels"
146+
)]
147+
InvalidSkinTexture,
140148
}
141149

142150
#[derive(Debug)]

packages/app-lib/src/state/minecraft_skins/mod.rs

+2-2
Original file line numberDiff line numberDiff line change
@@ -150,12 +150,12 @@ impl CustomMinecraftSkin {
150150
pub async fn texture_blob(
151151
&self,
152152
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
153-
) -> crate::Result<Option<Vec<u8>>> {
153+
) -> crate::Result<Vec<u8>> {
154154
Ok(sqlx::query_scalar!(
155155
"SELECT texture FROM custom_minecraft_skin_textures WHERE texture_key = ?",
156156
self.texture_key
157157
)
158-
.fetch_optional(&mut *db.acquire().await?)
158+
.fetch_one(&mut *db.acquire().await?)
159159
.await?)
160160
}
161161

0 commit comments

Comments
 (0)