Database implementation, migration, user rework, animated server icons

rust
Elnu 1 year ago
parent 48ac5a44e2
commit 427b019a49

3
.gitignore vendored

@ -7,4 +7,5 @@
# Added by cargo # Added by cargo
/target /target
.env .env
*.db

157
Cargo.lock generated

@ -8,6 +8,15 @@ version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2bc21ffc9b77e9c31e733bb7e937c11dcf6157bb74f80bf94734110aa9b9ebc" checksum = "c2bc21ffc9b77e9c31e733bb7e937c11dcf6157bb74f80bf94734110aa9b9ebc"
[[package]]
name = "addr2line"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a76fd60b23679b7d19bd066031410fb7e458ccc5e958eb5c325888ce4baedc97"
dependencies = [
"gimli",
]
[[package]] [[package]]
name = "adler" name = "adler"
version = "1.0.2" version = "1.0.2"
@ -49,6 +58,17 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "ahash"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c99f64d1e06488f620f932677e24bc6e2897582980441ae90a671415bd7ec2f"
dependencies = [
"cfg-if 1.0.0",
"once_cell",
"version_check 0.9.4",
]
[[package]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "0.7.20" version = "0.7.20"
@ -67,6 +87,12 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "allocator-api2"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56fc6cf8dc8c4158eed8649f9b8b0ea1518eb62b544fe9490d66fa0b349eafe9"
[[package]] [[package]]
name = "android-tzdata" name = "android-tzdata"
version = "0.1.1" version = "0.1.1"
@ -203,6 +229,21 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
[[package]]
name = "backtrace"
version = "0.3.67"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "233d376d6d185f2a3093e58f283f60f880315b6c60075b01f36b3b85154564ca"
dependencies = [
"addr2line",
"cc",
"cfg-if 1.0.0",
"libc",
"miniz_oxide 0.6.2",
"object",
"rustc-demangle",
]
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.9.3" version = "0.9.3"
@ -378,9 +419,9 @@ dependencies = [
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.3.0" version = "4.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93aae7a4192245f70fe75dd9157fc7b4a5bf53e88d30bd4396f7d8f9284d5acc" checksum = "bba77a07e4489fb41bd90e8d4201c3eb246b3c2c9ea2ba0bddd6c1d1df87db7d"
dependencies = [ dependencies = [
"clap_builder", "clap_builder",
"clap_derive", "clap_derive",
@ -389,9 +430,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_builder" name = "clap_builder"
version = "4.3.0" version = "4.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f423e341edefb78c9caba2d9c7f7687d0e72e89df3ce3394554754393ac3990" checksum = "2c9b4a88bb4bc35d3d6f65a21b0f0bafe9c894fa00978de242c555ec28bea1c0"
dependencies = [ dependencies = [
"anstream", "anstream",
"anstyle", "anstyle",
@ -403,9 +444,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_derive" name = "clap_derive"
version = "4.3.0" version = "4.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "191d9573962933b4027f932c600cd252ce27a8ad5979418fe78e43c07996f27b" checksum = "b8cd2b2a819ad6eec39e8f1d6b53001af1e5469f8c177579cdaeb313115b825f"
dependencies = [ dependencies = [
"heck", "heck",
"proc-macro2 1.0.59", "proc-macro2 1.0.59",
@ -592,7 +633,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "907076dfda823b0b36d2a1bb5f90c96660a5bbcd7729e10727f07858f22c4edc" checksum = "907076dfda823b0b36d2a1bb5f90c96660a5bbcd7729e10727f07858f22c4edc"
dependencies = [ dependencies = [
"cfg-if 1.0.0", "cfg-if 1.0.0",
"hashbrown", "hashbrown 0.12.3",
"lock_api", "lock_api",
"once_cell", "once_cell",
"parking_lot_core", "parking_lot_core",
@ -817,6 +858,18 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "fallible-iterator"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]] [[package]]
name = "fancy-regex" name = "fancy-regex"
version = "0.7.1" version = "0.7.1"
@ -869,7 +922,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b9429470923de8e8cbd4d2dc513535400b4b3fef0319fb5c4e1f520a7bef743" checksum = "3b9429470923de8e8cbd4d2dc513535400b4b3fef0319fb5c4e1f520a7bef743"
dependencies = [ dependencies = [
"crc32fast", "crc32fast",
"miniz_oxide", "miniz_oxide 0.7.1",
] ]
[[package]] [[package]]
@ -1077,6 +1130,12 @@ dependencies = [
"polyval", "polyval",
] ]
[[package]]
name = "gimli"
version = "0.27.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c80984affa11d98d1b88b66ac8853f143217b399d3c74116778ff8fdb4ed2e"
[[package]] [[package]]
name = "glob" name = "glob"
version = "0.3.1" version = "0.3.1"
@ -1132,6 +1191,25 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c6201b9ff9fd90a5a3bac2e56a830d0caa509576f0e503818ee82c181b3437a"
dependencies = [
"ahash",
"allocator-api2",
]
[[package]]
name = "hashlink"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "312f66718a2d7789ffef4f4b7b213138ed9f1eb3aa1d0d82fc99f88fb3ffd26f"
dependencies = [
"hashbrown 0.14.0",
]
[[package]] [[package]]
name = "heck" name = "heck"
version = "0.4.1" version = "0.4.1"
@ -1372,7 +1450,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [ dependencies = [
"autocfg", "autocfg",
"hashbrown", "hashbrown 0.12.3",
"serde", "serde",
] ]
@ -1534,9 +1612,19 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.144" version = "0.2.147"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b00cc1c228a6782d0f076e7b232802e0c5689d41bb5df366f2a6b6621cfdfe1" checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3"
[[package]]
name = "libsqlite3-sys"
version = "0.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "afc22eff61b133b115c6e8c74e818c628d6d5e7a502afea6f64dee076dd94326"
dependencies = [
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "line-wrap" name = "line-wrap"
@ -1651,6 +1739,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b275950c28b37e794e8c55d88aeb5e139d0ce23fdbbeda68f8d7174abdf9e8fa"
dependencies = [
"adler",
]
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.7.1" version = "0.7.1"
@ -1900,6 +1997,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "object"
version = "0.30.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03b4680b86d9cfafba8fc491dc9b6df26b68cf40e9e6cd73909194759a63c385"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.17.2" version = "1.17.2"
@ -2643,6 +2749,27 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "rusqlite"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "549b9d036d571d42e6e85d1c1425e2ac83491075078ca9a15be021c56b1641f2"
dependencies = [
"bitflags 2.3.1",
"chrono",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "rustc-demangle"
version = "0.1.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76"
[[package]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.0" version = "0.4.0"
@ -3113,6 +3240,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"accept-language", "accept-language",
"chrono", "chrono",
"clap",
"comrak", "comrak",
"derive_more", "derive_more",
"dotenv", "dotenv",
@ -3122,10 +3250,12 @@ dependencies = [
"rocket 0.5.0-rc.3", "rocket 0.5.0-rc.3",
"rocket_contrib", "rocket_contrib",
"rocket_dyn_templates", "rocket_dyn_templates",
"rusqlite",
"sass-rocket-fairing", "sass-rocket-fairing",
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
"tokio",
] ]
[[package]] [[package]]
@ -3258,11 +3388,12 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.28.2" version = "1.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94d7b1cfd2aa4011f2de74c2c4c63665e27a71006b0a192dcd2710272e73dfa2" checksum = "374442f06ee49c3a28a8fc9f01a2596fed7559c6b99b31279c3261778e77d84f"
dependencies = [ dependencies = [
"autocfg", "autocfg",
"backtrace",
"bytes", "bytes",
"libc", "libc",
"mio 0.8.8", "mio 0.8.8",

@ -8,6 +8,7 @@ edition = "2021"
[dependencies] [dependencies]
accept-language = "2.0.0" accept-language = "2.0.0"
chrono = { version = "0.4.26", features = ["serde"] } chrono = { version = "0.4.26", features = ["serde"] }
clap = "4.3.9"
comrak = "0.18.0" comrak = "0.18.0"
derive_more = "0.99.17" derive_more = "0.99.17"
dotenv = "0.15.0" dotenv = "0.15.0"
@ -17,7 +18,9 @@ reqwest = "0.11.18"
rocket = { version = "=0.5.0-rc.3", features = ["secrets", "json"] } rocket = { version = "=0.5.0-rc.3", features = ["secrets", "json"] }
rocket_contrib = { version = "0.4.11", features = ["templates"] } rocket_contrib = { version = "0.4.11", features = ["templates"] }
rocket_dyn_templates = { version = "0.1.0-rc.3", features = ["tera"] } rocket_dyn_templates = { version = "0.1.0-rc.3", features = ["tera"] }
rusqlite = { version = "0.29.0", features = ["chrono"] }
sass-rocket-fairing = "0.2.0" sass-rocket-fairing = "0.2.0"
serde = "1.0.163" serde = "1.0.163"
serde_json = "1.0.96" serde_json = "1.0.96"
serde_yaml = "0.9.21" serde_yaml = "0.9.21"
tokio = { version = "1.29.0", features = ["macros", "rt-multi-thread"] }

@ -18,5 +18,6 @@ pkgs.mkShell {
pkg-config pkg-config
openssl openssl
gettext gettext
sqlite
]; ];
} }

@ -2,13 +2,14 @@
extern crate rocket; extern crate rocket;
use poise::serenity_prelude::Http; use poise::serenity_prelude::Http;
use rocket::fs::{relative, FileServer}; use rocket::{fs::{relative, FileServer}, Rocket, Ignite};
use rocket_dyn_templates::{tera, Template}; use rocket_dyn_templates::{tera, Template};
use sass_rocket_fairing::SassFairing; use sass_rocket_fairing::SassFairing;
use std::{collections::HashMap, env}; use std::{collections::HashMap, env};
use clap::{Parser, Subcommand};
mod models; mod models;
use models::Settings; use models::{Settings, Database};
mod utils; mod utils;
@ -24,12 +25,52 @@ use crate::i18n::langs_filter;
mod prelude; mod prelude;
#[launch] #[derive(Parser)]
async fn rocket() -> _ { #[command(author, version, about, long_about = None)]
#[command(propagate_version = true)]
struct Args {
#[command(subcommand)]
cmd: Option<Command>,
}
#[derive(Subcommand)]
enum Command {
LoadLegacy,
}
#[tokio::main]
async fn main() {
dotenv::dotenv().expect("Failed to load .env file"); dotenv::dotenv().expect("Failed to load .env file");
let args = Args::parse();
match &args.cmd {
Some(cmd) => match cmd {
Command::LoadLegacy => {
if Database::file_exists() {
println!("Cannot load legacy submissions to an existing database");
return;
}
let http = http();
Database::new(false)
.expect("Failed to load database")
.load_legacy(&http).await
.expect("Failed to load legacy submissions");
},
},
None => {
rocket().await.expect("Failed to launch rocket");
},
}
}
fn http() -> Http {
let token = env::var("DISCORD_TOKEN").expect("Expected a token in the environment"); let token = env::var("DISCORD_TOKEN").expect("Expected a token in the environment");
let http = Http::new(&token); Http::new(&token)
}
async fn rocket() -> Result<Rocket<Ignite>, rocket::Error> {
let http = http();
let config = rocket::Config::figment().merge(("port", 1313)).merge(( let config = rocket::Config::figment().merge(("port", 1313)).merge((
"secret_key", "secret_key",
@ -63,4 +104,7 @@ async fn rocket() -> _ {
) )
})) }))
.attach(SassFairing::default()) .attach(SassFairing::default())
.ignite().await
.unwrap()
.launch().await
} }

@ -0,0 +1,153 @@
use std::{fs::File, io::Read, collections::HashMap, path::Path};
use poise::serenity_prelude::Http;
use rusqlite::{Connection, params};
use crate::{utils::get_challenge_number, models::User};
use super::{LegacySubmission, Submission};
pub struct Database {
conn: Connection,
}
const DATABASE_FILENAME: &str = "database.db";
impl Database {
pub fn file_exists() -> bool {
Path::new(DATABASE_FILENAME).exists()
}
pub fn new(
testing: bool,
) -> rusqlite::Result<Self> {
let conn = if testing {
Connection::open_in_memory()
} else {
Connection::open(DATABASE_FILENAME)
}?;
conn.execute(
"CREATE TABLE IF NOT EXISTS Submission (
id INTEGER PRIMARY KEY,
author_id INTEGER NOT NULL,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
image TEXT NOT NULL,
challenge INTEGER NOT NULL,
FOREIGN KEY (author_id) REFERENCES User(id)
)",
params![],
)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS User (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
discriminator INTEGER NOT NULL,
avatar TEXT
)",
params![],
)?;
Ok(Self { conn })
}
pub fn has_submitted(&self, user_id: u64) -> rusqlite::Result<bool> {
Ok(self.conn
.prepare("SELECT 1 FROM User WHERE id = ?1 LIMIT 1")?
.query_row(params![user_id], |_row| Ok(()))
.is_ok())
}
pub async fn load_legacy(&self, http: &Http) -> rusqlite::Result<()> {
let latest_challenge = get_challenge_number();
// HashMap of archived users that are no longer sharing a server with 字ちゃん
// Their historical usernames and discriminators will be used
let mut archived_users = HashMap::new();
for n in 1..=latest_challenge {
println!("Loading legacy challenge {n}/{latest_challenge}...");
let mut file = File::open(format!("data/challenges/{n}.json")).unwrap();
let mut contents = String::new();
file.read_to_string(&mut contents).unwrap();
for (legacy, submissions) in serde_json::from_str::<Vec<LegacySubmission>>(&contents)
.unwrap()
.iter()
.map(|legacy| (legacy, legacy.parse().unwrap())) {
let mut already_updated = false;
for submission in submissions {
self.conn.execute(
"INSERT INTO Submission(author_id, timestamp, image, challenge) VALUES (?1, ?2, ?3, ?4)",
params![
&submission.author_id,
&submission.timestamp,
&submission.image,
n,
]
)?;
let id = submission.author_id;
if !self.has_submitted(id)? {
println!("Fetching user {id}...");
let previously_archived = archived_users.contains_key(&id);
// Parse archived user out of legacy and insert into HashMap
let mut archive = || {
if already_updated {
return;
}
if previously_archived {
println!("Updating archived data for user {id}");
} else {
println!("Adding archived data for user {id}");
}
let (name, discriminator) = {
let mut iter = legacy.username.split("#");
let name = iter.next().unwrap().to_owned();
let discriminator = iter
.next()
.map(|str| str.parse().unwrap())
.unwrap_or(0);
(name, discriminator)
};
archived_users.insert(id, User {
id,
name,
discriminator,
avatar: None,
});
already_updated = true;
};
if previously_archived {
// If it already contains the archived user,
// overwrite write their data since they may have updated
// their username/discriminator since their previous submission
archive();
} else {
match User::fetch(&http, submission.author_id).await {
Ok(user) => {
self.conn.execute(
"INSERT INTO User(id, name, discriminator, avatar) VALUES (?1, ?2, ?3, ?4)",
params![user.id, user.name, user.discriminator, user.avatar]
)?;
},
Err(error) => {
println!("Failed to fetch user {}, may update archived data: {error}", submission.author_id);
archive();
},
};
}
}
}
}
}
Ok(())
}
#[allow(dead_code)]
pub fn refresh_users(&self) -> rusqlite::Result<()> {
// Periodically refresh all changable user data (name, discriminator, avatar)
// Ideally this should run periodically.
todo!()
}
#[allow(dead_code, unused_variables)]
pub fn insert_submission(&self, submission: &Submission) -> rusqlite::Result<()> {
// For new submissions only
todo!()
}
}

@ -1,8 +1,14 @@
mod user; mod user;
pub use user::User; pub use user::{Username, User, SessionUser};
mod challenge; mod challenge;
pub use challenge::Challenge; pub use challenge::Challenge;
mod settings; mod settings;
pub use settings::Settings; pub use settings::Settings;
mod submission;
pub use submission::*;
mod database;
pub use database::Database;

@ -50,7 +50,12 @@ pub struct Guild {
impl Guild { impl Guild {
pub async fn load(&mut self, http: &Http) -> poise::serenity_prelude::Result<()> { pub async fn load(&mut self, http: &Http) -> poise::serenity_prelude::Result<()> {
let server = http.get_guild(self.id).await?; let server = http.get_guild(self.id).await?;
self.icon = Some(server.icon); self.icon = Some(server.icon_url().map(|icon| if icon.contains("/a_") {
// serenity only gives non-animated URL
icon.replace("webp", "gif")
} else {
icon
}));
self.name = Some(server.name); self.name = Some(server.name);
Ok(()) Ok(())
} }

@ -0,0 +1,52 @@
use chrono::{Utc, TimeZone};
use serde::Deserialize;
use derive_more::From;
use super::Submission;
#[derive(Deserialize)]
pub struct LegacySubmission {
pub id: String,
pub images: Vec<String>,
pub username: String,
}
#[derive(From, Debug)]
pub enum LegacySubmissionParseError {
BadAuthorId(std::num::ParseIntError),
}
impl LegacySubmission {
pub fn parse(&self) -> Result<Vec<Submission>, LegacySubmissionParseError> {
let author_id = self.id.parse()?;
Ok(self.images
.iter()
.map(|image| {
Submission {
author_id,
timestamp: {
// The last number of the filename is either a discriminator or a datestamp
// We can split apart the filename and check if it's >9999. In that case,
// it's a datestamp.
(|| image
// Get filename without extension
.split(".")
.nth(0)?
// Get last number
.split("-")
.last()?
.parse()
.ok()
// Check if discriminator or timestamp, then convert
.map(|number| if number > 9999 {
Utc.timestamp_millis_opt(number).single()
} else {
None
})?)()
},
image: image.clone(),
}
})
.collect())
}
}

@ -0,0 +1,5 @@
mod submission;
pub use submission::Submission;
mod legacy_submission;
pub use legacy_submission::LegacySubmission;

@ -0,0 +1,15 @@
use chrono::{Utc, DateTime};
// Challenge submission
// In the legacy site version, one submission held 1 or more images.
// Now, 1 submission = 1 image, and the leaderboard count will be labeled as "participations"
pub struct Submission {
pub author_id: u64,
// Some fields might be empty for legacy submissions
// Starting during challenge #87, submissions have a datestamp appended to their filename.
// TODO: Determine whether this datestamp is local time or UTC
pub timestamp: Option<DateTime<Utc>>,
// Image path relative to submission folder.
// Thumbnail image path will be determined from this.
pub image: String,
}

@ -5,14 +5,19 @@ mod tests;
use chrono::Utc; use chrono::Utc;
use derive_more::From; use derive_more::From;
use poise::serenity_prelude::{self, UserId, Http};
use reqwest::StatusCode; use reqwest::StatusCode;
use rocket::http::{Cookie, CookieJar}; use rocket::http::{Cookie, CookieJar};
use serial::*; use serial::*;
use serde::Deserialize; use serde::{Deserialize, Serialize};
use crate::cookies::{token::*, user::*}; use crate::cookies::{token::*, user::*};
pub trait Username {
fn username(&self) -> String;
}
#[derive(Default, Deserialize)] #[derive(Default, Deserialize)]
pub struct User { pub struct User {
#[serde(deserialize_with = "deserialize_id")] #[serde(deserialize_with = "deserialize_id")]
@ -21,17 +26,56 @@ pub struct User {
pub name: String, pub name: String,
#[serde(deserialize_with = "deserialize_discriminator")] #[serde(deserialize_with = "deserialize_discriminator")]
pub discriminator: u16, pub discriminator: u16,
pub avatar: String, pub avatar: Option<String>,
} }
impl User { impl Username for User {
pub fn username(&self) -> String { fn username(&self) -> String {
if self.discriminator == 0 { if self.discriminator == 0 {
return self.name.clone(); return self.name.clone();
} }
format!("{}#{:0>4}", self.name, self.discriminator) format!("{}#{:0>4}", self.name, self.discriminator)
} }
}
impl User {
pub async fn fetch(http: &Http, id: u64) -> serenity_prelude::Result<Self> {
let user = UserId(id).to_user(http).await?;
Ok(Self {
id,
avatar: user.avatar,
name: user.name,
discriminator: user.discriminator,
})
}
fn avatar(&self) -> String {
match &self.avatar {
// https://cdn.discordapp.com/avatars/67795786229878784/e58524afe21b5058fc6f3cdc19aea8e1.webp?size=1024
Some(avatar) => format!(
"https://cdn.discordapp.com/avatars/{}/{}.{}?size=1024",
self.id,
avatar,
if avatar.starts_with("a_") { "gif "} else { "webp" }
),
// Archived user or user with no avatar, calculate default avatar
// https://www.reddit.com/r/discordapp/comments/au6v4e/comment/eh61dm6/
// https://docs.rs/serenity/0.11.5/serenity/model/user/struct.User.html#method.default_avatar_url
None => format!("https://cdn.discordapp.com/embed/avatars/{}.png", self.discriminator % 5),
}
}
}
#[derive(Default, Serialize)]
pub struct SessionUser(pub User);
impl Username for SessionUser {
fn username(&self) -> String {
self.0.username()
}
}
impl SessionUser {
pub async fn init(token: &str, cookies: &CookieJar<'_>) -> Result<Self, GetUserError> { pub async fn init(token: &str, cookies: &CookieJar<'_>) -> Result<Self, GetUserError> {
let (status, text) = { let (status, text) = {
let response = reqwest::Client::new() let response = reqwest::Client::new()
@ -47,7 +91,7 @@ impl User {
message: text.ok(), message: text.ok(),
}); });
} }
let user: Self = serde_json::from_str(&text?)?; let user: User = serde_json::from_str(&text?)?;
cookies.add_private(Cookie::new(TOKEN_COOKIE, token.to_owned())); cookies.add_private(Cookie::new(TOKEN_COOKIE, token.to_owned()));
cookies.add_private(Cookie::new(USER_ID_COOKIE, user.id.to_string())); cookies.add_private(Cookie::new(USER_ID_COOKIE, user.id.to_string()));
cookies.add_private(Cookie::new(USER_NAME_COOKIE, user.name.clone())); cookies.add_private(Cookie::new(USER_NAME_COOKIE, user.name.clone()));
@ -55,8 +99,8 @@ impl User {
USER_DISCRIMINATOR_COOKIE, USER_DISCRIMINATOR_COOKIE,
user.discriminator.to_string(), user.discriminator.to_string(),
)); ));
cookies.add_private(Cookie::new(USER_AVATAR_COOKIE, user.avatar.clone())); cookies.add_private(Cookie::new(USER_AVATAR_COOKIE, user.avatar()));
Ok(user) Ok(Self(user))
} }
pub fn purge(cookies: &CookieJar<'_>) { pub fn purge(cookies: &CookieJar<'_>) {
@ -69,12 +113,12 @@ impl User {
} }
fn from_cookies(cookies: &CookieJar<'_>) -> Option<Self> { fn from_cookies(cookies: &CookieJar<'_>) -> Option<Self> {
Some(Self { Some(Self(User {
id: parse_cookie_value(cookies, USER_ID_COOKIE)?, id: parse_cookie_value(cookies, USER_ID_COOKIE)?,
name: parse_cookie_value(cookies, USER_NAME_COOKIE)?, name: parse_cookie_value(cookies, USER_NAME_COOKIE)?,
discriminator: parse_cookie_value(cookies, USER_DISCRIMINATOR_COOKIE)?, discriminator: parse_cookie_value(cookies, USER_DISCRIMINATOR_COOKIE)?,
avatar: parse_cookie_value(cookies, USER_AVATAR_COOKIE)?, avatar: Some(parse_cookie_value(cookies, USER_AVATAR_COOKIE)?),
}) }))
} }
pub async fn get(cookies: &CookieJar<'_>) -> Result<Option<Self>, GetUserError> { pub async fn get(cookies: &CookieJar<'_>) -> Result<Option<Self>, GetUserError> {

@ -1,6 +1,6 @@
use serde::{ser::SerializeStruct, Serialize, Serializer}; use serde::{ser::SerializeStruct, Serialize, Serializer};
use super::User; use super::{User, Username};
pub fn deserialize_id<'de, D>(deserializer: D) -> Result<u64, D::Error> pub fn deserialize_id<'de, D>(deserializer: D) -> Result<u64, D::Error>
where where
@ -27,7 +27,7 @@ impl Serialize for User {
state.serialize_field("id", &self.id)?; state.serialize_field("id", &self.id)?;
state.serialize_field("name", &self.name)?; state.serialize_field("name", &self.name)?;
state.serialize_field("discriminator", &self.discriminator)?; state.serialize_field("discriminator", &self.discriminator)?;
state.serialize_field("avatar", &self.avatar)?; state.serialize_field("avatar", &self.avatar())?;
state.serialize_field("username", &self.username())?; state.serialize_field("username", &self.username())?;
state.end() state.end()
} }

@ -1,4 +1,4 @@
use super::User; use super::{User, Username};
fn test_user(name: &str, discriminator: u16) -> User { fn test_user(name: &str, discriminator: u16) -> User {
User { User {

@ -6,7 +6,7 @@ use rocket_dyn_templates::{context, Template};
use crate::{ use crate::{
cookies::LANG_COOKIE, cookies::LANG_COOKIE,
i18n::DEFAULT as DEFAULT_LANG, i18n::DEFAULT as DEFAULT_LANG,
models::{Challenge, Settings, User}, models::{Challenge, Settings, SessionUser},
utils::AcceptLanguage, utils::AcceptLanguage,
}; };
@ -27,7 +27,7 @@ pub async fn get_challenge(
.map(|cookie| vec![cookie.value().to_owned()]) .map(|cookie| vec![cookie.value().to_owned()])
.or(accept_language.0) .or(accept_language.0)
.unwrap_or_else(|| vec![DEFAULT_LANG.to_owned()]), .unwrap_or_else(|| vec![DEFAULT_LANG.to_owned()]),
user: User::get(cookies).await.unwrap(), user: SessionUser::get(cookies).await.unwrap(),
content: Challenge::get(challenge), content: Challenge::get(challenge),
}, },
) )

@ -2,7 +2,7 @@ use std::{collections::HashMap, env};
use rocket::{http::CookieJar, response::Redirect}; use rocket::{http::CookieJar, response::Redirect};
use crate::{cookies::token::TOKEN_COOKIE, models::User, utils::Referer}; use crate::{cookies::token::TOKEN_COOKIE, models::SessionUser, utils::Referer};
#[get("/logout")] #[get("/logout")]
pub fn logout(cookies: &CookieJar<'_>, referer: Referer) -> Redirect { pub fn logout(cookies: &CookieJar<'_>, referer: Referer) -> Redirect {
@ -29,7 +29,7 @@ pub fn logout(cookies: &CookieJar<'_>, referer: Referer) -> Redirect {
println!("Failed to revoke token: {:?}", error); println!("Failed to revoke token: {:?}", error);
} }
}); });
User::purge(cookies); SessionUser::purge(cookies);
let redirect_url = referer.0.unwrap_or("/".to_owned()); let redirect_url = referer.0.unwrap_or("/".to_owned());
Redirect::to(redirect_url) Redirect::to(redirect_url)
} }

@ -5,7 +5,7 @@ use rocket::{
response::Redirect, response::Redirect,
}; };
use crate::{cookies::{token::TOKEN_EXPIRE_COOKIE, WELCOMED_COOKIE}, models::User}; use crate::{cookies::{token::TOKEN_EXPIRE_COOKIE, WELCOMED_COOKIE}, models::SessionUser};
#[derive(FromForm)] #[derive(FromForm)]
pub struct Login<'r> { pub struct Login<'r> {
@ -18,7 +18,7 @@ pub struct Login<'r> {
#[post("/login", data = "<login>")] #[post("/login", data = "<login>")]
pub async fn post_login(login: Form<Login<'_>>, cookies: &CookieJar<'_>) -> Redirect { pub async fn post_login(login: Form<Login<'_>>, cookies: &CookieJar<'_>) -> Redirect {
if (login.token_type != "Bearer" || login.scope.split("+").any(|scope| scope == "identify")) if (login.token_type != "Bearer" || login.scope.split("+").any(|scope| scope == "identify"))
&& User::init(login.access_token, cookies).await.is_ok() && SessionUser::init(login.access_token, cookies).await.is_ok()
{ {
cookies.add(Cookie::new( cookies.add(Cookie::new(
TOKEN_EXPIRE_COOKIE, TOKEN_EXPIRE_COOKIE,

@ -3,7 +3,7 @@ use std::ops::Deref;
use poise::serenity_prelude::Http; use poise::serenity_prelude::Http;
use rocket::{http::CookieJar, State}; use rocket::{http::CookieJar, State};
use crate::models::User; use crate::models::SessionUser;
#[get("/testing")] #[get("/testing")]
pub async fn testing(cookies: &CookieJar<'_>, http: &State<Http>) -> String { pub async fn testing(cookies: &CookieJar<'_>, http: &State<Http>) -> String {
@ -15,11 +15,11 @@ pub async fn testing(cookies: &CookieJar<'_>, http: &State<Http>) -> String {
.expect("Failed to get testing guild") .expect("Failed to get testing guild")
.member( .member(
http.deref(), http.deref(),
User::get(cookies) SessionUser::get(cookies)
.await .await
.expect("Failed to get logged in user data") .expect("Failed to get logged in user data")
.expect("No logged in user") .expect("No logged in user")
.id .0.id
) )
.await .await
.expect("Failed to fetch user in server") .expect("Failed to fetch user in server")

@ -0,0 +1,21 @@
use std::fs;
pub fn get_challenge_number() -> i32 {
let paths = fs::read_dir("content/challenges").unwrap();
let mut max = 0;
for path in paths {
let number = path
.unwrap()
.path()
.file_stem()
.unwrap()
.to_str()
.unwrap()
.parse::<i32>()
.unwrap();
if number > max {
max = number;
}
}
max
}

@ -3,3 +3,6 @@ pub use kyujitai::Kyujitai;
mod headers; mod headers;
pub use headers::*; pub use headers::*;
mod challenge;
pub use challenge::*;

@ -25,7 +25,7 @@
{% if user %} {% if user %}
<div class="dropdown right"> <div class="dropdown right">
<a href="/users/{{ user.id }}" class="link"> <a href="/users/{{ user.id }}" class="link">
<span>{{ user.username }}</span> <img src="https://cdn.discordapp.com/avatars/{{ user.id }}/{{ user.avatar }}.webp?size=1024"> <span>{{ user.username }}</span> <img src="{{ user.avatar }}">
</a> </a>
<nav class="dropdown-content"> <nav class="dropdown-content">
<span class="link" onclick="showServers()"> <span class="link" onclick="showServers()">

@ -11,7 +11,7 @@
{% for guild in settings.guilds %} {% for guild in settings.guilds %}
{% if guild.hidden or not guild.invite %}{% continue %}{% endif %} {% if guild.hidden or not guild.invite %}{% continue %}{% endif %}
<div id="{{ guild.id }}" {% if guild.recommended %} class="recommended"{% endif %}> <div id="{{ guild.id }}" {% if guild.recommended %} class="recommended"{% endif %}>
<img src="https://cdn.discordapp.com/icons/{{ guild.id }}/{{ guild.icon }}.webp?size=96" alt="Server icon"> <img src="{{ guild.icon }}" alt="Server icon">
<div class="name">{{ guild.name }}</div> <div class="name">{{ guild.name }}</div>
<a href="https://discord.gg/{{ guild.invite }}" class="joinButton disabled"> <a href="https://discord.gg/{{ guild.invite }}" class="joinButton disabled">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="icon" style="height: 1em"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="icon" style="height: 1em">

Loading…
Cancel
Save