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 LANG_COOKIE: &str = "lang";
pub const WELCOMED_COOKIE: &str = "welcomed";
pub const WELCOMED_COOKIE: &str = "welcomed";

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

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

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

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

@ -51,11 +51,13 @@ pub struct Guild {
impl Guild {
pub async fn load(&mut self, http: &Http) -> poise::serenity_prelude::Result<()> {
let server = http.get_guild(self.id).await?;
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.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);
Ok(())

@ -1,6 +1,6 @@
use chrono::{Utc, TimeZone};
use serde::Deserialize;
use chrono::{TimeZone, Utc};
use derive_more::From;
use serde::Deserialize;
use super::Submission;
@ -28,7 +28,8 @@ pub enum LegacySubmissionParseError {
impl LegacySubmission {
pub fn parse(&self, challenge: u32) -> Result<Vec<Submission>, LegacySubmissionParseError> {
let author_id = self.id;
Ok(self.images
Ok(self
.images
.iter()
.map(|image| {
Submission {
@ -37,20 +38,25 @@ impl LegacySubmission {
// 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('.').next()?
// 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
// Get filename without extension
.split('.')
.next()?
// 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(),
challenge,
@ -58,4 +64,4 @@ impl LegacySubmission {
})
.collect())
}
}
}

@ -2,4 +2,4 @@ mod submission;
pub use submission::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;
// Challenge submission
@ -9,7 +9,7 @@ use serde::Serialize;
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.
// 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.
@ -18,4 +18,4 @@ pub struct Submission {
// Not necessary for challenge pages,
// but needed for user profile pages
pub challenge: u32,
}
}

@ -5,7 +5,7 @@ mod tests;
use chrono::Utc;
use derive_more::From;
use poise::serenity_prelude::{self, UserId, Http};
use poise::serenity_prelude::{self, Http, UserId};
use regex::Regex;
use reqwest::StatusCode;
use rocket::http::{Cookie, CookieJar};
@ -46,7 +46,9 @@ impl Username for User {
}
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 {
@ -54,10 +56,7 @@ impl User {
let (name, discriminator) = {
let mut iter = username.split('#');
let name = iter.next().unwrap().to_owned();
let discriminator = iter
.next()
.map(|str| str.parse().unwrap())
.unwrap_or(0);
let discriminator = iter.next().map(|str| str.parse().unwrap()).unwrap_or(0);
(name, discriminator)
};
Self {
@ -86,12 +85,19 @@ impl User {
"https://cdn.discordapp.com/avatars/{}/{}.{}?size=1024",
self.id,
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
// 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),
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 ce34a7d")); // hex too short
assert!(!is_name_deleted("Deleted User")); // no hex
}
}

@ -6,7 +6,7 @@ use rocket_dyn_templates::{context, Template};
use crate::{
cookies::LANG_COOKIE,
i18n::DEFAULT as DEFAULT_LANG,
models::{Challenge, Settings, SessionUser, Database},
models::{Challenge, Database, SessionUser, Settings},
utils::AcceptLanguage,
};

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

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

@ -5,7 +5,10 @@ use rocket::{
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)]
pub struct Login<'r> {

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

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

@ -12,14 +12,19 @@ pub fn furigana_to_html(text: &str) -> String {
// Curly brace literals \{ need to have their backslash escaped as \\{
// TODO: Modify so <span lang="ja"> only wraps continuous sections of furigana
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> {
if value.is_null() {
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)]
@ -36,4 +41,4 @@ mod tests {
</span>"
);
}
}
}

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

Loading…
Cancel
Save