Compare commits

..

28 Commits

Author SHA1 Message Date
cbaf52256d Remove unused import 2024-10-05 15:52:59 +02:00
8a7c321044 Fixed bug related to new Vote struct, id and submit date can be None
(useful for posting new votes, ie without id and submit date)
2024-10-05 15:09:51 +02:00
98dbcd11f1 Added submit date to votes 2024-10-05 15:08:41 +02:00
c07a304562 Votes div remains to have date displayed per vote 2024-10-04 22:49:51 +02:00
92c067c9ed small fixes (if player not exsits show ?) 2024-10-04 22:49:00 +02:00
164184e5e9 user can add player while voting 2024-10-04 21:00:53 +02:00
9c8e8beaa6 user can add player while voting 2024-10-04 21:00:43 +02:00
28b2411ceb user can add player 2024-10-04 17:28:20 +02:00
fb28221875 added check for token generation (look if exists) (better) 2024-10-04 17:17:46 +02:00
cd1632b533 added check for token generation (look if exists) 2024-10-04 17:15:59 +02:00
01c8248313 bug fix: ids can be wrong if players added/deleted 2024-10-04 17:05:39 +02:00
5a13cf9214 administration done (minimalistic) 2024-10-04 17:05:10 +02:00
894e102322 Custom Response Body, is authorised returning bool, ... 2024-10-02 19:36:50 +02:00
72fd45eb64 admin page (in progress) + better body (in progress) 2024-10-01 21:27:23 +02:00
4cb1ef1df9 admin page (in progress) 2024-09-28 23:28:14 +02:00
c66e854e03 main code + settings update 2024-09-28 23:27:55 +02:00
3924c87b93 register page 2024-09-28 23:27:44 +02:00
a8a913189e unauthorised page 2024-09-28 23:27:33 +02:00
de0ef9dd69 logout page 2024-09-28 23:27:21 +02:00
13265e1ef3 small login css fix 2024-09-28 23:27:10 +02:00
febd1d33f1 login link 2024-09-28 23:26:15 +02:00
4101e824a2 permissions mustn't be null 2024-09-28 23:25:47 +02:00
66486c3cea register progress 2024-09-28 00:07:20 +02:00
10aa258576 backend update (login modified and new logout) 2024-09-28 00:07:01 +02:00
19fe28be49 login page 2024-09-28 00:06:32 +02:00
0a87f67824 Check password 2024-09-27 18:22:51 +02:00
bcf3000bcc token cookie, register 2024-09-25 23:13:15 +02:00
8b09406339 login 2024-09-24 22:19:55 +02:00
24 changed files with 1316 additions and 124 deletions

View File

@@ -22,6 +22,7 @@ chrono = { version = "0.4.38", features = ["alloc"]}
futures-util = "0.3.30" futures-util = "0.3.30"
h2 = "0.4.6" h2 = "0.4.6"
argon2 = "0.5.3" argon2 = "0.5.3"
rand = "0.8.1"
[target.'cfg(unix)'.dependencies] [target.'cfg(unix)'.dependencies]
daemonize = "0.5.0" daemonize = "0.5.0"

View File

@@ -2,8 +2,8 @@ CREATE TABLE IF NOT EXISTS users
( (
id INTEGER PRIMARY KEY NOT NULL, id INTEGER PRIMARY KEY NOT NULL,
username TEXT NOT NULL, username TEXT NOT NULL,
salt text NOT NULL, saltyhash text NOT NULL,
hash TEXT NOT NULL, permissions INTEGER DEFAULT 0 NOT NULL,
permissions INTEGER DEFAULT 0, token TEXT NOT NULL,
token TEXT UNIQUE(id, username)
) )

View File

@@ -1,6 +1,11 @@
{ {
"/": "static/html/index.html", "/": {"file": "static/html/index.html", "permission": 0},
"/results": "static/html/results.html", "/results": {"file": "static/html/results.html", "permission": 1},
"/archives": "static/html/archives.html", "/archives": {"file": "static/html/archives.html", "permission": 2},
"/favicon.ico": "static/img/favicon.ico" "/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}
} }

View File

@@ -1,4 +1,4 @@
{ {
"database_url": "sqlite:vote.db", "database_url": "sqlite:vote.db",
"bind_address": "127.0.0.1:8080" "bind_address": "127.0.0.1:8000"
} }

View File

@@ -1,28 +1,65 @@
use bytes::Buf; use argon2::{
use bytes::Bytes; password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
use chrono::{DateTime, Utc}; Argon2,
use futures; };
use bytes::{Buf, Bytes};
use chrono::{DateTime, Days, NaiveTime, Utc};
#[cfg(target_os = "linux")]
use daemonize::Daemonize;
use http_body_util::{BodyExt, Full}; use http_body_util::{BodyExt, Full};
use hyper::body::Incoming; use hyper::{
use hyper::server::conn::http1; body::{Body as HyperBody, Frame, Incoming},
use hyper::service::service_fn; header::{COOKIE, SET_COOKIE},
use hyper::{Error, Method, Request, Response, StatusCode}; server::conn::http1,
service::service_fn,
Error, Method, Request, Response, StatusCode,
};
use hyper_util::rt::{TokioIo, TokioTimer}; 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 serde_json::{from_reader, Value};
use sqlx::sqlite::SqlitePool; use sqlx::sqlite::SqlitePool;
use std::collections::HashMap; use std::collections::HashMap;
use std::convert::Infallible;
use std::env; use std::env;
use std::fs::File; use std::fs::File;
use std::io::Read; use std::io::Read;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::pin::Pin;
use std::str::FromStr; use std::str::FromStr;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::task::{Context, Poll};
use std::time::SystemTime; use std::time::SystemTime;
use tokio::net::TcpListener; use tokio::net::TcpListener;
#[cfg(target_os = "linux")] enum Body {
use daemonize::Daemonize; Full(Full<Bytes>),
Empty,
}
impl Body {
fn new<T>(data: T) -> Self
where
Bytes: From<T>,
{
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<Option<Result<Frame<Self::Data>, Self::Error>>> {
match &mut *self.get_mut() {
Self::Full(incoming) => Pin::new(incoming).poll_frame(cx),
Self::Empty => Poll::Ready(None),
}
}
}
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
struct Player { struct Player {
id: i64, id: i64,
@@ -31,6 +68,8 @@ struct Player {
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
struct Vote { struct Vote {
id: Option<i64>,
submit_date: Option<String>,
plus_player_id: i64, plus_player_id: i64,
plus_nickname: String, plus_nickname: String,
plus_reason: String, plus_reason: String,
@@ -39,42 +78,89 @@ struct Vote {
minus_reason: String, 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)] #[derive(Serialize, Deserialize)]
struct Settings { struct Settings {
database_url: String, database_url: String,
bind_address: String, bind_address: String,
} }
async fn service(req: Request<Incoming>, db: Arc<Mutex<SqlitePool>>) -> Result<Response<Full<Bytes>>, Error> {
match req.method() { async fn service(
&Method::GET => { get(req, db).await } req: Request<Incoming>,
&Method::POST => { post(req, db).await } db: Arc<Mutex<SqlitePool>>,
_ => { Ok(Response::builder().status(StatusCode::IM_A_TEAPOT).body(Full::new(Bytes::new())).unwrap()) } ) -> Result<Response<Body>, 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<Incoming>, db: Arc<Mutex<SqlitePool>>) -> Result<Response<Full<Bytes>>, Error> { async fn get(req: Request<Incoming>, db: Arc<Mutex<SqlitePool>>) -> Result<Response<Body>, Error> {
let path = req.uri().path(); let path = req.uri().path();
if path.starts_with("/static/") { if path.starts_with("/static") {
get_file(path).await get_file(path).await
} else if path.starts_with("/data/") { } else if path.starts_with("/data") {
get_data(path, &req, db).await get_data(path, &req, db).await
} else if path.starts_with("/admin") {
get_admin(&req, path, db).await
} else { } else {
get_page(path).await get_page(&req, path, db).await
} }
} }
async fn get_page(path: &str) -> Result<Response<Full<Bytes>>, Error> { async fn get_page(
let mut routes = env::current_dir().expect("Could not get app directory (Required to get routes)"); req: &Request<Incoming>,
path: &str,
db: Arc<Mutex<SqlitePool>>,
) -> Result<Response<Body>, Error> {
let mut routes =
env::current_dir().expect("Could not get app directory (Required to get routes)");
routes.push("routes.json"); routes.push("routes.json");
let file = File::open(routes).expect("Could not open routes file."); 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."); let map: Value = from_reader(file).expect("Could not parse routes, please verify syntax.");
match &map[path] { match map.get(path) {
Value::String(s) => get_file(&s).await, Some(Value::Object(s)) => {
_ => not_found().await 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<Response<Full<Bytes>>, Error> { async fn get_file(mut path: &str) -> Result<Response<Body>, Error> {
let mut file_path = env::current_dir().expect("Could not get app directory."); let mut file_path = env::current_dir().expect("Could not get app directory.");
if path.starts_with(r"/") { if path.starts_with(r"/") {
path = path.strip_prefix(r"/").unwrap(); path = path.strip_prefix(r"/").unwrap();
@@ -89,58 +175,79 @@ async fn get_file(mut path: &str) -> Result<Response<Full<Bytes>>, Error> {
"js" => "text/javascript", "js" => "text/javascript",
"html" => "text/html", "html" => "text/html",
"css" => "text/css", "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<Incoming>, db: Arc<Mutex<SqlitePool>>) -> Result<Response<Full<Bytes>>, Error> { async fn get_data(
path: &str,
req: &Request<Incoming>,
db: Arc<Mutex<SqlitePool>>,
) -> Result<Response<Body>, Error> {
let pool = db.clone().lock().unwrap().clone(); let pool = db.clone().lock().unwrap().clone();
match path { match path {
"/data/players" => { "/data/players" => {
let items = sqlx::query!(r#"SELECT * FROM players"#).fetch_all(&pool).await.unwrap(); let items = sqlx::query!(r#"SELECT * FROM players"#)
let players: Vec<Player> = items.iter().map(|x| Player { id: x.id, name: x.name.clone() }).collect(); .fetch_all(&pool)
Ok(Response::new(Full::new(Bytes::from(serde_json::to_string(&players).unwrap())))) .await
.unwrap();
let players: Vec<Player> = 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" => { "/data/votes" => {
let votes = get_votes(req, db).await; 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" => { "/data/results" => {
let votes = get_votes(req, db).await; 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<i64, i64> = HashMap::new(); let mut plus_results: HashMap<i64, i64> = HashMap::new();
let mut minus_results: HashMap<i64, i64> = HashMap::new(); let mut minus_results: HashMap<i64, i64> = HashMap::new();
let _ = ids.iter().for_each(|x| { ids.iter().for_each(|x| {
let plus_id = x.0; let plus_id = x.0;
if !plus_results.contains_key(&plus_id) { plus_results.entry(plus_id).or_insert(0);
plus_results.insert(plus_id, 0);
}
*plus_results.get_mut(&plus_id).unwrap() += 1; *plus_results.get_mut(&plus_id).unwrap() += 1;
let minus_id = x.1; let minus_id = x.1;
if !minus_results.contains_key(&minus_id) { minus_results.entry(minus_id).or_insert(0);
minus_results.insert(minus_id, 0);
}
*minus_results.get_mut(&minus_id).unwrap() += 1; *minus_results.get_mut(&minus_id).unwrap() += 1;
}); });
let mut plus_results: Vec<(i64, i64)> = plus_results.into_iter().collect(); let mut plus_results: Vec<(i64, i64)> = plus_results.into_iter().collect();
let mut minus_results: Vec<(i64, i64)> = minus_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) }); plus_results.sort_by(|a, b| b.1.cmp(&a.1));
minus_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]; 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<Incoming>, db: Arc<Mutex<SqlitePool>>) -> Vec<V
let date = match headers.get("Date-to-fetch") { let date = match headers.get("Date-to-fetch") {
Some(date) => { Some(date) => {
let date = date.to_str().unwrap(); let date = date.to_str().unwrap();
let parsed_date = date.parse::<i64>(); if let Ok(parsed_date) = date.parse::<i64>() {
if parsed_date.is_err() { DateTime::from_timestamp_millis(parsed_date)
None
} else { } 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() { if date.is_none() {
return Vec::new(); return Vec::new();
} }
let items = futures::executor::block_on(async move {
let formatted_date = format!("{}", date.unwrap().format("%d/%m/%Y")); 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() sqlx::query_as!(
}); Vote,
items.iter().map(|x| Vote { r#"SELECT * FROM votes WHERE submit_date = ?1 ORDER BY id"#,
plus_player_id: x.plus_player_id, formatted_date
plus_nickname: x.plus_nickname.clone(), )
plus_reason: x.plus_reason.clone(), .fetch_all(&pool)
minus_player_id: x.minus_player_id, .await
minus_nickname: x.minus_nickname.clone(), .unwrap()
minus_reason: x.minus_reason.clone(),
}).collect()
} }
async fn post(req: Request<Incoming>, db: Arc<Mutex<SqlitePool>>) -> Result<Response<Full<Bytes>>, Error> { async fn get_admin(
let path = req.uri().path(); req: &Request<Incoming>,
if path != "/post" { path: &str,
return Ok(Response::builder().status(StatusCode::BAD_REQUEST).body(Full::new(Bytes::from("Bad Request (Bad Route)"))).unwrap()); db: Arc<Mutex<SqlitePool>>,
) -> Result<Response<Body>, 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?; match path {
let data: Result<Vote, serde_json::Error> = from_reader(body.aggregate().reader()); "/admin" => get_page(req, path, db).await,
if data.is_err() { "/admin/users" => {
return Ok(Response::builder().status(StatusCode::BAD_REQUEST).body(Full::new(Bytes::from("Bad Request (Bad Data)"))).unwrap()); 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<Incoming>, db: Arc<Mutex<SqlitePool>>) -> Result<Response<Body>, 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<Incoming>,
db: Arc<Mutex<SqlitePool>>,
) -> Result<Response<Body>, Error> {
let data = req_json::<Vote>(req).await;
if data.is_none() {
return bad_request().await;
} }
let vote = data.unwrap(); let vote = data.unwrap();
let timestamp: DateTime<Utc> = DateTime::from(SystemTime::now()); let timestamp: DateTime<Utc> = DateTime::from(SystemTime::now());
@@ -201,25 +363,456 @@ async fn post(req: Request<Incoming>, db: Arc<Mutex<SqlitePool>>) -> Result<Resp
vote.minus_reason, vote.minus_reason,
formatted).execute(&mut *conn).await; formatted).execute(&mut *conn).await;
if result.is_err() { if result.is_err() {
return Ok(Response::builder().status(StatusCode::INTERNAL_SERVER_ERROR).body(Full::new(Bytes::from("Internet Error"))).unwrap()); return Ok(Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Body::Empty)
.unwrap());
} }
Ok(Response::builder().body(Full::new(Bytes::new())).unwrap()) let date: DateTime<Utc> = 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<Response<Full<Bytes>>, Error> { async fn post_player(
req: Request<Incoming>,
db: Arc<Mutex<SqlitePool>>,
) -> Result<Response<Body>, Error> {
let data = req_json::<Value>(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<Incoming>,
db: Arc<Mutex<SqlitePool>>,
) -> Result<Response<Body>, 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::<Value>(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::<Value>(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::<Player>(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::<Value>(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::<Value>(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::<Vote>(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::<Value>(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<Incoming>,
db: Arc<Mutex<SqlitePool>>,
) -> Result<Response<Body>, Error> {
let body = req.into_body().collect().await;
let data: Result<Login, serde_json::Error> = 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<Utc> = 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<Incoming>,
db: Arc<Mutex<SqlitePool>>,
) -> Result<Response<Body>, Error> {
match req_json::<Login>(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<Response<Body>, Error> {
let date: DateTime<Utc> = 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<Incoming>, db: Arc<Mutex<SqlitePool>>, 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<Response<Body>, Error> {
let mut file_path = env::current_dir().expect("Could not get app directory."); let mut file_path = env::current_dir().expect("Could not get app directory.");
file_path.push("static/html/404.html"); file_path.push("static/html/404.html");
let mut file = File::open(file_path).unwrap(); let mut file = File::open(file_path).unwrap();
let mut buf = Vec::new(); let mut buf = Vec::new();
file.read_to_end(&mut buf).unwrap(); 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<Response<Body>, Error> {
Ok(Response::builder()
.status(StatusCode::BAD_REQUEST)
.body(Body::Empty)
.unwrap())
}
async fn ok() -> Result<Response<Body>, Error> {
Ok(Response::builder().body(Body::Empty).unwrap())
}
async fn req_json<T>(req: Request<Incoming>) -> Option<T>
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 { 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"); settings_path.push("settings.json");
let settings_file = File::open(settings_path).expect("Could not open settings file, does it exists?"); let settings_file =
let settings: Settings = from_reader(settings_file).expect("Could not parse settings, please check syntax."); 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 settings
} }
@@ -248,26 +841,32 @@ fn main() {
#[tokio::main] #[tokio::main]
async fn run() { async fn run() {
let settings = get_settings(); 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 db_pool = Arc::new(Mutex::new(
let bind_address: SocketAddr = SocketAddr::from_str(&settings.bind_address).expect("Could not parse bind address."); SqlitePool::connect(&settings.database_url)
let listener = TcpListener::bind(bind_address).await.expect("Could not bind to address."); .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 { 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 io = TokioIo::new(stream);
let db = db_pool.clone(); let db = db_pool.clone();
let service = service_fn( let service = service_fn(move |req| service(req, db.clone()));
move |req| { tokio::task::spawn(async move {
service(req, db.clone())
}
);
tokio::task::spawn(
async move {
if let Err(err) = http1::Builder::new() if let Err(err) = http1::Builder::new()
.timer(TokioTimer::new()) .timer(TokioTimer::new())
.serve_connection(io, service).await { .serve_connection(io, service)
.await
{
println!("Failed to serve connection: {:?}", err); println!("Failed to serve connection: {:?}", err);
} }
} });
);
} }
} }

View File

@@ -119,3 +119,11 @@ body {
border: none; border: none;
border-radius: 5px; border-radius: 5px;
} }
.login {
position: fixed;
right: 0;
top: 0;
color: white;
padding: 10px;
}

87
static/css/login.css Normal file
View File

@@ -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;
}

23
static/css/logout.css Normal file
View File

@@ -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;
}

7
static/css/register.css Normal file
View File

@@ -0,0 +1,7 @@
.error {
color: red;
}
.ok {
color: green;
}

View File

@@ -26,9 +26,8 @@ body {
} }
.app { .app {
margin: auto; margin: auto auto 60px;
margin-bottom: 60px; height: calc(100% + 60px);
height: calc(100dvh + 60px);
width: 100%; width: 100%;
max-width: 500px; max-width: 500px;
} }
@@ -76,3 +75,11 @@ body {
grid-column-start: 2; grid-column-start: 2;
grid-column-end: 3; grid-column-end: 3;
} }
.login {
position: fixed;
right: 0;
top: 0;
color: white;
padding: 10px;
}

View File

@@ -0,0 +1,16 @@
body {
background-color: #11111b;
text-align: center;
}
h1 {
color: red;
}
h3 {
color: yellow;
}
p, a {
color: white;
}

30
static/html/admin.html Normal file
View File

@@ -0,0 +1,30 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<base href="/" target="_top">
<script src="static/js/admin.js" defer="defer"></script>
<title>Administration</title>
</head>
<body>
<div>
<h1>Users</h1>
<p>id, username, permission</p>
<div id="users"></div>
</div>
<div>
<h1>Players</h1>
<p>id, name</p>
<div id="players"></div>
</div>
<div>
<h1>Votes</h1>
<h3 id="votes_number"></h3>
<p>id, submit_date, plus_id, plus_nickname, plus_reason, minus_id, minus_nickname, minus_reason</p>
<div id="votes"></div>
</div>
</body>
</html>

View File

@@ -15,6 +15,8 @@
<h1 id="app_title">Vote +</h1> <h1 id="app_title">Vote +</h1>
<label for="player_id">Pour qui votes tu?</label> <label for="player_id">Pour qui votes tu?</label>
<select id="player_id"></select> <select id="player_id"></select>
<label id="other_label" for="other" hidden="hidden">Autre:</label>
<input id="other" hidden="hidden">
<label for="nickname">As-tu un surnom à lui donner?</label> <label for="nickname">As-tu un surnom à lui donner?</label>
<input type="text" id="nickname"> <input type="text" id="nickname">
<label for="reason">Pourquoi votes-tu pour lui?</label> <label for="reason">Pourquoi votes-tu pour lui?</label>
@@ -34,5 +36,6 @@
<a href="/results">Résultats</a> <a href="/results">Résultats</a>
<a href="/archives">Archives</a> <a href="/archives">Archives</a>
</div> </div>
<a href="/login" class="login" id="login">Se connecter</a>
</body> </body>
</html> </html>

37
static/html/login.html Normal file
View File

@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<base href="/" target="_top">
<script src="static/js/login.js" defer="defer"></script>
<link rel="stylesheet" href="static/css/login.css">
<title>Login</title>
</head>
<body>
<div class="app">
<h1>Connection</h1>
<h3 id="message"></h3>
<div class="sub">
<label for="username">Nom d'utilisateur:</label>
<br>
<input id="username">
<br>
<label for="password">Mot de passe:</label>
<br>
<input id="password" type="password">
<br>
<button id="connect">Se connecter</button>
</div>
<p>Tu n'as pas de compte?</p>
<a href="/register">Créer un compte.</a>
</div>
<div id="footer" class="footer">
<a href="/">Voter</a>
<a href="/results">Résultats</a>
<a href="/archives">Archives</a>
</div>
</body>
</html>

20
static/html/logout.html Normal file
View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<base href="/" target="_top">
<script src="static/js/logout.js" defer="defer"></script>
<link rel="stylesheet" href="static/css/logout.css">
<title>Déconnection</title>
</head>
<body>
<div class="app">
<h2>Tu es désormais déconnecté.<br>Tu seras redirigé dans pas longtemps.</h2>
<p>Pas encore redirigé?</p>
<a href="/">Click ici pour être redirigé.</a>
</div>
</body>
</html>

52
static/html/register.html Normal file
View File

@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<base href="/" target="_top">
<script src="static/js/register.js" defer="defer"></script>
<link rel="stylesheet" href="static/css/login.css">
<link rel="stylesheet" href="static/css/register.css">
<title>Register</title>
</head>
<body>
<div class="app">
<h1>Créer un nouveau compte</h1>
<h3 id="message"></h3>
<div class="sub">
<label for="username">Nom d'utilisateur:</label>
<br>
<input id="username">
<br>
<p class="error" id="usernameNotice">Le nom d'utilisateur doit ne pas contenir de caractères spéciaux.</p>
<label for="password">Mot de passe:</label>
<br>
<input id="password" type="password">
<p class="error" id="passwordNotice">Le mot de passe doit contenir plus de 8 caractères et doit contenir au minimum:
<br>
1 Majuscule
<br>
1 Symbole
<br>
1 Minuscule
<br>
1 Nombre
</p>
<label for="passwordConfirm">Confirmer le mot de passe:</label>
<br>
<input id="passwordConfirm" type="password">
<p class="error" id="passwordConfirmNotice">Les mots de passe doivent être les mêmes.</p>
<button id="register">Créer un compte.</button>
</div>
<p>Tu as déjà un compte?</p>
<a href="/login">Se connecter.</a>
</div>
<div id="footer" class="footer">
<a href="/">Voter</a>
<a href="/results">Résultats</a>
<a href="/archives">Archives</a>
</div>
</body>
</html>

View File

@@ -11,6 +11,7 @@
<script src="/static/js/results.js" defer="defer"></script> <script src="/static/js/results.js" defer="defer"></script>
</head> </head>
<body> <body>
<a href="/login" class="login" id="login">Se connecter</a>
<div id="app" class="app"></div> <div id="app" class="app"></div>
<div id="footer" class="footer"> <div id="footer" class="footer">
<a href="/">Voter</a> <a href="/">Voter</a>

View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<base href="/" target="_top">
<link rel="stylesheet" href="static/css/unauthorised.css">
<title>Non autorisé</title>
</head>
<body>
<div class="app" id="app">
<h1>Tu n'es pas autorisé à accèder à cette page.</h1>
<h3>Merci de bien vouloir te connecter.</h3>
<a href="/login">Se connecter</a>
<p>Tu n'as pas de compte?</p>
<a href="/register">Créer un compte</a>
<h3>Si c'est une erreur, contacte un administrateur.</h3>
</div>
</body>
</html>

123
static/js/admin.js Normal file
View File

@@ -0,0 +1,123 @@
async function run() {
let users = await fetch("/admin/users").then(r => r.json());
let usersDiv = document.getElementById("users");
for (let i = 0; i < users.length; i++) {
let item = document.createElement("div");
let id = document.createElement("p");
let username = document.createElement("input");
let permissions = document.createElement("input");
let edit = document.createElement("button");
let del = document.createElement("button");
edit.textContent = "Edit";
del.textContent = "Delete";
id.textContent = users[i][0];
username.value = users[i][1];
permissions.value = users[i][2];
item.style.display = "flex";
item.append(id, username, permissions, edit, del);
usersDiv.appendChild(item);
edit.addEventListener("click", async () => {
await fetch("/admin/edit/user", {method: "POST", body: JSON.stringify({"id": users[i][0], "username": username.value, "permissions": parseInt(permissions.value)})});
window.location.reload();
})
del.addEventListener("click", async () => {
await fetch("/admin/delete/user", {method:"POST", body: JSON.stringify({"id": users[i][0]})});
window.location.reload();
})
}
let players = await fetch("/admin/players").then(r => r.json());
let playersDiv = document.getElementById("players");
for (let i=0; i<players.length; i++) {
let item = document.createElement("div");
let id = document.createElement("p");
let name = document.createElement("input");
let edit = document.createElement("button");
let del = document.createElement("button");
edit.textContent = "Edit";
del.textContent = "Delete";
id.textContent = players[i]["id"];
name.value = players[i]["name"];
item.style.display = "flex";
item.append(id, name, edit, del);
playersDiv.appendChild(item);
edit.addEventListener("click", async () => {
await fetch("/admin/edit/player", {method: "POST", body: JSON.stringify({"id": players[i]["id"], "name": name.value})});
window.location.reload();
})
del.addEventListener("click", async () => {
await fetch("/admin/delete/player", {method:"POST", body: JSON.stringify({"id": players[i]["id"]})});
window.location.reload();
})
}
let newPlayer = document.createElement("button");
newPlayer.textContent = "Add Player";
newPlayer.addEventListener("click", () => {
let item = document.createElement("div");
let name = document.createElement("input");
let save = document.createElement("button");
save.textContent = "Save";
item.append(name, save);
playersDiv.appendChild(item);
save.addEventListener("click", async () => {
await fetch("/admin/new/player", {method:"POST", body: JSON.stringify({"name": name.value})})
window.location.reload();
})
})
playersDiv.parentNode.append(newPlayer);
let votes = await fetch("/admin/votes").then(r => r.json());
let today = document.getElementById("votes_number");
let count = await fetch("/data/votes").then(r => r.json());
today.textContent = `Aujourd'hui il y a ${count.length} votes`;
let votesDiv = document.getElementById("votes");
for (let i=0; i<votes.length; i++) {
let vote = votes[i];
let item = document.createElement("div");
item.style.display = "flex";
let id = document.createElement("p");
id.textContent = vote["id"];
let submit_date = document.createElement("input");
submit_date.value = vote["submit_date"];
let plus_id = document.createElement("input");
plus_id.type = "number";
plus_id.value = vote["plus_player_id"];
let plus_nickname = document.createElement("input");
plus_nickname.value = vote["plus_nickname"];
let plus_reason = document.createElement("input");
plus_reason.value = vote["plus_reason"];
let minus_id = document.createElement("input");
minus_id.type = "number";
minus_id.value = vote["minus_player_id"];
let minus_nickname = document.createElement("input");
minus_nickname.value = vote["minus_nickname"];
let minus_reason = document.createElement("input");
minus_reason.value = vote["minus_reason"];
let edit = document.createElement("button");
edit.textContent = "Edit";
let del = document.createElement("button");
del.textContent = "Delete";
item.append(id,submit_date,plus_id,plus_nickname,plus_reason,minus_id,minus_nickname,minus_reason, edit, del);
votesDiv.append(item);
edit.addEventListener("click", async () => {
await fetch("/admin/edit/vote", {method: "POST", body: JSON.stringify({"id": votes[i]["id"],
"submit_date": submit_date.value,
"plus_player_id": parseInt(plus_id.value),
"plus_nickname": plus_nickname.value,
"plus_reason": plus_reason.value,
"minus_player_id": parseInt(minus_id.value),
"minus_nickname": minus_nickname.value,
"minus_reason": minus_reason.value})});
window.location.reload();
})
del.addEventListener("click", async () => {
await fetch("/admin/delete/vote", {method:"POST", body: JSON.stringify({"id": votes[i]["id"]})});
window.location.reload();
})
}
}
let _ = run();

View File

@@ -1,4 +1,5 @@
let vote = { let vote = {
submit_date: null,
plus_player_id: null, plus_player_id: null,
plus_nickname: "", plus_nickname: "",
plus_reason: "", plus_reason: "",
@@ -11,7 +12,7 @@ let current_page = 0;
async function main() { async function main() {
if (read_cookie()) { if (read_cookie()) {
showMessage("Merci pour ton vote!", "Ton vote a bien été prit en compte.", false, "info"); showMessage("Merci pour ton vote!", "Ton vote a bien été pris en compte.", false, "info");
return; return;
} }
let players = await fetch("/data/players").then(r => r.json()); let players = await fetch("/data/players").then(r => r.json());
@@ -24,10 +25,25 @@ async function main() {
option.textContent = x["name"]; option.textContent = x["name"];
select.append(option); select.append(option);
}) })
let other = document.createElement("option");
other.value = "other";
other.textContent = "Autre";
select.append(other);
select.value = null; select.value = null;
nickname.value = ""; nickname.value = "";
reason.value = ""; reason.value = "";
select.addEventListener("change", () => { select.addEventListener("change", () => {
if (select.value === "other") {
let other = document.getElementById("other");
other.hidden = false;
let otherLabel = document.getElementById("other_label");
otherLabel.hidden = false;
} else {
let other = document.getElementById("other");
other.hidden = true;
let otherLabel = document.getElementById("other_label");
otherLabel.hidden = true;
}
if (current_page) { if (current_page) {
vote.minus_player_id = parseInt(select.value); vote.minus_player_id = parseInt(select.value);
} else { } else {
@@ -59,12 +75,28 @@ async function main() {
true, "warning"); true, "warning");
return; return;
} }
if (await fetch("/post", { if (select.value === "other") {
let otherInput = document.getElementById("other");
if (otherInput.value === "") {
showMessage("Tu as sélectionné autre mais tu n'as pas donné de nom", "N'oublie pas de soit sélectioner une personne ou de mettre un nom dans ''Autre:''.", true, "warning");
return;
}
let otherInputLabel = document.getElementById("other_label");
let player = await fetch("/player", {method: "post", body: JSON.stringify({"name": otherInput.value})}).then(r => r.json());
let option = document.createElement("option");
option.value = player["id"];
option.textContent = player["name"];
select.insertBefore(option, other);
vote.minus_player_id = parseInt(player["id"]);
otherInput.value = "";
otherInput.hidden = true;
otherInputLabel.hidden = true;
}
if (await fetch("/vote", {
method: "post", body: JSON.stringify(vote) method: "post", body: JSON.stringify(vote)
}) })
.then(r => r.status) === 200) { .then(r => r.status) === 200) {
set_cookie(); showMessage("Merci pour ton vote!", "Ton vote a bien été pris en compte.", false, "info");
showMessage("Merci pour ton vote!", "Ton vote a bien été prit en compte.", false, "info");
} }
console.log(vote); console.log(vote);
} else { } else {
@@ -76,6 +108,23 @@ async function main() {
} }
rightButton.textContent = "À voté!"; rightButton.textContent = "À voté!";
title.textContent = "Vote -"; title.textContent = "Vote -";
if (select.value === "other") {
let otherInput = document.getElementById("other");
if (otherInput.value === "") {
showMessage("Tu as sélectionné autre mais tu n'as pas donné de nom", "N'oublie pas de soit sélectioner une personne ou de mettre un nom dans ''Autre:''.", true, "warning");
return;
}
let otherInputLabel = document.getElementById("other_label");
let player = await fetch("/player", {method: "post", body: JSON.stringify({"name": otherInput.value})}).then(r => r.json());
let option = document.createElement("option");
option.value = player["id"];
option.textContent = player["name"];
select.insertBefore(option, other);
vote.plus_player_id = parseInt(player["id"]);
otherInput.value = "";
otherInput.hidden = true;
otherInputLabel.hidden = true;
}
current_page = 1; current_page = 1;
leftButton.hidden = false; leftButton.hidden = false;
select.value = vote.minus_player_id; select.value = vote.minus_player_id;
@@ -111,16 +160,13 @@ function showMessage(title, description, canBeDismissed, type) {
}) })
} }
function set_cookie() {
let date = new Date(Date.now());
date.setDate(date.getDate() + 1);
date.setHours(0, 0,0);
console.log(date);
document.cookie = `hasvoted=true; expires=${date.toUTCString()}; path=/`;
}
function read_cookie() { function read_cookie() {
return document.cookie.includes("hasvoted=true"); return document.cookie.includes("hasvoted=true");
} }
let login = document.getElementById("login");
let logged = document.cookie.includes("logged=true");
login.textContent = logged ? "Se déconnecter" : "Se connecter";
login.href = logged ? "/logout" : "/login";
let _ = main(); let _ = main();

18
static/js/login.js Normal file
View File

@@ -0,0 +1,18 @@
async function login() {
let username = document.getElementById("username").value;
let password = document.getElementById("password").value;
let code = await fetch("/login",
{method: "POST", body: JSON.stringify({"username": username, "password": password})})
.then(r => {
if (r.status === 400) {
let message = document.getElementById("message");
message.textContent = "Mauvais mot de passe/nom d'utilisateur!";
} else {
window.location.href = "/"}
});
}
let button = document.getElementById("connect");
button.addEventListener("click", () => {
let _ = login();
})

4
static/js/logout.js Normal file
View File

@@ -0,0 +1,4 @@
setTimeout(async () => {
await fetch("/logout", {method: "POST"});
window.location.href = "/";
}, 3000);

58
static/js/register.js Normal file
View File

@@ -0,0 +1,58 @@
async function register() {
let username = document.getElementById("username").value;
let password = document.getElementById("password").value;
let passwordConfirm = document.getElementById("passwordConfirm").value;
if (!check_username(username) || !check_password(password) || password !== passwordConfirm) {
return;
}
let status = await fetch("/register", {method: "POST", body: JSON.stringify({"username": username, "password": password})}).then(r => r.status)
if (status === 200) {
window.location.href = "/login";
} else if (status === 400) {
document.getElementById("message").textContent = "Cet utilisateur existe déjà!"
}
}
function check_username(username) {
return username.match(/^[0-9a-zA-Z]+$/);
}
function check_password(password) {
return password.match(/^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[!-/:-@[-`{-~]).{8,}$/)
}
let button = document.getElementById("register");
button.addEventListener("click", () => {
let _ = register();
})
let username = document.getElementById("username");
username.addEventListener("input", () => {
if (check_username(username.value)) {
document.getElementById("usernameNotice").className = "ok";
} else {
document.getElementById("usernameNotice").className = "error";
}
})
let password = document.getElementById("password");
password.addEventListener("input", () => {
if (check_password(password.value)) {
document.getElementById("passwordNotice").className = "ok";
} else {
document.getElementById("passwordNotice").className = "error";
}
})
let passwordConfirm = document.getElementById("passwordConfirm");
passwordConfirm.addEventListener("input", () => {
if (passwordConfirm.value === password.value) {
document.getElementById("passwordConfirmNotice").className = "ok";
} else {
document.getElementById("passwordConfirmNotice").className = "error";
}
})

View File

@@ -23,7 +23,12 @@ function show_plus(id, votes, players) {
const app = document.getElementById("app"); const app = document.getElementById("app");
app.innerHTML = ""; app.innerHTML = "";
let vote = votes[id]; let vote = votes[id];
let player = players[vote["plus_player_id"] - 1]["name"]; let player = "?";
for (let i = 0; i < players.length; i++) {
if (players[i].id === vote["plus_player_id"]) {
player = players[i]["name"];
}
}
let nickname = vote["plus_nickname"]; let nickname = vote["plus_nickname"];
let reason = vote["plus_reason"]; let reason = vote["plus_reason"];
let minus = document.createElement("button"); let minus = document.createElement("button");
@@ -58,7 +63,12 @@ function show_minus(id, votes, players) {
let vote = votes[id]; let vote = votes[id];
let nickname = vote["minus_nickname"]; let nickname = vote["minus_nickname"];
let reason = vote["minus_reason"]; let reason = vote["minus_reason"];
let player = players[vote["minus_player_id"] - 1]["name"]; let player = "?";
for (let i = 0; i < players.length; i++) {
if (players[i].id === vote["minus_player_id"]) {
player = players[i]["name"];
}
}
let next = document.createElement("button"); let next = document.createElement("button");
if (id === votes.length - 1) { if (id === votes.length - 1) {
next.textContent = "Résultats"; next.textContent = "Résultats";
@@ -112,7 +122,12 @@ async function show_results(players) {
let counter = 0; let counter = 0;
for (let i = 0; i < plus.length; i++) { for (let i = 0; i < plus.length; i++) {
let p = plus[i]; let p = plus[i];
let player = players[p[0] - 1]["name"]; let player = "?";
for (let i = 0; i < players.length; i++) {
if (players[i].id === p[0]) {
player = players[i]["name"];
}
}
let score = p[1]; let score = p[1];
if (prev_score == null || score < prev_score) { if (prev_score == null || score < prev_score) {
counter += 1; counter += 1;
@@ -134,7 +149,12 @@ async function show_results(players) {
counter = 0; counter = 0;
for (let i = 0; i < minus.length; i++) { for (let i = 0; i < minus.length; i++) {
let p = minus[i]; let p = minus[i];
let player = players[p[0] - 1]["name"]; let player = "?";
for (let i = 0; i < players.length; i++) {
if (players[i].id === p[0]) {
player = players[i]["name"];
}
}
let score = p[1]; let score = p[1];
if (prev_score == null || score < prev_score) { if (prev_score == null || score < prev_score) {
counter += 1; counter += 1;
@@ -149,4 +169,9 @@ async function show_results(players) {
} }
} }
let login = document.getElementById("login");
let logged = document.cookie.includes("logged=true");
login.textContent = logged ? "Se déconnecter" : "Se connecter";
login.href = logged ? "/logout" : "/login";
let _ = run(); let _ = run();