tickets/src/main.rs

433 lines
12 KiB
Rust

use std::sync::Arc;
use askama::Template;
use axum::{
body::Body,
extract::{Form, FromRequest, Path, Query, RequestParts},
http::{header, StatusCode},
response::{Html, IntoResponse, Redirect, Response},
routing::{get, get_service, post},
Extension, Json,
};
use axum_extra::extract::{cookie::Cookie, CookieJar};
use db::{Database, Db, Party, Ticket, User};
use rand::Rng;
use serde::{Deserialize, Serialize};
use sqlx::postgres::PgPoolOptions;
use tower_http::services::ServeDir;
mod db;
#[axum::async_trait]
impl<B: Send> FromRequest<B> for User {
/// If the extractor fails it'll use this "rejection" type. A rejection is
/// a kind of error that can be converted into a response.
type Rejection = Redirect;
/// Perform the extraction.
async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
let jar = req
.extract::<CookieJar>()
.await
.or_else(|_| Err(Redirect::to("/login")))?;
let token = jar.get("token").ok_or_else(|| Redirect::to("/login"))?;
let db = req
.extensions()
.get::<Database>()
.ok_or_else(|| Redirect::to("/login"))?
.clone();
if let Ok(uuid) = uuid::Uuid::parse_str(&token.value()) {
let user = db.get_user_token_by_token(uuid).await;
if let Some(user) = user {
if let Some(user) = db.get_user_by_token(user).await {
Ok(user)
} else {
Err(Redirect::to("/login"))
}
} else {
Err(Redirect::to("/login"))
}
} else {
Err(Redirect::to("/login"))
}
}
}
#[derive(Template)]
#[template(path = "index.html")]
struct IndexPage {
user: User,
parties: Vec<Party>,
}
async fn index(Extension(pool): Extension<Database>, user: User) -> impl IntoResponse {
let parties = pool.get_all_parties().await;
let page = IndexPage { user, parties };
Html(page.render().unwrap())
}
#[derive(Template)]
#[template(path = "login.html")]
struct LoginPage {
not_found: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct LoginQuery {
not_found: Option<bool>,
}
async fn login_page(Query(query): Query<LoginQuery>) -> impl IntoResponse {
Html(
LoginPage {
not_found: query.not_found.unwrap_or(false),
}
.render()
.unwrap(),
)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct LoginForm {
username: String,
password: String,
}
async fn login(
Form(user): Form<LoginForm>,
Extension(pool): Extension<Database>,
jar: CookieJar,
) -> impl IntoResponse {
let u = pool.get_user_by_username(&user.username).await;
if let Some(u) = u {
if u.verify_password(&user.password) {
let token = pool
.add_new_user_token(&u, chrono::Utc::now() + chrono::Duration::weeks(5))
.await;
let cookie = token.to_cookie();
(jar.add(cookie), Redirect::to("/"))
} else {
(jar, Redirect::to("/login?not_found=true"))
}
} else {
(jar, Redirect::to("/login?not_found=true"))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ScanRequest {
code: String,
check: bool,
party: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
enum ScanState {
NotFound,
Found,
Added,
AlreadyScanned,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ScanResponse {
code: String,
state: ScanState,
}
async fn scan_card(
_: User,
Extension(pool): Extension<Database>,
Json(scan): Json<ScanRequest>,
) -> impl IntoResponse {
let scanned = pool.is_student_in_party(scan.party, &scan.code).await;
if scan.check {
if let Some(mut ticket) = scanned {
if ticket.inside {
Json(ScanResponse {
code: scan.code,
state: ScanState::AlreadyScanned,
})
} else {
ticket.inside = true;
pool.update_ticket(&ticket).await;
Json(ScanResponse {
code: scan.code,
state: ScanState::Found,
})
}
} else {
Json(ScanResponse {
code: scan.code,
state: ScanState::NotFound,
})
}
} else {
if let Some(_) = scanned {
Json(ScanResponse {
code: scan.code,
state: ScanState::AlreadyScanned,
})
} else {
pool.add_ticket(scan.party, &scan.code).await;
Json(ScanResponse {
code: scan.code,
state: ScanState::Added,
})
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct TicketsQuery {
party: i32,
}
async fn get_tickets(
_: User,
Extension(pool): Extension<Database>,
Query(q): Query<TicketsQuery>,
) -> impl IntoResponse {
let tickets = pool.get_all_tickets_for_party(q.party).await;
Json(tickets)
}
async fn get_parties(_: User, Extension(pool): Extension<Database>) -> impl IntoResponse {
let parties = pool.get_all_parties().await;
Json(parties)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CreatePartyParams {
name: String,
}
async fn create_party(
_: User,
Extension(pool): Extension<Database>,
Json(party): Json<CreatePartyParams>,
) -> impl IntoResponse {
let party = pool.add_party(&party.name).await;
Json(party)
}
async fn get_party_by_name(
_: User,
Extension(pool): Extension<Database>,
Json(party): Json<CreatePartyParams>,
) -> impl IntoResponse {
let party = pool.get_party_by_name(&party.name).await;
Json(party)
}
#[derive(Template)]
#[template(path = "party.html")]
struct PartyPage {
name: String,
id: i32,
}
async fn party_page(
_: User,
Extension(pool): Extension<Database>,
Path(party): Path<i32>,
) -> impl IntoResponse {
let party = pool.get_party(party).await;
if let Some(party) = party {
let page = PartyPage {
name: party.name,
id: party.id,
};
Html(page.render().unwrap()).into_response()
} else {
Redirect::to("/").into_response()
}
}
async fn export_party(
_: User,
Extension(pool): Extension<Database>,
Path(party): Path<i32>,
) -> impl IntoResponse {
let tickets = pool.get_all_tickets_for_party(party).await;
let mut csv = String::new();
csv.push_str("leerlingnummer,binnen\n");
for ticket in tickets {
csv.push_str(&format!("{},{}\n", ticket.student, ticket.inside));
}
let mut resp = Response::new(Body::from(csv));
resp.headers_mut().insert(
header::CONTENT_DISPOSITION,
header::HeaderValue::from_static("attachment; filename=tickets.csv"),
);
resp.headers_mut().insert(
header::CONTENT_TYPE,
header::HeaderValue::from_static("text/csv"),
);
resp
}
#[derive(Template)]
#[template(path = "party_goers.html")]
struct PartyGoersPage {
id: i32,
name: String,
guests: Vec<Ticket>,
}
async fn party_goers(
_: User,
Extension(pool): Extension<Database>,
Path(party): Path<i32>,
) -> impl IntoResponse {
let party = pool.get_party(party).await;
if let Some(party) = party {
let guests = pool.get_all_tickets_for_party(party.id).await;
let page = PartyGoersPage {
id: party.id,
name: party.name,
guests,
};
Html(page.render().unwrap()).into_response()
} else {
Redirect::to("/").into_response()
}
}
#[derive(Template)]
#[template(path = "password.html")]
struct PasswordPage {
wrong_password: bool,
wrong_dupe_password: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PasswordParams {
wrong: Option<bool>,
dupe: Option<bool>,
}
async fn change_password(Query(params): Query<PasswordParams>) -> impl IntoResponse {
let page = PasswordPage {
wrong_password: params.wrong.unwrap_or(false),
wrong_dupe_password: params.dupe.unwrap_or(false),
};
Html(page.render().unwrap()).into_response()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ChangePasswordForm {
password: String,
password_new: String,
password_new_again: String,
}
async fn change_password_post(
mut user: User,
Extension(pool): Extension<Database>,
Form(form): Form<ChangePasswordForm>,
) -> impl IntoResponse {
if !user.verify_password(&form.password) {
return Redirect::to("/password?wrong=true").into_response();
}
if form.password_new != form.password_new_again {
return Redirect::to("/password?dupe=true").into_response();
}
user.update_password(&form.password_new);
pool.save_user(&user).await;
Redirect::to("/").into_response()
}
async fn logout(_: User, jar: CookieJar) -> impl IntoResponse {
(jar.remove(Cookie::named("token")), Redirect::to("/"))
}
#[derive(Template)]
#[template(path = "add_user.html")]
struct AddUserPage {}
async fn add_user_page(_: User) -> impl IntoResponse {
Html(AddUserPage {}.render().unwrap())
}
async fn add_user(
_: User,
Extension(pool): Extension<Database>,
Form(form): Form<LoginForm>,
) -> impl IntoResponse {
pool.add_new_user(&form.username, &form.password).await;
Redirect::to("/")
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct TicketDeleteRequest {
ticket_id: i32,
}
async fn remove_ticket(
_: User,
Extension(pool): Extension<Database>,
Json(ticket): Json<TicketDeleteRequest>,
) -> impl IntoResponse {
pool.remove_ticket(ticket.ticket_id).await;
"OK"
}
#[tokio::main]
async fn main() {
let url = if let Some(url) = std::env::var("DATABASE_URL").ok() {
url
} else {
"postgres://postgres:postgres@localhost:5432/postgres".to_string()
};
let pool = PgPoolOptions::new()
.connect(&url)
.await
.unwrap();
db::setup_db(&pool).await;
let pool = Arc::new(Db::new(pool));
if let None = pool.get_user_by_username("admin").await {
let password = if let Some(pass) = std::env::var("ADMIN_PASSWORD").ok() {
pass
} else {
rand::thread_rng()
.sample_iter(rand::distributions::Alphanumeric)
.take(32)
.map(char::from)
.collect::<String>()
};
println!("User admin was created with password {{{password}}}! Save it somewhere!");
pool.add_new_user("admin", &password).await;
}
let router = axum::Router::new()
.route("/", get(index))
.route("/login", get(login_page).post(login))
.route("/logout", get(logout))
.route("/password", get(change_password).post(change_password_post))
.route("/add_user", get(add_user_page).post(add_user))
.route("/party/:id", get(party_page))
.route("/party/:id/export", get(export_party))
.route("/party/:id/lijst", get(party_goers))
.route("/api/ticket", get(get_tickets).post(scan_card))
.route("/api/ticket/delete", post(remove_ticket))
.route("/api/party", get(get_party_by_name).post(create_party))
.route("/api/party/list", get(get_parties))
.nest(
"/static",
get_service(ServeDir::new("static")).handle_error(handle_error),
)
.layer(Extension(pool));
axum::Server::bind(&"0.0.0.0:8080".parse().unwrap())
.serve(router.into_make_service())
.await
.unwrap();
}
async fn handle_error(_err: std::io::Error) -> impl IntoResponse {
(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong...")
}