diff --git a/Cargo.lock b/Cargo.lock index be9362a3..284658ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1370,6 +1370,7 @@ dependencies = [ "sqlx", "thiserror 2.0.18", "tokio", + "urlencoding", "utoipa", "utoipa-swagger-ui", "uuid", @@ -3923,6 +3924,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index b98f89f3..386e1809 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,4 +43,5 @@ thiserror = "2.0.12" moka = { version = "0.12.13", features = ["future"] } utoipa = { version = "5.4.0", features = ["actix_extras", "chrono", "uuid"] } utoipa-swagger-ui = { version = "9.0.2", features = ["actix-web"] } +urlencoding = "2.1.3" validator = { version = "0.20.0", features = ["derive"] } diff --git a/src/abbreviate.rs b/src/abbreviate.rs new file mode 100644 index 00000000..481cece2 --- /dev/null +++ b/src/abbreviate.rs @@ -0,0 +1,16 @@ +const ONE_THOUSAND: f64 = 1_000.0; +const ONE_MILLION: f64 = 1_000_000.0; +const ONE_BILLION: f64 = 1_000_000_000.0; + +pub fn abbreviate_number(n: i32) -> String { + let n = n as f64; + if n.abs() >= ONE_BILLION { + format!("{:.1}B", n / ONE_BILLION) + } else if n.abs() >= ONE_MILLION { + format!("{:.1}M", n / ONE_MILLION) + } else if n.abs() >= ONE_THOUSAND { + format!("{:.1}K", n / ONE_THOUSAND) + } else { + format!("{:.0}", n) + } +} diff --git a/src/database/repository/mod_versions.rs b/src/database/repository/mod_versions.rs index d624dbb6..78bbf4b8 100644 --- a/src/database/repository/mod_versions.rs +++ b/src/database/repository/mod_versions.rs @@ -43,7 +43,7 @@ impl ModVersionRow { geode: self.geode, early_load: self.early_load, requires_patching: self.requires_patching, - download_count: self.download_count, + download_count: self.download_count.into(), api: self.api, mod_id: self.mod_id, status: self.status, @@ -224,7 +224,7 @@ pub async fn create_from_json( download_link: row.download_link, hash: row.hash, geode: geode.to_string(), - download_count: 0, + download_count: 0.into(), early_load: row.early_load, requires_patching: row.requires_patching, api: row.api, @@ -333,7 +333,7 @@ pub async fn update_pending_version( download_link: row.download_link, hash: row.hash, geode: geode.to_string(), - download_count: row.download_count, + download_count: row.download_count.into(), early_load: row.early_load, requires_patching: row.requires_patching, api: row.api, diff --git a/src/database/repository/mods.rs b/src/database/repository/mods.rs index c338801f..54bc4d54 100644 --- a/src/database/repository/mods.rs +++ b/src/database/repository/mods.rs @@ -26,7 +26,7 @@ impl ModRecordGetOne { id: self.id, repository: self.repository, featured: self.featured, - download_count: self.download_count, + download_count: self.download_count.into(), versions: Default::default(), tags: Default::default(), developers: Default::default(), diff --git a/src/endpoints/mod.rs b/src/endpoints/mod.rs index b7a00e10..b519f9ed 100644 --- a/src/endpoints/mod.rs +++ b/src/endpoints/mod.rs @@ -9,6 +9,7 @@ pub mod developers; pub mod health; pub mod loader; pub mod mod_versions; +pub mod mod_status_badge; pub mod mods; pub mod stats; pub mod tags; diff --git a/src/endpoints/mod_status_badge.rs b/src/endpoints/mod_status_badge.rs new file mode 100644 index 00000000..a2ffd9b7 --- /dev/null +++ b/src/endpoints/mod_status_badge.rs @@ -0,0 +1,88 @@ +use crate::config::AppData; +use crate::endpoints::ApiError; +use actix_web::{HttpResponse, Responder, get, web}; +use serde::Deserialize; +use utoipa::{IntoParams, ToSchema}; + +use std::fs; +use std::path::Path; +use urlencoding; + +const LABEL_COLOR: &str = "#0c0811"; +const STAT_COLOR: &str = "#5f3d84"; + +#[derive(Deserialize, Clone, Copy, PartialEq, Eq, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum StatusBadgeStat { + Version, + GdVersion, + GeodeVersion, + Downloads, +} + +#[derive(Deserialize, IntoParams)] +pub struct StatusBadgeQuery { + pub stat: StatusBadgeStat, +} + +#[utoipa::path( + get, + path = "/v1/mods/{id}/status_badge", + tag = "mods", + params( + ("id" = String, Path, description = "Mod ID"), + StatusBadgeQuery + ), + responses( + (status = 302, description = "Redirect to Shields.io badge"), + (status = 400, description = "Invalid stat or missing parameter"), + (status = 404, description = "Mod not found") + ) +)] +#[get("/v1/mods/{id}/status_badge")] +pub async fn status_badge( + data: web::Data, + id: web::Path, + query: web::Query, +) -> Result { + let (stat, label, svg_path) = match query.stat { + StatusBadgeStat::Version => ( + "payload.versions[0].version", + "Version", + "storage/public/shields/mod_version.svg", + ), + StatusBadgeStat::GdVersion => ( + "payload.versions[0].gd.win", + "Geometry Dash", + "storage/public/shields/mod_gd_version.svg", + ), + StatusBadgeStat::GeodeVersion => ( + "payload.versions[0].geode", + "Geode", + "storage/public/shields/mod_geode_version.svg", + ), + StatusBadgeStat::Downloads => ( + "payload.download_count", + "Downloads", + "storage/public/shields/mod_downloads.svg", + ), + }; + let svg = fs::read_to_string(Path::new(svg_path)) + .map_err(|_| ApiError::BadRequest(format!("Could not read SVG file: {}", svg_path)))?; + let api_url = format!("{}/v1/mods/{}?abbreviate=true", data.app_url(), id); + let mod_link = format!("{}/mods/{}", data.front_url(), id); + let svg_data_url = format!("data:image/svg+xml;utf8,{}", urlencoding::encode(&svg)); + let shields_url = format!( + "https://img.shields.io/badge/dynamic/json?url={}&query={}&label={}&labelColor={}&color={}&link={}&style=plastic&logo={}", + urlencoding::encode(&api_url), + urlencoding::encode(stat), + label, + urlencoding::encode(LABEL_COLOR), + urlencoding::encode(STAT_COLOR), + urlencoding::encode(&mod_link), + urlencoding::encode(&svg_data_url) + ); + Ok(HttpResponse::Found() + .append_header(("Location", shields_url)) + .finish()) +} diff --git a/src/endpoints/mods.rs b/src/endpoints/mods.rs index 9bc5c934..5e579fd3 100644 --- a/src/endpoints/mods.rs +++ b/src/endpoints/mods.rs @@ -1,3 +1,7 @@ +#[derive(Deserialize, ToSchema)] +pub struct ModGetQueryParams { + pub abbreviate: Option, +} use crate::config::AppData; use crate::database::repository::developers; use crate::database::repository::incompatibilities; @@ -126,6 +130,7 @@ pub async fn index( pub async fn get( data: web::Data, id: web::Path, + query: web::Query, auth: Auth, ) -> Result { let dev = auth.developer().ok(); @@ -171,6 +176,8 @@ pub async fn get( i.modify_metadata(data.app_url(), has_extended_permissions); } + the_mod.set_abbreviated_download_counts(query.abbreviate.unwrap_or(false)); + Ok(web::Json(ApiResponse { error: "".into(), payload: the_mod, diff --git a/src/main.rs b/src/main.rs index 081182d3..d1bff29f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ use actix_web::{ use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; +mod abbreviate; mod auth; mod cli; mod config; @@ -65,6 +66,7 @@ async fn main() -> anyhow::Result<()> { .service(endpoints::mods::create) .service(endpoints::mods::update_mod) .service(endpoints::mods::get_logo) + .service(endpoints::mod_status_badge::status_badge) .service(endpoints::mod_versions::get_version_index) .service(endpoints::mod_versions::get_one) .service(endpoints::mod_versions::download_version) diff --git a/src/types/models/download_count.rs b/src/types/models/download_count.rs new file mode 100644 index 00000000..54854654 --- /dev/null +++ b/src/types/models/download_count.rs @@ -0,0 +1,40 @@ +use crate::abbreviate::abbreviate_number; +use serde::{Serialize, Serializer}; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct DownloadCount { + count: i32, + abbreviate: bool, +} + +impl DownloadCount { + pub const fn new(count: i32) -> Self { + Self { + count, + abbreviate: false, + } + } + + pub fn set_abbreviated(&mut self, abbreviate: bool) { + self.abbreviate = abbreviate; + } +} + +impl From for DownloadCount { + fn from(count: i32) -> Self { + Self::new(count) + } +} + +impl Serialize for DownloadCount { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + if self.abbreviate { + serializer.serialize_str(&abbreviate_number(self.count)) + } else { + serializer.serialize_i32(self.count) + } + } +} diff --git a/src/types/models/mod.rs b/src/types/models/mod.rs index f9719dc9..e3f46125 100644 --- a/src/types/models/mod.rs +++ b/src/types/models/mod.rs @@ -1,4 +1,5 @@ pub mod dependency; +pub mod download_count; pub mod developer; pub mod github_login_attempt; pub mod incompatibility; diff --git a/src/types/models/mod_entity.rs b/src/types/models/mod_entity.rs index f658be4a..a1bc6198 100644 --- a/src/types/models/mod_entity.rs +++ b/src/types/models/mod_entity.rs @@ -1,5 +1,6 @@ use super::{ dependency::ResponseDependency, + download_count::DownloadCount, developer::ModDeveloper, incompatibility::{Replacement, ResponseIncompatibility}, mod_gd_version::{DetailedGDVersion, GDVersionEnum, ModGDVersion, VerPlatform}, @@ -35,7 +36,8 @@ pub struct Mod { pub id: String, pub repository: Option, pub featured: bool, - pub download_count: i32, + #[schema(value_type = i32)] + pub download_count: DownloadCount, pub developers: Vec, pub versions: Vec, pub tags: Vec, @@ -107,6 +109,13 @@ pub struct ModStats { } impl Mod { + pub fn set_abbreviated_download_counts(&mut self, abbreviate: bool) { + self.download_count.set_abbreviated(abbreviate); + for version in &mut self.versions { + version.set_abbreviated_download_count(abbreviate); + } + } + pub async fn get_stats(pool: &mut PgConnection) -> Result { let result = sqlx::query!( " @@ -356,7 +365,7 @@ impl Mod { Mod { id: x.id, repository: x.repository, - download_count: x.download_count, + download_count: x.download_count.into(), featured: x.featured, versions: vec![version], tags, @@ -403,7 +412,7 @@ impl Mod { Mod { id: x.id.clone(), repository: x.repository.clone(), - download_count: x.download_count, + download_count: x.download_count.into(), featured: x.featured, versions: version, tags, @@ -548,7 +557,7 @@ impl Mod { description: x.description.clone(), version: x.version.clone(), download_link: x.download_link.clone(), - download_count: x.mod_version_download_count, + download_count: x.mod_version_download_count.into(), hash: x.hash.clone(), geode: x.geode.clone(), early_load: x.early_load, @@ -592,7 +601,7 @@ impl Mod { id: records[0].id.clone(), repository: records[0].repository.clone(), featured: records[0].featured, - download_count: records[0].mod_download_count, + download_count: records[0].mod_download_count.into(), versions, tags, developers: devs, diff --git a/src/types/models/mod_version.rs b/src/types/models/mod_version.rs index 83ca094d..b66c7b77 100644 --- a/src/types/models/mod_version.rs +++ b/src/types/models/mod_version.rs @@ -1,5 +1,6 @@ use super::{ dependency::{Dependency, ModVersionCompare, ResponseDependency}, + download_count::DownloadCount, developer::ModDeveloper, incompatibility::{Incompatibility, ResponseIncompatibility}, mod_gd_version::{DetailedGDVersion, GDVersionEnum, ModGDVersion, VerPlatform}, @@ -31,7 +32,8 @@ pub struct ModVersion { pub download_link: String, pub hash: String, pub geode: String, - pub download_count: i32, + #[schema(value_type = i32)] + pub download_count: DownloadCount, pub early_load: bool, pub requires_patching: bool, pub api: bool, @@ -101,7 +103,7 @@ impl ModVersionGetOne { geode: self.geode.clone(), early_load: self.early_load, requires_patching: self.requires_patching, - download_count: self.download_count, + download_count: self.download_count.into(), api: self.api, mod_id: self.mod_id.clone(), status: self.status, @@ -128,6 +130,10 @@ impl ModVersionGetOne { } impl ModVersion { + pub fn set_abbreviated_download_count(&mut self, abbreviate: bool) { + self.download_count.set_abbreviated(abbreviate); + } + fn modify_download_link(&mut self, app_url: &str) { self.download_link = create_download_link(app_url, &self.mod_id, &self.version) } diff --git a/storage/public/shields/mod_downloads.svg b/storage/public/shields/mod_downloads.svg new file mode 100644 index 00000000..06a71e4d --- /dev/null +++ b/storage/public/shields/mod_downloads.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/storage/public/shields/mod_gd_version.svg b/storage/public/shields/mod_gd_version.svg new file mode 100644 index 00000000..c77ea886 --- /dev/null +++ b/storage/public/shields/mod_gd_version.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/storage/public/shields/mod_geode_version.svg b/storage/public/shields/mod_geode_version.svg new file mode 100644 index 00000000..e6b31eef --- /dev/null +++ b/storage/public/shields/mod_geode_version.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/storage/public/shields/mod_version.svg b/storage/public/shields/mod_version.svg new file mode 100644 index 00000000..8b0289e7 --- /dev/null +++ b/storage/public/shields/mod_version.svg @@ -0,0 +1 @@ + \ No newline at end of file