diff --git a/Cargo.toml b/Cargo.toml index e0d427f..71a6ed6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ chrono = { version = "0.4.38", features = ["alloc"]} futures-util = "0.3.30" h2 = "0.4.6" argon2 = "0.5.3" +rand = "0.8.1" [target.'cfg(unix)'.dependencies] daemonize = "0.5.0" \ No newline at end of file diff --git a/migrations/20240921193253_create_auth_table.sql b/migrations/20240921193253_create_auth_table.sql index 0939fac..cb4e08f 100644 --- a/migrations/20240921193253_create_auth_table.sql +++ b/migrations/20240921193253_create_auth_table.sql @@ -2,8 +2,8 @@ CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY NOT NULL, username TEXT NOT NULL, -salt text NOT NULL, -hash TEXT NOT NULL, -permissions INTEGER DEFAULT 0, -token TEXT +saltyhash text NOT NULL, +permissions INTEGER DEFAULT 0 NOT NULL, +token TEXT NOT NULL, +UNIQUE(id, username) ) \ No newline at end of file diff --git a/routes.json b/routes.json index 4d230a9..d6b6395 100644 --- a/routes.json +++ b/routes.json @@ -1,6 +1,11 @@ { - "/": "static/html/index.html", - "/results": "static/html/results.html", - "/archives": "static/html/archives.html", - "/favicon.ico": "static/img/favicon.ico" -} + "/": {"file": "static/html/index.html", "permission": 0}, + "/results": {"file": "static/html/results.html", "permission": 1}, + "/archives": {"file": "static/html/archives.html", "permission": 2}, + "/favicon.ico": {"file": "static/img/favicon.ico", "permission": 0}, + "/login": {"file": "static/html/login.html", "permission": 0}, + "/register": {"file": "static/html/register.html", "permission": 0}, + "/logout": {"file": "static/html/logout.html", "permission": 0}, + "/unauthorised": {"file": "static/html/unauthorised.html", "permission": 0}, + "/admin" : {"file": "static/html/admin.html", "permission": 3} +} \ No newline at end of file diff --git a/settings.json b/settings.json index 1cf14dd..70b86c4 100644 --- a/settings.json +++ b/settings.json @@ -1,4 +1,4 @@ { "database_url": "sqlite:vote.db", - "bind_address": "127.0.0.1:8080" + "bind_address": "127.0.0.1:8000" } diff --git a/src/main.rs b/src/main.rs index 3be1819..429834e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,28 +1,65 @@ -use bytes::Buf; -use bytes::Bytes; -use chrono::{DateTime, Utc}; -use futures; +use argon2::{ + password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + Argon2, +}; +use bytes::{Buf, Bytes}; +use chrono::{DateTime, Days, NaiveTime, Utc}; +#[cfg(target_os = "linux")] +use daemonize::Daemonize; use http_body_util::{BodyExt, Full}; -use hyper::body::Incoming; -use hyper::server::conn::http1; -use hyper::service::service_fn; -use hyper::{Error, Method, Request, Response, StatusCode}; +use hyper::{ + body::{Body as HyperBody, Frame, Incoming}, + header::{COOKIE, SET_COOKIE}, + server::conn::http1, + service::service_fn, + Error, Method, Request, Response, StatusCode, +}; use hyper_util::rt::{TokioIo, TokioTimer}; -use serde::{Deserialize, Serialize}; +use rand::distributions::{Alphanumeric, DistString}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde_json::{from_reader, Value}; use sqlx::sqlite::SqlitePool; use std::collections::HashMap; +use std::convert::Infallible; use std::env; use std::fs::File; use std::io::Read; use std::net::SocketAddr; +use std::pin::Pin; use std::str::FromStr; use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll}; use std::time::SystemTime; use tokio::net::TcpListener; -#[cfg(target_os = "linux")] -use daemonize::Daemonize; +enum Body { + Full(Full), + Empty, +} + +impl Body { + fn new(data: T) -> Self + where + Bytes: From, + { + Body::Full(Full::new(Bytes::from(data))) + } +} + +impl HyperBody for Body { + type Data = Bytes; + type Error = Infallible; + fn poll_frame( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + match &mut *self.get_mut() { + Self::Full(incoming) => Pin::new(incoming).poll_frame(cx), + Self::Empty => Poll::Ready(None), + } + } +} + #[derive(Serialize, Deserialize)] struct Player { id: i64, @@ -31,6 +68,8 @@ struct Player { #[derive(Serialize, Deserialize, Debug)] struct Vote { + id: Option, + submit_date: Option, plus_player_id: i64, plus_nickname: String, plus_reason: String, @@ -39,42 +78,89 @@ struct Vote { minus_reason: String, } +#[derive(Serialize, Deserialize)] +struct User { + username: String, + saltyhash: String, + permissions: i64, + token: String, +} + +#[derive(Serialize, Deserialize)] +struct Login { + username: String, + password: String, +} #[derive(Serialize, Deserialize)] struct Settings { database_url: String, bind_address: String, } -async fn service(req: Request, db: Arc>) -> Result>, Error> { - match req.method() { - &Method::GET => { get(req, db).await } - &Method::POST => { post(req, db).await } - _ => { Ok(Response::builder().status(StatusCode::IM_A_TEAPOT).body(Full::new(Bytes::new())).unwrap()) } + +async fn service( + req: Request, + db: Arc>, +) -> Result, Error> { + match *req.method() { + Method::GET => get(req, db).await, + Method::POST => post(req, db).await, + _ => Ok(Response::builder() + .status(StatusCode::IM_A_TEAPOT) + .body(Body::Empty) + .unwrap()), } } -async fn get(req: Request, db: Arc>) -> Result>, Error> { +async fn get(req: Request, db: Arc>) -> Result, Error> { let path = req.uri().path(); - if path.starts_with("/static/") { + if path.starts_with("/static") { get_file(path).await - } else if path.starts_with("/data/") { + } else if path.starts_with("/data") { get_data(path, &req, db).await + } else if path.starts_with("/admin") { + get_admin(&req, path, db).await } else { - get_page(path).await + get_page(&req, path, db).await } } -async fn get_page(path: &str) -> Result>, Error> { - let mut routes = env::current_dir().expect("Could not get app directory (Required to get routes)"); +async fn get_page( + req: &Request, + path: &str, + db: Arc>, +) -> Result, Error> { + let mut routes = + env::current_dir().expect("Could not get app directory (Required to get routes)"); routes.push("routes.json"); let file = File::open(routes).expect("Could not open routes file."); let map: Value = from_reader(file).expect("Could not parse routes, please verify syntax."); - match &map[path] { - Value::String(s) => get_file(&s).await, - _ => not_found().await + match map.get(path) { + Some(Value::Object(s)) => { + let authorised = is_authorised( + req, + db, + s.get("permission").unwrap().as_u64().unwrap() as u8, + ) + .await; + if authorised { + get_file(s.get("file").unwrap().as_str().unwrap()).await + } else { + get_file( + map.get("/unauthorised") + .unwrap() + .get("file") + .unwrap() + .as_str() + .unwrap(), + ) + .await + } + } + _ => not_found().await, } } -async fn get_file(mut path: &str) -> Result>, Error> { +async fn get_file(mut path: &str) -> Result, Error> { let mut file_path = env::current_dir().expect("Could not get app directory."); if path.starts_with(r"/") { path = path.strip_prefix(r"/").unwrap(); @@ -89,58 +175,79 @@ async fn get_file(mut path: &str) -> Result>, Error> { "js" => "text/javascript", "html" => "text/html", "css" => "text/css", - _ => "" + _ => "", }; - Ok(Response::builder().header("content-type", content_type).body(Full::new(Bytes::from(buf))).unwrap()) + Ok(Response::builder() + .header("content-type", content_type) + .body(Body::new(buf)) + .unwrap()) } - Err(_) => not_found().await + Err(_) => not_found().await, } } -async fn get_data(path: &str, req: &Request, db: Arc>) -> Result>, Error> { +async fn get_data( + path: &str, + req: &Request, + db: Arc>, +) -> Result, Error> { let pool = db.clone().lock().unwrap().clone(); match path { "/data/players" => { - let items = sqlx::query!(r#"SELECT * FROM players"#).fetch_all(&pool).await.unwrap(); - let players: Vec = items.iter().map(|x| Player { id: x.id, name: x.name.clone() }).collect(); - Ok(Response::new(Full::new(Bytes::from(serde_json::to_string(&players).unwrap())))) + let items = sqlx::query!(r#"SELECT * FROM players"#) + .fetch_all(&pool) + .await + .unwrap(); + let players: Vec = items + .iter() + .map(|x| Player { + id: x.id, + name: x.name.clone(), + }) + .collect(); + Ok(Response::new(Body::new( + serde_json::to_string(&players).unwrap(), + ))) } "/data/votes" => { let votes = get_votes(req, db).await; - Ok(Response::new(Full::new(Bytes::from(serde_json::to_string(&votes).unwrap())))) + Ok(Response::new(Body::new( + serde_json::to_string(&votes).unwrap(), + ))) } "/data/results" => { let votes = get_votes(req, db).await; - let ids: Vec<(i64, i64)> = votes.iter().map(|x| (x.plus_player_id, x.minus_player_id)).collect(); + let ids: Vec<(i64, i64)> = votes + .iter() + .map(|x| (x.plus_player_id, x.minus_player_id)) + .collect(); let mut plus_results: HashMap = HashMap::new(); let mut minus_results: HashMap = HashMap::new(); - let _ = ids.iter().for_each(|x| { + ids.iter().for_each(|x| { let plus_id = x.0; - if !plus_results.contains_key(&plus_id) { - plus_results.insert(plus_id, 0); - } + plus_results.entry(plus_id).or_insert(0); *plus_results.get_mut(&plus_id).unwrap() += 1; let minus_id = x.1; - if !minus_results.contains_key(&minus_id) { - minus_results.insert(minus_id, 0); - } + minus_results.entry(minus_id).or_insert(0); *minus_results.get_mut(&minus_id).unwrap() += 1; }); let mut plus_results: Vec<(i64, i64)> = plus_results.into_iter().collect(); let mut minus_results: Vec<(i64, i64)> = minus_results.into_iter().collect(); - plus_results.sort_by(|a, b| { b.1.cmp(&a.1) }); - minus_results.sort_by(|a, b| { b.1.cmp(&a.1) }); + plus_results.sort_by(|a, b| b.1.cmp(&a.1)); + minus_results.sort_by(|a, b| b.1.cmp(&a.1)); let sorted_results = vec![plus_results, minus_results]; - Ok(Response::new(Full::new(Bytes::from(serde_json::to_string(&sorted_results).unwrap())))) + Ok(Response::new(Body::new( + serde_json::to_string(&sorted_results).unwrap(), + ))) } - _ => not_found().await + _ => not_found().await, } } @@ -150,41 +257,96 @@ async fn get_votes(req: &Request, db: Arc>) -> Vec { let date = date.to_str().unwrap(); - let parsed_date = date.parse::(); - if parsed_date.is_err() { - None + if let Ok(parsed_date) = date.parse::() { + DateTime::from_timestamp_millis(parsed_date) } else { - DateTime::from_timestamp_millis(parsed_date.unwrap()) + None } } - None => Some(DateTime::from(SystemTime::now())) + None => Some(DateTime::from(SystemTime::now())), }; if date.is_none() { return Vec::new(); } - let items = futures::executor::block_on(async move { - let formatted_date = format!("{}", date.unwrap().format("%d/%m/%Y")); - sqlx::query!(r#"SELECT * FROM votes WHERE submit_date = ?1 ORDER BY id"#, formatted_date).fetch_all(&pool).await.unwrap() - }); - items.iter().map(|x| Vote { - plus_player_id: x.plus_player_id, - plus_nickname: x.plus_nickname.clone(), - plus_reason: x.plus_reason.clone(), - minus_player_id: x.minus_player_id, - minus_nickname: x.minus_nickname.clone(), - minus_reason: x.minus_reason.clone(), - }).collect() + let formatted_date = format!("{}", date.unwrap().format("%d/%m/%Y")); + sqlx::query_as!( + Vote, + r#"SELECT * FROM votes WHERE submit_date = ?1 ORDER BY id"#, + formatted_date + ) + .fetch_all(&pool) + .await + .unwrap() } -async fn post(req: Request, db: Arc>) -> Result>, Error> { - let path = req.uri().path(); - if path != "/post" { - return Ok(Response::builder().status(StatusCode::BAD_REQUEST).body(Full::new(Bytes::from("Bad Request (Bad Route)"))).unwrap()); +async fn get_admin( + req: &Request, + path: &str, + db: Arc>, +) -> Result, Error> { + let authorised = is_authorised(req, db.clone(), 3).await; + if !authorised { + return get_page(req, "/unauthorised", db).await; } - let body = req.into_body().collect().await?; - let data: Result = from_reader(body.aggregate().reader()); - if data.is_err() { - return Ok(Response::builder().status(StatusCode::BAD_REQUEST).body(Full::new(Bytes::from("Bad Request (Bad Data)"))).unwrap()); + match path { + "/admin" => get_page(req, path, db).await, + "/admin/users" => { + let pool = db.clone().lock().unwrap().clone(); + let users = sqlx::query!(r#"SELECT id, username, permissions FROM users"#) + .fetch_all(&pool) + .await + .unwrap(); + let users: Vec<(i64, String, i64)> = users + .iter() + .map(|x| (x.id, x.username.clone(), x.permissions)) + .collect(); + let stringed = serde_json::to_string(&users).unwrap_or("".to_string()); + Ok(Response::builder().body(Body::new(stringed)).unwrap()) + } + "/admin/players" => { + let pool = db.clone().lock().unwrap().clone(); + let players = sqlx::query_as!(Player, r#"SELECT id, name FROM players"#) + .fetch_all(&pool) + .await + .unwrap(); + let stringed = serde_json::to_string(&players).unwrap_or("".to_string()); + Ok(Response::builder().body(Body::new(stringed)).unwrap()) + } + "/admin/votes" => { + let pool = db.clone().lock().unwrap().clone(); + let votes = sqlx::query_as!(Vote, r#"SELECT * FROM votes"#) + .fetch_all(&pool) + .await + .unwrap(); + let stringed = serde_json::to_string(&votes).unwrap_or("".to_string()); + Ok(Response::builder().body(Body::new(stringed)).unwrap()) + } + _ => not_found().await, + } +} + +async fn post(req: Request, db: Arc>) -> Result, Error> { + let path = req.uri().path(); + if path.starts_with("/admin") { + return post_admin(req, db).await; + } + match path { + "/vote" => post_vote(req, db).await, + "/login" => login(req, db).await, + "/register" => register(req, db).await, + "/logout" => logout().await, + "/player" => post_player(req, db).await, + _ => not_found().await, + } +} + +async fn post_vote( + req: Request, + db: Arc>, +) -> Result, Error> { + let data = req_json::(req).await; + if data.is_none() { + return bad_request().await; } let vote = data.unwrap(); let timestamp: DateTime = DateTime::from(SystemTime::now()); @@ -201,25 +363,456 @@ async fn post(req: Request, db: Arc>) -> Result = DateTime::from(SystemTime::now()); + let date = date.checked_add_days(Days::new(1)).unwrap(); + let date = date + .with_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap()) + .unwrap(); + Ok(Response::builder() + .header( + SET_COOKIE, + format!( + "hasvoted=true; Expires={}; Secure; SameSite=Strict", + date.to_rfc2822() + ), + ) + .body(Body::Empty) + .unwrap()) } -async fn not_found() -> Result>, Error> { +async fn post_player( + req: Request, + db: Arc>, +) -> Result, Error> { + let data = req_json::(req).await; + if data.is_none() { + return bad_request().await; + } + let data = data.unwrap(); + let name = data.get("name").unwrap().as_str().unwrap(); + let pool = db.clone().lock().unwrap().clone(); + let mut conn = pool.acquire().await.unwrap(); + if let Ok(Some(player)) = sqlx::query!(r#"SELECT * FROM players WHERE name = ?1"#, name) + .fetch_optional(&pool) + .await + { + let player = Player { + id: player.id, + name: player.name, + }; + return Ok(Response::builder() + .body(Body::new(serde_json::to_string(&player).unwrap())) + .unwrap()); + } + let r = sqlx::query!( + r#"INSERT INTO players (name) VALUES (?1) RETURNING id"#, + name + ) + .fetch_one(&mut *conn) + .await; + if r.is_err() { + return Ok(Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(Body::Empty) + .unwrap()); + } + let player = Player { + id: r.unwrap().id, + name: name.to_string(), + }; + Ok(Response::builder() + .body(Body::new(serde_json::to_string(&player).unwrap())) + .unwrap()) +} + +async fn post_admin( + req: Request, + db: Arc>, +) -> Result, Error> { + let authorised = is_authorised(&req, db.clone(), 3).await; + if !authorised { + return get_page(&req, "/unauthorised", db).await; + } + let path = req.uri().path(); + match path { + "/admin/edit/user" => match req_json::(req).await { + Some(Value::Object(user)) => { + let username = user.get("username"); + let permissions = user.get("permissions"); + let id = user.get("id"); + if username.is_none() || permissions.is_none() || id.is_none() { + return bad_request().await; + } + let pool = db.clone().lock().unwrap().clone(); + let mut conn = pool.acquire().await.unwrap(); + let username = username.unwrap().as_str().unwrap(); + let permissions = permissions.unwrap(); + let id = id.unwrap(); + let _ = sqlx::query!( + r#"UPDATE users SET username = ?1, permissions = ?2 WHERE id = ?3"#, + username, + permissions, + id + ) + .execute(&mut *conn) + .await; + ok().await + } + _ => bad_request().await, + }, + "/admin/delete/user" => match req_json::(req).await { + Some(Value::Object(user)) => { + let id = user.get("id"); + if id.is_none() { + return bad_request().await; + } + let pool = db.clone().lock().unwrap().clone(); + let mut conn = pool.acquire().await.unwrap(); + let id = id.unwrap().as_i64().unwrap(); + let _ = sqlx::query!(r#"DELETE FROM users WHERE id = ?1"#, id) + .execute(&mut *conn) + .await; + ok().await + } + _ => bad_request().await, + }, + "/admin/edit/player" => match req_json::(req).await { + Some(player) => { + let pool = db.clone().lock().unwrap().clone(); + let mut conn = pool.acquire().await.unwrap(); + let _ = sqlx::query!( + r#"UPDATE players SET name = ?1 WHERE id = ?2"#, + player.name, + player.id + ) + .execute(&mut *conn) + .await; + ok().await + } + _ => bad_request().await, + }, + "/admin/new/player" => match req_json::(req).await { + Some(Value::Object(player)) => { + let name = player.get("name"); + if name.is_none() { + return bad_request().await; + } + let pool = db.clone().lock().unwrap().clone(); + let mut conn = pool.acquire().await.unwrap(); + let name = name.unwrap().as_str().unwrap(); + let _ = sqlx::query!(r#"INSERT INTO players (name) VALUES (?1)"#, name) + .execute(&mut *conn) + .await; + ok().await + } + _ => bad_request().await, + }, + "/admin/delete/player" => match req_json::(req).await { + Some(Value::Object(player)) => { + let id = player.get("id"); + if id.is_none() { + return bad_request().await; + } + let pool = db.clone().lock().unwrap().clone(); + let mut conn = pool.acquire().await.unwrap(); + let id = id.unwrap().as_i64().unwrap(); + let _ = sqlx::query!(r#"DELETE FROM players WHERE id = ?1"#, id) + .execute(&mut *conn) + .await; + ok().await + } + _ => bad_request().await, + }, + "/admin/edit/vote" => match req_json::(req).await { + Some(vote) => { + if vote.id.is_none() || vote.submit_date.is_none() { + return bad_request().await; + } + let pool = db.clone().lock().unwrap().clone(); + let _ = sqlx::query!( + r#"UPDATE votes + SET submit_date = ?1, + plus_player_id = ?2, + plus_nickname = ?3, + plus_reason = ?4, + minus_player_id = ?5, + minus_nickname = ?6, + minus_reason = ?7 + WHERE id = ?8"#, + vote.submit_date, + vote.plus_player_id, + vote.plus_nickname, + vote.plus_reason, + vote.minus_player_id, + vote.minus_nickname, + vote.minus_reason, + vote.id + ) + .execute(&pool) + .await; + ok().await + } + _ => bad_request().await, + }, + "/admin/delete/vote" => match req_json::(req).await { + Some(Value::Object(vote)) => { + let id = vote.get("id"); + if id.is_none() { + return bad_request().await; + } + let pool = db.clone().lock().unwrap().clone(); + let mut conn = pool.acquire().await.unwrap(); + let id = id.unwrap().as_i64().unwrap(); + let _ = sqlx::query!(r#"DELETE FROM votes WHERE id = ?1"#, id) + .execute(&mut *conn) + .await; + ok().await + } + _ => bad_request().await, + }, + _ => bad_request().await, + } +} + +async fn login( + req: Request, + db: Arc>, +) -> Result, Error> { + let body = req.into_body().collect().await; + let data: Result = from_reader(body?.aggregate().reader()); + if data.is_err() { + return bad_request().await; + } + let data = data.unwrap(); + if !check_username(&data.username) { + return bad_request().await; + } + let pool = db.clone().lock().unwrap().clone(); + let result = sqlx::query!(r#"SELECT * FROM users WHERE username=?1"#, data.username) + .fetch_optional(&pool) + .await; + match result { + Ok(Some(user)) => { + let argon = Argon2::default(); + let hash = PasswordHash::new(&user.saltyhash).unwrap(); + match argon.verify_password(data.password.as_bytes(), &hash) { + Ok(()) => { + let date: DateTime = DateTime::from(SystemTime::now()); + let date = date.checked_add_days(Days::new(7)).unwrap(); + Ok(Response::builder() + .header( + SET_COOKIE, + format!( + "token={}; Expires={}; Secure; HttpOnly; SameSite=Strict", + user.token, + date.to_rfc2822() + ), + ) + .header( + SET_COOKIE, + format!( + "logged=true; Expires={}; Secure; SameSite=Strict", + date.to_rfc2822() + ), + ) + .body(Body::Empty) + .unwrap()) + } + Err(_) => bad_request().await, + } + } + Ok(None) => bad_request().await, + Err(_) => Ok(Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(Body::Empty) + .unwrap()), + } +} + +async fn register( + req: Request, + db: Arc>, +) -> Result, Error> { + match req_json::(req).await { + None => Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(Body::Empty) + .unwrap()), + Some(login) => { + if !check_username(&login.username) { + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(Body::Empty) + .unwrap()); + } + if !check_password(&login.password) { + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(Body::Empty) + .unwrap()); + } + let pool = db.clone().lock().unwrap().clone(); + let mut conn = pool.acquire().await.unwrap(); + let exists = sqlx::query!(r#"SELECT id FROM users WHERE username=?1"#, login.username) + .fetch_optional(&mut *conn) + .await; + if exists.unwrap().is_some() { + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(Body::Empty) + .unwrap()); + } + let argon2 = Argon2::default(); + let hash = argon2 + .hash_password(login.password.as_bytes(), &SaltString::generate(&mut OsRng)) + .unwrap() + .to_string(); + let mut token = Alphanumeric.sample_string(&mut OsRng, 256); + while let Ok(Some(_)) = sqlx::query!(r#"SELECT id FROM users WHERE token=?1"#, token) + .fetch_optional(&mut *conn) + .await + { + token = Alphanumeric.sample_string(&mut OsRng, 256); + } + let result = sqlx::query!(r#"INSERT INTO users ( username, saltyhash, permissions, token) VALUES ( ?1, ?2, ?3, ?4 )"#, login.username, hash, 0, token).execute(&mut *conn).await; + match result { + Ok(_) => Ok(Response::builder().body(Body::Empty).unwrap()), + Err(_) => Ok(Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(Body::Empty) + .unwrap()), + } + } + } +} + +async fn logout() -> Result, Error> { + let date: DateTime = DateTime::from(SystemTime::now()); + Ok(Response::builder() + .header( + SET_COOKIE, + format!( + "token=''; Expires={}; Secure; HttpOnly; SameSite=Strict", + date.to_rfc2822() + ), + ) + .header( + SET_COOKIE, + format!( + "logged=false; Expires={}; Secure; HttpOnly; SameSite=Strict", + date.to_rfc2822() + ), + ) + .body(Body::Empty) + .unwrap()) +} + +async fn is_authorised(req: &Request, db: Arc>, level: u8) -> bool { + let cookies = req.headers().get(COOKIE); + let token = match cookies { + Some(cookies) => cookies + .to_str() + .unwrap_or("") + .split("; ") + .find(|x| x.starts_with("token=")) + .unwrap_or("") + .strip_prefix("token=") + .unwrap_or(""), + None => "", + }; + let pool = db.clone().lock().unwrap().clone(); + let user = sqlx::query!(r#"SELECT permissions FROM users WHERE token=?1"#, token) + .fetch_optional(&pool) + .await; + match user { + Ok(Some(user)) => { + let perm = user.permissions as u8; + perm >= level + } + _ => matches!(level, 0), + } +} + +fn check_username(username: &str) -> bool { + if username.len() > 21 { + return false; + } + for x in username.chars() { + if !x.is_ascii_alphanumeric() { + return false; + } + } + true +} + +fn check_password(password: &str) -> bool { + // one symbol, 10 chars min, one capital letter, one number + if password.len() < 10 { + return false; + } + let mut up = false; + let mut num = false; + let mut sym = false; + for c in password.chars() { + if c.is_uppercase() { + up = true; + } + if c.is_numeric() { + num = true; + } + if !c.is_alphanumeric() { + sym = true; + } + } + up && num && sym +} + +async fn not_found() -> Result, Error> { let mut file_path = env::current_dir().expect("Could not get app directory."); file_path.push("static/html/404.html"); let mut file = File::open(file_path).unwrap(); let mut buf = Vec::new(); file.read_to_end(&mut buf).unwrap(); - Ok(Response::builder().status(StatusCode::NOT_FOUND).body(Full::new(Bytes::from(buf))).unwrap()) + Ok(Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Body::new(buf)) + .unwrap()) +} + +async fn bad_request() -> Result, Error> { + Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(Body::Empty) + .unwrap()) +} + +async fn ok() -> Result, Error> { + Ok(Response::builder().body(Body::Empty).unwrap()) +} +async fn req_json(req: Request) -> Option +where + T: DeserializeOwned, +{ + let body = req.into_body().collect().await.unwrap(); + match from_reader(body.aggregate().reader()) { + Ok(val) => Some(val), + Err(_) => None, + } } fn get_settings() -> Settings { - let mut settings_path = env::current_dir().expect("Could not get app directory. (Required to read settings)"); + let mut settings_path = + env::current_dir().expect("Could not get app directory. (Required to read settings)"); settings_path.push("settings.json"); - let settings_file = File::open(settings_path).expect("Could not open settings file, does it exists?"); - let settings: Settings = from_reader(settings_file).expect("Could not parse settings, please check syntax."); + let settings_file = + File::open(settings_path).expect("Could not open settings file, does it exists?"); + let settings: Settings = + from_reader(settings_file).expect("Could not parse settings, please check syntax."); settings } @@ -248,26 +841,32 @@ fn main() { #[tokio::main] async fn run() { let settings = get_settings(); - let db_pool = Arc::new(Mutex::new(SqlitePool::connect(&settings.database_url).await.expect("Could not connect to database. Make sure the url is correct."))); - let bind_address: SocketAddr = SocketAddr::from_str(&settings.bind_address).expect("Could not parse bind address."); - let listener = TcpListener::bind(bind_address).await.expect("Could not bind to address."); + let db_pool = Arc::new(Mutex::new( + SqlitePool::connect(&settings.database_url) + .await + .expect("Could not connect to database. Make sure the url is correct."), + )); + let bind_address: SocketAddr = + SocketAddr::from_str(&settings.bind_address).expect("Could not parse bind address."); + let listener = TcpListener::bind(bind_address) + .await + .expect("Could not bind to address."); loop { - let (stream, _) = listener.accept().await.expect("Could not accept incoming stream."); + let (stream, _) = listener + .accept() + .await + .expect("Could not accept incoming stream."); let io = TokioIo::new(stream); let db = db_pool.clone(); - let service = service_fn( - move |req| { - service(req, db.clone()) + let service = service_fn(move |req| service(req, db.clone())); + tokio::task::spawn(async move { + if let Err(err) = http1::Builder::new() + .timer(TokioTimer::new()) + .serve_connection(io, service) + .await + { + println!("Failed to serve connection: {:?}", err); } - ); - tokio::task::spawn( - async move { - if let Err(err) = http1::Builder::new() - .timer(TokioTimer::new()) - .serve_connection(io, service).await { - println!("Failed to serve connection: {:?}", err); - } - } - ); + }); } } diff --git a/static/css/index.css b/static/css/index.css index 16ebf2a..0ddc9b7 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -119,3 +119,11 @@ body { border: none; border-radius: 5px; } + +.login { + position: fixed; + right: 0; + top: 0; + color: white; + padding: 10px; +} diff --git a/static/css/login.css b/static/css/login.css new file mode 100644 index 0000000..22a1f75 --- /dev/null +++ b/static/css/login.css @@ -0,0 +1,87 @@ +body { + background-color: #11111b; + padding:0; + margin:0; +} + +.footer { + margin: 0; + position: fixed; + bottom: 0; + width: 100%; + max-height: 50px; + display: flex; + background-color: #b4befe; + justify-content: center; +} + +.footer > a { + padding: 10px; + margin: 5px; + background-color: #94e2d5; + border-radius: 5px; + color: #1e1e2e; + font-family: "Segoe UI"; + font-size: 14px; +} + +.app { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, calc(-50% - 60px)); + background-color: #181926; + border-radius: 12px; + width: calc(100dvw - 40px); + max-width: 500px; + height: fit-content; + padding: 10px; +} + +.app > h1 { + margin-top: 5px; + color: yellow; +} + +.app > h3 { + color: red; +} + +.sub { + display: grid; + width: fit-content; + margin: auto; +} + +.sub > input { + margin-bottom: 5px; + max-width: 300px; + width: 80dvw; + color: black; +} + +.sub > label { + color: white; +} + +.sub > button { + justify-self: right; + font-size: 14px; + margin: 4px 0 0 0; + padding: 5px; + background-color: cornflowerblue; + color: white; + border: none; + border-radius: 5px; +} + +.app > p { + text-align: center; + color: white; +} + +.app > a { + display: flow; + text-align: center; + color: white; +} \ No newline at end of file diff --git a/static/css/logout.css b/static/css/logout.css new file mode 100644 index 0000000..399e827 --- /dev/null +++ b/static/css/logout.css @@ -0,0 +1,23 @@ +body { + background-color: #11111b; + padding:0; + margin:0; +} + +h2 { + color: yellow; +} + +p { + color: white; +} + +a { + color: white; +} + +.app { + width: fit-content; + margin: auto; + text-align: center; +} \ No newline at end of file diff --git a/static/css/register.css b/static/css/register.css new file mode 100644 index 0000000..747a067 --- /dev/null +++ b/static/css/register.css @@ -0,0 +1,7 @@ +.error { + color: red; +} + +.ok { + color: green; +} \ No newline at end of file diff --git a/static/css/results.css b/static/css/results.css index a232ff0..7ea1742 100644 --- a/static/css/results.css +++ b/static/css/results.css @@ -26,9 +26,8 @@ body { } .app { - margin: auto; - margin-bottom: 60px; - height: calc(100dvh + 60px); + margin: auto auto 60px; + height: calc(100% + 60px); width: 100%; max-width: 500px; } @@ -76,3 +75,11 @@ body { grid-column-start: 2; grid-column-end: 3; } + +.login { + position: fixed; + right: 0; + top: 0; + color: white; + padding: 10px; +} diff --git a/static/css/unauthorised.css b/static/css/unauthorised.css new file mode 100644 index 0000000..5ddb578 --- /dev/null +++ b/static/css/unauthorised.css @@ -0,0 +1,16 @@ +body { + background-color: #11111b; + text-align: center; +} + +h1 { + color: red; +} + +h3 { + color: yellow; +} + +p, a { + color: white; +} \ No newline at end of file diff --git a/static/html/admin.html b/static/html/admin.html new file mode 100644 index 0000000..7ca45c8 --- /dev/null +++ b/static/html/admin.html @@ -0,0 +1,30 @@ + + + + + + + + + Administration + + +
+

Users

+

id, username, permission

+
+
+
+

Players

+

id, name

+
+
+
+

Votes

+

+

id, submit_date, plus_id, plus_nickname, plus_reason, minus_id, minus_nickname, minus_reason

+
+
+ + \ No newline at end of file diff --git a/static/html/index.html b/static/html/index.html index 8cec0cd..51d73e8 100644 --- a/static/html/index.html +++ b/static/html/index.html @@ -15,6 +15,8 @@

Vote +

+ + @@ -34,5 +36,6 @@ Résultats Archives + \ No newline at end of file diff --git a/static/html/login.html b/static/html/login.html new file mode 100644 index 0000000..867ef5d --- /dev/null +++ b/static/html/login.html @@ -0,0 +1,37 @@ + + + + + + + + + + Login + + +
+

Connection

+

+
+ +
+ +
+ +
+ +
+ +
+

Tu n'as pas de compte?

+ Créer un compte. +
+ + + \ No newline at end of file diff --git a/static/html/logout.html b/static/html/logout.html new file mode 100644 index 0000000..bc0ccf0 --- /dev/null +++ b/static/html/logout.html @@ -0,0 +1,20 @@ + + + + + + + + + + Déconnection + + +
+

Tu es désormais déconnecté.
Tu seras redirigé dans pas longtemps.

+

Pas encore redirigé?

+ Click ici pour être redirigé. +
+ + \ No newline at end of file diff --git a/static/html/register.html b/static/html/register.html new file mode 100644 index 0000000..df83562 --- /dev/null +++ b/static/html/register.html @@ -0,0 +1,52 @@ + + + + + + + + + + + Register + + +
+

Créer un nouveau compte

+

+
+ +
+ +
+

Le nom d'utilisateur doit ne pas contenir de caractères spéciaux.

+ +
+ +

Le mot de passe doit contenir plus de 8 caractères et doit contenir au minimum: +
+ 1 Majuscule +
+ 1 Symbole +
+ 1 Minuscule +
+ 1 Nombre +

+ +
+ +

Les mots de passe doivent être les mêmes.

+ +
+

Tu as déjà un compte?

+ Se connecter. +
+ + + diff --git a/static/html/results.html b/static/html/results.html index 36da9d7..113e697 100644 --- a/static/html/results.html +++ b/static/html/results.html @@ -11,6 +11,7 @@ +