Compare commits

..

8 Commits

Author SHA1 Message Date
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
21 changed files with 312 additions and 34 deletions

View File

@@ -3,7 +3,7 @@ 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,
saltyhash text NOT NULL, saltyhash text NOT NULL,
permissions INTEGER DEFAULT 0, permissions INTEGER DEFAULT 0 NOT NULL,
token TEXT NOT NULL, token TEXT NOT NULL,
UNIQUE(id, username) UNIQUE(id, username)
) )

View File

@@ -1,8 +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": "static/html/login.html", "/login": {"file": "static/html/login.html", "permission": 0},
"/register": "static/html/register.html" "/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

@@ -13,7 +13,7 @@ use daemonize::Daemonize;
use futures; use futures;
use http_body_util::{BodyExt, Full}; use http_body_util::{BodyExt, Full};
use hyper::body::{Body, Incoming}; use hyper::body::{Body, Incoming};
use hyper::header::{LOCATION, SET_COOKIE}; use hyper::header::{LOCATION, SET_COOKIE, COOKIE};
use hyper::server::conn::http1; use hyper::server::conn::http1;
use hyper::service::service_fn; use hyper::service::service_fn;
use hyper::{Error, Method, Request, Response, StatusCode}; use hyper::{Error, Method, Request, Response, StatusCode};
@@ -30,6 +30,7 @@ use std::net::SocketAddr;
use std::str::FromStr; use std::str::FromStr;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::SystemTime; use std::time::SystemTime;
use hyper::http::HeaderValue;
use tokio::net::TcpListener; use tokio::net::TcpListener;
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
struct Player { struct Player {
@@ -79,20 +80,27 @@ async fn get(req: Request<Incoming>, db: Arc<Mutex<SqlitePool>>) -> Result<Respo
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("/logout") { } else if path.starts_with("/admin") {
logout().await 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(req: &Request<Incoming>, path: &str, db: Arc<Mutex<SqlitePool>>) -> Result<Response<Full<Bytes>>, Error> {
let mut routes = env::current_dir().expect("Could not get app directory (Required to get routes)"); 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)) => {
let perm = is_authorised(req, db).await;
if s.get("permission").unwrap().as_i64().unwrap() <= perm {
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 _ => not_found().await
} }
} }
@@ -185,10 +193,8 @@ async fn get_votes(req: &Request<Incoming>, db: Arc<Mutex<SqlitePool>>) -> Vec<V
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() let items = 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 { items.iter().map(|x| Vote {
plus_player_id: x.plus_player_id, plus_player_id: x.plus_player_id,
plus_nickname: x.plus_nickname.clone(), plus_nickname: x.plus_nickname.clone(),
@@ -199,6 +205,24 @@ async fn get_votes(req: &Request<Incoming>, db: Arc<Mutex<SqlitePool>>) -> Vec<V
}).collect() }).collect()
} }
async fn get_admin(req: &Request<Incoming>, path: &str, db: Arc<Mutex<SqlitePool>>) -> Result<Response<Full<Bytes>>, Error> {
let perm = is_authorised(req, db.clone()).await;
if perm < 3 {
return not_found().await;
}
if path == "/admin" {
return get_page(req, path, db).await;
}
if path == "/admin/users" {
let pool = db.clone().lock().unwrap().clone();
let users = sqlx::query!(r#"SELECT username, permissions FROM users"#).fetch_all(&pool).await.unwrap();
let users: Vec<(String, i64)> = users.iter().map(|x| (x.username.clone(), x.permissions)).collect();
let stringed = serde_json::to_string(&users).unwrap_or("".to_string());
return Ok(Response::builder().body(Full::new(Bytes::from(stringed))).unwrap());
}
not_found().await
}
async fn post(req: Request<Incoming>, db: Arc<Mutex<SqlitePool>>) -> Result<Response<Full<Bytes>>, Error> { async fn post(req: Request<Incoming>, db: Arc<Mutex<SqlitePool>>) -> Result<Response<Full<Bytes>>, Error> {
let path = req.uri().path(); let path = req.uri().path();
match path { match path {
@@ -211,6 +235,9 @@ async fn post(req: Request<Incoming>, db: Arc<Mutex<SqlitePool>>) -> Result<Resp
"/register" => { "/register" => {
register(req, db).await register(req, db).await
} }
"/logout" => {
logout().await
}
_ => { _ => {
not_found().await not_found().await
} }
@@ -254,8 +281,7 @@ async fn login(req: Request<Incoming>, db: Arc<Mutex<SqlitePool>>) -> Result<Res
return Ok(Response::builder().status(StatusCode::BAD_REQUEST).body(Full::new(Bytes::from("Bad Request"))).unwrap()); return Ok(Response::builder().status(StatusCode::BAD_REQUEST).body(Full::new(Bytes::from("Bad Request"))).unwrap());
} }
let pool = db.clone().lock().unwrap().clone(); let pool = db.clone().lock().unwrap().clone();
let mut conn = pool.acquire().await.unwrap(); let result = sqlx::query!(r#"SELECT * FROM users WHERE username=?1"#, data.username).fetch_optional(&pool).await;
let result = sqlx::query!(r#"SELECT * FROM users WHERE username=?1"#, data.username).fetch_optional(&mut *conn).await;
match result { match result {
Ok(Some(user)) => { Ok(Some(user)) => {
let argon = Argon2::default(); let argon = Argon2::default();
@@ -268,6 +294,7 @@ async fn login(req: Request<Incoming>, db: Arc<Mutex<SqlitePool>>) -> Result<Res
Ok(Response::builder() Ok(Response::builder()
.header(SET_COOKIE, .header(SET_COOKIE,
format!("token={}; Expires={}; Secure; HttpOnly; SameSite=Strict", user.token, date.to_rfc2822())) 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(Full::new(Bytes::from("Ok"))) .body(Full::new(Bytes::from("Ok")))
.unwrap()) .unwrap())
} }
@@ -305,7 +332,6 @@ async fn register(req: Request<Incoming>, db: Arc<Mutex<SqlitePool>>) -> Result<
let argon2 = Argon2::default(); let argon2 = Argon2::default();
let hash = argon2.hash_password(data.password.as_bytes(), &SaltString::generate(&mut OsRng)).unwrap().to_string(); let hash = argon2.hash_password(data.password.as_bytes(), &SaltString::generate(&mut OsRng)).unwrap().to_string();
let token = Alphanumeric.sample_string(&mut OsRng, 256); let token = Alphanumeric.sample_string(&mut OsRng, 256);
println!("{}", token);
let result = sqlx::query!(r#"INSERT INTO users ( username, saltyhash, permissions, token) VALUES ( ?1, ?2, ?3, ?4 )"#, data.username, hash, 0, token).execute(&mut *conn).await; let result = sqlx::query!(r#"INSERT INTO users ( username, saltyhash, permissions, token) VALUES ( ?1, ?2, ?3, ?4 )"#, data.username, hash, 0, token).execute(&mut *conn).await;
match result { match result {
Ok(_) => Ok(Response::builder().body(Full::new(Bytes::from(""))).unwrap()), Ok(_) => Ok(Response::builder().body(Full::new(Bytes::from(""))).unwrap()),
@@ -316,12 +342,40 @@ async fn register(req: Request<Incoming>, db: Arc<Mutex<SqlitePool>>) -> Result<
async fn logout() -> Result<Response<Full<Bytes>>, Error> { async fn logout() -> Result<Response<Full<Bytes>>, Error> {
let date: DateTime<Utc> = DateTime::from(SystemTime::now()); let date: DateTime<Utc> = DateTime::from(SystemTime::now());
Ok(Response::builder() Ok(Response::builder()
.status(StatusCode::SEE_OTHER) //.status(StatusCode::SEE_OTHER)
.header(LOCATION, "/") //.header(LOCATION, "/")
.header(SET_COOKIE, format!("token=''; Expires={}; Secure; HttpOnly; SameSite=Strict", date.to_rfc2822())) .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(Full::new(Bytes::from(""))).unwrap()) .body(Full::new(Bytes::from(""))).unwrap())
} }
async fn is_authorised(req: &Request<Incoming>, db: Arc<Mutex<SqlitePool>>) -> i64 {
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(user) => {
match user {
Some(user) => user.permissions,
None => 0
}
},
Err(_) => 0
}
}
fn check_username(username: &String) -> bool { fn check_username(username: &String) -> bool {
if username.len() > 21 { if username.len() > 21 {
return false; return false;

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

View File

@@ -32,7 +32,7 @@ body {
transform: translate(-50%, calc(-50% - 60px)); transform: translate(-50%, calc(-50% - 60px));
background-color: #181926; background-color: #181926;
border-radius: 12px; border-radius: 12px;
width: 100%; width: calc(100dvw - 40px);
max-width: 500px; max-width: 500px;
height: fit-content; height: fit-content;
padding: 10px; padding: 10px;
@@ -55,7 +55,8 @@ body {
.sub > input { .sub > input {
margin-bottom: 5px; margin-bottom: 5px;
width: 300px; max-width: 300px;
width: 80dvw;
color: black; color: black;
} }

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

@@ -76,3 +76,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;
}

22
static/html/admin.html Normal file
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">
<script src="static/js/admin.js" defer="defer"></script>
<title>Administration</title>
</head>
<body>
<div>
<h1>Users</h1>
<div id="users"></div>
</div>
<div>
<h1>Votes</h1>
<div id="votes"></div>
</div>
</body>
</html>

View File

@@ -11,6 +11,7 @@
<link rel="stylesheet" href="static/css/index.css"> <link rel="stylesheet" href="static/css/index.css">
</head> </head>
<body> <body>
<a href="/login" class="login" id="login">Se connecter</a>
<div id="app" class="app"> <div id="app" class="app">
<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>

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>

View File

@@ -8,6 +8,7 @@
<base href="/" target="_top"> <base href="/" target="_top">
<script src="static/js/register.js" defer="defer"></script> <script src="static/js/register.js" defer="defer"></script>
<link rel="stylesheet" href="static/css/login.css"> <link rel="stylesheet" href="static/css/login.css">
<link rel="stylesheet" href="static/css/register.css">
<title>Register</title> <title>Register</title>
</head> </head>
<body> <body>
@@ -19,14 +20,24 @@
<br> <br>
<input id="username"> <input id="username">
<br> <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> <label for="password">Mot de passe:</label>
<br> <br>
<input id="password" type="password"> <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> <br>
1 Majuscule
<br>
1 Symbole
<br>
1 Minuscule
<br>
1 Nombre
</p>
<label for="passwordConfirm">Confirmer le mot de passe:</label> <label for="passwordConfirm">Confirmer le mot de passe:</label>
<br> <br>
<input id="passwordConfirm" type="password"> <input id="passwordConfirm" type="password">
<br> <p class="error" id="passwordConfirmNotice">Les mots de passe doivent être les mêmes.</p>
<button id="register">Créer un compte.</button> <button id="register">Créer un compte.</button>
</div> </div>
<p>Tu as déjà un compte?</p> <p>Tu as déjà un compte?</p>

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>

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

@@ -0,0 +1,20 @@
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 username = document.createElement("p");
let permissions = document.createElement("p");
username.textContent = users[i][0];
permissions.textContent = users[i][1];
item.style.display = "flex";
item.append(username, permissions);
usersDiv.appendChild(item);
}
// let votes = await fetch("/admin/votes").then(r => r.json());
}
let _ = run();

View File

@@ -64,7 +64,7 @@ async function main() {
}) })
.then(r => r.status) === 200) { .then(r => r.status) === 200) {
set_cookie(); set_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");
} }
console.log(vote); console.log(vote);
} else { } else {
@@ -123,4 +123,9 @@ 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();

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

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

View File

@@ -1,11 +1,58 @@
async function register() {} 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() {} function check_username(username) {
return username.match(/^[0-9a-zA-Z]+$/);
}
function check_password() {} function check_password(password) {
return password.match(/^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[!-/:-@[-`{-~]).{8,}$/)
}
let button = document.getElementById("register"); let button = document.getElementById("register");
button.addEventListener("click", () => { button.addEventListener("click", () => {
let _ = register(); 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

@@ -149,4 +149,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();