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

- #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:
funman300
2026-05-28 13:07:22 -07:00
parent 8cb4c9808e
commit 6e407a3ea7
104 changed files with 6356 additions and 3092 deletions
+33 -55
View File
@@ -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 (~12 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");