Compare commits

...
Sign in to create a new pull request.

12 commits

13 changed files with 391 additions and 26 deletions

View file

@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "update doll_profiles set bound_to_id = $1 where id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Int4"
]
},
"nullable": []
},
"hash": "f5e08fe2f3c38631022a83e0038257905b21581564499b5d44d3c7448fb3fb50"
}

View file

@ -66,11 +66,11 @@ impl<'a> FromRequest<'a> for User {
let cookies = req.cookies();
if let Some(id) = check_login(&cookies) {
let db = DollTagsDb::from_request(req)
let mut db = DollTagsDb::from_request(req)
.await
.expect("User::from_request cannot get DB connection");
match user::get_by_id(db, &id).await {
match user::get_by_id(&mut *db, &id).await {
Err(err) => {
error!("User::from_request internal error: {:?}", err);
Outcome::Error((Status::InternalServerError, SessionInternalFailure()))

View file

@ -1,4 +1,6 @@
use super::schema::{ServiceStatus, TrxHook};
use uuid::Uuid;
use super::schema::{DbHook, ServiceStatus, TrxHook};
/// Aggregates info on the current service status, incl. accounts and tags
pub async fn get_service_status(trx: &mut TrxHook<'_>) -> sqlx::Result<ServiceStatus> {
@ -19,3 +21,15 @@ pub async fn get_service_status(trx: &mut TrxHook<'_>) -> sqlx::Result<ServiceSt
active_tags_count,
})
}
pub async fn handover_tag(db: &mut DbHook, tag_id: i32, target_user_id: &Uuid) -> sqlx::Result<()> {
sqlx::query!(
"update doll_profiles set bound_to_id = $1 where id = $2",
target_user_id,
tag_id
)
.execute(&mut **db)
.await?;
Ok(())
}

View file

@ -2,22 +2,22 @@ use uuid::Uuid;
use crate::db::schema::User;
use super::schema::{DbHook, DollTagsDb, TrxHook};
use super::schema::{DbHook, TrxHook};
pub async fn get(mut db: DollTagsDb, username: &str) -> sqlx::Result<Option<User>> {
pub async fn get(db: &mut DbHook, username: &str) -> sqlx::Result<Option<User>> {
sqlx::query_as!(User, "select * from users where username = $1", username)
.fetch_optional(&mut **db)
.await
}
pub async fn get_by_id(mut db: DollTagsDb, id: &Uuid) -> sqlx::Result<Option<User>> {
pub async fn get_by_id(db: &mut DbHook, id: &Uuid) -> sqlx::Result<Option<User>> {
sqlx::query_as!(User, "select * from users where id = $1", id)
.fetch_optional(&mut **db)
.await
}
pub async fn create(
mut db: DollTagsDb,
db: &mut DbHook,
username: &str,
hashed_password: &str,
email: Option<&str>,

View file

@ -1,5 +1,6 @@
use rand::{distributions::Uniform, prelude::Distribution, thread_rng};
use regex::Regex;
use rocket::{form, request::FromParam};
use crate::db::{doll, schema::DollTagsDb};
@ -25,8 +26,40 @@ pub fn id_public_to_db(id: &str) -> Option<i32> {
None
}
}
/// TODO: Check if used anywhere else than template rendering
pub fn id_db_to_public(id: i32) -> String {
let first = id / 1000;
let second = id % 1000;
format!("{:0>3}-{:0>3}", first, second)
}
/// A cleaner way to handle on-the-fly in-URL parameter format validation for IDs
/// TODO: check and remove all other usages
#[derive(UriDisplayPath)]
pub struct PublicId(pub i32);
impl<'a> FromParam<'a> for PublicId {
type Error = &'static str;
fn from_param(param: &'a str) -> Result<Self, Self::Error> {
if let Some(v) = id_public_to_db(param) {
Ok(PublicId(v))
} else {
Err("id not formatted properly")
}
}
}
impl Into<i32> for PublicId {
fn into(self) -> i32 {
self.0
}
}
pub fn validate_id<'v>(id: &str) -> form::Result<'v, ()> {
if let None = id_public_to_db(id) {
Err(form::Error::validation("id not formatted properly"))?;
}
Ok(())
}

View file

@ -84,7 +84,15 @@ fn rocket() -> _ {
account::export_data,
],
)
.mount("/admin", routes![admin::index,])
.mount(
"/admin",
routes![
admin::index,
admin::handle_in_page_forms,
admin::show_confirm_tag_handover,
admin::handle_tag_handover,
],
)
.mount(
"/",
routes![

View file

@ -2,6 +2,7 @@ use std::collections::HashMap;
use rocket::{
fairing::Fairing,
http::CookieJar,
outcome::try_outcome,
request::{FromRequest, Outcome},
serde::Serialize,
@ -71,7 +72,7 @@ impl<'r> FromRequest<'r> for CommonTemplateState {
/// FakeContext exists to be used as a replacement for Context when reusing a form for creation and edition, to avoid repeating lots of code.
///
/// Note: i made this custom context thingy because i couldn't find a way to create a real context with the right populated data.
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, Default)]
#[serde(crate = "rocket::serde")]
pub struct FakeContext {
/// Values are made to simulate the same structure as the real Context
@ -81,3 +82,23 @@ pub struct FakeContext {
/// NOP, used to placehold global errors
pub form_errors: Vec<()>,
}
/// writes a toast msg into an encrypted cookie
/// (note: this overwrites any existing toast)
pub fn write_toast(jar: &CookieJar<'_>, message: String) {
jar.add_private(("toast", message))
}
/// gets a toast from the encrypted toast cookie if any is set,
/// then removes the cookie
pub fn pop_toast(jar: &CookieJar<'_>) -> Option<String> {
let toast = jar
.get_private("toast")
.map(|c| String::from(c.value_trimmed()));
if toast.is_some() {
jar.remove_private("toast");
}
toast
}

View file

@ -1,26 +1,230 @@
use std::net::IpAddr;
use rocket::{
form::{Context, Contextual, Error, Form},
http::CookieJar,
response::Redirect,
serde::Serialize,
};
use rocket_dyn_templates::{context, Template};
use sqlx::Acquire;
use crate::{
auth::session::Admin,
db::{admin, schema::DollTagsDb},
pages::CommonTemplateState,
db::{admin, doll, schema::DollTagsDb, user},
ids::{id_public_to_db, PublicId},
pages::{pop_toast, write_toast, CommonTemplateState},
};
use super::error_handlers::PageResult;
#[get("/")]
pub async fn index(meta: CommonTemplateState, mut db: DollTagsDb, user: Admin) -> PageResult {
pub async fn index(
meta: CommonTemplateState,
mut db: DollTagsDb,
user: Admin,
jar: &'_ CookieJar<'_>,
) -> PageResult {
let mut trx = db.begin().await?;
let service_status = admin::get_service_status(&mut trx).await?;
trx.commit().await?;
let toast = pop_toast(jar);
Ok(Template::render(
"admin/index",
context! {
meta: meta.for_user(&user.0),
service_status,
previous: context! {
tag_handover: Context::default(),
},
toast,
},
)
.into())
}
#[derive(Debug, FromFormField, Serialize)]
#[serde(crate = "rocket::serde")]
pub enum SelectedForm {
TagHandover,
}
#[derive(Debug, FromForm)]
pub struct Forms<'a> {
pub form: SelectedForm,
pub tag_handover: Contextual<'a, TagHandover<'a>>,
}
/// extracts one of the forms' contexts from the form group,
/// otherwise uses the $def value (usually you'll give [`&Context::default()`][`Context::default()`])
macro_rules! get_for_form {
($def:expr, $curr:expr, $target:pat, $value:expr) => {
if matches!($curr, $target) {
$value
} else {
$def
}
};
}
#[derive(Debug, FromForm, Clone)]
pub struct TagHandover<'a> {
#[field(validate=crate::ids::validate_id())]
pub tag_id: &'a str,
#[field(validate=len(..=256))]
pub dest_account: &'a str,
}
#[post("/", data = "<form>")]
pub async fn handle_in_page_forms(
meta: CommonTemplateState,
mut db: DollTagsDb,
user: Admin,
mut form: Form<Forms<'_>>,
client_ip: IpAddr,
) -> PageResult {
match form.form {
SelectedForm::TagHandover => {
if let Some(values) = &form.tag_handover.value.clone() {
let target_user = user::get(&mut *db, values.dest_account).await?;
let user_valid = match target_user {
Some(user) => {
if !user.enabled {
form.tag_handover.context.push_error(
Error::validation("this user's account is deactivated")
.with_name("tag_handover.dest_account"),
);
false
} else {
true
}
}
None => {
form.tag_handover.context.push_error(
Error::validation("this user doesn't exist")
.with_name("tag_handover.dest_account"),
);
false
}
};
let target_tag = doll::get(
&mut *db,
id_public_to_db(values.tag_id)
.expect("is form-validated so should always succeed"),
"",
true,
)
.await?;
if target_tag.is_none() {
form.tag_handover.context.push_error(
Error::validation("no tag exists with this ID")
.with_name("tag_handover.tag_id"),
);
}
if user_valid && target_tag.is_some() {
let tag = target_tag.unwrap();
warn!(
"[audit|{}] [{}] beginning tag handover of tag {} to \"{}\" (previous user id: {})",
client_ip,
user.0.id.to_string(),
tag.id,
values.dest_account,
tag.bound_to_id,
);
return Ok(Redirect::to(uri!(
"/admin",
show_confirm_tag_handover(PublicId(tag.id), values.dest_account)
))
.into());
}
}
}
};
let mut trx = db.begin().await?;
let service_status = admin::get_service_status(&mut trx).await?;
trx.commit().await?;
let def = Context::default();
Ok(Template::render(
"admin/index",
context! {
meta: meta.for_user(&user.0),
service_status,
previous: context! {
tag_handover: get_for_form!(
&def,
&form.form,
SelectedForm::TagHandover,
&form.tag_handover.context
),
},
},
)
.into())
}
#[get("/tag-handover/<id>/<dest_username>")]
pub fn show_confirm_tag_handover(
meta: CommonTemplateState,
user: Admin,
id: PublicId,
dest_username: &str,
) -> Template {
let tag_id = id.0;
Template::render(
"admin/confirm_tag_handover",
context! {
meta: meta.for_user(&user.0),
tag_id,
dest_username,
proceed_url: uri!("/admin", handle_tag_handover(id, dest_username)),
},
)
}
#[get("/YES-GIMME/<id>/<dest_username>")]
pub async fn handle_tag_handover<'a>(
mut db: DollTagsDb,
user: Admin,
client_ip: IpAddr,
jar: &'a CookieJar<'_>,
id: PublicId,
dest_username: &'a str,
) -> PageResult {
// note: there is currently no trace of the previous user in this handover code's audit log.
let id = id.0;
match user::get(&mut *db, dest_username).await? {
Some(u) => {
admin::handover_tag(&mut *db, id, &u.id).await?;
warn!(
"[audit|{}] [{}] handed over tag {} to \"{}\"",
client_ip,
user.0.id.to_string(),
id,
dest_username
);
write_toast(
jar,
format!("tag successfully handed over to \"{}\"", dest_username),
);
}
None => {
warn!(
"[audit|{}] [{}] tried to hand over tag {} to nonexistent user \"{}\"",
client_ip,
user.0.id.to_string(),
id,
dest_username
);
write_toast(jar, String::from("this username doesn't exist"));
}
};
Ok(Redirect::to("/admin").into())
}

View file

@ -45,7 +45,7 @@ pub fn show_login(
#[post("/login?<next>", data = "<form>")]
pub async fn handle_login(
db: DollTagsDb,
mut db: DollTagsDb,
next: Option<&str>,
form: Form<Contextual<'_, AuthForm<'_>>>,
cookies: &CookieJar<'_>,
@ -77,7 +77,7 @@ pub async fn handle_login(
warn!("[audit|{}] login attempt ({})", client_ip, &values.username);
let user_in_db = user::get(db, &values.username).await?;
let user_in_db = user::get(&mut *db, &values.username).await?;
let user = match user_in_db {
None => {
task::spawn_blocking(move || pw::verify("meow", "$argon2i$v=19$m=65536,t=3,p=1$fJ+f67UGHB+EIjGIDEwbSQ$V/nZPHmdyqHq8fTBTdt3sEmTyr0W7i/F98EIxaaJJt0")).await??;
@ -151,7 +151,7 @@ fn validate_email<'v>(email: &str) -> form::Result<'v, ()> {
#[post("/register", data = "<form>")]
pub async fn handle_register(
db: DollTagsDb,
mut db: DollTagsDb,
form: Form<Contextual<'_, RegisterForm<'_>>>,
cookies: &CookieJar<'_>,
maybe_loggedin: Option<User>,
@ -185,7 +185,7 @@ pub async fn handle_register(
let hashed_password = task::spawn_blocking(move || pw::hash(&password)).await??;
let account_id = user::create(
db,
&mut *db,
values.username,
&hashed_password,
if values.email.len() != 0 {

View file

@ -106,7 +106,7 @@ pub async fn show_edit_tag(
#[derive(Debug, FromForm)]
pub struct TagForm<'a> {
#[field(validate=validate_id())]
#[field(validate=crate::ids::validate_id())]
pub ident: &'a str,
#[field(validate=len(..32))]
pub microchip_id: &'a str,
@ -142,14 +142,6 @@ pub struct TagForm<'a> {
pub chassis_color: &'a str,
}
fn validate_id<'v>(id: &str) -> form::Result<'v, ()> {
if let None = id_public_to_db(id) {
Err(form::Error::validation("id not formatted properly"))?;
}
Ok(())
}
fn validate_chassis<'v>(a: &str, b: &str, c: &str, field: &str) -> form::Result<'v, ()> {
let all_empty = a.len() == 0 && b.len() == 0 && c.len() == 0;
let all_full = a.len() != 0

View file

@ -5,6 +5,9 @@
<p class="subnav">
<a href="/account/new_tag">New tag</a>
<a href="/account/settings">Account settings</a>
{% if user.is_admin %}
<a href="/admin">Admin panel</a>
{% endif %}
<a href="/logout">Log out</a>
</p>
</aside>
@ -39,7 +42,8 @@
<summary>show the QR code</summary>
<picture class="block">
<img loading="lazy" src="/account/qr-png/{{profile.id}}" alt="A QrCode containing a direct link to the profile" />
<img loading="lazy" src="/account/qr-png/{{profile.id}}"
alt="A QrCode containing a direct link to the profile" />
</picture>
</details>
</article>

View file

@ -0,0 +1,18 @@
{% extends "base" %}
{% block title %}Confirm tag handover? - {% endblock title %}
{% block main %}
<section>
<h2>Confirm tag handover?</h2>
<p>
You are about to hand over the tag <code>{{tag_id|pretty_id}}</code> to the user named "{{dest_username}}".<br />
It will give full control of the tag to them and will not let you edit it anymore as it is a complete handover.
</p>
<p>Do you wish to proceed?</p>
<div>
<a href="{{proceed_url}}" class="btn">Yes, hand it over to them</a>
<a href="/admin" class="btn">No, cancel the handover</a>
</div>
</section>
{% endblock main %}

View file

@ -1,4 +1,5 @@
{% extends "base" %}
{% import "macros/form" as form %}
{% block title %}Admin panel - {% endblock title %}
{% block main %}
<aside>
@ -8,6 +9,12 @@
</p>
</aside>
{% if toast %}
<div class="raised">
<p>{{toast|capitalize}}</p>
</div>
{% endif %}
<section>
<h2>Service status</h2>
@ -22,4 +29,53 @@
</div>
</div>
</section>
<section>
{% set ctx = previous.tag_handover %}
<h2>Tag handover</h2>
<p>
To begin handover of a tag, enter the tag's ID and destination account nickname below.<br />
It will let you confirm the tag's account holder and the destination account before executing the handover.
</p>
{% if ctx.form_errors | length > 0 %}
<div class="form-error">
<h3>Some errors were encountered...</h3>
<ul>
{% for err in ctx.form_errors %}
<li>{{err}}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<form method="post">
<input type="hidden" name="form" value="TagHandover" />
<div class="fields raised">
<div class="dual-fields">
<div>
<p class="ident center">ID of the tag to hand over</p>
<input type="text" name="tag_handover.tag_id" id="tag_id" placeholder="000000" minlength="6"
maxlength="6" required {{form::value(ctx=ctx, name="tag_handover.tag_id" )}} />
{{form::error(ctx=ctx, name="tag_handover.tag_id")}}
</div>
<div>
<p class="ident center">Destination account username</p>
<input type="text" name="tag_handover.dest_account" id="dest_account" autocomplete="off" required
{{form::value(ctx=ctx, name="tag_handover.dest_account" )}} />
{{form::error(ctx=ctx, name="tag_handover.dest_account")}}
</div>
</div>
<div class="block">
<button type="submit">Begin tag handover</button>
</div>
</div>
</form>
</section>
{% endblock main %}