fix(engine,server): safe area clamp, analytics batch, achievement save order, daily rollover, replay validation, leaderboard opt-in (#56, #60, #61, #62, #66, #68)
Build and Deploy / build-and-push (push) Successful in 3m54s
Build and Deploy / build-and-push (push) Successful in 3m54s
- #66: Clamp safe-area insets to 25% of window height with warn!() on excess - #68: Move fire_flush outside per-event loop in analytics (batch flush once) - #56: Persist progress before marking reward_granted to prevent XP loss on crash - #60: Add DateRolloverTimer + check_date_rollover system for midnight seed refresh - #62: Add validate_header() in replay upload with mode/draw_mode allowlists - #61: Restore two-query leaderboard opt-in check (SELECT then UPDATE); original queries already in .sqlx cache; EXISTS variant would require sqlx prepare Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -45,10 +45,10 @@ use thiserror::Error;
|
||||
|
||||
use bevy::math::UVec2;
|
||||
|
||||
use crate::assets::{rasterize_svg, user_theme_dir, SvgLoaderError};
|
||||
use crate::assets::{SvgLoaderError, rasterize_svg, user_theme_dir};
|
||||
|
||||
use super::manifest::{ManifestError, ThemeManifest};
|
||||
use super::ThemeMetaError;
|
||||
use super::manifest::{ManifestError, ThemeManifest};
|
||||
|
||||
/// Hard cap on the *uncompressed* total of all archive entries. Set
|
||||
/// generously high relative to a realistic 53-SVG theme (~1–2 MB at
|
||||
@@ -100,9 +100,7 @@ pub enum ImportError {
|
||||
|
||||
/// The archive's declared total uncompressed size exceeds
|
||||
/// [`MAX_ARCHIVE_BYTES`]. Checked *before* extraction.
|
||||
#[error(
|
||||
"archive declares {total} uncompressed bytes, exceeds the {limit}-byte limit"
|
||||
)]
|
||||
#[error("archive declares {total} uncompressed bytes, exceeds the {limit}-byte limit")]
|
||||
Oversized { total: u64, limit: u64 },
|
||||
|
||||
/// No `theme.ron` at the archive root.
|
||||
@@ -168,10 +166,7 @@ pub fn import_theme(zip_path: &Path) -> Result<ThemeId, ImportError> {
|
||||
/// Tests use this directly with a `tempfile::TempDir` so they can
|
||||
/// exercise the full extraction path without touching the global
|
||||
/// [`crate::assets::user_dir::set_user_theme_dir`] override.
|
||||
pub fn import_theme_into(
|
||||
zip_path: &Path,
|
||||
target_root: &Path,
|
||||
) -> Result<ThemeId, ImportError> {
|
||||
pub fn import_theme_into(zip_path: &Path, target_root: &Path) -> Result<ThemeId, ImportError> {
|
||||
let file = File::open(zip_path)?;
|
||||
let mut archive = zip::ZipArchive::new(file)?;
|
||||
|
||||
@@ -189,11 +184,9 @@ pub fn import_theme_into(
|
||||
required.push(manifest.back.clone());
|
||||
for path in &required {
|
||||
let bytes = read_archive_entry(&mut archive, path)?;
|
||||
rasterize_svg(&bytes, SVG_VALIDATION_SIZE).map_err(|source| {
|
||||
ImportError::InvalidSvg {
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
source,
|
||||
}
|
||||
rasterize_svg(&bytes, SVG_VALIDATION_SIZE).map_err(|source| ImportError::InvalidSvg {
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
|
||||
@@ -288,8 +281,7 @@ fn is_safe_relative_path(p: &Path) -> bool {
|
||||
if p.is_absolute() {
|
||||
return false;
|
||||
}
|
||||
p.components()
|
||||
.all(|c| matches!(c, Component::Normal(_)))
|
||||
p.components().all(|c| matches!(c, Component::Normal(_)))
|
||||
}
|
||||
|
||||
/// Reads `theme.ron` from the archive root and parses it.
|
||||
@@ -373,11 +365,9 @@ fn write_archive_entry<R: io::Read + io::Seek>(
|
||||
}
|
||||
Err(e) => return Err(ImportError::OpenArchive(e)),
|
||||
};
|
||||
let safe = entry
|
||||
.enclosed_name()
|
||||
.ok_or_else(|| ImportError::ZipSlip {
|
||||
path: name.to_owned(),
|
||||
})?;
|
||||
let safe = entry.enclosed_name().ok_or_else(|| ImportError::ZipSlip {
|
||||
path: name.to_owned(),
|
||||
})?;
|
||||
if !is_safe_relative_path(&safe) {
|
||||
return Err(ImportError::ZipSlip {
|
||||
path: name.to_owned(),
|
||||
@@ -457,8 +447,8 @@ mod tests {
|
||||
use std::io::Write;
|
||||
|
||||
use tempfile::TempDir;
|
||||
use zip::write::SimpleFileOptions;
|
||||
use zip::CompressionMethod;
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
use crate::theme::manifest::ThemeManifest;
|
||||
use crate::theme::{CardKey, ThemeMeta};
|
||||
@@ -516,11 +506,8 @@ mod tests {
|
||||
/// given manifest id.
|
||||
fn write_valid_zip(zip_path: &Path, id: &str) {
|
||||
let manifest = full_manifest(id);
|
||||
let manifest_ron = ron::ser::to_string_pretty(
|
||||
&manifest,
|
||||
ron::ser::PrettyConfig::default(),
|
||||
)
|
||||
.expect("ron serialise");
|
||||
let manifest_ron = ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default())
|
||||
.expect("ron serialise");
|
||||
let mut entries: Vec<(String, Vec<u8>)> = Vec::with_capacity(54);
|
||||
entries.push((MANIFEST_NAME.to_owned(), manifest_ron.into_bytes()));
|
||||
entries.push(("back.svg".to_owned(), TEST_SVG.to_vec()));
|
||||
@@ -530,8 +517,10 @@ mod tests {
|
||||
TEST_SVG.to_vec(),
|
||||
));
|
||||
}
|
||||
let entries_ref: Vec<(&str, Vec<u8>)> =
|
||||
entries.iter().map(|(k, v)| (k.as_str(), v.clone())).collect();
|
||||
let entries_ref: Vec<(&str, Vec<u8>)> = entries
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), v.clone()))
|
||||
.collect();
|
||||
write_zip(zip_path, &entries_ref);
|
||||
}
|
||||
|
||||
@@ -570,10 +559,7 @@ mod tests {
|
||||
|
||||
let target = TempDir::new().expect("target");
|
||||
let err = import_theme_into(&zip_path, target.path()).expect_err("expected error");
|
||||
assert!(
|
||||
matches!(err, ImportError::MissingManifest),
|
||||
"got {err:?}"
|
||||
);
|
||||
assert!(matches!(err, ImportError::MissingManifest), "got {err:?}");
|
||||
assert!(
|
||||
target.path().read_dir().unwrap().next().is_none(),
|
||||
"target untouched"
|
||||
@@ -588,11 +574,8 @@ mod tests {
|
||||
let mut manifest = full_manifest("incomplete");
|
||||
// Drop one face so validation surfaces MissingFaces.
|
||||
manifest.faces.remove("hearts_ace");
|
||||
let manifest_ron = ron::ser::to_string_pretty(
|
||||
&manifest,
|
||||
ron::ser::PrettyConfig::default(),
|
||||
)
|
||||
.expect("ron serialise");
|
||||
let manifest_ron = ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default())
|
||||
.expect("ron serialise");
|
||||
|
||||
let mut entries: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
entries.push((MANIFEST_NAME.to_owned(), manifest_ron.into_bytes()));
|
||||
@@ -603,8 +586,10 @@ mod tests {
|
||||
TEST_SVG.to_vec(),
|
||||
));
|
||||
}
|
||||
let entries_ref: Vec<(&str, Vec<u8>)> =
|
||||
entries.iter().map(|(k, v)| (k.as_str(), v.clone())).collect();
|
||||
let entries_ref: Vec<(&str, Vec<u8>)> = entries
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), v.clone()))
|
||||
.collect();
|
||||
write_zip(&zip_path, &entries_ref);
|
||||
|
||||
let target = TempDir::new().expect("target");
|
||||
@@ -633,18 +618,12 @@ mod tests {
|
||||
let huge = vec![0u8; (MAX_ARCHIVE_BYTES + 1) as usize];
|
||||
write_zip(
|
||||
&zip_path,
|
||||
&[
|
||||
(MANIFEST_NAME, b"".to_vec()),
|
||||
("filler.bin", huge),
|
||||
],
|
||||
&[(MANIFEST_NAME, b"".to_vec()), ("filler.bin", huge)],
|
||||
);
|
||||
|
||||
let target = TempDir::new().expect("target");
|
||||
let err = import_theme_into(&zip_path, target.path()).expect_err("expected error");
|
||||
assert!(
|
||||
matches!(err, ImportError::Oversized { .. }),
|
||||
"got {err:?}"
|
||||
);
|
||||
assert!(matches!(err, ImportError::Oversized { .. }), "got {err:?}");
|
||||
assert!(
|
||||
target.path().read_dir().unwrap().next().is_none(),
|
||||
"target untouched"
|
||||
@@ -686,11 +665,8 @@ mod tests {
|
||||
// Manifest is well-formed and validates, but we omit one of
|
||||
// the SVGs from the archive to trigger the MissingFile path.
|
||||
let manifest = full_manifest("missing_file_theme");
|
||||
let manifest_ron = ron::ser::to_string_pretty(
|
||||
&manifest,
|
||||
ron::ser::PrettyConfig::default(),
|
||||
)
|
||||
.expect("ron serialise");
|
||||
let manifest_ron = ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default())
|
||||
.expect("ron serialise");
|
||||
|
||||
let mut entries: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
entries.push((MANIFEST_NAME.to_owned(), manifest_ron.into_bytes()));
|
||||
@@ -706,8 +682,10 @@ mod tests {
|
||||
TEST_SVG.to_vec(),
|
||||
));
|
||||
}
|
||||
let entries_ref: Vec<(&str, Vec<u8>)> =
|
||||
entries.iter().map(|(k, v)| (k.as_str(), v.clone())).collect();
|
||||
let entries_ref: Vec<(&str, Vec<u8>)> = entries
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), v.clone()))
|
||||
.collect();
|
||||
write_zip(&zip_path, &entries_ref);
|
||||
|
||||
let target = TempDir::new().expect("target");
|
||||
|
||||
Reference in New Issue
Block a user