From 6d16b68f114cb791cfa2eb31c34b72205938efca Mon Sep 17 00:00:00 2001 From: Aeledfyr <45501007+Aeledfyr@users.noreply.github.com> Date: Wed, 1 Jul 2020 15:24:42 -0500 Subject: [PATCH] Create schema for the API (#28) * feat(schema): add basic structs for schema * feat(schema): implement base62 id parsing * docs(schema): add documentation for schema structs fix(schema): prevent integer overflow in base62 decoding * refactor(schema): move ids into submodules, reexport from ids mod * feat(schema): add random generation of base62 ids style: run rustfmt --- Cargo.toml | 2 + src/models/error.rs | 2 + src/models/ids.rs | 180 ++++++++++++++++++++++++++++++++++++++++++++ src/models/mod.rs | 2 + src/models/mods.rs | 108 ++++++++++++++++++++++++++ src/models/teams.rs | 33 ++++++++ 6 files changed, 327 insertions(+) create mode 100644 src/models/ids.rs create mode 100644 src/models/teams.rs diff --git a/Cargo.toml b/Cargo.toml index 7d64d9bd..328eba1a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,8 @@ meilisearch-sdk = "0.1.4" serde_json = "1.0" serde = {version="1.0", features=["derive"]} +chrono = { version = "0.4", features = ["serde"] } +rand = "0.7" dotenv = "0.15" log = "0.4.8" diff --git a/src/models/error.rs b/src/models/error.rs index a124d03b..5ac3c607 100644 --- a/src/models/error.rs +++ b/src/models/error.rs @@ -1,4 +1,6 @@ use serde::{Deserialize, Serialize}; + +/// An error returned by the API #[derive(Serialize, Deserialize)] pub struct ApiError<'a> { pub error: &'a str, diff --git a/src/models/ids.rs b/src/models/ids.rs new file mode 100644 index 00000000..aec62822 --- /dev/null +++ b/src/models/ids.rs @@ -0,0 +1,180 @@ +use thiserror::Error; + +pub use super::mods::{ModId, VersionId}; +pub use super::teams::{TeamId, UserId}; + +/// Generates a random 64 bit integer that is exactly `n` characters +/// long when encoded as base62. +/// +/// Uses `rand`'s thread rng on every call. +/// +/// # Panics +/// +/// This method panics if `n` is 0 or greater than 11, since a `u64` +/// can only represent up to 11 character base62 strings +pub fn random_base62(n: usize) -> u64 { + use rand::Rng; + assert!(n > 0 && n <= 11); + let mut rng = rand::thread_rng(); + // gen_range is [low, high): max value is `MULTIPLES[n] - 1`, + // which is n characters long when encoded + rng.gen_range(MULTIPLES[n - 1], MULTIPLES[n]) +} + +/// Generates a random 64 bit integer that is exactly `n` characters +/// long when encoded as base62, using the given rng. +/// +/// # Panics +/// +/// This method panics if `n` is 0 or greater than 11, since a `u64` +/// can only represent up to 11 character base62 strings +pub fn random_base62_rng(rng: &mut R, n: usize) -> u64 { + use rand::Rng; + assert!(n > 0 && n <= 11); + rng.gen_range(MULTIPLES[n - 1], MULTIPLES[n]) +} + +const MULTIPLES: [u64; 12] = [ + 1, + 62, + 62 * 62, + 62 * 62 * 62, + 62 * 62 * 62 * 62, + 62 * 62 * 62 * 62 * 62, + 62 * 62 * 62 * 62 * 62 * 62, + 62 * 62 * 62 * 62 * 62 * 62 * 62, + 62 * 62 * 62 * 62 * 62 * 62 * 62 * 62, + 62 * 62 * 62 * 62 * 62 * 62 * 62 * 62 * 62, + 62 * 62 * 62 * 62 * 62 * 62 * 62 * 62 * 62 * 62, + std::u64::MAX, +]; + +/// An ID encoded as base62 for use in the API. +/// +/// All ids should be random and encode to 8-10 character base62 strings, +/// to avoid enumeration and other attacks. +#[derive(Copy, Clone, PartialEq, Eq)] +pub struct Base62Id(pub u64); + +/// An error decoding a number from base62. +#[derive(Error, Debug)] +pub enum DecodingError { + /// Encountered a non base62 character in base62 string + #[error("Invalid character `{0:?}` in base62 encoding")] + InvalidBase62(char), + /// Encountered integer overflow when decoding a base62 id. + #[error("Base62 decoding overflowed")] + Overflow, +} + +macro_rules! from_base62id { + ($($struct:ty, $con:expr;)+) => { + $( + impl From for $struct { + fn from(id: Base62Id) -> $struct { + $con(id.0) + } + } + impl From<$struct> for Base62Id { + fn from(id: $struct) -> Base62Id { + Base62Id(id.0) + } + } + )+ + }; +} + +from_base62id! { + ModId, ModId; + UserId, UserId; + VersionId, VersionId; + TeamId, TeamId; +} + +pub mod base62_impl { + use serde::de::{self, Deserializer, Visitor}; + use serde::ser::Serializer; + use serde::{Deserialize, Serialize}; + + use super::{Base62Id, DecodingError}; + + impl<'de> Deserialize<'de> for Base62Id { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct Base62Visitor; + + impl<'de> Visitor<'de> for Base62Visitor { + type Value = Base62Id; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a base62 string id") + } + + fn visit_str(self, string: &str) -> Result + where + E: de::Error, + { + parse_base62(string).map(Base62Id).map_err(E::custom) + } + } + + deserializer.deserialize_str(Base62Visitor) + } + } + + impl Serialize for Base62Id { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&to_base62(self.0)) + } + } + + const BASE62_CHARS: [u8; 62] = [ + b'0', b'1', b'2', b'3', b'4', b'5', b'6', b'7', b'8', b'9', b'A', b'B', b'C', b'D', b'E', + b'F', b'G', b'H', b'I', b'J', b'K', b'L', b'M', b'N', b'O', b'P', b'Q', b'R', b'S', b'T', + b'U', b'V', b'W', b'X', b'Y', b'Z', b'a', b'b', b'c', b'd', b'e', b'f', b'g', b'h', b'i', + b'j', b'k', b'l', b'm', b'n', b'o', b'p', b'q', b'r', b's', b't', b'u', b'v', b'w', b'x', + b'y', b'z', + ]; + + pub fn to_base62(mut num: u64) -> String { + let length = (num as f64).log(62.0).ceil() as usize; + let mut output = String::with_capacity(length); + + while num > 0 { + // Could be done more efficiently, but requires byte + // manipulation of strings & Vec -> String conversion + output.insert(0, BASE62_CHARS[(num % 62) as usize] as char); + num /= 62; + } + output + } + + fn parse_base62(string: &str) -> Result { + let mut num: u64 = 0; + for c in string.chars().rev() { + let next_digit; + if c.is_ascii_digit() { + next_digit = (c as u8 - b'0') as u64; + } else if c.is_ascii_uppercase() { + next_digit = 10 + (c as u8 - b'A') as u64; + } else if c.is_ascii_lowercase() { + next_digit = 36 + (c as u8 - b'a') as u64; + } else { + return Err(DecodingError::InvalidBase62(c)); + } + + // We don't want this panicing or wrapping on integer overflow + if let Some(n) = num.checked_mul(62).and_then(|n| n.checked_add(next_digit)) { + num = n; + } else { + return Err(DecodingError::Overflow); + } + } + Ok(num) + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs index 103fe987..603d9fc4 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,2 +1,4 @@ pub mod error; +pub mod ids; pub mod mods; +pub mod teams; diff --git a/src/models/mods.rs b/src/models/mods.rs index 6f2a793b..2419f59c 100644 --- a/src/models/mods.rs +++ b/src/models/mods.rs @@ -1,5 +1,113 @@ +use super::ids::Base62Id; +use super::teams::Team; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +/// The ID of a specific mod, encoded as base62 for usage in the API +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "Base62Id")] +#[serde(into = "Base62Id")] +pub struct ModId(pub u64); + +/// The ID of a specific version of a mod +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "Base62Id")] +#[serde(into = "Base62Id")] +pub struct VersionId(pub u64); + +/// A mod returned from the API +#[derive(Serialize, Deserialize)] +pub struct Mod { + /// The ID of the mod, encoded as a base62 string. + pub id: ModId, + // TODO: send partial team structure to reduce requests, but avoid sending + // unnecessary info + /// The team of people that has ownership of this mod. + pub team: Team, + + /// The title or name of the mod. + pub title: String, + /// A short description of the mod. + pub description: String, + /// The date at which the mod was first published. + pub published: DateTime, + + /// The total number of downloads the mod has had. + pub downloads: u32, + /// A list of the categories that the mod is in. + pub categories: Vec, + /// A list of ids for versions of the mod. + pub versions: Vec, + + /// The latest version of the mod. + pub latest_version: Version, + + /// An optional link to where to submit bugs or issues with the mod. + pub issues_url: Option, + /// An optional link to the source code for the mod. + pub source_url: Option, + /// An optional link to the mod's wiki page or other relevant information. + pub wiki_url: Option, +} + +/// A specific version of a mod +#[derive(Serialize, Deserialize)] +pub struct Version { + /// The ID of the version, encoded as a base62 string. + pub id: VersionId, + /// The ID of the mod this version is for. + pub mod_id: ModId, + + /// The name of this version + pub name: String, + /// A link to the changelog for this version of the mod. + pub changelog_url: Option, + /// The date that this version was published. + pub date_published: DateTime, + /// The number of downloads this specific version has had. + pub downloads: u32, + /// The type of the release - `Alpha`, `Beta`, or `Release`. + pub version_type: VersionType, + + /// A list of files available for download for this version. + pub files: Vec, + /// A list of mods that this version depends on. + pub dependencies: Vec, + /// A list of versions of Minecraft that this version of the mod supports. + pub game_versions: Vec, +} + +/// A single mod file, with a url for the file and the file's hash +#[derive(Serialize, Deserialize)] +pub struct VersionFile { + /// A list of hashes of the file + pub hashes: Vec, + /// A direct link to the file for downloading it. + pub url: String, +} + +/// A hash of a mod's file +#[derive(Serialize, Deserialize)] +pub struct FileHash { + // TODO: decide specific algorithms + /// The hashing algorithm used for this hash; could be "md5", "sha1", etc + pub algorithm: String, + /// The file hash, using the specified algorithm + pub hash: String, +} + +#[derive(Serialize, Deserialize)] +pub enum VersionType { + Release, + Beta, + Alpha, +} + +/// A specific version of Minecraft +#[derive(Serialize, Deserialize)] +#[serde(transparent)] +pub struct GameVersion(pub String); + #[derive(Serialize, Deserialize)] pub struct SearchRequest { pub query: Option, diff --git a/src/models/teams.rs b/src/models/teams.rs new file mode 100644 index 00000000..4a7da249 --- /dev/null +++ b/src/models/teams.rs @@ -0,0 +1,33 @@ +use super::ids::Base62Id; +use serde::{Deserialize, Serialize}; + +/// The ID of a specific user, encoded as base62 for usage in the API +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "Base62Id")] +#[serde(into = "Base62Id")] +pub struct UserId(pub u64); + +/// The ID of a team +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "Base62Id")] +#[serde(into = "Base62Id")] +pub struct TeamId(pub u64); + +// TODO: permissions, role names, etc +/// A team of users who control a mod +#[derive(Serialize, Deserialize)] +pub struct Team { + /// The id of the team + pub id: TeamId, + /// A list of the members of the team + pub members: Vec, +} + +/// A member of a team +#[derive(Serialize, Deserialize)] +pub struct TeamMember { + /// The ID of the user associated with the member + pub user_id: UserId, + /// The name of the user + pub name: String, +}