rust
Elnu 1 year ago
parent 6286b73b4b
commit 79705d03a4

@ -9,4 +9,4 @@ pub mod user {
pub const USER_AVATAR_COOKIE: &str = "user_avatar"; pub const USER_AVATAR_COOKIE: &str = "user_avatar";
} }
pub const LANG_COOKIE: &str = "lang"; pub const LANG_COOKIE: &str = "lang";
pub const WELCOMED_COOKIE: &str = "welcomed"; pub const WELCOMED_COOKIE: &str = "welcomed";

@ -1,14 +1,17 @@
#[macro_use] #[macro_use]
extern crate rocket; extern crate rocket;
use clap::{Parser, Subcommand};
use poise::serenity_prelude::Http; use poise::serenity_prelude::Http;
use rocket::{fs::{relative, FileServer}, Rocket, Ignite}; use rocket::{
fs::{relative, FileServer},
Ignite, Rocket,
};
use rocket_dyn_templates::{tera, Template}; use rocket_dyn_templates::{tera, Template};
use std::{collections::HashMap, env}; use std::{collections::HashMap, env};
use clap::{Parser, Subcommand};
mod models; mod models;
use models::{Settings, Database}; use models::{Database, Settings};
mod utils; mod utils;
@ -52,13 +55,14 @@ async fn main() {
} }
let http = http(); let http = http();
load_database() load_database()
.load_legacy(&http).await .load_legacy(&http)
.await
.expect("Failed to load legacy submissions"); .expect("Failed to load legacy submissions");
}, }
}, },
None => { None => {
rocket().await.expect("Failed to launch rocket"); rocket().await.expect("Failed to launch rocket");
}, }
} }
} }
@ -86,7 +90,16 @@ async fn rocket() -> Result<Rocket<Ignite>, rocket::Error> {
.manage(load_database()) .manage(load_database())
.mount( .mount(
"/", "/",
routes![get_challenge, get_guilds, get_user, login, post_login, success, logout, testing], routes![
get_challenge,
get_guilds,
get_user,
login,
post_login,
success,
logout,
testing
],
) )
.mount("/css", FileServer::from(relative!("styles/css"))) .mount("/css", FileServer::from(relative!("styles/css")))
.mount("/", FileServer::from(relative!("assets")).rank(2)) .mount("/", FileServer::from(relative!("assets")).rank(2))
@ -108,12 +121,12 @@ async fn rocket() -> Result<Rocket<Ignite>, rocket::Error> {
); );
engines.tera.register_filter( engines.tera.register_filter(
"furigana", "furigana",
move |value: &Value, args: &HashMap<String, Value>| { move |value: &Value, args: &HashMap<String, Value>| furigana_filter(value, args),
furigana_filter(value, args)
},
) )
})) }))
.ignite().await .ignite()
.await
.unwrap() .unwrap()
.launch().await .launch()
.await
} }

@ -35,7 +35,7 @@ impl Challenge {
.expect("Couldn't find challenge file"); .expect("Couldn't find challenge file");
let root = parse_document( let root = parse_document(
&arena, &arena,
&(challenge_text &(challenge_text
// comrak can't find frontmatter if there's only frontmatter and no newline at end // comrak can't find frontmatter if there's only frontmatter and no newline at end
// TODO: Open issue in comrak // TODO: Open issue in comrak
+ "\n"), + "\n"),
@ -53,8 +53,9 @@ impl Challenge {
let mut html = vec![]; let mut html = vec![];
format_html(root, &ComrakOptions::default(), &mut html) format_html(root, &ComrakOptions::default(), &mut html)
.expect("Failed to format HTML"); .expect("Failed to format HTML");
challenge.text = Some(furigana_to_html(&gh_emoji::Replacer::new() challenge.text = Some(furigana_to_html(
.replace_all(&String::from_utf8(html).unwrap()))); &gh_emoji::Replacer::new().replace_all(&String::from_utf8(html).unwrap()),
));
challenge challenge
} else { } else {
panic!("No frontmatter!") panic!("No frontmatter!")

@ -1,13 +1,13 @@
use std::collections::HashSet; use std::collections::HashSet;
use std::{fs::File, io::Read, collections::HashMap, path::Path}; use std::{collections::HashMap, fs::File, io::Read, path::Path};
use poise::serenity_prelude::{SerenityError, Http}; use derive_more::From;
use poise::serenity_prelude::{Http, SerenityError};
use r2d2::{Pool, PooledConnection}; use r2d2::{Pool, PooledConnection};
use r2d2_sqlite::SqliteConnectionManager;
use r2d2_sqlite::rusqlite::{self, params}; use r2d2_sqlite::rusqlite::{self, params};
use derive_more::From; use r2d2_sqlite::SqliteConnectionManager;
use crate::{utils::get_challenge_number, models::User}; use crate::{models::User, utils::get_challenge_number};
use super::{LegacySubmission, Submission}; use super::{LegacySubmission, Submission};
@ -38,9 +38,7 @@ impl Database {
Path::new(DATABASE_FILENAME).exists() Path::new(DATABASE_FILENAME).exists()
} }
pub fn new( pub fn new(testing: bool) -> Result<Self> {
testing: bool,
) -> Result<Self> {
let connection_manager = if testing { let connection_manager = if testing {
SqliteConnectionManager::memory() SqliteConnectionManager::memory()
} else { } else {
@ -77,7 +75,8 @@ impl Database {
} }
pub fn has_submitted(&self, user_id: u64) -> Result<bool> { pub fn has_submitted(&self, user_id: u64) -> Result<bool> {
Ok(self.conn()? Ok(self
.conn()?
.prepare("SELECT 1 FROM User WHERE id = ?1 LIMIT 1")? .prepare("SELECT 1 FROM User WHERE id = ?1 LIMIT 1")?
.query_row(params![user_id], |_row| Ok(())) .query_row(params![user_id], |_row| Ok(()))
.is_ok()) .is_ok())
@ -103,38 +102,47 @@ impl Database {
// their username/discriminator since their previous submission // their username/discriminator since their previous submission
match archived_users.get(&id) { match archived_users.get(&id) {
Some(User { deleted, .. }) => { Some(User { deleted, .. }) => {
archived_users.insert(id, User { archived_users.insert(
id, id,
avatar: None, User {
deleted: *deleted,
..User::from_username(&legacy.username)
});
},
None => match User::fetch(http, id).await {
Ok(User { deleted: true, .. }) => {
archived_users.insert(id, User {
id, id,
avatar: None, avatar: None,
deleted: true, deleted: *deleted,
..User::from_username(&legacy.username) ..User::from_username(&legacy.username)
}); },
}, );
}
None => match User::fetch(http, id).await {
Ok(User { deleted: true, .. }) => {
archived_users.insert(
id,
User {
id,
avatar: None,
deleted: true,
..User::from_username(&legacy.username)
},
);
}
Ok(user) => { Ok(user) => {
conn.execute( conn.execute(
"INSERT INTO User(id, name, discriminator, avatar, deleted) VALUES (?1, ?2, ?3, ?4, ?5)", "INSERT INTO User(id, name, discriminator, avatar, deleted) VALUES (?1, ?2, ?3, ?4, ?5)",
params![user.id, user.name, user.discriminator, user.avatar, user.deleted] params![user.id, user.name, user.discriminator, user.avatar, user.deleted]
).map_err(DatabaseError::Rusqlite)?; ).map_err(DatabaseError::Rusqlite)?;
}, }
Err(error) if error.to_string().eq("Unknown User") => { Err(error) if error.to_string().eq("Unknown User") => {
// This will also be called in the case of an invalid user ID // This will also be called in the case of an invalid user ID
println!("Failed to fetch user {id}, adding to archive"); println!("Failed to fetch user {id}, adding to archive");
archived_users.insert(id, User { archived_users.insert(
id, id,
avatar: None, User {
deleted: false, id,
..User::from_username(&legacy.username) avatar: None,
}); deleted: false,
}, ..User::from_username(&legacy.username)
},
);
}
Err(error) => return Err(LoadLegacyError::Serenity(error)), Err(error) => return Err(LoadLegacyError::Serenity(error)),
}, },
}; };
@ -160,8 +168,11 @@ impl Database {
} }
Ok(()) Ok(())
} }
pub fn get_challenge_user_data(&self, challenge: u32) -> Result<(Vec<Submission>, HashMap<String, User>)> { pub fn get_challenge_user_data(
&self,
challenge: u32,
) -> Result<(Vec<Submission>, HashMap<String, User>)> {
let submissions = self.get_submissions(challenge)?; let submissions = self.get_submissions(challenge)?;
let users = self.get_users({ let users = self.get_users({
let mut user_ids = HashSet::new(); let mut user_ids = HashSet::new();
@ -174,7 +185,8 @@ impl Database {
} }
pub fn get_submissions(&self, challenge: u32) -> Result<Vec<Submission>> { pub fn get_submissions(&self, challenge: u32) -> Result<Vec<Submission>> {
Ok(self.conn()? Ok(self
.conn()?
.prepare("SELECT author_id, timestamp, image FROM Submission WHERE challenge = ?1")? .prepare("SELECT author_id, timestamp, image FROM Submission WHERE challenge = ?1")?
.query_map(params![challenge], |row| { .query_map(params![challenge], |row| {
Ok(Submission { Ok(Submission {
@ -188,7 +200,8 @@ impl Database {
} }
pub fn get_user_by_name(&self, name: &str) -> Result<Option<User>> { pub fn get_user_by_name(&self, name: &str) -> Result<Option<User>> {
Ok(self.conn()? Ok(self
.conn()?
.prepare("SELECT id, discriminator, avatar, deleted FROM User where name = ?1")? .prepare("SELECT id, discriminator, avatar, deleted FROM User where name = ?1")?
.query_row(params![name], |row| { .query_row(params![name], |row| {
Ok(Some(User { Ok(Some(User {
@ -202,8 +215,10 @@ impl Database {
} }
pub fn get_submissions_by_user_name(&self, name: &str) -> Result<Vec<Submission>> { pub fn get_submissions_by_user_name(&self, name: &str) -> Result<Vec<Submission>> {
Ok(self.conn()? Ok(self
.prepare(" .conn()?
.prepare(
"
SELECT author_id, timestamp, image, challenge SELECT author_id, timestamp, image, challenge
FROM Submission s FROM Submission s
JOIN User u ON s.author_id = u.id JOIN User u ON s.author_id = u.id
@ -227,16 +242,23 @@ impl Database {
.iter() .iter()
// u64 must be converted to String for templates // u64 must be converted to String for templates
.map(|id| -> Result<(String, User)> { .map(|id| -> Result<(String, User)> {
match conn.prepare("SELECT name, discriminator, avatar, deleted FROM User WHERE id = ?1") { match conn
Ok(mut statement) => statement.query_row(params![id], |row| { .prepare("SELECT name, discriminator, avatar, deleted FROM User WHERE id = ?1")
Ok((id.to_string(), User { {
id: *id, Ok(mut statement) => statement
name: row.get(0)?, .query_row(params![id], |row| {
discriminator: row.get(1)?, Ok((
avatar: row.get(2)?, id.to_string(),
deleted: row.get(3)?, User {
})) id: *id,
}).map_err(DatabaseError::Rusqlite), name: row.get(0)?,
discriminator: row.get(1)?,
avatar: row.get(2)?,
deleted: row.get(3)?,
},
))
})
.map_err(DatabaseError::Rusqlite),
Err(error) => Err(DatabaseError::Rusqlite(error)), Err(error) => Err(DatabaseError::Rusqlite(error)),
} }
}) })
@ -255,4 +277,4 @@ impl Database {
// For new submissions only // For new submissions only
todo!() todo!()
} }
} }

@ -1,5 +1,5 @@
mod user; mod user;
pub use user::{Username, User, SessionUser}; pub use user::{SessionUser, User, Username};
mod challenge; mod challenge;
pub use challenge::Challenge; pub use challenge::Challenge;
@ -11,4 +11,4 @@ mod submission;
pub use submission::*; pub use submission::*;
mod database; mod database;
pub use database::{Database, DatabaseError}; pub use database::{Database, DatabaseError};

@ -51,11 +51,13 @@ 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_url().map(|icon| if icon.contains("/a_") { self.icon = Some(server.icon_url().map(|icon| {
// serenity only gives non-animated URL if icon.contains("/a_") {
icon.replace("webp", "gif") // serenity only gives non-animated URL
} else { icon.replace("webp", "gif")
icon } else {
icon
}
})); }));
self.name = Some(server.name); self.name = Some(server.name);
Ok(()) Ok(())

@ -1,6 +1,6 @@
use chrono::{Utc, TimeZone}; use chrono::{TimeZone, Utc};
use serde::Deserialize;
use derive_more::From; use derive_more::From;
use serde::Deserialize;
use super::Submission; use super::Submission;
@ -28,7 +28,8 @@ pub enum LegacySubmissionParseError {
impl LegacySubmission { impl LegacySubmission {
pub fn parse(&self, challenge: u32) -> Result<Vec<Submission>, LegacySubmissionParseError> { pub fn parse(&self, challenge: u32) -> Result<Vec<Submission>, LegacySubmissionParseError> {
let author_id = self.id; let author_id = self.id;
Ok(self.images Ok(self
.images
.iter() .iter()
.map(|image| { .map(|image| {
Submission { Submission {
@ -37,20 +38,25 @@ impl LegacySubmission {
// The last number of the filename is either a discriminator or a datestamp // 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, // We can split apart the filename and check if it's >9999. In that case,
// it's a datestamp. // it's a datestamp.
(|| image (|| {
// Get filename without extension image
.split('.').next()? // Get filename without extension
// Get last number .split('.')
.split('-') .next()?
.last()? // Get last number
.parse() .split('-')
.ok() .last()?
// Check if discriminator or timestamp, then convert .parse()
.map(|number| if number > 9999 { .ok()
Utc.timestamp_millis_opt(number).single() // Check if discriminator or timestamp, then convert
} else { .map(|number| {
None if number > 9999 {
})?)() Utc.timestamp_millis_opt(number).single()
} else {
None
}
})?
})()
}, },
image: image.clone(), image: image.clone(),
challenge, challenge,
@ -58,4 +64,4 @@ impl LegacySubmission {
}) })
.collect()) .collect())
} }
} }

@ -2,4 +2,4 @@ mod submission;
pub use submission::Submission; pub use submission::Submission;
mod legacy_submission; mod legacy_submission;
pub use legacy_submission::LegacySubmission; pub use legacy_submission::LegacySubmission;

@ -1,4 +1,4 @@
use chrono::{Utc, DateTime}; use chrono::{DateTime, Utc};
use serde::Serialize; use serde::Serialize;
// Challenge submission // Challenge submission
@ -9,7 +9,7 @@ use serde::Serialize;
pub struct Submission { pub struct Submission {
pub author_id: u64, pub author_id: u64,
// Some fields might be empty for legacy submissions // Some fields might be empty for legacy submissions
// Starting during challenge #87, submissions have a datestamp appended to their filename. // Starting during challenge #87, submissions have a datestamp appended to their filename.
// TODO: Determine whether this datestamp is local time or UTC // TODO: Determine whether this datestamp is local time or UTC
pub timestamp: Option<DateTime<Utc>>, pub timestamp: Option<DateTime<Utc>>,
// Image path relative to submission folder. // Image path relative to submission folder.
@ -18,4 +18,4 @@ pub struct Submission {
// Not necessary for challenge pages, // Not necessary for challenge pages,
// but needed for user profile pages // but needed for user profile pages
pub challenge: u32, pub challenge: u32,
} }

@ -5,7 +5,7 @@ mod tests;
use chrono::Utc; use chrono::Utc;
use derive_more::From; use derive_more::From;
use poise::serenity_prelude::{self, UserId, Http}; use poise::serenity_prelude::{self, Http, UserId};
use regex::Regex; use regex::Regex;
use reqwest::StatusCode; use reqwest::StatusCode;
use rocket::http::{Cookie, CookieJar}; use rocket::http::{Cookie, CookieJar};
@ -46,7 +46,9 @@ impl Username for User {
} }
fn is_name_deleted(name: &str) -> bool { fn is_name_deleted(name: &str) -> bool {
Regex::new(r"Deleted User [a-f0-9]{8}").unwrap().is_match(name) Regex::new(r"Deleted User [a-f0-9]{8}")
.unwrap()
.is_match(name)
} }
impl User { impl User {
@ -54,10 +56,7 @@ impl User {
let (name, discriminator) = { let (name, discriminator) = {
let mut iter = username.split('#'); let mut iter = username.split('#');
let name = iter.next().unwrap().to_owned(); let name = iter.next().unwrap().to_owned();
let discriminator = iter let discriminator = iter.next().map(|str| str.parse().unwrap()).unwrap_or(0);
.next()
.map(|str| str.parse().unwrap())
.unwrap_or(0);
(name, discriminator) (name, discriminator)
}; };
Self { Self {
@ -86,12 +85,19 @@ impl User {
"https://cdn.discordapp.com/avatars/{}/{}.{}?size=1024", "https://cdn.discordapp.com/avatars/{}/{}.{}?size=1024",
self.id, self.id,
avatar, avatar,
if avatar.starts_with("a_") { "gif" } else { "webp" } if avatar.starts_with("a_") {
"gif"
} else {
"webp"
}
), ),
// Archived user or user with no avatar, calculate default avatar // Archived user or user with no avatar, calculate default avatar
// https://www.reddit.com/r/discordapp/comments/au6v4e/comment/eh61dm6/ // 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 // 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), None => format!(
"https://cdn.discordapp.com/embed/avatars/{}.png",
self.discriminator % 5
),
} }
} }
} }

@ -27,4 +27,4 @@ fn is_name_deleted() {
assert!(!is_name_deleted("Deleted User Ce34a7da")); // capital letter in hex assert!(!is_name_deleted("Deleted User Ce34a7da")); // capital letter in hex
assert!(!is_name_deleted("Deleted User ce34a7d")); // hex too short assert!(!is_name_deleted("Deleted User ce34a7d")); // hex too short
assert!(!is_name_deleted("Deleted User")); // no hex assert!(!is_name_deleted("Deleted User")); // no hex
} }

@ -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, SessionUser, Database}, models::{Challenge, Database, SessionUser, Settings},
utils::AcceptLanguage, utils::AcceptLanguage,
}; };

@ -1,6 +1,6 @@
use poise::serenity_prelude::Http; use poise::serenity_prelude::Http;
use rocket::{http::CookieJar, State};
use rocket::response::stream::{Event, EventStream}; use rocket::response::stream::{Event, EventStream};
use rocket::{http::CookieJar, State};
use crate::{cookies::user::USER_ID_COOKIE, models::Settings}; use crate::{cookies::user::USER_ID_COOKIE, models::Settings};

@ -1,13 +1,15 @@
use std::ops::Deref; use std::ops::Deref;
use rocket::{
use rocket::{http::{CookieJar, Status}, State}; http::{CookieJar, Status},
State,
};
use rocket_dyn_templates::{context, Template}; 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::{Settings, SessionUser, Database, DatabaseError}, models::{Database, DatabaseError, SessionUser, Settings},
utils::AcceptLanguage, utils::AcceptLanguage,
}; };
@ -21,11 +23,13 @@ pub async fn get_user(
) -> Result<Template, Status> { ) -> Result<Template, Status> {
let profile_user = match database.get_user_by_name(&user) { let profile_user = match database.get_user_by_name(&user) {
Ok(profile_user) => profile_user, Ok(profile_user) => profile_user,
Err(DatabaseError::Rusqlite(rusqlite::Error::QueryReturnedNoRows)) => return Err(Status::NotFound), Err(DatabaseError::Rusqlite(rusqlite::Error::QueryReturnedNoRows)) => {
return Err(Status::NotFound)
}
Err(error) => { Err(error) => {
eprintln!("{:?}", error); eprintln!("{:?}", error);
return Err(Status::InternalServerError); return Err(Status::InternalServerError);
}, }
}; };
Ok(Template::render( Ok(Template::render(
"user", "user",
@ -41,4 +45,4 @@ pub async fn get_user(
user: SessionUser::get(cookies).await.unwrap(), user: SessionUser::get(cookies).await.unwrap(),
}, },
)) ))
} }

@ -5,7 +5,10 @@ use rocket::{
response::Redirect, response::Redirect,
}; };
use crate::{cookies::{token::TOKEN_EXPIRE_COOKIE, WELCOMED_COOKIE}, models::SessionUser}; use crate::{
cookies::{token::TOKEN_EXPIRE_COOKIE, WELCOMED_COOKIE},
models::SessionUser,
};
#[derive(FromForm)] #[derive(FromForm)]
pub struct Login<'r> { pub struct Login<'r> {

@ -19,7 +19,8 @@ pub async fn testing(cookies: &CookieJar<'_>, http: &State<Http>) -> String {
.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")
.0.id .0
.id
) )
.await .await
.expect("Failed to fetch user in server") .expect("Failed to fetch user in server")

@ -18,4 +18,4 @@ pub fn get_challenge_number() -> i32 {
} }
} }
max max
} }

@ -12,14 +12,19 @@ pub fn furigana_to_html(text: &str) -> String {
// Curly brace literals \{ need to have their backslash escaped as \\{ // Curly brace literals \{ need to have their backslash escaped as \\{
// TODO: Modify so <span lang="ja"> only wraps continuous sections of furigana // TODO: Modify so <span lang="ja"> only wraps continuous sections of furigana
let re = Regex::new(r"\[([^\]]*)\]\{([^\\}]*)}").unwrap(); let re = Regex::new(r"\[([^\]]*)\]\{([^\\}]*)}").unwrap();
format!("<span lang=\"ja\">{}</span>", re.replace_all(text, "<ruby>$1<rp>(</rp><rt>$2</rt><rp>)</rp></ruby>")) format!(
"<span lang=\"ja\">{}</span>",
re.replace_all(text, "<ruby>$1<rp>(</rp><rt>$2</rt><rp>)</rp></ruby>")
)
} }
pub fn furigana_filter(value: &Value, _args: &HashMap<String, Value>) -> tera::Result<Value> { pub fn furigana_filter(value: &Value, _args: &HashMap<String, Value>) -> tera::Result<Value> {
if value.is_null() { if value.is_null() {
return Ok(Value::String("".to_string())); return Ok(Value::String("".to_string()));
} }
Ok(Value::String(furigana_to_html(value.as_str().expect("The furigana input must be a string")))) Ok(Value::String(furigana_to_html(
value.as_str().expect("The furigana input must be a string"),
)))
} }
#[cfg(test)] #[cfg(test)]
@ -36,4 +41,4 @@ mod tests {
</span>" </span>"
); );
} }
} }

@ -8,4 +8,4 @@ mod challenge;
pub use challenge::*; pub use challenge::*;
mod furigana; mod furigana;
pub use furigana::*; pub use furigana::*;

Loading…
Cancel
Save