diff --git a/Cargo.lock b/Cargo.lock index 65b8c9d..b3eece5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -578,7 +578,7 @@ checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" [[package]] name = "fabula_votes_server" -version = "1.3.0" +version = "1.4.1" dependencies = [ "argon2", "blake2", diff --git a/Cargo.toml b/Cargo.toml index d20f8c4..298d596 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ name = "fabula_votes_server" license = "MPL-2.0" readme = "README.md" authors = ["trotFunky"] -version = "1.3.0" +version = "1.4.1" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/README.md b/README.md index 2b6d940..c2e95a2 100644 --- a/README.md +++ b/README.md @@ -16,18 +16,23 @@ A list of things that could be implemented/added to the application, some of the - [ ] Move the database queries to their own functions - [ ] Cache those results - [ ] Centralize Markdown parsing ? - - [ ] Use fairings for the different elements ? + - [x] Use fairings for the different elements - [ ] Use guards for User calls ? + - [ ] Use SQLite Row ID for User IDs rather than regular IDs, for randomness ? + - [x] Split user from vote data # Dependencies -This project currently uses : +This project currently uses, for the backend : - [Rocket](https://docs.rs/rocket/0.5.1/rocket/), for the web application backend - [SQLX](https://docs.rs/sqlx/0.7.4/sqlx/), for database access (this is only expeceted to be used with SQLite) - [Tera](https://docs.rs/tera/latest/tera/), for templating - [Argon2](https://docs.rs/argon2/latest/argon2/), for password hashing - [Pull_down CMark](https://docs.rs/pulldown-cmark/0.11.0/pulldown_cmark/), for markdown rendering +For the frontend : + - [Chart.js](https://www.chartjs.org/), for rendering the vote graph. + # License The code present in this repository is licensed under the Mozilla Public License 2.0. diff --git a/db/04_create-tags-tables.sql b/db/04_create-tags-tables.sql new file mode 100644 index 0000000..e7bb714 --- /dev/null +++ b/db/04_create-tags-tables.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS Tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR NOT NULL UNIQUE, + color VARCHAR NOT NULL +); + +CREATE TABLE IF NOT EXISTS TaggedTruths ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + truth_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + FOREIGN KEY (truth_id) REFERENCES Truths(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES Tags(id) ON DELETE CASCADE + ); diff --git a/src/auth.rs b/src/auth.rs index e022532..672b79d 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -8,7 +8,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; use argon2::{Argon2, PasswordHash, PasswordVerifier}; use blake2::{Blake2b512, Digest}; use blake2::digest::FixedOutput; -use crate::database_records::{AuthTokens, PlayerLoginInfo, Vote}; +use rocket::fairing::AdHoc; +use crate::database_records::{AuthTokens, PlayerLoginInfo}; use crate::{database, week}; use database::Db; @@ -20,11 +21,9 @@ pub struct User { pub is_admin: bool, pub id: u16, pub name: String, - pub has_week_vote: bool, - pub votes: Vec } -pub async fn get_user(week: u8, db: &mut Connection, cookies: &CookieJar<'_>) -> User { +pub async fn get_user(db: &mut Connection, cookies: &CookieJar<'_>) -> User { let auth_token: Option = match cookies.get_private("auth_token") { Some(cookie) => Some(cookie.value().to_string()), None => None @@ -86,27 +85,12 @@ pub async fn get_user(week: u8, db: &mut Connection, cookies: &CookieJar<'_> (String::new(), false) }; - // TODO: Move to src/vote.rs - let votes: Vec = if logged_in && !is_admin { - sqlx::query_as("SELECT Votes.* FROM Votes JOIN Truths ON Votes.truth_id == Truths.id AND Truths.week == $1 WHERE voter_id == $2 ORDER BY Truths.number;") - .bind(week) - .bind(&id_str) - .fetch_all(&mut ***db).await.unwrap_or_else(|error| { - error!("Error while getting votes : {error}"); - Vec::::new() - }) - } else { - Vec::::new() - }; - if logged_in { User { logged_in, is_admin, id: id_str.parse::().unwrap(), name, - has_week_vote: if votes.is_empty() { false } else { true }, - votes } } else { User { @@ -114,8 +98,6 @@ pub async fn get_user(week: u8, db: &mut Connection, cookies: &CookieJar<'_> is_admin: false, id: 0, name, - has_week_vote: false, - votes } } } @@ -211,17 +193,8 @@ pub async fn logout(week: u8, mut db: Connection, cookies: &CookieJar<'_>) - Redirect::to(uri!(week::week(week))) } -pub fn bypass_auth_debug(cookies: &CookieJar<'_>) { - if cookies.get_private("auth_token").is_some() { - return - } - let mut hasher = Blake2b512::new(); - hasher.update(b"8"); - hasher.update(SystemTime::now().duration_since(UNIX_EPOCH).expect("Non monotonous time event").as_secs().to_le_bytes()); - let hash = hasher.finalize_fixed().to_ascii_lowercase(); - let hash_str = String::from_utf8_lossy(hash.as_slice()).to_ascii_lowercase(); - cookies.add_private(("auth_token", hash_str.clone())); - cookies.add_private(("auth_id", 8.to_string())); - - println!("Generated hash string : {hash_str}"); -} \ No newline at end of file +pub fn stage() -> AdHoc { + AdHoc::on_ignite("Auth stage", |rocket| async { + rocket.mount("/", routes![login, logout]) + }) +} diff --git a/src/database_records.rs b/src/database_records.rs index b647a2c..2f49061 100644 --- a/src/database_records.rs +++ b/src/database_records.rs @@ -19,21 +19,26 @@ pub struct PlayerLoginInfo { #[derive(sqlx::FromRow, Deserialize, Serialize)] #[serde(crate = "rocket::serde")] pub struct Truth { - id: u32, + pub id: u32, week: u8, number: u8, author_id: u16, rendered_text: String, raw_text: String, + #[sqlx(skip)] + pub tags: Vec, } #[derive(sqlx::FromRow, Deserialize, Serialize)] #[serde(crate = "rocket::serde")] pub struct DisplayTruth { - id: u32, + pub id: u32, + week: u8, number: u8, author_id: u16, rendered_text: String, + #[sqlx(skip)] + pub tags: Vec, } #[derive(sqlx::FromRow, Deserialize, Serialize)] @@ -67,3 +72,11 @@ pub struct Week { pub rendered_text: String, pub raw_text: String, } + +#[derive(FromForm)] +#[derive(sqlx::FromRow, Deserialize, Serialize)] +#[serde(crate = "rocket::serde")] +pub struct Tag { + pub name: String, + pub color: String +} diff --git a/src/main.rs b/src/main.rs index ac5bc3f..bf2299c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,10 +5,10 @@ use rocket::response::Redirect; use rocket_dyn_templates::Template; -use rocket_db_pools::{sqlx, sqlx::Row, Connection}; +use rocket_db_pools::Connection; mod auth; - +mod tag; mod truth; mod vote; mod week; @@ -20,14 +20,7 @@ use database::Db; #[get("/")] async fn index(mut db: Connection) -> Redirect { - let current_week: u8 = match sqlx::query("SELECT number FROM Weeks WHERE is_last_week == 1;") - .fetch_one(&mut **db).await { - Ok(v) => v.try_get(0).ok().unwrap_or_else(|| 1), // If error, go back to 1 - Err(error) => { - error!("Error while getting current week : {error:?}"); - 1 - } - }; + let current_week: u8 = week::get_last_week(&mut db).await; Redirect::to(uri!(week::week(week_number = if current_week == 0 {1} else {current_week}))) } @@ -35,14 +28,13 @@ async fn index(mut db: Connection) -> Redirect { #[launch] fn rocket() -> _ { rocket::build() - .mount("/", FileServer::from(relative!("static_files"))) - .mount("/", routes![index, - vote::fetch_vote_data, vote::vote, - truth::create_truth, truth::edit_truth, - week::week, week::update_week, week::set_last_week, week::create_week, - auth::login, auth::logout]) + .mount("/static/", FileServer::from(relative!("static_files"))) + .attach(auth::stage()) + .attach(week::stage()) + .attach(truth::stage()) + .attach(vote::stage()) + .attach(tag::stage()) + .mount("/", routes![index]) .attach(database::stage()) .attach(Template::fairing()) } - -// TODO: Random Row ID \ No newline at end of file diff --git a/src/tag.rs b/src/tag.rs new file mode 100644 index 0000000..1069e36 --- /dev/null +++ b/src/tag.rs @@ -0,0 +1,295 @@ +use rocket::fairing::AdHoc; +use rocket::form::Form; +use rocket::http::CookieJar; +use rocket::response::Redirect; +use rocket_db_pools::Connection; +use rocket_dyn_templates::{context, Template}; +use sqlx::{Sqlite, QueryBuilder}; +use crate::database::Db; +use crate::database_records::{DisplayTruth, Tag}; + +use crate::auth; +use crate::week; + +pub async fn get_all_tags(db: &mut Connection, cookies: &CookieJar<'_>) -> Vec { + match sqlx::query_as("SELECT name, color FROM Tags") + .fetch_all(&mut ***db) + .await { + Ok(tags) => tags, + Err(error) => { + error!("Database error while fetching tags : {error}"); + cookies.add(("toast_error", "Erreur lors du chargement des tags.")); + Vec::::new() + } + } +} + +pub async fn get_truth_tags(truth_id: u32, db: &mut Connection) -> Vec { + match sqlx::query_as(" + SELECT Tags.name, Tags.color FROM Tags + JOIN TaggedTruths ON TaggedTruths.tag_id == Tags.id AND TaggedTruths.truth_id == $1 + ORDER BY Tags.id;") + .bind(truth_id) + .fetch_all(&mut ***db) + .await { + Ok(tags) => tags, + Err(error) => { + error!("Could not fetch tags for truth {:?} : {error}", truth_id); + Vec::::new() + } + } +} + +#[get("/")] +pub async fn tag_index(mut db: Connection, cookies: &CookieJar<'_>) -> Template { + let user = auth::get_user(&mut db, cookies).await; + + let tags: Vec = get_all_tags(&mut db, cookies).await; + + Template::render("tags/index", context!{ + user: user, + tags: tags + }) +} + +#[get("/filter?")] +pub async fn filtered_by_tags(tags: Vec, mut db: Connection) -> Template { + let last_week = week::get_last_week(&mut db).await; + let filtered_truths: Vec = if tags.len() == 0 { + match sqlx::query_as("SELECT id, week, number, author_id, rendered_text FROM Truths WHERE week <= $1 ORDER BY week, number;") + .bind(last_week) + .fetch_all(&mut **db) + .await { + Ok(all_truths) => all_truths, + Err(error) => { + error!("Error while fetching all truths for the filter : {error}"); + Vec::::new() + } + } + } else { + let mut query_builder: QueryBuilder = QueryBuilder::new(" + SELECT Truths.id, Truths.week, Truths.number, Truths.author_id, Truths.rendered_text FROM Truths + JOIN TaggedTruths ON Truths.id == TaggedTruths.truth_id + JOIN Tags ON Tags.id == TaggedTruths.tag_id AND Tags.name IN ( + "); + let mut separated_args = query_builder.separated(", "); + for tag in &tags { + separated_args.push_bind(tag); + } + + // Now that the comma separated list of strings is built, finish the query normally. + query_builder.push(") WHERE Truths.week <= "); + query_builder.push_bind(last_week); + query_builder.push("GROUP BY Truths.id ORDER BY Truths.week, Truths.number;"); + match query_builder.build_query_as::().fetch_all(&mut **db).await { + Ok(truths) => { + debug!("Got filtered truths by tags"); + truths + }, + Err(error) => { + error!("Error while fetching filtered truths : {error}"); + Vec::::new() + } + } + }; + + let filter_tags: Vec:: = match sqlx::query_as("SELECT name, color FROM Tags;") + .fetch_all(&mut **db) + .await { + Ok(all_tags) => { + all_tags.into_iter().filter(|tag: &Tag| tags.contains(&tag.name)).collect() + }, + Err(error) => { + error!("Error getting tags to show on filter list : {error}"); + Vec::::new() + } + }; + + Template::render("tags/filtered_truths", context!{ + tags: filter_tags, + truths: filtered_truths + }) +} + + +#[post("/create", data="")] +pub async fn create_tag(new_tag: Form, mut db: Connection, cookies: &CookieJar<'_>) -> Redirect { + let user = auth::get_user(&mut db, cookies).await; + if !user.is_admin { + cookies.add(("toast_error", "Vous n'avez pas la permission de créer une étiquette")); + return Redirect::to(uri!("/tags", tag_index)); + } + + match sqlx::query("INSERT INTO Tags (name, color) VALUES ($1, $2);") + .bind(&new_tag.name) + .bind(&new_tag.color) + .execute(&mut **db) + .await { + Ok(affected_lines) => if affected_lines.rows_affected() != 1 { + error!("Did not create tag : {:?}", &new_tag.name); + cookies.add(("toast_error", "L'étiquette n'a pas pû être créer")); + } else { + debug!("Successfully created new tag {:?}", &new_tag.name); + }, + Err(error) => { + error!("Error whilea adding tag {:?} : {error}", &new_tag.name); + cookies.add(("toast_error", "Erreur à la création de l'étiquette")); + } + }; + + Redirect::to(uri!("/tags", tag_index)) +} + +#[post("/delete/")] +pub async fn delete_tag(tag_name: &str, mut db: Connection, cookies: &CookieJar<'_>) -> Redirect { + let user = auth::get_user(&mut db, cookies).await; + if !user.is_admin { + cookies.add(("toast_error", "Vous n'avez pas la permission de supprimer une étiquette")); + return Redirect::to(uri!("/tags", tag_index)); + } + + match sqlx::query("DELETE FROM Tags WHERE name == $1;") + .bind(&tag_name) + .execute(&mut **db) + .await { + Ok(affected_lines) => if affected_lines.rows_affected() != 1 { + error!("Did not remove {tag_name}"); + cookies.add(("toast_error", "L'étiquette n'a pas pû être supprimée.")); + } + else { + debug!("Tag {tag_name} successfully removed.") + }, + Err(error) => { + error!("Error trying to remove {tag_name} : {error}"); + cookies.add(("toast_error", "Erreur lors de la suppression de l'étiquette")); + } + }; + + Redirect::to(uri!("/tags", tag_index)) +} + +#[post("/update/", data="")] +pub async fn update_tag(tag_name: &str, updated_tag: Form, mut db: Connection, cookies: &CookieJar<'_>) -> Redirect { + let user = auth::get_user(&mut db, cookies).await; + if !user.is_admin { + cookies.add(("toast_error", "Vous n'avez pas la permission de modifier une étiquette")); + return Redirect::to(uri!("/tags", tag_index)); + } + + match sqlx::query("UPDATE Tags SET name = $1, color = $2 WHERE name == $3;") + .bind(&updated_tag.name) + .bind(&updated_tag.color) + .bind(&tag_name) + .execute(&mut **db) + .await { + Ok(affected_lines) => if affected_lines.rows_affected() != 1 { + error!("Did not update tag {tag_name}"); + cookies.add(("toast_error", "L'étiquette n'a pas pû être modifiée.")); + } else { + debug!("Updated {tag_name} successfully."); + } + Err(error) => { + error!("Error while updating {tag_name} : {error}"); + cookies.add(("toast_error", "Erreur lors de la modification de l'étiquette.")); + } + }; + + Redirect::to(uri!("/tags", tag_index)) +} + +#[derive(FromForm)] +pub struct TagForm { + name: String, +} + +#[post("//tag/", data="")] +pub async fn tag_truth(week: u8, truth_id: u32, tag_form: Form, mut db: Connection, cookies: &CookieJar<'_>) -> Redirect { + let user = auth::get_user(&mut db, cookies).await; + if !user.is_admin { + cookies.add(("toast_error", "Vous n'avez pas la permission d'ajouter une étiquette")); + return Redirect::to(uri!("/tags", tag_index)); + } + + let error_cookie = || + cookies.add(("toast_error", "Erreur lors de l'ajout de l'étiquette.")); + + let tag_id: u32 = sqlx::query_scalar("SELECT id FROM Tags WHERE name == $1;") + .bind(&tag_form.name) + .fetch_one(&mut **db) + .await.unwrap_or(0); + + if tag_id == 0 { + error_cookie(); + error!("Error while trying to figure tag ID of {:?} to create the relation with {:?}.", &tag_form.name, &truth_id); + return Redirect::to(uri!(week::week(week))); + } + + match sqlx::query("INSERT INTO TaggedTruths (truth_id, tag_id) VALUES ($1, $2);") + .bind(&truth_id) + .bind(&tag_id) + .execute(&mut **db) + .await { + Ok(affected_lines) => if affected_lines.rows_affected() != 1 { + error_cookie(); + error!("Tag relation not added."); + } else { + debug!("Created tag relation with truth {truth_id} and tag {tag_id}"); + } + Err(error) => { + error_cookie(); + error!("Error while trying to add tag relation : {error}"); + } + } + + Redirect::to(uri!(week::week(week))) +} + +#[post("//untag/", data="")] +pub async fn untag_truth(week: u8, truth_id: u32, tag_form: Form, mut db: Connection, cookies: &CookieJar<'_>) -> Redirect { + let user = auth::get_user(&mut db, cookies).await; + if !user.is_admin { + cookies.add(("toast_error", "Vous n'avez pas la permission de retirer une étiquette")); + return Redirect::to(uri!("/tags", tag_index)); + } + + let error_cookie = || + cookies.add(("toast_error", "Erreur lors de la suppression de l'étiquette.")); + + let tag_id: u32 = sqlx::query_scalar("SELECT id FROM Tags WHERE name == $1;") + .bind(&tag_form.name) + .fetch_one(&mut **db) + .await.unwrap_or(0); + + if tag_id == 0 { + error_cookie(); + error!("Error while trying to figure tag ({:?}) ID to remove the relation with {:?}.", &tag_id, &truth_id); + return Redirect::to(uri!(week::week(week))); + } + + match sqlx::query("DELETE FROM TaggedTruths WHERE truth_id == $1 AND tag_id == $2;") + .bind(&truth_id) + .bind(&tag_id) + .execute(&mut **db) + .await { + Ok(affected_lines) => if affected_lines.rows_affected() != 1 { + error_cookie(); + error!("Tag relation not deleted."); + } else { + debug!("Removed tag relation with truth {truth_id} and tag {tag_id}"); + } + Err(error) => { + error_cookie(); + error!("Error while trying to remove tag relation : {error}"); + } + } + + Redirect::to(uri!(week::week(week))) +} + +pub fn stage() -> AdHoc { + AdHoc::on_ignite("Tag stage", |rocket| async { + rocket + .mount("/tags", routes![tag_index, filtered_by_tags, create_tag, update_tag, delete_tag]) + .mount("/", routes![tag_truth, untag_truth]) + }) +} diff --git a/src/truth.rs b/src/truth.rs index 861449c..f3b6add 100644 --- a/src/truth.rs +++ b/src/truth.rs @@ -5,6 +5,7 @@ use rocket::response::Redirect; use rocket_db_pools::{sqlx, Connection}; use pulldown_cmark::{Parser, Options}; +use rocket::fairing::AdHoc; use sqlx::Row; use crate::{auth, database, week}; @@ -17,7 +18,7 @@ pub struct TruthUpdateForm { #[post("//edit/", data="
")] pub async fn edit_truth(week: u8, truth_number: u8, form: Form, mut db: Connection, cookies: &CookieJar<'_>) -> Redirect { - let user = auth::get_user(week, &mut db, cookies).await; + let user = auth::get_user(&mut db, cookies).await; if !user.is_admin { cookies.add(("toast_error", "Vous n'avez pas la permission de changer la vérité.")); return Redirect::to(uri!(week::week(week))); @@ -56,7 +57,7 @@ pub async fn edit_truth(week: u8, truth_number: u8, form: Form, #[post("//new_truth", data="")] pub async fn create_truth(week: u8, form: Form, mut db: Connection, cookies: &CookieJar<'_>) -> Redirect { - let user = auth::get_user(week, &mut db, cookies).await; + let user = auth::get_user(&mut db, cookies).await; if !user.is_admin { cookies.add(("toast_error", "Vous n'avez pas la permission d'ajouter de vérité.")); return Redirect::to(uri!(week::week(week))); @@ -110,3 +111,9 @@ pub async fn create_truth(week: u8, form: Form, Redirect::to(uri!(week::week(week))) } + +pub fn stage() -> AdHoc { + AdHoc::on_ignite("Truth stage", |rocket| async { + rocket.mount("/", routes![create_truth, edit_truth]) + }) +} diff --git a/src/vote.rs b/src/vote.rs index 7bd400f..550707c 100644 --- a/src/vote.rs +++ b/src/vote.rs @@ -12,6 +12,29 @@ use rocket_db_pools::{sqlx, Connection}; use crate::{auth, database, week}; use crate::database_records::{Vote, VotingData}; +#[derive(Serialize, Deserialize)] +#[serde(crate = "rocket::serde")] +pub struct WeeklyUserVotes { + pub has_week_vote: bool, + pub votes: Vec +} + +pub async fn get_weekly_user_votes(week: u8, user: &auth::User, db: &mut Connection) -> WeeklyUserVotes { + let votes: Vec = if user.logged_in && !user.is_admin { + sqlx::query_as("SELECT Votes.* FROM Votes JOIN Truths ON Votes.truth_id == Truths.id AND Truths.week == $1 WHERE voter_id == $2 ORDER BY Truths.number;") + .bind(week) + .bind(user.id) + .fetch_all(&mut ***db).await.unwrap_or_else(|error| { + error!("Error while getting votes : {error}"); + Vec::::new() + }) + } else { + Vec::::new() + }; + + WeeklyUserVotes {has_week_vote: if votes.is_empty() { false } else { true }, votes} +} + #[derive(FromForm)] pub struct VoteForm { truth_votes: HashMap @@ -20,7 +43,7 @@ pub struct VoteForm { #[post("//vote", data="")] pub async fn vote(week: u8, form: Form, mut db: Connection, cookies: &CookieJar<'_>) -> Redirect { - let user = auth::get_user(week, &mut db, cookies).await; + let user = auth::get_user(&mut db, cookies).await; if !user.logged_in { cookies.add(("toast_error", "Vous n'avez pas la permission de changer de vote.")); @@ -37,9 +60,11 @@ pub async fn vote(week: u8, form: Form, } ); + let existing_votes = get_weekly_user_votes(week, &user, &mut db).await; + let mut had_error = false; for (truth_id, voted_id) in filtered_votes { - match user.votes.iter().find(|vote: &&Vote| {vote.truth_id == *truth_id}) { + match existing_votes.votes.iter().find(|vote: &&Vote| {vote.truth_id == *truth_id}) { Some(vote) => { if *voted_id == vote.voted_id { continue; @@ -97,7 +122,7 @@ pub struct VoteData { // TODO: Cache vote count ? Maintain in state ? #[get("//votes", format = "application/json")] pub async fn fetch_vote_data(week: u8, mut db: Connection, cookies: &CookieJar<'_>) -> Option> { - let user = auth::get_user(week, &mut db, cookies).await; + let user = auth::get_user(&mut db, cookies).await; let raw_votes: Vec = sqlx::query_as(" SELECT Players.name as votes_for, Truths.number as truth_number, count(*) as votes FROM Votes JOIN Players ON Votes.voted_id == Players.id @@ -164,9 +189,8 @@ pub async fn fetch_vote_data(week: u8, mut db: Connection, cookies Some(Json(VoteData{truth_count: truth_count, votes: vote_data})) } -// FIXME: pub fn stage() -> AdHoc { - AdHoc::on_ignite("SQLx Stage", |rocket| async { + AdHoc::on_ignite("Vote stage", |rocket| async { rocket.mount("/", routes![vote, fetch_vote_data]) }) } diff --git a/src/week.rs b/src/week.rs index 1eb52db..79608b7 100644 --- a/src/week.rs +++ b/src/week.rs @@ -4,17 +4,30 @@ use rocket::form::Form; use rocket::http::CookieJar; use rocket::response::Redirect; -use rocket_db_pools::{sqlx, Connection}; +use rocket_db_pools::{sqlx, sqlx::Row, Connection}; use rocket_dyn_templates::{context, Template}; use sqlx::{Acquire, Executor}; -use crate::auth; +use crate::{auth, vote}; use crate::auth::User; use crate::database::Db; -use crate::database_records::{DisplayTruth, Player, Truth, Week}; +use crate::database_records::{DisplayTruth, Player, Tag, Truth, Week}; +use crate::tag; +use crate::vote::WeeklyUserVotes; + +pub async fn get_last_week(db: &mut Connection) -> u8 { + match sqlx::query("SELECT number FROM Weeks WHERE is_last_week == 1;") + .fetch_one(&mut ***db).await { + Ok(row) => row.try_get(0).ok().unwrap_or_else(|| 1), // If error, go back to 1 + Err(error) => { + error!("Error while getting current week : {error:?}"); + 1 + } + } +} #[get("/")] pub async fn week(week_number: u8, mut db: Connection, cookies: &CookieJar<'_>) -> Template { - let user: User = auth::get_user(week_number, &mut db, cookies).await; + let user: User = auth::get_user(&mut db, cookies).await; let other_players = if user.logged_in { match sqlx::query_as("SELECT id, name FROM Players WHERE id <> $1 AND is_admin == 0 ORDER BY name") @@ -41,9 +54,11 @@ pub async fn week(week_number: u8, mut db: Connection, cookies: &CookieJar<' } }; + let vote_data: WeeklyUserVotes = vote::get_weekly_user_votes(week_number,&user, &mut db).await; + // FIXME : This is fucking *trash* but fucking hell mate if user.is_admin { - let truths: Vec = match sqlx::query_as("SELECT * FROM Truths WHERE week == $1 ORDER BY number") + let mut truths: Vec = match sqlx::query_as("SELECT * FROM Truths WHERE week == $1 ORDER BY number") .bind(week_number) .fetch_all(&mut **db).await { Ok(v) => v, @@ -53,14 +68,22 @@ pub async fn week(week_number: u8, mut db: Connection, cookies: &CookieJar<' } }; - Template::render("index", context! { + for truth in &mut truths { + truth.tags = tag::get_truth_tags(truth.id, &mut db).await; + } + + let tags: Vec = tag::get_all_tags(&mut db, cookies).await; + + Template::render("weeks/index", context! { week_data: week_data, truths: truths, user: user, other_players: other_players, + vote_data: vote_data, + tags: tags }) } else { - let truths: Vec = match sqlx::query_as("SELECT id, number, author_id, rendered_text FROM Truths WHERE week == $1 ORDER BY number") + let mut truths: Vec = match sqlx::query_as("SELECT id, week, number, author_id, rendered_text FROM Truths WHERE week == $1 ORDER BY number") .bind(week_number) .fetch_all(&mut **db).await { Ok(v) => v, @@ -70,11 +93,16 @@ pub async fn week(week_number: u8, mut db: Connection, cookies: &CookieJar<' } }; - Template::render("index", context! { + for truth in &mut truths { + truth.tags = tag::get_truth_tags(truth.id, &mut db).await; + } + + Template::render("weeks/index", context! { week_data: week_data, truths: truths, user: user, other_players: other_players, + vote_data: vote_data }) } } @@ -82,7 +110,7 @@ pub async fn week(week_number: u8, mut db: Connection, cookies: &CookieJar<' #[post("//edit", data="")] pub async fn update_week(week: u8, raw_intro: Form, mut db: Connection, cookies: &CookieJar<'_>) -> Redirect { - let user = auth::get_user(week, &mut db, cookies).await; + let user = auth::get_user(&mut db, cookies).await; if !user.is_admin { cookies.add(("toast_error", "Vous n'avez pas la permission de changer la semaine.")); return Redirect::to(uri!(week(week))); @@ -118,7 +146,7 @@ pub async fn update_week(week: u8, raw_intro: Form, #[post("//set_last")] pub async fn set_last_week(week: u8, mut db: Connection, cookies: &CookieJar<'_>) -> Redirect { - let user = auth::get_user(week, &mut db, cookies).await; + let user = auth::get_user(&mut db, cookies).await; if !user.is_admin { cookies.add(("toast_error", "Vous n'avez pas la permission de changer la semaine.")); return Redirect::to(uri!(week(week))); @@ -173,7 +201,7 @@ pub async fn set_last_week(week: u8, mut db: Connection, cookies: &CookieJar #[get("//create")] pub async fn create_week(week: u8, mut db: Connection, cookies: &CookieJar<'_>) -> Redirect { - let user = auth::get_user(week, &mut db, cookies).await; + let user = auth::get_user(&mut db, cookies).await; if !user.is_admin { cookies.add(("toast_error", "Vous n'avez pas la permission de changer la semaine.")); return Redirect::to(uri!(week(week - 1))); @@ -210,3 +238,9 @@ pub async fn create_week(week: u8, mut db: Connection, cookies: &CookieJar<' } } } + +pub fn stage() -> AdHoc { + AdHoc::on_ignite("Week stage", |rocket| async { + rocket.mount("/", routes![week, create_week, update_week, set_last_week]) + }) +} diff --git a/static_files/style.css b/static_files/style.css index edc39a7..0721675 100644 --- a/static_files/style.css +++ b/static_files/style.css @@ -2,10 +2,22 @@ display: flex; flex-direction: row; justify-content: space-between; + align-items: center; margin-right: 2em; } -.page_body { +.top_bar_side { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: baseline; +} + +.top_bar_side > * { + padding: 0 0.5em; +} + +.truth_page_body { display: flex; flex-direction: row; justify-content: space-between; @@ -23,12 +35,16 @@ padding-bottom: 2em; } -.individual_truth > h3 { +.individual_truth h3 { text-align: center; padding-bottom: 4px; border-bottom: slategray solid 0.15em; } +.individual_truth p { + padding: 0 0.5em; +} + .editor { width: calc(100% - 1.25em); /* The width is calculated *inside* the padding, so adjust for it. */ height: 6eM; @@ -37,6 +53,10 @@ margin-bottom: 1em; } +.truth_edit_form { + margin-bottom: 1em; +} + .graph { display: flex; flex-direction: column; @@ -53,13 +73,19 @@ top: 25%; } -.week_change:link, .week_change:visited { +.week_change:link, .week_change:visited, +.individual_truth > a, .individual_truth > a:link, .individual_truth > a:visited, +nav > a, nav > a:link, nav > a:visited, +h1 > a, h1 > a:link, h1 > a:visited { color: black; text-decoration: none; transition: all .2s ease-in-out; } -.week_change:hover, .week_change:focus { +.week_change:hover, .week_change:focus, +.individual_truth > a:hover, .individual_truth > a:focus, +nav > a:hover, nav > a:focus, +h1 > a:hover, h1 > a:focus { color: mediumpurple; text-shadow: 0 2px 2px slategray; } @@ -79,6 +105,93 @@ } } +.tag_list { + display: flex; + flex-flow: column wrap; + align-content: flex-start; + max-height: 40vh; + margin-bottom: 1em; +} + +@media (orientation: portrait) { + .tag_list { + max-height: none; + } +} + +.new_tag { + display: flex; + flex-direction: column; + border: black dashed 1px; + padding: 0.5em; + margin-top: 0.5em; + width: fit-content; +} + +.new_tag > button { + margin-top: 1em; +} + +.tag { + display: flex; + justify-content: center; + margin: 0.25em; + width: fit-content; + text-decoration: none; + text-shadow: + -1px 0 3px black, + 1px 0 3px black, + 0 -1px 3px black, + 0 1px 3px black; + color: white; + padding: 0.25em 0.75em 0.25em 0.25em; + border-bottom-left-radius: 0.4em; + border-top-left-radius: 0.4em; + border-bottom-right-radius: 1.5em 0.8em; + border-top-right-radius: 1.5em 0.8em; + + transition: all .2s ease-in-out; +} + +.tag:hover, .tag:focus { + color: mediumpurple; +} + +.truth_tags { + display: flex; + flex-flow: wrap; +} + +.truth_tags > .tag { /* We want smaller tags, the shadows must follow as well */ + font-size: smaller; + text-shadow: + -1px 0 2px black, + 1px 0 2px black, + 0 -1px 2px black, + 0 1px 2px black; +} + +.truth_tags > form { + display: contents; +} + +.login { + display: flex; + flex-direction: column; +} + +.login > * { + padding-bottom: 0.1em; +} + +.login > label { + align-self: flex-end; +} + +.login > b { + align-self: center; +} + /* Global styling */ hr { @@ -108,3 +221,10 @@ body { body > h2 { padding-left: 0.25eM; } + +nav { + height: fit-content; + margin: 0 0.25em; + border-left: 0.15em solid slategray; + border-right: 0.15em solid slategray; +} diff --git a/static_files/vote_chart.js b/static_files/vote_chart.js index f82b977..306a01d 100644 --- a/static_files/vote_chart.js +++ b/static_files/vote_chart.js @@ -1,7 +1,9 @@ const limit_ratio = 1.3 const colors = ['#b6b8fc', '#b6f4fc', '#fcb6cc', '#e0fcb6', '#fcdcb6', '#b6fcc8', '#f0b6fc'] async function main() { - const vote_response = await fetch(document.URL+"/votes"); + let current_url = new URL(document.URL) + current_url.hash = '' // Strip the local part of the URL, for example if we select a Truth + const vote_response = await fetch(current_url.toString()+"/votes"); if (!vote_response.ok) { return; } diff --git a/templates/base_page.html.tera b/templates/base_page.html.tera new file mode 100644 index 0000000..c598fa8 --- /dev/null +++ b/templates/base_page.html.tera @@ -0,0 +1,50 @@ + + +{% set title = "Vérités Nova Borealis" %} + + + {{ title }} + + + {% block scripts %} + {% endblock %} + + + +{% block top_bar %} +
+
+

{{ title }}

+ +
+
+ {% block top_bar_side %} +
+ {% if user.logged_in == true %} + + Connecté en tant que {{ user.name }} + + + {% else %} + + {% endif %} +
+ {% endblock %} +
+
+{% endblock %} + +{% block body %} +{% endblock %} + + + diff --git a/templates/editable_truth.tera b/templates/editable_truth.tera deleted file mode 100644 index 22d3470..0000000 --- a/templates/editable_truth.tera +++ /dev/null @@ -1,8 +0,0 @@ -
-

Vérité {{ truth.number }}

-

{{ truth.rendered_text | safe }}

-
-
- {% include "truth_editor" %} -
-
diff --git a/templates/index.html.tera b/templates/index.html.tera deleted file mode 100644 index e10d6f5..0000000 --- a/templates/index.html.tera +++ /dev/null @@ -1,141 +0,0 @@ - - -{% set title = "Vérités Fabula Ultima" %} - - - {{ title }} - - {% if user.logged_in == true and not user.is_admin %} - - {% endif %} - - - - -{#{% import "week_change_arrows" as week_macro %}#} -{# For some reason the import does not work ? Figure it out at some point... #} -{%- macro display(display_character, to, enabled) -%} - {%- set class = "week_change" -%} - {%- if enabled == true %} - {% set target = ("href=/" ~ to) %} - {%- else -%} - {% set class = class ~ " week_change_hidden" -%} - {% set target = "" %} - {%- endif -%} - {%- if enabled == true -%}{{- display_character -}}{%- endif -%} -{%- endmacro display -%} - - -{% set back_arrow_enabled = week_data.number > 1 %} -{% set next_arrow_enabled = (week_data.is_last_week != true or user.is_admin == true) %} -{% set next_arrow_href = (week_data.number + 1) %} -{% if user.is_admin == true %} - {% set next_arrow_href = next_arrow_href ~ "/create" %} - {% set next_arrow_chara = '⥅' %} -{% else %} - {% set next_arrow_chara = '⟹' %} -{% endif %} - -{# Remove the form if all votes are locked, to reduce confusion. #} -{% set lock_truth_form = user.votes | length + 1 == truths | length and week_data.is_last_week != true %} - - -
-

{{ title }}

- {% if user.logged_in == true %} - - {% else %} - - {% endif %} -
- -

{{- self::display(display_character='⟸', to=(week_data.number - 1), enabled=back_arrow_enabled) }} - Semaine {{ week_data.number }} - {{- self::display(display_character=next_arrow_chara, to=next_arrow_href, enabled=next_arrow_enabled) -}}

-
-
- {% if user.is_admin == true and week_data.is_last_week != true %} -
- -
- {% endif %} -
- {{ week_data.rendered_text | safe }} - {%- if user.is_admin == true -%} -
-
- - -
- {% endif %} -
- {% if user.logged_in == true and user.is_admin == false and not lock_truth_form %} -
- {% endif %} - - {# Truths start at 1 but the array starts at 0 #} - {% set index_delta = 1 %} - {% for truth in truths %} - {# - The truths are in an ordered array, but one of them might be the user's. - In this case, we need to stop the array index from incrementing if the current - truth is the user's, as they cannot have voted for themselves, leading to one - less votes than there are truths. - #} - {%- if truth.author_id == user.id -%} - {%- set_global index_delta = 2 -%} - {% endif %} - {% set truth_index = truth.number - index_delta %} - - {% if user.is_admin == true %} - {% include "editable_truth" %} - {% else %} - {% include "truth" %} - {% endif %} - {% endfor %} - - {% if user.logged_in == true and user.is_admin == false and not lock_truth_form %} -
- -
- {% endif %} - - {# If admin, show an additional box for creating a new Truth. #} - {% if user.is_admin == true %} -
-

Nouvelle vérité

-
- {% include "truth_editor" %} -
-
- {% endif %} -
- -
-
- -
-
-
- - - diff --git a/templates/tags/filtered_truths.html.tera b/templates/tags/filtered_truths.html.tera new file mode 100644 index 0000000..cb94d54 --- /dev/null +++ b/templates/tags/filtered_truths.html.tera @@ -0,0 +1,28 @@ +{% extends "base_page" %} + +{% block top_bar_side %} +{% endblock %} + +{% block body %} + {% if tags | length == 0 %} +

Ensemble des vérités à ce jour

+ {% elif tags | length == 1 %} +

Vérités correponsdantes au thème

+ {% else %} +

Vérités correponsdantes aux thèmes

+ {% endif %} + +
+ {% for tag in tags %} + {{ tag.name }} + {% endfor %} +
+ + {% for truth in truths %} +
+

Semaine {{ truth.week }} - Vérité {{ truth.number }}

+

{{ truth.rendered_text | safe }}

+
+ {% endfor %} +{% endblock %} diff --git a/templates/tags/index.html.tera b/templates/tags/index.html.tera new file mode 100644 index 0000000..5e423d4 --- /dev/null +++ b/templates/tags/index.html.tera @@ -0,0 +1,42 @@ +{% extends "base_page" %} + +{% block top_bar_side %} +{% endblock %} + +{% block body %} +

Liste des thèmes de Vérité

+ +
+ {% for tag in tags %} +
+ +
+ {% endfor %} +
+ + + {% if user.is_admin %} +
+ +

Éditer les thèmes

+ +
+ {% for tag in tags %} +
+ + + + +
+ {% endfor %} +
+ +
+
+ + +
+ +
+ {% endif %} +{% endblock %} diff --git a/templates/weeks/editable_truth.tera b/templates/weeks/editable_truth.tera new file mode 100644 index 0000000..8d75882 --- /dev/null +++ b/templates/weeks/editable_truth.tera @@ -0,0 +1,32 @@ +
+

Vérité {{ truth.number }}

+

{{ truth.rendered_text | safe }}

+
+ {% for tag in truth.tags %} +
+ +
+ + {{ tag.name }} + + {% endfor %} +
+
+
+ {% include "weeks/truth_editor" %} +
+ {% if tags | length > 0 and tags | length > truth.tags | length %} +
+ + +
+ {% endif %} +
diff --git a/templates/weeks/index.html.tera b/templates/weeks/index.html.tera new file mode 100644 index 0000000..b3527d5 --- /dev/null +++ b/templates/weeks/index.html.tera @@ -0,0 +1,120 @@ +{% extends "base_page" %} + +{% block scripts %} + {% if user.logged_in == true and not user.is_admin %} + + {% endif %} + + +{% endblock %} + +{#{% import "week_change_arrows" as week_macro %}#} +{# For some reason the import does not work ? Figure it out at some point... #} +{%- macro display(display_character, to, enabled) -%} + {%- set class = "week_change" -%} + {%- if enabled == true %} + {% set target = ("href=/" ~ to) %} + {%- else -%} + {% set class = class ~ " week_change_hidden" -%} + {% set target = "" %} + {%- endif -%} + {%- if enabled == true -%}{{- display_character -}}{%- endif -%} +{%- endmacro display -%} + +{# Remove the form if all votes are locked, to reduce confusion. #} +{% set lock_truth_form = vote_data.votes | length + 1 == truths | length and week_data.is_last_week != true %} + +{% block body %} + + {# Apparently needs to be inside the block ? #} + {% set back_arrow_enabled = week_data.number > 1 %} + {% set next_arrow_enabled = (week_data.is_last_week != true or user.is_admin == true) %} + {% set next_arrow_href = (week_data.number + 1) %} + {% if user.is_admin == true %} + {% set next_arrow_href = next_arrow_href ~ "/create" %} + {% set next_arrow_chara = '⥅' %} + {% else %} + {% set next_arrow_chara = '⟹' %} + {% endif %} + +

{{- self::display(display_character='⟸', to=(week_data.number - 1), enabled=back_arrow_enabled) }} + Semaine {{ week_data.number }} + {{- self::display(display_character=next_arrow_chara, to=next_arrow_href, enabled=next_arrow_enabled) -}}

+
+
+ {% if user.is_admin == true and week_data.is_last_week != true %} +
+ +
+ {% endif %} +
+ {{ week_data.rendered_text | safe }} + {%- if user.is_admin == true -%} +
+
+ + +
+ {% endif %} +
+ {% if user.logged_in == true and user.is_admin == false and not lock_truth_form %} +
+ {% endif %} + + {# Truths start at 1 but the array starts at 0 #} + {% set index_delta = 1 %} + {% for truth in truths %} + {# + The truths are in an ordered array, but one of them might be the user's. + In this case, we need to stop the array index from incrementing if the current + truth is the user's, as they cannot have voted for themselves, leading to one + less votes than there are truths. + #} + {%- if truth.author_id == user.id -%} + {%- set_global index_delta = 2 -%} + {% endif %} + {% set truth_index = truth.number - index_delta %} + + {% if user.is_admin == true %} + {% include "weeks/editable_truth" %} + {% else %} + {% include "weeks/truth" %} + {% endif %} + {% endfor %} + + {% if user.logged_in == true and user.is_admin == false and not lock_truth_form %} +
+ +
+ {% endif %} + + {# If admin, show an additional box for creating a new Truth. #} + {% if user.is_admin == true %} +
+

Nouvelle vérité

+
+ {% include "weeks/truth_editor" %} +
+
+ {% endif %} +
+ +
+
+ +
+
+
+{% endblock %} diff --git a/templates/truth.html.tera b/templates/weeks/truth.html.tera similarity index 69% rename from templates/truth.html.tera rename to templates/weeks/truth.html.tera index 14df3e5..5eaa580 100644 --- a/templates/truth.html.tera +++ b/templates/weeks/truth.html.tera @@ -1,12 +1,18 @@ {%- set is_disabled = "" -%} {# If we are not during the active week, prevent changing vote but not sending a missing one. #} -{%- if week_data.is_last_week != true and user.votes | filter(attribute="truth_id", value=truth.id) -%} +{%- if week_data.is_last_week != true and vote_data.votes | filter(attribute="truth_id", value=truth.id) -%} {%- set is_disabled = "disabled" -%} {%- endif -%}
-

Vérité {{ truth.number }}

+

Vérité {{ truth.number }}

{{ truth.rendered_text | safe }}

+
+
+ {% for tag in truth.tags %} + {{ tag.name }} + {% endfor %} +
{% if user.logged_in %}