merge upstream
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 3m21s

This commit is contained in:
2026-04-27 21:52:00 +00:00
48 changed files with 2860 additions and 1263 deletions

1191
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,10 +17,10 @@ log = "0.4.29"
maud = { version = "0.27.0", features = ["axum"] } maud = { version = "0.27.0", features = ["axum"] }
rand = "0.10.0" rand = "0.10.0"
rand08 = { version = "0.8.5", package = "rand" } rand08 = { version = "0.8.5", package = "rand" }
rusqlite = { version = "0.38.0", features = ["bundled", "chrono", "uuid"] }
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.149"
sha2 = "0.10.9" sha2 = "0.10.9"
sqlx = { version = "0.8.6", features = ["postgres", "uuid", "chrono", "json", "runtime-tokio", "tls-rustls", "migrate"] }
strum = { version = "0.27.0", features = ["derive"] } strum = { version = "0.27.0", features = ["derive"] }
thiserror = "2.0.18" thiserror = "2.0.18"
tokio = { version = "1.49.0", features = ["full"] } tokio = { version = "1.49.0", features = ["full"] }

View File

@@ -6,6 +6,7 @@ use std::process::Command;
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=src/web"); println!("cargo:rerun-if-changed=src/web");
println!("cargo:rerun-if-changed=src/database/migrations");
let os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_else(|_| String::from("unknown")); let os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_else(|_| String::from("unknown"));
let arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_else(|_| String::from("unknown")); let arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_else(|_| String::from("unknown"));

View File

@@ -1,6 +1,5 @@
services: services:
mnemosyne: core:
container_name: mnemosyne
build: build:
context: . context: .
target: final target: final
@@ -8,19 +7,33 @@ services:
- 39321:39321 - 39321:39321
restart: unless-stopped restart: unless-stopped
volumes: volumes:
# Mnemosyne would greatly enjoy not having an ephemeral database.
# If you're okay with storing it side by side with the compose file,
# a bind mount like this is one way to do it. Remember to mkdir!
- ./mnemodata:/app/data - ./mnemodata:/app/data
# Another way is to use a docker volume.
# - mnemodata:/app/data
environment: environment:
# DATABASE_URL is crucial for Mnemosyne to work; it will fail without it. # - PORT=39321 # Mnemosyne uses port 39321 for HTTP by default;
# Point it at where you'd like your database to be. - DATABASE_URL=postgres://mnemo:syne@postgres:5432/mnemosyne
- DATABASE_URL=/app/data/db.db networks:
# Mnemosyne uses port 39321 for HTTP by default; - mnemosyne
# - PORT=39321 depends_on:
- postgres
postgres:
image: postgres:18.2-alpine3.23
restart: unless-stopped
ports:
- 5432:5432
volumes:
- pg_volume:/var/lib/postgresql/data:rw
stop_grace_period: 120s
environment:
POSTGRES_USER: mnemo
POSTGRES_PASSWORD: syne
POSTGRES_DB: mnemosyne
networks:
- mnemosyne
# Declaring a volume for the docker volume example. networks:
# volumes: mnemosyne:
# mnemodata: driver: bridge
volumes:
pg_volume:
driver: local

View File

@@ -1,12 +1,14 @@
use axum::{ use axum::{
Form, Json, Form, Json,
extract::State,
http::{HeaderMap, header}, http::{HeaderMap, header},
response::{IntoResponse, Redirect, Response}, response::{IntoResponse, Redirect, Response},
}; };
use serde::Deserialize; use serde::Deserialize;
use sqlx::PgPool;
use crate::{ use crate::{
database, MnemoState,
users::{ users::{
User, User,
auth::{ auth::{
@@ -23,10 +25,14 @@ pub struct LoginForm {
password: String, password: String,
} }
fn login_common(creds: LoginForm) -> Result<(String, String), AuthError> { async fn login_common(pool: &PgPool, creds: LoginForm) -> Result<(String, String), AuthError> {
let u = authenticate_via_credentials(&creds.handle, &creds.password)?.required()?; let mut conn = pool.acquire().await?;
let conn = database::conn()?; let u = authenticate_via_credentials(&mut conn, &creds.handle, &creds.password)
let (_, token) = Session::new_for_user(&conn, &u)?; .await?
.required()?;
let (_, token) = Session::new_for_user(&mut conn, &u).await?;
let secure = match cfg!(debug_assertions) { let secure = match cfg!(debug_assertions) {
false => "; Secure", false => "; Secure",
true => "", true => "",
@@ -38,12 +44,20 @@ fn login_common(creds: LoginForm) -> Result<(String, String), AuthError> {
); );
Ok((token, cookie)) Ok((token, cookie))
} }
pub async fn login(Json(creds): Json<LoginForm>) -> Result<Response, AuthError> {
let (token, cookie) = login_common(creds)?; pub async fn login(
State(state): State<MnemoState>,
Json(creds): Json<LoginForm>,
) -> Result<Response, AuthError> {
let (token, cookie) = login_common(&state.pool, creds).await?;
Ok(([(header::SET_COOKIE, cookie)], token).into_response()) Ok(([(header::SET_COOKIE, cookie)], token).into_response())
} }
pub async fn login_form(Form(creds): Form<LoginForm>) -> Result<Response, AuthError> {
match login_common(creds) { pub async fn login_form(
State(state): State<MnemoState>,
Form(creds): Form<LoginForm>,
) -> Result<Response, AuthError> {
match login_common(&state.pool, creds).await {
Ok((_, cookie)) => { Ok((_, cookie)) => {
Ok(([(header::SET_COOKIE, cookie)], Redirect::to("/dashboard")).into_response()) Ok(([(header::SET_COOKIE, cookie)], Redirect::to("/dashboard")).into_response())
} }
@@ -51,17 +65,32 @@ pub async fn login_form(Form(creds): Form<LoginForm>) -> Result<Response, AuthEr
} }
} }
pub async fn logout(headers: HeaderMap) -> Result<Response, AuthError> { pub async fn logout(
let mut s = Session::authenticate(&headers)?.required()?; State(state): State<MnemoState>,
let conn = database::conn()?; headers: HeaderMap,
s.revoke(&conn, Some(&User::get_by_id(&conn, s.user_id)?))?; ) -> Result<Response, AuthError> {
let mut conn = state.pool.acquire().await?;
let mut s = Session::authenticate(&mut conn, &headers)
.await?
.required()?;
let user = User::get_by_id(&mut conn, s.user_id).await?;
s.revoke(&mut conn, Some(&user)).await?;
let cookie = format!("{COOKIE_NAME}=revoking; Path=/; HttpOnly; Max-Age=0"); let cookie = format!("{COOKIE_NAME}=revoking; Path=/; HttpOnly; Max-Age=0");
Ok(([(header::SET_COOKIE, cookie)], "Logged out!").into_response()) Ok(([(header::SET_COOKIE, cookie)], "Logged out!").into_response())
} }
pub async fn logout_form(headers: HeaderMap) -> Result<Response, AuthError> {
let mut s = Session::authenticate(&headers)?.required()?; pub async fn logout_form(
let conn = database::conn()?; State(state): State<MnemoState>,
s.revoke(&conn, Some(&User::get_by_id(&conn, s.user_id)?))?; headers: HeaderMap,
) -> Result<Response, AuthError> {
let mut conn = state.pool.acquire().await?;
let mut s = Session::authenticate(&mut conn, &headers)
.await?
.required()?;
let user = User::get_by_id(&mut conn, s.user_id).await?;
s.revoke(&mut conn, Some(&user)).await?;
let cookie = format!("{COOKIE_NAME}=revoking; Path=/; HttpOnly; Max-Age=0"); let cookie = format!("{COOKIE_NAME}=revoking; Path=/; HttpOnly; Max-Age=0");
Ok(([(header::SET_COOKIE, cookie)], Redirect::to("/")).into_response()) Ok(([(header::SET_COOKIE, cookie)], Redirect::to("/")).into_response())
} }

View File

@@ -3,6 +3,8 @@ use axum::{
routing::{delete, get, patch, post}, routing::{delete, get, patch, post},
}; };
use crate::MnemoState;
mod auth; mod auth;
mod persons; mod persons;
mod quotes; mod quotes;
@@ -10,7 +12,7 @@ mod sessions;
mod tags; mod tags;
mod users; mod users;
pub fn api_router() -> Router { pub fn api_router() -> Router<MnemoState> {
Router::new() Router::new()
.route("/api/live", get(async || "Mnemosyne lives")) .route("/api/live", get(async || "Mnemosyne lives"))
// auth // auth
@@ -48,4 +50,5 @@ pub fn api_router() -> Router {
// quotes // quotes
.route("/api/quotes", post(quotes::create)) .route("/api/quotes", post(quotes::create))
.route("/api/quotes/{id}", get(quotes::get_by_id)) .route("/api/quotes/{id}", get(quotes::get_by_id))
.route("/api/quotes/search", get(quotes::get_by_query))
} }

View File

@@ -1,6 +1,6 @@
use axum::{ use axum::{
Json, Json,
extract::Path, extract::{Path, State},
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
@@ -8,7 +8,7 @@ use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
database::{self}, MnemoState,
error::CompositeError, error::CompositeError,
logs::{LogAction, LogEntry}, logs::{LogAction, LogEntry},
persons::{Name, Person}, persons::{Name, Person},
@@ -21,26 +21,34 @@ use crate::{
pub const CANT_SET_PRIMARYNAME: &str = "You don't have permission to swap primary names."; pub const CANT_SET_PRIMARYNAME: &str = "You don't have permission to swap primary names.";
pub async fn get_all(headers: HeaderMap) -> Result<Response, CompositeError> { pub async fn get_all(
User::authenticate(&headers)?.required()?; State(state): State<MnemoState>,
let conn = database::conn()?; headers: HeaderMap,
Ok(Json(Person::get_all(&conn)?).into_response()) ) -> Result<Response, CompositeError> {
let mut conn = state.pool.acquire().await?;
User::authenticate(&mut conn, &headers).await?.required()?;
Ok(Json(Person::get_all(&mut conn).await?).into_response())
} }
pub async fn get_by_id( pub async fn get_by_id(
State(state): State<MnemoState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
headers: HeaderMap, headers: HeaderMap,
) -> Result<Response, CompositeError> { ) -> Result<Response, CompositeError> {
User::authenticate(&headers)?.required()?; let mut conn = state.pool.acquire().await?;
let conn = database::conn()?; User::authenticate(&mut conn, &headers).await?.required()?;
Ok(Json(Person::get_by_id(&conn, id)?).into_response()) Ok(Json(Person::get_by_id(&mut conn, id).await?).into_response())
} }
pub async fn pid_names( pub async fn pid_names(
State(state): State<MnemoState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
headers: HeaderMap, headers: HeaderMap,
) -> Result<Response, CompositeError> { ) -> Result<Response, CompositeError> {
User::authenticate(&headers)?.required()?; let mut conn = state.pool.acquire().await?;
let conn = database::conn()?; User::authenticate(&mut conn, &headers).await?.required()?;
Ok(Json(Person::get_by_id(&conn, id)?.get_all_names(&conn)?).into_response()) let person = Person::get_by_id(&mut conn, id).await?;
Ok(Json(person.get_all_names(&mut conn).await?).into_response())
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -49,38 +57,41 @@ pub struct PersonNameForm {
} }
pub async fn create( pub async fn create(
State(state): State<MnemoState>,
headers: HeaderMap, headers: HeaderMap,
Json(form): Json<PersonNameForm>, Json(form): Json<PersonNameForm>,
) -> Result<Response, CompositeError> { ) -> Result<Response, CompositeError> {
let u = User::authenticate(&headers)?.required()?; let mut tx = state.pool.begin().await?;
let mut conn = database::conn()?; let u = User::authenticate(&mut tx, &headers).await?.required()?;
let tx = conn.transaction()?;
let p = Person::create(&tx, form.name, u.id)?; let p = Person::create(&mut tx, form.name, u.id).await?;
LogEntry::new( LogEntry::new(
&tx, &mut tx,
u, u,
LogAction::CreatePerson { LogAction::CreatePerson {
id: p.id, id: p.id,
pname: p.primary_name.as_str().to_string(), pname: p.primary_name.clone(),
}, },
)?; )
tx.commit()?; .await?;
tx.commit().await?;
Ok((StatusCode::CREATED, Json(p)).into_response()) Ok((StatusCode::CREATED, Json(p)).into_response())
} }
pub async fn add_name( pub async fn add_name(
State(state): State<MnemoState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
headers: HeaderMap, headers: HeaderMap,
Json(form): Json<PersonNameForm>, Json(form): Json<PersonNameForm>,
) -> Result<Response, CompositeError> { ) -> Result<Response, CompositeError> {
let u = User::authenticate(&headers)?.required()?; let mut tx = state.pool.begin().await?;
let mut conn = database::conn()?; let u = User::authenticate(&mut tx, &headers).await?.required()?;
let tx = conn.transaction()?;
let p = Person::get_by_id(&tx, id)?; let p = Person::get_by_id(&mut tx, id).await?;
let n = p.add_name(&tx, form.name, u.id)?; let n = p.add_name(&mut tx, form.name, u.id).await?;
LogEntry::new( LogEntry::new(
&tx, &mut tx,
u, u,
LogAction::AddPersonName { LogAction::AddPersonName {
pid: p.id, pid: p.id,
@@ -88,39 +99,54 @@ pub async fn add_name(
pn: p.primary_name, pn: p.primary_name,
nn: n.name.clone(), nn: n.name.clone(),
}, },
)?; )
tx.commit()?; .await?;
tx.commit().await?;
Ok((StatusCode::CREATED, Json(n)).into_response()) Ok((StatusCode::CREATED, Json(n)).into_response())
} }
pub async fn n_all(headers: HeaderMap) -> Result<Response, CompositeError> { pub async fn n_all(
User::authenticate(&headers)?.required()?; State(state): State<MnemoState>,
let conn = database::conn()?; headers: HeaderMap,
Ok(Json(Name::get_all(&conn)?).into_response()) ) -> Result<Response, CompositeError> {
let mut conn = state.pool.acquire().await?;
User::authenticate(&mut conn, &headers).await?.required()?;
Ok(Json(Name::get_all(&mut conn).await?).into_response())
} }
pub async fn n_by_id(Path(id): Path<Uuid>, headers: HeaderMap) -> Result<Response, CompositeError> {
User::authenticate(&headers)?.required()?; pub async fn n_by_id(
let conn = database::conn()?; State(state): State<MnemoState>,
Ok(Json(Name::get_by_id(&conn, id)?).into_response())
}
pub async fn n_setprimary(
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
headers: HeaderMap, headers: HeaderMap,
) -> Result<Response, CompositeError> { ) -> Result<Response, CompositeError> {
let u = User::authenticate(&headers)?.required()?; let mut conn = state.pool.acquire().await?;
let mut conn = database::conn()?; User::authenticate(&mut conn, &headers).await?.required()?;
let tx = conn.transaction()?; Ok(Json(Name::get_by_id(&mut conn, id).await?).into_response())
}
if !u.has_permission(&tx, Permission::ChangePersonPrimaryName)? { pub async fn n_setprimary(
State(state): State<MnemoState>,
Path(id): Path<Uuid>,
headers: HeaderMap,
) -> Result<Response, CompositeError> {
let mut tx = state.pool.begin().await?;
let u = User::authenticate(&mut tx, &headers).await?.required()?;
if !u
.has_permission(&mut tx, Permission::ChangePersonPrimaryName)
.await?
{
return Ok((StatusCode::FORBIDDEN, CANT_SET_PRIMARYNAME).into_response()); return Ok((StatusCode::FORBIDDEN, CANT_SET_PRIMARYNAME).into_response());
} }
let mut n = Name::get_by_id(&tx, id)?; let mut n = Name::get_by_id(&mut tx, id).await?;
let p = Person::get_by_id(&tx, n.person_id)?; let p = Person::get_by_id(&mut tx, n.person_id).await?;
n.set_primary(&tx)?; n.set_primary(&mut tx).await?;
n.is_primary = true; n.is_primary = true;
LogEntry::new( LogEntry::new(
&tx, &mut tx,
u, u,
LogAction::SetPersonPrimaryName { LogAction::SetPersonPrimaryName {
pid: p.id, pid: p.id,
@@ -128,8 +154,9 @@ pub async fn n_setprimary(
on: p.primary_name, on: p.primary_name,
nn: n.name.clone(), nn: n.name.clone(),
}, },
)?; )
tx.commit()?; .await?;
tx.commit().await?;
Ok(Json(n).into_response()) Ok(Json(n).into_response())
} }

View File

@@ -1,15 +1,15 @@
use axum::{ use axum::{
Json, Json,
extract::Path, extract::{Path, State},
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use chrono::{DateTime, FixedOffset}; use chrono::NaiveDateTime;
use serde::Deserialize; use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
database::{self}, MnemoState,
error::CompositeError, error::CompositeError,
logs::{LogAction, LogEntry}, logs::{LogAction, LogEntry},
persons::Name, persons::Name,
@@ -21,54 +21,69 @@ use crate::{
}; };
pub async fn get_by_id( pub async fn get_by_id(
State(state): State<MnemoState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
headers: HeaderMap, headers: HeaderMap,
) -> Result<Response, CompositeError> { ) -> Result<Response, CompositeError> {
User::authenticate(&headers)?.required()?; let mut conn = state.pool.acquire().await?;
let conn = database::conn()?; User::authenticate(&mut conn, &headers).await?.required()?;
Ok(Json(Quote::get_by_id(&conn, id)?).into_response()) Ok(Json(Quote::get_by_id(&mut conn, id).await?).into_response())
}
pub async fn get_by_query(
State(state): State<MnemoState>,
headers: HeaderMap,
Json(q): Json<String>,
) -> Result<Response, CompositeError> {
let mut conn = state.pool.acquire().await?;
User::authenticate(&mut conn, &headers).await?.required()?;
Ok(Json(Quote::get_by_search_query(&mut conn, &q, 20, 0).await?).into_response())
} }
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct QuoteLineForm { pub struct QuoteLineForm {
pub content: String, pub content: String,
pub name_id: Uuid, pub name_ids: Vec<Uuid>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct QuoteCreateForm { pub struct QuoteCreateForm {
pub lines: Vec<QuoteLineForm>, pub lines: Vec<QuoteLineForm>,
pub timestamp: DateTime<FixedOffset>, pub timestamp: NaiveDateTime,
pub context: Option<String>, pub context: Option<String>,
pub location: Option<String>, pub location: Option<String>,
pub public: bool, pub public: bool,
} }
pub async fn create( pub async fn create(
State(state): State<MnemoState>,
headers: HeaderMap, headers: HeaderMap,
Json(form): Json<QuoteCreateForm>, Json(form): Json<QuoteCreateForm>,
) -> Result<Response, CompositeError> { ) -> Result<Response, CompositeError> {
let u = User::authenticate(&headers)?.required()?; let mut tx = state.pool.begin().await?;
let mut conn = database::conn()?; let u = User::authenticate(&mut tx, &headers).await?.required()?;
let tx = conn.transaction()?;
let lines = form let mut lines = Vec::with_capacity(form.lines.len());
.lines for l in form.lines {
.into_iter() let mut names = Vec::with_capacity(l.name_ids.len());
.map(|l| Ok((l.content, Name::get_by_id(&tx, l.name_id)?))) for id in l.name_ids {
.collect::<Result<Vec<(String, Name)>, CompositeError>>()?; names.push(Name::get_by_id(&mut tx, id).await?);
}
lines.push((l.content, names));
}
let q = Quote::create( let q = Quote::create(
&tx, &mut tx,
lines, lines,
form.timestamp, form.timestamp,
form.context, form.context,
form.location, form.location,
u.id, u.id,
form.public, form.public,
)?; )
.await?;
LogEntry::new(&tx, u, LogAction::CreateQuote { id: q.id })?; LogEntry::new(&mut tx, u, LogAction::CreateQuote { id: q.id }).await?;
tx.commit()?; tx.commit().await?;
Ok((StatusCode::CREATED, Json(q)).into_response()) Ok((StatusCode::CREATED, Json(q)).into_response())
} }

View File

@@ -1,13 +1,13 @@
use axum::{ use axum::{
Json, Json,
extract::Path, extract::{Path, State},
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
database::{self}, MnemoState,
error::CompositeError, error::CompositeError,
logs::{LogAction, LogEntry}, logs::{LogAction, LogEntry},
users::{ users::{
@@ -21,15 +21,17 @@ use crate::{
const CANT_REVOKE: &str = "You don't have permission to revoke this user's sessions."; const CANT_REVOKE: &str = "You don't have permission to revoke this user's sessions.";
pub async fn get_by_id( pub async fn get_by_id(
State(state): State<MnemoState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
headers: HeaderMap, headers: HeaderMap,
) -> Result<Response, CompositeError> { ) -> Result<Response, CompositeError> {
let u = User::authenticate(&headers)?.required()?; let mut conn = state.pool.acquire().await?;
let conn = database::conn()?; let u = User::authenticate(&mut conn, &headers).await?.required()?;
let s = Session::get_by_id(&conn, id)?; let s = Session::get_by_id(&mut conn, id).await?;
match s.user_id == u.id match s.user_id == u.id
|| u.has_permission(&conn, Permission::ListOthersSessions) || u.has_permission(&mut conn, Permission::ListOthersSessions)
.await
.is_ok_and(|v| v) .is_ok_and(|v| v)
{ {
true => Ok(Json(s).into_response()), true => Ok(Json(s).into_response()),
@@ -38,25 +40,29 @@ pub async fn get_by_id(
} }
pub async fn revoke_by_id( pub async fn revoke_by_id(
State(state): State<MnemoState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
headers: HeaderMap, headers: HeaderMap,
) -> Result<Response, CompositeError> { ) -> Result<Response, CompositeError> {
let u = User::authenticate(&headers)?.required()?; let mut tx = state.pool.begin().await?;
let mut conn = database::conn()?; let u = User::authenticate(&mut tx, &headers).await?.required()?;
let tx = conn.transaction()?;
let mut s = Session::get_by_id(&tx, id)?; let mut s = Session::get_by_id(&mut tx, id).await?;
match s.user_id == u.id match s.user_id == u.id
|| u.has_permission(&tx, Permission::RevokeOthersSessions) || u.has_permission(&mut tx, Permission::RevokeOthersSessions)
.await
.is_ok_and(|v| v) .is_ok_and(|v| v)
{ {
true => { true => {
s.revoke(&tx, Some(&u))?; s.revoke(&mut tx, Some(&u)).await?;
LogEntry::new(&tx, u, LogAction::ManuallyRevokeSession { id })?; LogEntry::new(&mut tx, u, LogAction::ManuallyRevokeSession { id }).await?;
tx.commit()?; tx.commit().await?;
Ok(Json(s).into_response()) Ok(Json(s).into_response())
} }
false => match u.has_permission(&tx, Permission::ListOthersSessions)? { false => match u
.has_permission(&mut tx, Permission::ListOthersSessions)
.await?
{
true => Ok((StatusCode::FORBIDDEN, CANT_REVOKE).into_response()), true => Ok((StatusCode::FORBIDDEN, CANT_REVOKE).into_response()),
false => Err(SessionError::NoSessionWithId(id))?, false => Err(SessionError::NoSessionWithId(id))?,
}, },

View File

@@ -1,6 +1,6 @@
use axum::{ use axum::{
Json, Json,
extract::Path, extract::{Path, State},
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
@@ -8,7 +8,7 @@ use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
database::{self}, MnemoState,
error::CompositeError, error::CompositeError,
logs::{LogAction, LogEntry}, logs::{LogAction, LogEntry},
tags::{Tag, TagName}, tags::{Tag, TagName},
@@ -24,101 +24,112 @@ const CANT_DEL_TAGS: &str = "You don't have permission to delete tags.";
const CANT_RENAME_TAGS: &str = "You don't have permission to rename tags."; const CANT_RENAME_TAGS: &str = "You don't have permission to rename tags.";
const TAG_DELETED: &str = "Tag deleted successfully."; const TAG_DELETED: &str = "Tag deleted successfully.";
pub async fn get_all(headers: HeaderMap) -> Result<Response, CompositeError> { pub async fn get_all(
User::authenticate(&headers)?.required()?; State(state): State<MnemoState>,
let conn = database::conn()?; headers: HeaderMap,
Ok(Json(Tag::get_all(&conn)?).into_response()) ) -> Result<Response, CompositeError> {
let mut conn = state.pool.acquire().await?;
User::authenticate(&mut conn, &headers).await?.required()?;
Ok(Json(Tag::get_all(&mut conn).await?).into_response())
} }
pub async fn get_by_id( pub async fn get_by_id(
State(state): State<MnemoState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
headers: HeaderMap, headers: HeaderMap,
) -> Result<Response, CompositeError> { ) -> Result<Response, CompositeError> {
User::authenticate(&headers)?.required()?; let mut conn = state.pool.acquire().await?;
let conn = database::conn()?; User::authenticate(&mut conn, &headers).await?.required()?;
Ok(Json(Tag::get_by_id(&conn, id)?).into_response()) Ok(Json(Tag::get_by_id(&mut conn, id).await?).into_response())
} }
pub async fn get_by_name( pub async fn get_by_name(
State(state): State<MnemoState>,
Path(name): Path<TagName>, Path(name): Path<TagName>,
headers: HeaderMap, headers: HeaderMap,
) -> Result<Response, CompositeError> { ) -> Result<Response, CompositeError> {
User::authenticate(&headers)?.required()?; let mut conn = state.pool.acquire().await?;
let conn = database::conn()?; User::authenticate(&mut conn, &headers).await?.required()?;
Ok(Json(Tag::get_by_name(&conn, name)?).into_response()) Ok(Json(Tag::get_by_name(&mut conn, name).await?).into_response())
} }
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct TagNameForm { pub struct TagNameForm {
name: TagName, name: TagName,
} }
pub async fn create( pub async fn create(
State(state): State<MnemoState>,
headers: HeaderMap, headers: HeaderMap,
Json(form): Json<TagNameForm>, Json(form): Json<TagNameForm>,
) -> Result<Response, CompositeError> { ) -> Result<Response, CompositeError> {
let u = User::authenticate(&headers)?.required()?; let mut tx = state.pool.begin().await?;
let mut conn = database::conn()?; let u = User::authenticate(&mut tx, &headers).await?.required()?;
let tx = conn.transaction()?;
if !u.has_permission(&tx, Permission::CreateTags)? { if !u.has_permission(&mut tx, Permission::CreateTags).await? {
return Ok((StatusCode::FORBIDDEN, CANT_MAKE_TAGS).into_response()); return Ok((StatusCode::FORBIDDEN, CANT_MAKE_TAGS).into_response());
} }
let t = Tag::create(&tx, form.name)?; let t = Tag::create(&mut tx, form.name).await?;
LogEntry::new( LogEntry::new(
&tx, &mut tx,
u, u,
LogAction::CreateTag { LogAction::CreateTag {
id: t.id, id: t.id,
name: t.name.as_str().to_string(), name: t.name.as_str().to_string(),
}, },
)?; )
tx.commit()?; .await?;
tx.commit().await?;
Ok(Json(t).into_response()) Ok(Json(t).into_response())
} }
pub async fn rename( pub async fn rename(
State(state): State<MnemoState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
headers: HeaderMap, headers: HeaderMap,
Json(form): Json<TagNameForm>, Json(form): Json<TagNameForm>,
) -> Result<Response, CompositeError> { ) -> Result<Response, CompositeError> {
let u = User::authenticate(&headers)?.required()?; let mut tx = state.pool.begin().await?;
let mut conn = database::conn()?; let u = User::authenticate(&mut tx, &headers).await?.required()?;
let tx = conn.transaction()?;
if !u.has_permission(&tx, Permission::RenameTags)? { if !u.has_permission(&mut tx, Permission::RenameTags).await? {
return Ok((StatusCode::FORBIDDEN, CANT_RENAME_TAGS).into_response()); return Ok((StatusCode::FORBIDDEN, CANT_RENAME_TAGS).into_response());
} }
let mut tag = Tag::get_by_id(&tx, id)?; let mut tag = Tag::get_by_id(&mut tx, id).await?;
let on = tag.name.as_str().to_string(); let on = tag.name.as_str().to_string();
tag.rename(&tx, form.name)?; tag.rename(&mut tx, form.name).await?;
LogEntry::new( LogEntry::new(
&tx, &mut tx,
u, u,
LogAction::RenameTag { LogAction::RenameTag {
id, id,
on, on,
nn: tag.name.as_str().to_string(), nn: tag.name.as_str().to_string(),
}, },
)?; )
tx.commit()?; .await?;
tx.commit().await?;
Ok(Json(tag).into_response()) Ok(Json(tag).into_response())
} }
pub async fn delete(Path(id): Path<Uuid>, headers: HeaderMap) -> Result<Response, CompositeError> { pub async fn delete(
let u = User::authenticate(&headers)?.required()?; State(state): State<MnemoState>,
let mut conn = database::conn()?; Path(id): Path<Uuid>,
let tx = conn.transaction()?; headers: HeaderMap,
) -> Result<Response, CompositeError> {
let mut tx = state.pool.begin().await?;
let u = User::authenticate(&mut tx, &headers).await?.required()?;
if !u.has_permission(&tx, Permission::DeleteTags)? { if !u.has_permission(&mut tx, Permission::DeleteTags).await? {
return Ok((StatusCode::FORBIDDEN, CANT_DEL_TAGS).into_response()); return Ok((StatusCode::FORBIDDEN, CANT_DEL_TAGS).into_response());
} }
let t = Tag::get_by_id(&tx, id)?; let t = Tag::get_by_id(&mut tx, id).await?;
let name = t.name.as_str().to_string(); let name = t.name.as_str().to_string();
t.delete(&tx)?; t.delete(&mut tx).await?;
LogEntry::new(&tx, u, LogAction::DeleteTag { id, name })?; LogEntry::new(&mut tx, u, LogAction::DeleteTag { id, name }).await?;
tx.commit()?; tx.commit().await?;
Ok((StatusCode::OK, TAG_DELETED).into_response()) Ok((StatusCode::OK, TAG_DELETED).into_response())
} }

View File

@@ -1,6 +1,6 @@
use axum::{ use axum::{
Json, Json,
extract::Path, extract::{Path, State},
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
@@ -8,7 +8,7 @@ use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
database::{self}, MnemoState,
error::CompositeError, error::CompositeError,
logs::{LogAction, LogEntry}, logs::{LogAction, LogEntry},
users::{ users::{
@@ -25,32 +25,41 @@ const CANT_MANUALLY_MAKE_USERS: &str = "You don't have permission to manually cr
const HANDLE_CHANGED_SUCCESS: &str = "Handle changed successfully."; const HANDLE_CHANGED_SUCCESS: &str = "Handle changed successfully.";
const PASSW_CHANGED_SUCCESS: &str = "Password changed successfully."; const PASSW_CHANGED_SUCCESS: &str = "Password changed successfully.";
pub async fn get_me(headers: HeaderMap) -> Result<Response, CompositeError> { pub async fn get_me(
Ok(Json(User::authenticate(&headers)?.required()?).into_response()) State(state): State<MnemoState>,
headers: HeaderMap,
) -> Result<Response, CompositeError> {
let mut conn = state.pool.acquire().await?;
Ok(Json(User::authenticate(&mut conn, &headers).await?.required()?).into_response())
} }
pub async fn get_by_id( pub async fn get_by_id(
State(state): State<MnemoState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
headers: HeaderMap, headers: HeaderMap,
) -> Result<Response, CompositeError> { ) -> Result<Response, CompositeError> {
User::authenticate(&headers)?.required()?; let mut conn = state.pool.acquire().await?;
let conn = database::conn()?; User::authenticate(&mut conn, &headers).await?.required()?;
Ok(Json(User::get_by_id(&conn, id)?).into_response()) Ok(Json(User::get_by_id(&mut conn, id).await?).into_response())
} }
pub async fn get_by_handle( pub async fn get_by_handle(
State(state): State<MnemoState>,
Path(handle): Path<UserHandle>, Path(handle): Path<UserHandle>,
headers: HeaderMap, headers: HeaderMap,
) -> Result<Response, CompositeError> { ) -> Result<Response, CompositeError> {
User::authenticate(&headers)?.required()?; let mut conn = state.pool.acquire().await?;
let conn = database::conn()?; User::authenticate(&mut conn, &headers).await?.required()?;
Ok(Json(User::get_by_handle(&conn, handle)?).into_response()) Ok(Json(User::get_by_handle(&mut conn, handle).await?).into_response())
} }
pub async fn get_all(headers: HeaderMap) -> Result<Response, CompositeError> { pub async fn get_all(
User::authenticate(&headers)?.required()?; State(state): State<MnemoState>,
let conn = database::conn()?; headers: HeaderMap,
Ok(Json(User::get_all(&conn)?).into_response()) ) -> Result<Response, CompositeError> {
let mut conn = state.pool.acquire().await?;
User::authenticate(&mut conn, &headers).await?.required()?;
Ok(Json(User::get_all(&mut conn).await?).into_response())
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -58,60 +67,69 @@ pub struct HandleForm {
handle: UserHandle, handle: UserHandle,
} }
pub async fn create( pub async fn create(
State(state): State<MnemoState>,
headers: HeaderMap, headers: HeaderMap,
Json(form): Json<HandleForm>, Json(form): Json<HandleForm>,
) -> Result<Response, CompositeError> { ) -> Result<Response, CompositeError> {
let u = User::authenticate(&headers)?.required()?; let mut tx = state.pool.begin().await?;
let mut conn = database::conn()?; let u = User::authenticate(&mut tx, &headers).await?.required()?;
let tx = conn.transaction()?;
if !u.has_permission(&tx, Permission::ManuallyCreateUsers)? { if !u
.has_permission(&mut tx, Permission::ManuallyCreateUsers)
.await?
{
return Ok((StatusCode::FORBIDDEN, CANT_MANUALLY_MAKE_USERS).into_response()); return Ok((StatusCode::FORBIDDEN, CANT_MANUALLY_MAKE_USERS).into_response());
} }
let nu = User::create(&tx, form.handle)?; let nu = User::create(&mut tx, form.handle).await?;
LogEntry::new( LogEntry::new(
&tx, &mut tx,
u, u,
LogAction::CreateUser { LogAction::CreateUser {
id: nu.id, id: nu.id,
handle: nu.handle.as_str().to_string(), handle: nu.handle.as_str().to_string(),
}, },
)?; )
tx.commit()?; .await?;
tx.commit().await?;
Ok(Json(nu).into_response()) Ok(Json(nu).into_response())
} }
pub async fn change_handle( pub async fn change_handle(
State(state): State<MnemoState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
headers: HeaderMap, headers: HeaderMap,
Json(form): Json<HandleForm>, Json(form): Json<HandleForm>,
) -> Result<Response, CompositeError> { ) -> Result<Response, CompositeError> {
let u = User::authenticate(&headers)?.required()?; let mut tx = state.pool.begin().await?;
let mut conn = database::conn()?; let u = User::authenticate(&mut tx, &headers).await?.required()?;
let tx = conn.transaction()?;
let mut target = if u.id == id { let mut target = if u.id == id {
u.clone() u.clone()
} else { } else {
if !u.has_permission(&tx, Permission::ChangeOthersHandles)? { if !u
.has_permission(&mut tx, Permission::ChangeOthersHandles)
.await?
{
return Ok((StatusCode::FORBIDDEN, CANT_CHANGE_OTHERS_HANDLE).into_response()); return Ok((StatusCode::FORBIDDEN, CANT_CHANGE_OTHERS_HANDLE).into_response());
} }
User::get_by_id(&tx, id)? User::get_by_id(&mut tx, id).await?
}; };
let old_handle = target.handle.as_str().to_string(); let old_handle = target.handle.as_str().to_string();
target.set_handle(&tx, form.handle)?; target.set_handle(&mut tx, form.handle).await?;
LogEntry::new( LogEntry::new(
&tx, &mut tx,
u, u,
LogAction::ChangeUserHandle { LogAction::ChangeUserHandle {
id: target.id, id: target.id,
old: old_handle, old: old_handle,
new: target.handle.as_str().to_string(), new: target.handle.as_str().to_string(),
}, },
)?; )
tx.commit()?; .await?;
tx.commit().await?;
Ok(HANDLE_CHANGED_SUCCESS.into_response()) Ok(HANDLE_CHANGED_SUCCESS.into_response())
} }
@@ -121,30 +139,34 @@ pub struct ChangePasswordForm {
password: String, password: String,
} }
pub async fn change_password( pub async fn change_password(
State(state): State<MnemoState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
headers: HeaderMap, headers: HeaderMap,
Json(form): Json<ChangePasswordForm>, Json(form): Json<ChangePasswordForm>,
) -> Result<Response, CompositeError> { ) -> Result<Response, CompositeError> {
let u = User::authenticate(&headers)?.required()?; let mut tx = state.pool.begin().await?;
let mut conn = database::conn()?; let u = User::authenticate(&mut tx, &headers).await?.required()?;
let tx = conn.transaction()?;
let mut target = if u.id == id { let mut target = if u.id == id {
u.clone() u.clone()
} else { } else {
if !u.has_permission(&tx, Permission::ChangeOthersPasswords)? { if !u
.has_permission(&mut tx, Permission::ChangeOthersPasswords)
.await?
{
return Ok((StatusCode::FORBIDDEN, CANT_CHANGE_OTHERS_PASSW).into_response()); return Ok((StatusCode::FORBIDDEN, CANT_CHANGE_OTHERS_PASSW).into_response());
} }
User::get_by_id(&tx, id)? User::get_by_id(&mut tx, id).await?
}; };
target.set_password(&tx, Some(&form.password))?; target.set_password(&mut tx, Some(&form.password)).await?;
LogEntry::new( LogEntry::new(
&tx, &mut tx,
u, u,
LogAction::ManuallyChangeUsersPassword { id: target.id }, LogAction::ManuallyChangeUsersPassword { id: target.id },
)?; )
tx.commit()?; .await?;
tx.commit().await?;
Ok(PASSW_CHANGED_SUCCESS.into_response()) Ok(PASSW_CHANGED_SUCCESS.into_response())
} }

View File

@@ -1,7 +1,16 @@
use std::io::{self, Write}; use std::{
env::var,
error::Error,
io::{self, Write},
time::Duration,
};
use env_logger::fmt::Formatter; use env_logger::fmt::Formatter;
use log::Record; use log::{LevelFilter, Record};
use sqlx::PgPool;
/// Mnemosyne, the mother of the nine muses
pub const DEFAULT_PORT: u16 = 0x9999; // 39321
pub const REFERENCE_SPLASHES: &[&str] = &[ pub const REFERENCE_SPLASHES: &[&str] = &[
"quote engine", "quote engine",
@@ -17,6 +26,44 @@ pub const REFERENCE_SPLASHES: &[&str] = &[
"over 100 lines of git history!", "over 100 lines of git history!",
]; ];
pub async fn init_pool() -> Result<PgPool, Box<dyn Error>> {
Ok(sqlx::postgres::PgPoolOptions::new()
.max_connections(20)
.acquire_timeout(Duration::from_secs(3))
.idle_timeout(Duration::from_secs(60))
.connect(var("DATABASE_URL")?.as_str())
.await?)
}
pub fn port() -> Result<u16, Box<dyn Error>> {
Ok(match std::env::var("PORT") {
Ok(p) => p.parse::<u16>()?,
Err(e) => match e {
std::env::VarError::NotPresent => DEFAULT_PORT,
_ => return Err(e)?,
},
})
}
pub fn dotenv() -> Result<(), Box<dyn Error>> {
if let Err(e) = dotenvy::dotenv()
&& !e.not_found()
{
return Err(e.into());
}
Ok(())
}
pub fn env_logger() -> Result<(), Box<dyn Error>> {
env_logger::builder()
.filter_level(LevelFilter::Info)
.filter_module("sqlx", LevelFilter::Warn)
.parse_default_env()
.format(envlogger_write_format)
.init();
Ok(())
}
pub fn envlogger_write_format(buf: &mut Formatter, rec: &Record) -> io::Result<()> { pub fn envlogger_write_format(buf: &mut Formatter, rec: &Record) -> io::Result<()> {
let level_string = format!("{}", rec.level()); let level_string = format!("{}", rec.level());
let level_style = buf.default_level_style(rec.level()); let level_style = buf.default_level_style(rec.level());

View File

@@ -0,0 +1,134 @@
CREATE EXTENSION citext;
CREATE EXTENSION pg_trgm;
CREATE TABLE users(
id UUID NOT NULL PRIMARY KEY,
handle CITEXT NOT NULL UNIQUE,
password TEXT,
profpic TEXT
);
CREATE TABLE sessions (
id UUID NOT NULL PRIMARY KEY,
token BYTEA NOT NULL UNIQUE,
user_id UUID NOT NULL REFERENCES users(id),
expiry TIMESTAMPTZ NOT NULL,
revoked BOOLEAN NOT NULL DEFAULT FALSE,
revoked_at TIMESTAMPTZ DEFAULT NULL,
revoked_by UUID DEFAULT NULL REFERENCES users(id),
CHECK(
(revoked = FALSE AND revoked_at IS NULL AND revoked_by IS NULL) OR
(revoked = TRUE AND revoked_at IS NOT NULL AND revoked_by IS NOT NULL)
)
);
CREATE INDEX sessions_by_userid ON sessions(user_id);
CREATE TABLE user_permissions (
user_id UUID NOT NULL REFERENCES users(id),
permission TEXT NOT NULL,
state BOOLEAN NOT NULL,
PRIMARY KEY (user_id, permission)
);
CREATE TABLE quotes (
id UUID NOT NULL PRIMARY KEY,
timestamp TIMESTAMP NOT NULL,
location TEXT DEFAULT NULL,
context TEXT DEFAULT NULL,
created_by UUID REFERENCES users(id),
public BOOLEAN DEFAULT FALSE,
fts TEXT NOT NULL DEFAULT ''
);
CREATE INDEX quotes_by_creation_user ON quotes(created_by);
CREATE INDEX quotes_fts_trgm_idx ON quotes USING gin (fts gin_trgm_ops);
CREATE TABLE persons (
id UUID NOT NULL PRIMARY KEY,
bio TEXT DEFAULT NULL,
profpic TEXT DEFAULT NULL
);
CREATE TABLE names (
id UUID NOT NULL PRIMARY KEY,
name CITEXT NOT NULL,
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
person_id UUID NOT NULL REFERENCES persons(id)
);
CREATE INDEX names_by_personid ON names(person_id);
CREATE UNIQUE INDEX no_name_duplicate_for_same_person ON names(person_id, name);
CREATE UNIQUE INDEX primary_name_uniqueness ON names(person_id) WHERE is_primary = TRUE;
CREATE TABLE lines (
id UUID NOT NULL PRIMARY KEY,
quote_id UUID NOT NULL REFERENCES quotes(id),
ordering SMALLINT NOT NULL,
content TEXT NOT NULL
);
CREATE INDEX lines_by_quoteid ON lines(quote_id);
CREATE UNIQUE INDEX lines_unique_ordering ON lines(quote_id, ordering);
CREATE TABLE line_authors (
line_id UUID REFERENCES lines(id),
name_id UUID REFERENCES names(id),
PRIMARY KEY (line_id, name_id)
);
CREATE TABLE tags (
id UUID NOT NULL PRIMARY KEY,
name CITEXT NOT NULL UNIQUE
);
CREATE TABLE user_quote_likes (
quote_id UUID NOT NULL REFERENCES quotes(id),
user_id UUID NOT NULL REFERENCES users(id),
PRIMARY KEY (quote_id, user_id)
);
CREATE INDEX quote_likes_by_user ON user_quote_likes(user_id);
CREATE INDEX quote_likes_by_quote ON user_quote_likes(quote_id);
CREATE TABLE quote_tags (
quote_id UUID NOT NULL REFERENCES quotes(id),
tag_id UUID NOT NULL REFERENCES tags(id),
PRIMARY KEY (quote_id, tag_id)
);
CREATE INDEX quote_tags_by_quote ON quote_tags(quote_id);
CREATE INDEX quote_tags_by_tag ON quote_tags(tag_id);
CREATE TABLE logs (
id UUID NOT NULL PRIMARY KEY,
actor UUID NOT NULL REFERENCES users(id),
target UUID,
actiontype TEXT NOT NULL,
payload JSONB
);
CREATE INDEX logs_by_actor ON logs(actor);
CREATE INDEX logs_by_target ON logs(target);
CREATE OR REPLACE FUNCTION update_quote_fts_from_lines()
RETURNS TRIGGER AS $$
DECLARE
affected_quote_id UUID;
quote_lines_content TEXT;
BEGIN
IF TG_OP = 'DELETE' THEN
affected_quote_id := OLD.quote_id;
ELSE
affected_quote_id := NEW.quote_id;
END IF;
SELECT string_agg(content, ' ' ORDER BY ordering)
INTO quote_lines_content
FROM lines
WHERE quote_id = affected_quote_id;
UPDATE quotes
SET fts =
COALESCE(quote_lines_content, '') || ' ' ||
COALESCE(context, '') || ' ' ||
COALESCE(location, '')
WHERE id = affected_quote_id;
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_lines_update_quote_fts
AFTER INSERT OR UPDATE OR DELETE ON lines
FOR EACH ROW
EXECUTE FUNCTION update_quote_fts_from_lines();

View File

@@ -1,100 +0,0 @@
CREATE TABLE users (
id BLOB NOT NULL UNIQUE PRIMARY KEY, -- UUIDv7 as bytes
handle TEXT NOT NULL UNIQUE COLLATE NOCASE,
password TEXT, -- hashed, nullable in case of OAuth2-only login
prof_pic TEXT -- serialized ProfilePic
);
CREATE TABLE sessions (
id BLOB NOT NULL UNIQUE PRIMARY KEY, -- UUIDv7 as bytes
token BLOB NOT NULL UNIQUE,
user_id BLOB NOT NULL REFERENCES users(id), -- UUIDv7 bytes (userID)
expiry TEXT NOT NULL, -- RFC3339 into DateTime<Utc>
revoked INTEGER NOT NULL DEFAULT 0, -- bool (int 0 or int 1)
revoked_at TEXT DEFAULT NULL, -- RFC3339 into DateTime<Utc>
revoked_by BLOB DEFAULT NULL REFERENCES users(id) -- UUIDv7 bytes (userID)
CHECK(
(revoked = 0 AND revoked_at IS NULL AND revoked_by IS NULL) OR
(revoked = 1 AND revoked_at IS NOT NULL AND revoked_by IS NOT NULL)
)
);
CREATE INDEX sessions_by_userid ON sessions(user_id);
CREATE TABLE user_permissions (
user_id BLOB NOT NULL REFERENCES users(id), -- UUIDv7 as bytes
permission TEXT NOT NULL, -- serialized name
PRIMARY KEY (user_id, permission)
) WITHOUT ROWID;
CREATE TABLE quotes (
id BLOB NOT NULL UNIQUE PRIMARY KEY, -- UUIDv7 as bytes
timestamp TEXT NOT NULL, -- RFC3339 into DateTime<FixedOffset>
location TEXT,
context TEXT,
created_by BLOB NOT NULL REFERENCES users(id), -- UUIDv7 as bytes
public INTEGER NOT NULL DEFAULT 0 -- bool (int 0 or int 1)
-- this is to be followed by a bigger role-based viewership scoping mechanism
);
CREATE INDEX quotes_by_creation_user ON quotes(created_by);
CREATE TABLE persons (
id BLOB NOT NULL UNIQUE PRIMARY KEY, -- UUIDv7 as bytes
created_by BLOB NOT NULL REFERENCES users(id), -- UUIDv7 as bytes
bio TEXT,
prof_pic TEXT -- link probably
);
CREATE TABLE names (
id BLOB NOT NULL UNIQUE PRIMARY KEY, -- UUIDv7 as bytes
is_primary INTEGER NOT NULL DEFAULT 0,
person_id BLOB NOT NULL REFERENCES persons(id),
created_by BLOB NOT NULL REFERENCES users(id),
name TEXT NOT NULL
);
CREATE INDEX names_by_personid ON names(person_id);
CREATE UNIQUE INDEX no_name_duplicate_for_same_person ON names(person_id, name);
CREATE UNIQUE INDEX primary_name_uniqueness ON names(person_id) WHERE is_primary = 1;
CREATE TABLE lines (
id BLOB NOT NULL UNIQUE PRIMARY KEY, -- UUIDv7 as bytes
quote_id BLOB NOT NULL REFERENCES quotes(id), -- UUIDv7 as bytes
name_id BLOB NOT NULL REFERENCES names(id), -- UUIDv7 as bytes
ordering INTEGER NOT NULL,
content TEXT NOT NULL
);
CREATE INDEX lines_by_quoteid ON lines(quote_id);
CREATE INDEX lines_by_nameid ON lines(name_id);
CREATE UNIQUE INDEX lines_unique_ordering ON lines(quote_id, ordering);
CREATE TABLE tags (
id BLOB NOT NULL UNIQUE PRIMARY KEY, -- UUIDv7 as bytes
tagname TEXT NOT NULL UNIQUE COLLATE NOCASE
);
CREATE TABLE user_quote_likes (
quote_id BLOB NOT NULL REFERENCES quotes(id), -- UUIDv7 as bytes
user_id BLOB NOT NULL REFERENCES users(id), -- UUIDv7 as bytes
PRIMARY KEY (quote_id, user_id)
) WITHOUT ROWID;
CREATE INDEX likes_by_reverse_index ON user_quote_likes(user_id, quote_id);
CREATE TABLE quote_tags (
quote_id BLOB NOT NULL REFERENCES quotes(id), -- UUIDv7 as bytes
tag_id BLOB NOT NULL REFERENCES tags(id), -- UUIDv7 as bytes
PRIMARY KEY (quote_id, tag_id)
) WITHOUT ROWID;
CREATE INDEX quote_tags_reverse_index ON quote_tags(tag_id, quote_id);
CREATE TABLE logs (
id BLOB NOT NULL UNIQUE PRIMARY KEY, -- UUIDv7 as bytes
actor BLOB NOT NULL REFERENCES users(id), -- UUIDv7 as bytes
-- (userID with special cases: UUID::nil if system, UUID::max if infradmin)
-- ((infradmin & system shall both be users))
target BLOB, -- Option<UUIDv7 as bytes (userID)>
actiontype TEXT NOT NULL,
payload TEXT
);
CREATE INDEX logs_by_actor ON logs(actor);
CREATE INDEX logs_by_target ON logs(target);
-- all this to be followed by:
-- - a better access scoping mechanism (role-based like discord)
-- - photos just like quotes
-- - OAuth2 login via Steam/GitHub/Discord/Google/Potato/Whatever
-- - comments

View File

@@ -1,68 +1,14 @@
use std::{env, error::Error, sync::LazyLock}; use axum::{
http::StatusCode,
use axum::{http::StatusCode, response::IntoResponse}; response::{IntoResponse, Response},
use rusqlite::{Connection, OptionalExtension}; };
macro_rules! migration {
($name:literal) => {
($name, include_str!(concat!("./migrations/", $name, ".sql")))
};
}
const MIGRATIONS: &[(&str, &str)] = &[migration!("2026-04-04--01")];
pub static DB_URL: LazyLock<String> =
LazyLock::new(|| env::var("DATABASE_URL").expect("DATABASE_URL is not set"));
const PERSISTENT_PRAGMAS: &[&str] = &["PRAGMA journal_mode = WAL"];
const CONNECTION_PRAGMAS: &[&str] = &["PRAGMA foreign_keys = ON", "PRAGMA busy_timeout = 5000"];
const TABLE_MIGRATIONS: &str = r#"
CREATE TABLE IF NOT EXISTS migrations (
id TEXT PRIMARY KEY,
time INTEGER DEFAULT (unixepoch())
);
"#;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
#[error("{0}")] #[error("{0}")]
pub struct DatabaseError(#[from] rusqlite::Error); pub struct DatabaseError(#[from] sqlx::Error);
impl IntoResponse for DatabaseError { impl IntoResponse for DatabaseError {
fn into_response(self) -> axum::response::Response { fn into_response(self) -> Response {
log::error!("[DB ERROR] {}", self); log::error!("[DB ERROR] {}", self);
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error.").into_response() (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error.").into_response()
} }
} }
pub fn conn() -> Result<Connection, DatabaseError> {
let conn = Connection::open(&*DB_URL)?;
for pragma in CONNECTION_PRAGMAS {
conn.query_row(pragma, (), |_| Ok(())).optional()?;
}
Ok(conn)
}
pub fn migrations() -> Result<(), Box<dyn Error>> {
let conn = Connection::open(&*DB_URL)?;
for pragma in PERSISTENT_PRAGMAS {
conn.query_row(pragma, (), |_| Ok(()))?;
}
conn.execute(TABLE_MIGRATIONS, ())?;
let mut changes = false;
for (key, sql) in MIGRATIONS {
let mut statement = conn.prepare("SELECT id, time FROM migrations WHERE id = ?1")?;
let query = statement.query_one([key], |_| Ok(())).optional()?;
if query.is_some() {
continue;
}
changes = true;
log::info!("Applying migration {key}...");
conn.execute_batch(sql)?;
conn.execute("INSERT INTO migrations(id) VALUES (?1)", [key])?;
}
if changes {
log::info!("Migrations applied.")
}
Ok(())
}

View File

@@ -1,13 +1,6 @@
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use crate::{ use crate::database::DatabaseError;
database::DatabaseError,
persons::PersonError,
quotes::QuoteError,
tags::TagError,
users::{UserError, auth::AuthError, sessions::SessionError},
web::RedirectViaError,
};
pub struct CompositeError(Response); pub struct CompositeError(Response);
impl IntoResponse for CompositeError { impl IntoResponse for CompositeError {
@@ -28,18 +21,18 @@ macro_rules! composite_from {
}; };
} }
composite_from!( composite_from!(
AuthError, crate::users::auth::AuthError,
UserError, crate::users::UserError,
SessionError, crate::users::sessions::SessionError,
TagError, crate::tags::TagError,
PersonError, crate::persons::PersonError,
QuoteError, crate::quotes::QuoteError,
DatabaseError, DatabaseError,
RedirectViaError, // RedirectViaError,
); );
impl From<rusqlite::Error> for CompositeError { impl From<sqlx::Error> for CompositeError {
fn from(e: rusqlite::Error) -> Self { fn from(value: sqlx::Error) -> Self {
CompositeError(DatabaseError::from(e).into_response()) CompositeError(DatabaseError::from(value).into_response())
} }
} }

View File

@@ -1,9 +1,9 @@
use rusqlite::Connection;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use strum::IntoStaticStr; use sqlx::{PgConnection, Row};
use strum::{EnumDiscriminants, EnumIter, IntoStaticStr, VariantNames};
use uuid::Uuid; use uuid::Uuid;
use crate::{database::DatabaseError, users::User}; use crate::{database::DatabaseError, users::User, web::icons};
#[derive(Debug)] #[derive(Debug)]
pub struct LogEntry { pub struct LogEntry {
@@ -13,52 +13,91 @@ pub struct LogEntry {
} }
impl LogEntry { impl LogEntry {
pub fn new(conn: &Connection, actor: User, data: LogAction) -> Result<LogEntry, DatabaseError> { pub async fn new(
conn: &mut PgConnection,
actor: User,
data: LogAction,
) -> Result<LogEntry, DatabaseError> {
let log = LogEntry { let log = LogEntry {
id: Uuid::now_v7(), id: Uuid::now_v7(),
actor, actor,
data, data,
}; };
let actiontype: &'static str = (&log.data).into(); let actiontype: &'static str = (&log.data).into();
let payload = serde_json::to_string(&log.data).unwrap(); let payload = serde_json::to_value(&log.data).unwrap();
conn.prepare( sqlx::query(
"INSERT INTO logs(id, actor, target, actiontype, payload) VALUES (?1,?2,?3,?4,?5)", "INSERT INTO logs(id, actor, target, actiontype, payload) VALUES ($1, $2, $3, $4, $5)",
)? )
.execute(( .bind(log.id)
&log.id, .bind(log.actor.id)
&log.actor.id, .bind(log.data.get_target_id())
log.data.get_target_id(), .bind(actiontype)
actiontype, .bind(payload)
payload, .execute(&mut *conn)
))?; .await?;
Ok(log) Ok(log)
} }
pub fn total_count(conn: &Connection) -> Result<i64, DatabaseError> {
Ok(conn.query_row("SELECT COUNT(*) FROM logs", (), |r| r.get(0))?) pub async fn count(
conn: &mut PgConnection,
action_type: Option<LogActionDiscriminant>,
) -> Result<i64, DatabaseError> {
let count = match action_type {
Some(at) => {
let atstr: &'static str = at.into();
sqlx::query_scalar("SELECT COUNT(*) FROM logs WHERE actiontype = $1")
.bind(atstr)
.fetch_one(&mut *conn)
.await?
}
None => {
sqlx::query_scalar("SELECT COUNT(*) FROM logs")
.fetch_one(&mut *conn)
.await?
}
};
Ok(count)
} }
pub fn get_chronological_offset(
conn: &Connection, pub async fn get_chronological_offset(
conn: &mut PgConnection,
action_type: Option<LogActionDiscriminant>,
offset: i64, offset: i64,
limit: i64, limit: i64,
) -> Result<Vec<LogEntry>, DatabaseError> { ) -> Result<Vec<LogEntry>, DatabaseError> {
Ok(conn let mut qstr = String::from("SELECT id, actor, payload FROM logs ");
.prepare("SELECT id, actor, target, actiontype, payload FROM logs ORDER BY id DESC LIMIT ?1 OFFSET ?2")? if action_type.is_some() {
.query_map((limit, offset), |r| { qstr += "WHERE actiontype = $3 "
let payload: String = r.get(4)?; }
Ok(LogEntry { qstr += "ORDER BY id DESC LIMIT $1 OFFSET $2";
id: r.get(0)?,
actor: User::get_by_id(conn, r.get(1)?).unwrap(), let q = sqlx::query(&qstr).bind(limit).bind(offset);
data: serde_json::from_str(&payload).unwrap(),
}) let rows = match action_type {
})? Some(at) => {
.collect::<Result<Vec<LogEntry>, _>>()?) let atstr: &'static str = at.into();
q.bind(atstr).fetch_all(&mut *conn).await?
}
None => q.fetch_all(&mut *conn).await?,
};
let mut entries = Vec::new();
for row in rows {
let payload: serde_json::Value = row.try_get("payload")?;
let actor_id: Uuid = row.try_get("actor")?;
entries.push(LogEntry {
id: row.try_get("id")?,
actor: User::get_by_id(&mut *conn, actor_id).await.unwrap(),
data: serde_json::from_value(payload).unwrap(),
});
}
Ok(entries)
} }
} }
// #[derive(Debug, thiserror::Error)] #[derive(Debug, IntoStaticStr, Serialize, Deserialize, VariantNames, EnumDiscriminants)]
// pub enum LogError {} #[strum_discriminants(derive(EnumIter, IntoStaticStr, Serialize, Deserialize))]
#[strum_discriminants(name(LogActionDiscriminant))]
#[derive(Debug, IntoStaticStr, Serialize, Deserialize)]
pub enum LogAction { pub enum LogAction {
Initialize, Initialize,
RegenInfradmin, RegenInfradmin,
@@ -177,3 +216,45 @@ impl LogAction {
} }
} }
} }
impl LogActionDiscriminant {
pub fn human_readable(&self) -> &'static str {
use LogActionDiscriminant as LAD;
match self {
LAD::Initialize => "Mnemosyne Initialization",
LAD::RegenInfradmin => "Infradmin Regeneration",
LAD::CreateUser => "User Creation",
LAD::ManuallyChangeUsersPassword => "Password Override",
LAD::CreateTag => "Tag Creation",
LAD::RenameTag => "Tag Rename",
LAD::DeleteTag => "Tag Deletion",
LAD::CreatePerson => "Person Creation",
LAD::ChangeUserHandle => "User Handle Change",
LAD::AddPersonName => "Person Name Addition",
LAD::DeletePersonName => "Person Name Deletion",
LAD::SetPersonPrimaryName => "Person Primary Name Set",
LAD::CreateQuote => "Quote Creation",
LAD::ManuallyRevokeSession => "Manual Session Revocation",
}
}
pub fn icon(&self) -> &'static str {
use LogActionDiscriminant as LAD;
match self {
LAD::Initialize => icons::LINE_DOT_RIGHT_HORIZONTAL,
// LAD::RegenInfradmin =>
// LAD::CreateUser =>
// LAD::ManuallyChangeUsersPassword =>
// LAD::CreateTag =>
// LAD::RenameTag =>
// LAD::DeleteTag =>
// LAD::CreatePerson =>
// LAD::ChangeUserHandle =>
// LAD::AddPersonName =>
// LAD::DeletePersonName =>
// LAD::SetPersonPrimaryName =>
// LAD::CreateQuote =>
// LAD::ManuallyRevokeSession =>
_ => icons::GIT_COMMIT_VERTICAL,
}
}
}

View File

@@ -1,6 +1,7 @@
use std::error::Error; use std::error::Error;
use axum::Router; use axum::Router;
use sqlx::PgPool;
use tokio::net::TcpListener; use tokio::net::TcpListener;
mod api; mod api;
@@ -14,39 +15,30 @@ mod tags;
mod users; mod users;
mod web; mod web;
/// Mnemosyne, the mother of the nine muses
const DEFAULT_PORT: u16 = 0x9999; // 39321
/// The string to be returned alongside HTTP 500 /// The string to be returned alongside HTTP 500
const ISE_MSG: &str = "Internal server error"; const ISE_MSG: &str = "Internal server error";
#[derive(Debug, Clone)]
pub struct MnemoState {
pool: PgPool,
}
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> { async fn main() -> Result<(), Box<dyn Error>> {
if let Err(e) = dotenvy::dotenv() config::dotenv()?;
&& !e.not_found() config::env_logger()?;
{
return Err(e.into());
}
env_logger::builder()
.filter_level(log::LevelFilter::Info)
.parse_default_env()
.format(config::envlogger_write_format)
.init();
database::migrations()?; let pool = config::init_pool().await?;
sqlx::migrate!("src/database/migrations").run(&pool).await?;
log::info!("Migrations applied successfully.");
users::auth::init_password_dummies(); users::auth::init_password_dummies();
users::setup::initialise_reserved_users_if_needed()?; users::setup::initialise_reserved_users_if_needed(&pool).await?;
let port = match std::env::var("PORT") { let port = config::port()?;
Ok(p) => p.parse::<u16>()?,
Err(e) => match e {
std::env::VarError::NotPresent => DEFAULT_PORT,
_ => return Err(e)?,
},
};
let r = Router::new() let r = Router::new()
.merge(api::api_router()) .merge(api::api_router())
.merge(web::web_router()); .merge(web::web_router())
.with_state(MnemoState { pool });
let l = TcpListener::bind(format!("0.0.0.0:{port}")).await?; let l = TcpListener::bind(format!("0.0.0.0:{port}")).await?;
log::info!("Listener bound to {}", l.local_addr()?); log::info!("Listener bound to {}", l.local_addr()?);

View File

@@ -2,8 +2,8 @@ use axum::{
http::StatusCode, http::StatusCode,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use rusqlite::{Connection, OptionalExtension};
use serde::Serialize; use serde::Serialize;
use sqlx::{PgConnection, Row};
use uuid::Uuid; use uuid::Uuid;
use crate::database::DatabaseError; use crate::database::DatabaseError;
@@ -12,7 +12,6 @@ use crate::database::DatabaseError;
pub struct Person { pub struct Person {
pub id: Uuid, pub id: Uuid,
pub primary_name: String, pub primary_name: String,
pub created_by: Uuid,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -20,7 +19,6 @@ pub struct Name {
pub id: Uuid, pub id: Uuid,
pub is_primary: bool, pub is_primary: bool,
pub person_id: Uuid, pub person_id: Uuid,
pub created_by: Uuid,
pub name: String, pub name: String,
} }
@@ -39,169 +37,213 @@ pub enum PersonError {
} }
impl Person { impl Person {
pub fn total_count(conn: &Connection) -> Result<i64, PersonError> { pub async fn total_count(conn: &mut PgConnection) -> Result<i64, PersonError> {
Ok(conn.query_row("SELECT COUNT(*) FROM persons", (), |r| r.get(0))?) let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM persons")
} .fetch_one(conn)
pub fn get_all(conn: &Connection) -> Result<Vec<Person>, PersonError> { .await?;
Ok(conn Ok(count)
.prepare("SELECT p.id, p.created_by, n.name FROM persons p JOIN names n ON p.id = n.person_id AND n.is_primary = 1")?
.query_map((), |r| {
Ok(Person {
id: r.get(0)?,
created_by: r.get(1)?,
primary_name: r.get(2)?,
})
})?
.collect::<Result<Vec<Person>, _>>()?)
} }
pub fn get_by_id(conn: &Connection, id: Uuid) -> Result<Person, PersonError> { pub async fn get_all(conn: &mut PgConnection) -> Result<Vec<Person>, PersonError> {
let res = conn let rows = sqlx::query(
.prepare("SELECT p.created_by, n.name FROM persons p JOIN names n ON p.id = n.person_id AND n.is_primary = 1 WHERE p.id = ?1")? "SELECT p.id, n.name FROM persons p JOIN names n ON p.id = n.person_id AND n.is_primary = true"
.query_one((&id,), |r| { )
Ok(Person { .fetch_all(conn)
id, .await?;
created_by: r.get(0)?,
primary_name: r.get(1)?, let mut persons = Vec::with_capacity(rows.len());
}) for r in rows {
}) persons.push(Person {
.optional()?; id: r.try_get("id")?,
match res { primary_name: r.try_get("name")?,
Some(p) => Ok(p), });
}
Ok(persons)
}
pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> Result<Person, PersonError> {
let row = sqlx::query(
"SELECT n.name FROM persons p JOIN names n ON p.id = n.person_id AND n.is_primary = true WHERE p.id = $1"
)
.bind(id)
.fetch_optional(conn)
.await?;
match row {
Some(r) => Ok(Person {
id,
primary_name: r.try_get("name")?,
}),
None => Err(PersonError::NoPersonWithId(id)), None => Err(PersonError::NoPersonWithId(id)),
} }
} }
pub fn get_in_quote_count(&self, conn: &Connection) -> Result<i64, PersonError> { pub async fn get_in_quote_count(&self, conn: &mut PgConnection) -> Result<i64, PersonError> {
Ok(conn let count: i64 = sqlx::query_scalar(
.prepare( r#"
r#" SELECT COUNT(DISTINCT l.quote_id)
SELECT COUNT(DISTINCT l.quote_id) AS quote_count FROM lines l JOIN line_authors la ON l.id = la.line_id
FROM lines l WHERE l.name_id IN ( WHERE la.name_id IN (
SELECT id FROM names WHERE person_id = ?1 SELECT id FROM names WHERE person_id = $1
);"#, );"#,
)? )
.query_one((self.id,), |r| Ok(r.get(0)?))?) .bind(self.id)
.fetch_one(conn)
.await?;
Ok(count)
} }
pub fn get_all_names(&self, conn: &Connection) -> Result<Vec<Name>, PersonError> { pub async fn get_all_names(&self, conn: &mut PgConnection) -> Result<Vec<Name>, PersonError> {
Ok(conn let rows = sqlx::query(
.prepare("SELECT id, is_primary, person_id, created_by, name FROM names WHERE person_id = ?1 ORDER BY id")? "SELECT id, is_primary, person_id, name FROM names WHERE person_id = $1 ORDER BY id",
.query_map((&self.id,), |r| { )
Ok(Name { .bind(self.id)
id: r.get(0)?, .fetch_all(conn)
is_primary: r.get(1)?, .await?;
person_id: r.get(2)?,
created_by: r.get(3)?, let mut names = Vec::with_capacity(rows.len());
name: r.get(4)?, for r in rows {
}) names.push(Name {
})? id: r.try_get("id")?,
.collect::<Result<Vec<Name>, _>>()?) is_primary: r.try_get("is_primary")?,
person_id: r.try_get("person_id")?,
name: r.try_get("name")?,
});
}
Ok(names)
} }
pub fn add_name( pub async fn add_name(
&self, &self,
conn: &Connection, conn: &mut PgConnection,
name: String, name: String,
created_by: Uuid, _created_by: Uuid,
) -> Result<Name, PersonError> { ) -> Result<Name, PersonError> {
let id = Uuid::now_v7(); let id = Uuid::now_v7();
conn.prepare("INSERT INTO names VALUES (?1, ?2, ?3, ?4, ?5)")? sqlx::query("INSERT INTO names (id, is_primary, person_id, name) VALUES ($1, $2, $3, $4)")
.execute((id, 0, self.id, created_by, &name))?; .bind(id)
.bind(false)
.bind(self.id)
.bind(&name)
.execute(conn)
.await?;
Ok(Name { Ok(Name {
id, id,
is_primary: false, is_primary: false,
person_id: self.id, person_id: self.id,
created_by,
name, name,
}) })
} }
pub fn create( pub async fn create(
conn: &Connection, conn: &mut PgConnection,
primary_name: String, primary_name: String,
created_by: Uuid, _created_by: Uuid,
) -> Result<Person, PersonError> { ) -> Result<Person, PersonError> {
let person_id = Uuid::now_v7(); let person_id = Uuid::now_v7();
let name_id = Uuid::now_v7(); let name_id = Uuid::now_v7();
conn.prepare("INSERT INTO persons(id, created_by) VALUES (?1, ?2)")? sqlx::query("INSERT INTO persons(id) VALUES ($1)")
.execute((person_id, created_by))?; .bind(person_id)
conn.prepare("INSERT INTO names VALUES (?1, ?2, ?3, ?4, ?5)")? .execute(&mut *conn)
.execute((name_id, 1, person_id, created_by, &primary_name))?; .await?;
sqlx::query("INSERT INTO names (id, is_primary, person_id, name) VALUES ($1, $2, $3, $4)")
.bind(name_id)
.bind(true)
.bind(person_id)
.bind(&primary_name)
.execute(&mut *conn)
.await?;
Ok(Person { Ok(Person {
id: person_id, id: person_id,
primary_name, primary_name,
created_by,
}) })
} }
} }
impl Name { impl Name {
pub fn get_by_id(conn: &Connection, id: Uuid) -> Result<Name, PersonError> { pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> Result<Name, PersonError> {
let res = conn let row = sqlx::query("SELECT id, is_primary, person_id, name FROM names WHERE id = $1")
.prepare("SELECT id, is_primary, person_id, created_by, name FROM names WHERE id = ?1")? .bind(id)
.query_one((&id,), |r| { .fetch_optional(conn)
Ok(Name { .await?;
id: r.get(0)?,
is_primary: r.get(1)?, match row {
person_id: r.get(2)?, Some(r) => Ok(Name {
created_by: r.get(3)?, id: r.try_get("id")?,
name: r.get(4)?, is_primary: r.try_get("is_primary")?,
}) person_id: r.try_get("person_id")?,
}) name: r.try_get("name")?,
.optional()?; }),
match res {
Some(n) => Ok(n),
None => Err(PersonError::NoNameWithId(id)), None => Err(PersonError::NoNameWithId(id)),
} }
} }
pub fn get_all(conn: &Connection) -> Result<Vec<Name>, PersonError> {
Ok(conn pub async fn get_all(conn: &mut PgConnection) -> Result<Vec<Name>, PersonError> {
.prepare("SELECT id, is_primary, person_id, created_by, name FROM names")? let rows = sqlx::query("SELECT id, is_primary, person_id, name FROM names")
.query_map((), |r| { .fetch_all(conn)
Ok(Name { .await?;
id: r.get(0)?,
is_primary: r.get(1)?, let mut names = Vec::with_capacity(rows.len());
person_id: r.get(2)?, for r in rows {
created_by: r.get(3)?, names.push(Name {
name: r.get(4)?, id: r.try_get("id")?,
}) is_primary: r.try_get("is_primary")?,
})? person_id: r.try_get("person_id")?,
.collect::<Result<Vec<Name>, _>>()?) name: r.try_get("name")?,
});
}
Ok(names)
} }
pub fn times_attributed(&self, conn: &Connection) -> Result<i64, PersonError> {
Ok(conn pub async fn times_attributed(&self, conn: &mut PgConnection) -> Result<i64, PersonError> {
.prepare("SELECT COUNT(*) FROM lines WHERE name_id = ?1")? let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM line_authors WHERE name_id = $1")
.query_row((&self.id,), |r| r.get(0))?) .bind(self.id)
.fetch_one(conn)
.await?;
Ok(count)
} }
pub fn delete(self, conn: &Connection) -> Result<(), PersonError> {
conn.prepare("DELETE FROM names WHERE id = ?1")? pub async fn delete(self, conn: &mut PgConnection) -> Result<(), PersonError> {
.execute((&self.id,))?; sqlx::query("DELETE FROM names WHERE id = $1")
.bind(self.id)
.execute(conn)
.await?;
Ok(()) Ok(())
} }
pub fn set_primary(&mut self, conn: &Connection) -> Result<(), PersonError> {
pub async fn set_primary(&mut self, conn: &mut PgConnection) -> Result<(), PersonError> {
if self.is_primary { if self.is_primary {
return Err(PersonError::AlreadyPrimary); return Err(PersonError::AlreadyPrimary);
} }
conn.prepare("UPDATE names SET is_primary = 0 WHERE person_id = ?1 AND is_primary = 1")?
.execute((&self.person_id,))?; sqlx::query(
conn.prepare("UPDATE names SET is_primary = 1 WHERE id = ?1")? "UPDATE names SET is_primary = false WHERE person_id = $1 AND is_primary = true",
.execute((&self.id,))?; )
.bind(self.person_id)
.execute(&mut *conn)
.await?;
sqlx::query("UPDATE names SET is_primary = true WHERE id = $1")
.bind(self.id)
.execute(&mut *conn)
.await?;
self.is_primary = true; self.is_primary = true;
Ok(()) Ok(())
} }
} }
impl From<rusqlite::Error> for PersonError { impl From<sqlx::Error> for PersonError {
fn from(error: rusqlite::Error) -> Self { fn from(error: sqlx::Error) -> Self {
if let rusqlite::Error::SqliteFailure(e, Some(msg)) = &error if let sqlx::Error::Database(err) = &error {
&& e.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE if err.is_unique_violation() && err.message().contains("name") {
&& msg.contains("name") return PersonError::NameAlreadyExists;
{ }
return PersonError::NameAlreadyExists;
} }
PersonError::DatabaseError(DatabaseError::from(error)) PersonError::DatabaseError(DatabaseError::from(error))
} }

View File

@@ -1,7 +1,10 @@
use axum::{http::StatusCode, response::IntoResponse}; use axum::{
use chrono::{DateTime, FixedOffset, Utc}; http::StatusCode,
use rusqlite::{Connection, OptionalExtension}; response::{IntoResponse, Response},
};
use chrono::{DateTime, NaiveDateTime, Utc};
use serde::Serialize; use serde::Serialize;
use sqlx::{PgConnection, Row};
use uuid::Uuid; use uuid::Uuid;
use crate::{database::DatabaseError, persons::Name}; use crate::{database::DatabaseError, persons::Name};
@@ -10,7 +13,7 @@ use crate::{database::DatabaseError, persons::Name};
pub struct Quote { pub struct Quote {
pub id: Uuid, pub id: Uuid,
pub lines: Vec<QuoteLine>, pub lines: Vec<QuoteLine>,
pub timestamp: DateTime<FixedOffset>, pub timestamp: NaiveDateTime,
pub location: Option<String>, pub location: Option<String>,
pub context: Option<String>, pub context: Option<String>,
pub created_by: Uuid, pub created_by: Uuid,
@@ -20,7 +23,7 @@ pub struct Quote {
#[derive(Serialize)] #[derive(Serialize)]
pub struct QuoteLine { pub struct QuoteLine {
pub id: Uuid, pub id: Uuid,
pub attribution: Name, pub attribution: Vec<Name>,
pub content: String, pub content: String,
} }
@@ -44,52 +47,65 @@ impl Quote {
} }
impl Quote { impl Quote {
pub fn total_count(conn: &Connection) -> Result<i64, QuoteError> { pub async fn total_count(conn: &mut PgConnection) -> Result<i64, QuoteError> {
Ok(conn.query_row("SELECT COUNT(*) FROM quotes", (), |r| r.get(0))?) let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM quotes")
.fetch_one(conn)
.await?;
Ok(count)
} }
pub fn get_by_id(conn: &Connection, id: Uuid) -> Result<Quote, QuoteError> {
let quotemain = conn
.prepare(
"SELECT timestamp, location, context, created_by, public FROM quotes WHERE id = ?1",
)?
.query_row((id,), |r| {
Ok((
r.get::<_, DateTime<FixedOffset>>(0)?,
r.get::<_, Option<String>>(1)?,
r.get::<_, Option<String>>(2)?,
r.get::<_, Uuid>(3)?,
r.get::<_, bool>(4)?,
))
})
.optional()?;
let (timestamp, location, context, created_by, public) = match quotemain { pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> Result<Quote, QuoteError> {
Some(data) => data, let quotemain = sqlx::query(
"SELECT timestamp, location, context, created_by, public FROM quotes WHERE id = $1",
)
.bind(id)
.fetch_optional(&mut *conn)
.await?;
let row = match quotemain {
Some(row) => row,
None => return Err(QuoteError::NoQuoteWithId(id)), None => return Err(QuoteError::NoQuoteWithId(id)),
}; };
let lines = conn let timestamp: NaiveDateTime = row.try_get("timestamp")?;
.prepare( let location: Option<String> = row.try_get("location")?;
r#" let context: Option<String> = row.try_get("context")?;
SELECT l.id, l.content, n.id, n.is_primary, n.person_id, n.created_by, n.name let created_by: Uuid = row.try_get("created_by")?;
FROM lines AS l JOIN names AS n ON l.name_id = n.id let public: bool = row.try_get("public")?;
WHERE l.quote_id = ?1 ORDER BY l.ordering
"#, let line_rows = sqlx::query(
)? r#"
.query_map((id,), |r| { SELECT l.id, l.content, n.id as name_id, n.is_primary, n.person_id, n.name
Ok(QuoteLine { FROM lines AS l
id: r.get(0)?, JOIN line_authors AS la ON l.id = la.line_id
content: r.get(1)?, JOIN names AS n ON la.name_id = n.id
attribution: Name { WHERE l.quote_id = $1 ORDER BY l.ordering
id: r.get(2)?, "#,
is_primary: r.get(3)?, )
person_id: r.get(4)?, .bind(id)
created_by: r.get(5)?, .fetch_all(&mut *conn)
name: r.get(6)?, .await?;
},
}) let mut lines: Vec<QuoteLine> = Vec::new();
})? for r in line_rows {
.collect::<Result<Vec<QuoteLine>, _>>()?; let line_id: Uuid = r.try_get("id")?;
let name = Name {
id: r.try_get("name_id")?,
is_primary: r.try_get("is_primary")?,
person_id: r.try_get("person_id")?,
name: r.try_get("name")?,
};
if let Some(last) = lines.last_mut().filter(|l| l.id == line_id) {
last.attribution.push(name);
} else {
lines.push(QuoteLine {
id: line_id,
content: r.try_get("content")?,
attribution: vec![name],
});
}
}
Ok(Quote { Ok(Quote {
id, id,
@@ -101,60 +117,102 @@ impl Quote {
public, public,
}) })
} }
pub fn get_newest(conn: &Connection) -> Result<Option<Quote>, QuoteError> {
let id: Option<Uuid> = conn
.query_row("SELECT id FROM quotes ORDER BY id DESC LIMIT 1", (), |r| {
r.get(0)
})
.optional()?;
match id { pub async fn get_newest(conn: &mut PgConnection) -> Result<Option<Quote>, QuoteError> {
Some(id) => Ok(Some(Self::get_by_id(conn, id)?)), let id_opt: Option<Uuid> =
sqlx::query_scalar("SELECT id FROM quotes ORDER BY id DESC LIMIT 1")
.fetch_optional(&mut *conn)
.await?;
match id_opt {
Some(id) => Ok(Some(Self::get_by_id(conn, id).await?)),
None => Ok(None), None => Ok(None),
} }
} }
pub fn get_newest_public(conn: &Connection) -> Result<Option<Quote>, QuoteError> {
let id: Option<Uuid> = conn
.query_row(
"SELECT id FROM quotes WHERE public = 1 ORDER BY id DESC LIMIT 1",
(),
|r| r.get(0),
)
.optional()?;
match id { pub async fn get_newest_public(conn: &mut PgConnection) -> Result<Option<Quote>, QuoteError> {
Some(id) => Ok(Some(Self::get_by_id(conn, id)?)), let id_opt: Option<Uuid> = sqlx::query_scalar(
"SELECT id FROM quotes WHERE public = true ORDER BY id DESC LIMIT 1",
)
.fetch_optional(&mut *conn)
.await?;
match id_opt {
Some(id) => Ok(Some(Self::get_by_id(conn, id).await?)),
None => Ok(None), None => Ok(None),
} }
} }
pub fn get_random(conn: &Connection) -> Result<Option<Quote>, QuoteError> {
let id: Option<Uuid> = conn
.query_row("SELECT id FROM quotes ORDER BY RANDOM() LIMIT 1", (), |r| {
r.get(0)
})
.optional()?;
match id { pub async fn get_random(conn: &mut PgConnection) -> Result<Option<Quote>, QuoteError> {
Some(id) => Ok(Some(Self::get_by_id(conn, id)?)), let id_opt: Option<Uuid> =
sqlx::query_scalar("SELECT id FROM quotes ORDER BY RANDOM() LIMIT 1")
.fetch_optional(&mut *conn)
.await?;
match id_opt {
Some(id) => Ok(Some(Self::get_by_id(conn, id).await?)),
None => Ok(None), None => Ok(None),
} }
} }
pub fn get_chronological_offset(
conn: &Connection, pub async fn get_chronological_offset(
conn: &mut PgConnection,
offset: i64, offset: i64,
limit: i64, limit: i64,
) -> Result<Vec<Quote>, QuoteError> { ) -> Result<Vec<Quote>, QuoteError> {
let ids = conn let ids: Vec<Uuid> =
.prepare("SELECT id FROM quotes ORDER BY timestamp DESC LIMIT ?1 OFFSET ?2")? sqlx::query_scalar("SELECT id FROM quotes ORDER BY timestamp DESC LIMIT $1 OFFSET $2")
.query_map((limit, offset), |r| r.get(0))? .bind(limit)
.collect::<Result<Vec<Uuid>, _>>()?; .bind(offset)
.fetch_all(&mut *conn)
.await?;
ids.iter().map(|id| Self::get_by_id(conn, *id)).collect() let mut quotes = Vec::with_capacity(ids.len());
for id in ids {
quotes.push(Self::get_by_id(&mut *conn, id).await?);
}
Ok(quotes)
} }
pub fn create(
conn: &Connection, pub async fn search_query_count(
lines: Vec<(String, Name)>, conn: &mut PgConnection,
timestamp: DateTime<FixedOffset>, query: &str,
) -> Result<i64, QuoteError> {
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM quotes WHERE fts ILIKE '%' || $1 || '%'")
.bind(query)
.fetch_one(&mut *conn)
.await?;
Ok(count)
}
pub async fn get_by_search_query(
conn: &mut PgConnection,
query: &str,
offset: i64,
limit: i64,
) -> Result<Vec<Quote>, QuoteError> {
let ids: Vec<Uuid> = sqlx::query_scalar(
"SELECT id FROM quotes WHERE fts ILIKE '%' || $1 || '%' LIMIT $2 OFFSET $3",
)
.bind(query)
.bind(limit)
.bind(offset)
.fetch_all(&mut *conn)
.await?;
let mut quotes = Vec::with_capacity(ids.len());
for id in ids {
quotes.push(Self::get_by_id(&mut *conn, id).await?);
}
Ok(quotes)
}
pub async fn create(
conn: &mut PgConnection,
lines: Vec<(String, Vec<Name>)>,
timestamp: NaiveDateTime,
context: Option<String>, context: Option<String>,
location: Option<String>, location: Option<String>,
created_by: Uuid, created_by: Uuid,
@@ -165,27 +223,52 @@ impl Quote {
} }
let quote_id = Uuid::now_v7(); let quote_id = Uuid::now_v7();
let lines: Vec<(Uuid, String, Name)> = lines let lines: Vec<(Uuid, String, Vec<Name>)> = lines
.into_iter() .into_iter()
.map(|(c, a)| (Uuid::now_v7(), c, a)) .map(|(c, a)| (Uuid::now_v7(), c, a))
.collect(); .collect();
let mut quote_stmt = conn.prepare( sqlx::query(
r#" r#"
INSERT INTO quotes (id, timestamp, location, context, created_by, public) INSERT INTO quotes (id, timestamp, location, context, created_by, public)
VALUES (?1, ?2, ?3, ?4, ?5, ?6) VALUES ($1, $2, $3, $4, $5, $6)
"#, "#,
)?; )
quote_stmt.execute((quote_id, timestamp, &location, &context, created_by, public))?; .bind(quote_id)
.bind(timestamp)
.bind(&location)
.bind(&context)
.bind(created_by)
.bind(public)
.execute(&mut *conn)
.await?;
let mut line_stmt = conn.prepare(
r#"
INSERT INTO lines (id, quote_id, content, name_id, ordering)
VALUES (?1, ?2, ?3, ?4, ?5)
"#,
)?;
for (ordering, (id, content, attr)) in lines.iter().enumerate() { for (ordering, (id, content, attr)) in lines.iter().enumerate() {
line_stmt.execute((id, quote_id, content, attr.id, ordering as i64))?; sqlx::query(
r#"
INSERT INTO lines (id, quote_id, content, ordering)
VALUES ($1, $2, $3, $4)
"#,
)
.bind(id)
.bind(quote_id)
.bind(content)
.bind(ordering as i16)
.execute(&mut *conn)
.await?;
for a in attr {
sqlx::query(
r#"
INSERT INTO line_authors (line_id, name_id)
VALUES ($1, $2)
"#,
)
.bind(id)
.bind(a.id)
.execute(&mut *conn)
.await?;
}
} }
Ok(Quote { Ok(Quote {
@@ -207,14 +290,14 @@ impl Quote {
} }
} }
impl From<rusqlite::Error> for QuoteError { impl From<sqlx::Error> for QuoteError {
fn from(error: rusqlite::Error) -> Self { fn from(error: sqlx::Error) -> Self {
QuoteError::DatabaseError(DatabaseError::from(error)) QuoteError::DatabaseError(DatabaseError::from(error))
} }
} }
impl IntoResponse for QuoteError { impl IntoResponse for QuoteError {
fn into_response(self) -> axum::response::Response { fn into_response(self) -> Response {
match self { match self {
Self::DatabaseError(e) => e.into_response(), Self::DatabaseError(e) => e.into_response(),
Self::NoQuoteWithId(_) => (StatusCode::BAD_REQUEST, self.to_string()).into_response(), Self::NoQuoteWithId(_) => (StatusCode::BAD_REQUEST, self.to_string()).into_response(),

View File

@@ -4,12 +4,8 @@ use axum::{
http::StatusCode, http::StatusCode,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use rusqlite::{
Connection, OptionalExtension, Result as RusqliteResult, ToSql,
ffi::SQLITE_CONSTRAINT_UNIQUE,
types::{FromSql, FromSqlError, FromSqlResult, ToSqlOutput, ValueRef},
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{PgConnection, Row};
use uuid::Uuid; use uuid::Uuid;
use crate::database::DatabaseError; use crate::database::DatabaseError;
@@ -21,70 +17,95 @@ pub struct Tag {
} }
impl Tag { impl Tag {
pub fn total_count(conn: &Connection) -> Result<i64, TagError> { pub async fn total_count(conn: &mut PgConnection) -> Result<i64, TagError> {
Ok(conn.query_row("SELECT COUNT(*) FROM tags", (), |r| r.get(0))?) let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM tags")
.fetch_one(conn)
.await?;
Ok(count)
} }
pub fn get_all(conn: &Connection) -> Result<Vec<Tag>, TagError> {
Ok(conn pub async fn get_all(conn: &mut PgConnection) -> Result<Vec<Tag>, TagError> {
.prepare("SELECT id, tagname FROM tags")? let rows = sqlx::query("SELECT id, name FROM tags")
.query_map((), |r| { .fetch_all(conn)
Ok(Tag { .await?;
id: r.get(0)?,
name: r.get(1)?, let mut tags = Vec::with_capacity(rows.len());
}) for r in rows {
})? let name_str: String = r.try_get("name")?;
.collect::<Result<Vec<Tag>, _>>()?) tags.push(Tag {
id: r.try_get("id")?,
name: TagName::new(name_str)?,
});
}
Ok(tags)
} }
pub fn get_by_id(conn: &Connection, id: Uuid) -> Result<Tag, TagError> {
let res = conn pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> Result<Tag, TagError> {
.prepare("SELECT tagname FROM tags WHERE id = ?1")? let res = sqlx::query("SELECT name FROM tags WHERE id = $1")
.query_one((&id,), |r| { .bind(id)
.fetch_optional(conn)
.await?;
match res {
Some(r) => {
let name_str: String = r.try_get("name")?;
Ok(Tag { Ok(Tag {
id, id,
name: r.get(0)?, name: TagName::new(name_str)?,
}) })
}) }
.optional()?;
match res {
Some(t) => Ok(t),
None => Err(TagError::NoTagWithId(id)), None => Err(TagError::NoTagWithId(id)),
} }
} }
pub fn get_tagged_quotes_count(&self, conn: &Connection) -> Result<i64, TagError> {
Ok(conn pub async fn get_tagged_quotes_count(&self, conn: &mut PgConnection) -> Result<i64, TagError> {
.prepare("SELECT COUNT(*) FROM quote_tags WHERE tag_id = ?1")? let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM quote_tags WHERE tag_id = $1")
.query_one((self.id,), |r| Ok(r.get(0)?))?) .bind(self.id)
.fetch_one(conn)
.await?;
Ok(count)
} }
pub fn get_by_name(conn: &Connection, name: TagName) -> Result<Tag, TagError> {
let res = conn pub async fn get_by_name(conn: &mut PgConnection, name: TagName) -> Result<Tag, TagError> {
.prepare("SELECT id, tagname FROM tags WHERE tagname = ?1")? let res = sqlx::query("SELECT id FROM tags WHERE name = $1")
.query_one((&name,), |r| { .bind(name.as_str())
Ok(Tag { .fetch_optional(conn)
id: r.get(0)?, .await?;
name: r.get(1)?,
})
})
.optional()?;
match res { match res {
Some(u) => Ok(u), Some(r) => Ok(Tag {
id: r.try_get("id")?,
name,
}),
None => Err(TagError::NoTagWithName(name)), None => Err(TagError::NoTagWithName(name)),
} }
} }
pub fn create(conn: &Connection, name: TagName) -> Result<Tag, TagError> {
pub async fn create(conn: &mut PgConnection, name: TagName) -> Result<Tag, TagError> {
let id = Uuid::now_v7(); let id = Uuid::now_v7();
conn.prepare("INSERT INTO tags(id, tagname) VALUES (?1, ?2)")? sqlx::query("INSERT INTO tags(id, name) VALUES ($1, $2)")
.execute((id, &name))?; .bind(id)
.bind(name.as_str())
.execute(conn)
.await?;
Ok(Tag { id, name }) Ok(Tag { id, name })
} }
pub fn rename(&mut self, conn: &Connection, name: TagName) -> Result<(), TagError> {
conn.prepare("UPDATE tags SET tagname = ?1 WHERE id = ?2")? pub async fn rename(&mut self, conn: &mut PgConnection, name: TagName) -> Result<(), TagError> {
.execute((&name, self.id))?; sqlx::query("UPDATE tags SET name = $1 WHERE id = $2")
.bind(name.as_str())
.bind(self.id)
.execute(conn)
.await?;
self.name = name; self.name = name;
Ok(()) Ok(())
} }
pub fn delete(self, conn: &Connection) -> Result<(), TagError> {
conn.prepare("DELETE FROM tags WHERE id = ?1")? pub async fn delete(self, conn: &mut PgConnection) -> Result<(), TagError> {
.execute((self.id,))?; sqlx::query("DELETE FROM tags WHERE id = $1")
.bind(self.id)
.execute(conn)
.await?;
Ok(()) Ok(())
} }
} }
@@ -102,17 +123,18 @@ pub enum TagError {
#[error("Database error: {0}")] #[error("Database error: {0}")]
DatabaseError(#[from] DatabaseError), DatabaseError(#[from] DatabaseError),
} }
impl From<rusqlite::Error> for TagError {
fn from(error: rusqlite::Error) -> Self { impl From<sqlx::Error> for TagError {
if let rusqlite::Error::SqliteFailure(e, Some(msg)) = &error fn from(error: sqlx::Error) -> Self {
&& e.extended_code == SQLITE_CONSTRAINT_UNIQUE if let sqlx::Error::Database(err) = &error {
&& msg.contains("tagname") if err.is_unique_violation() && err.message().contains("tagname") {
{ return TagError::TagAlreadyExists;
return TagError::TagAlreadyExists; }
} }
TagError::DatabaseError(DatabaseError::from(error)) TagError::DatabaseError(DatabaseError::from(error))
} }
} }
impl IntoResponse for TagError { impl IntoResponse for TagError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
match self { match self {
@@ -125,7 +147,8 @@ impl IntoResponse for TagError {
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type)]
#[sqlx(transparent)]
#[serde(into = "String")] #[serde(into = "String")]
#[serde(try_from = "String")] #[serde(try_from = "String")]
pub struct TagName(String); pub struct TagName(String);
@@ -224,18 +247,6 @@ impl From<TagName> for String {
} }
} }
impl ToSql for TagName {
fn to_sql(&self) -> RusqliteResult<ToSqlOutput<'_>> {
self.0.to_sql()
}
}
impl FromSql for TagName {
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
TagName::from_str(value.as_str()?).map_err(|e| FromSqlError::Other(Box::new(e)))
}
}
#[test] #[test]
#[should_panic] #[should_panic]
fn tagname_leading_dash_fail() { fn tagname_leading_dash_fail() {

View File

@@ -7,12 +7,12 @@ use axum::{
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use base64::{Engine, prelude::BASE64_STANDARD}; use base64::{Engine, prelude::BASE64_STANDARD};
use rusqlite::OptionalExtension; use sqlx::{PgConnection, Row};
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
ISE_MSG, ISE_MSG,
database::{self, DatabaseError}, database::DatabaseError,
users::{ users::{
User, User,
auth::{ auth::{
@@ -53,8 +53,8 @@ impl IntoResponse for AuthError {
} }
} }
} }
impl From<rusqlite::Error> for AuthError { impl From<sqlx::Error> for AuthError {
fn from(value: rusqlite::Error) -> Self { fn from(value: sqlx::Error) -> Self {
AuthError::DatabaseError(DatabaseError::from(value)) AuthError::DatabaseError(DatabaseError::from(value))
} }
} }
@@ -122,21 +122,27 @@ impl<'a> AuthScheme<'a> {
} }
impl UserAuthenticate for User { impl UserAuthenticate for User {
fn authenticate(headers: &HeaderMap) -> Result<Option<User>, AuthError> { async fn authenticate(
conn: &mut PgConnection,
headers: &HeaderMap,
) -> Result<Option<User>, AuthError> {
let (basic_auth, bearer_auth) = auth_common(headers); let (basic_auth, bearer_auth) = auth_common(headers);
match (basic_auth, bearer_auth) { match (basic_auth, bearer_auth) {
(Some(creds), _) => authenticate_basic(&creds), (Some(creds), _) => authenticate_basic(conn, &creds).await,
(None, Some(token)) => authenticate_bearer(&token), (None, Some(token)) => authenticate_bearer(conn, &token).await,
_ => Ok(None), _ => Ok(None),
} }
} }
} }
impl SessionAuthenticate for Session { impl SessionAuthenticate for Session {
fn authenticate(headers: &HeaderMap) -> Result<Option<Session>, AuthError> { async fn authenticate(
conn: &mut PgConnection,
headers: &HeaderMap,
) -> Result<Option<Session>, AuthError> {
let (_, bearer_auth) = auth_common(headers); let (_, bearer_auth) = auth_common(headers);
if let Some(token) = bearer_auth { if let Some(token) = bearer_auth {
authenticate_bearer_with_session(&token) authenticate_bearer_with_session(conn, &token).await
} else { } else {
Ok(None) Ok(None)
} }
@@ -181,52 +187,71 @@ fn auth_common(headers: &HeaderMap) -> (Option<String>, Option<String>) {
(basic_auth, bearer_auth) (basic_auth, bearer_auth)
} }
fn authenticate_basic(credentials: &str) -> Result<Option<User>, AuthError> { async fn authenticate_basic(
conn: &mut PgConnection,
credentials: &str,
) -> Result<Option<User>, AuthError> {
let decoded = BASE64_STANDARD.decode(credentials)?; let decoded = BASE64_STANDARD.decode(credentials)?;
let credentials_str = String::from_utf8(decoded)?; let credentials_str = String::from_utf8(decoded)?;
let Some((handle, password)) = credentials_str.split_once(':') else { let Some((handle, password)) = credentials_str.split_once(':') else {
return Err(AuthError::InvalidFormat); return Err(AuthError::InvalidFormat);
}; };
authenticate_via_credentials(handle, password) authenticate_via_credentials(conn, handle, password).await
} }
pub fn authenticate_via_credentials(
pub async fn authenticate_via_credentials(
conn: &mut PgConnection,
handle: &str, handle: &str,
password: &str, password: &str,
) -> Result<Option<User>, AuthError> { ) -> Result<Option<User>, AuthError> {
let conn = database::conn()?; let row = sqlx::query("SELECT id, password FROM users WHERE handle = $1")
let user: Option<(Uuid, Option<String>)> = conn .bind(handle)
.prepare("SELECT id, password FROM users WHERE handle = ?1")? .fetch_optional(&mut *conn)
.query_row([handle], |r| Ok((r.get(0)?, r.get(1)?))) .await?;
.optional()?;
match user { match row {
Some((id, Some(passhash))) => match User::match_hash_password(password, &passhash)? { Some(r) => {
true => Ok(Some(User::get_by_id(&conn, id)?)), let id: Uuid = r.try_get("id")?;
false => Err(AuthError::InvalidCredentials), let passhash: Option<String> = r.try_get("password")?;
}, match passhash {
_ => { Some(p) => match User::match_hash_password(password, &p)? {
true => Ok(Some(User::get_by_id(conn, id).await?)),
false => Err(AuthError::InvalidCredentials),
},
None => {
let _ = User::match_hash_password(DUMMY_PASSWORD, &DUMMY_PASSWORD_PHC)?;
Err(AuthError::InvalidCredentials)
}
}
}
None => {
let _ = User::match_hash_password(DUMMY_PASSWORD, &DUMMY_PASSWORD_PHC)?; let _ = User::match_hash_password(DUMMY_PASSWORD, &DUMMY_PASSWORD_PHC)?;
Err(AuthError::InvalidCredentials) Err(AuthError::InvalidCredentials)
} }
} }
} }
fn authenticate_bearer(token: &str) -> Result<Option<User>, AuthError> { async fn authenticate_bearer(
let conn = database::conn().map_err(|e| DatabaseError::from(e))?; conn: &mut PgConnection,
let mut s = Session::get_by_token(&conn, token)?; token: &str,
) -> Result<Option<User>, AuthError> {
let mut s = Session::get_by_token(&mut *conn, token).await?;
if s.is_expired_or_revoked() { if s.is_expired_or_revoked() {
return Err(AuthError::InvalidCredentials); return Err(AuthError::InvalidCredentials);
} }
s.prolong(&conn)?; s.prolong(&mut *conn).await?;
Ok(Some(User::get_by_id(&conn, s.user_id)?)) Ok(Some(User::get_by_id(conn, s.user_id).await?))
} }
fn authenticate_bearer_with_session(token: &str) -> Result<Option<Session>, AuthError> {
let conn = database::conn().map_err(|e| DatabaseError::from(e))?; async fn authenticate_bearer_with_session(
let mut s = Session::get_by_token(&conn, token)?; conn: &mut PgConnection,
token: &str,
) -> Result<Option<Session>, AuthError> {
let mut s = Session::get_by_token(&mut *conn, token).await?;
if s.is_expired_or_revoked() { if s.is_expired_or_revoked() {
return Err(AuthError::InvalidCredentials); return Err(AuthError::InvalidCredentials);
} }
s.prolong(&conn)?; s.prolong(conn).await?;
Ok(Some(s)) Ok(Some(s))
} }

View File

@@ -16,14 +16,22 @@ pub mod implementation;
pub const COOKIE_NAME: &str = "mnemohash"; pub const COOKIE_NAME: &str = "mnemohash";
use sqlx::PgConnection;
pub trait UserAuthenticate { pub trait UserAuthenticate {
fn authenticate(headers: &HeaderMap) -> Result<Option<User>, AuthError>; async fn authenticate(
conn: &mut PgConnection,
headers: &HeaderMap,
) -> Result<Option<User>, AuthError>;
} }
pub trait UserAuthRequired { pub trait UserAuthRequired {
fn required(self) -> Result<User, AuthError>; fn required(self) -> Result<User, AuthError>;
} }
pub trait SessionAuthenticate { pub trait SessionAuthenticate {
fn authenticate(headers: &HeaderMap) -> Result<Option<Session>, AuthError>; async fn authenticate(
conn: &mut PgConnection,
headers: &HeaderMap,
) -> Result<Option<Session>, AuthError>;
} }
pub trait SessionAuthRequired { pub trait SessionAuthRequired {
fn required(self) -> Result<Session, AuthError>; fn required(self) -> Result<Session, AuthError>;

View File

@@ -1,12 +1,9 @@
use std::{fmt::Display, hash::Hash, ops::Deref, str::FromStr}; use std::{fmt::Display, hash::Hash, ops::Deref, str::FromStr};
use rusqlite::{
Result as RusqliteResult,
types::{FromSql, FromSqlError, FromSqlResult, ToSql, ToSqlOutput, ValueRef},
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type)]
#[sqlx(transparent)]
#[serde(into = "String")] #[serde(into = "String")]
#[serde(try_from = "String")] #[serde(try_from = "String")]
pub struct UserHandle(String); pub struct UserHandle(String);
@@ -90,15 +87,3 @@ impl From<UserHandle> for String {
value.0 value.0
} }
} }
impl ToSql for UserHandle {
fn to_sql(&self) -> RusqliteResult<ToSqlOutput<'_>> {
self.0.to_sql()
}
}
impl FromSql for UserHandle {
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
UserHandle::from_str(value.as_str()?).map_err(|e| FromSqlError::Other(Box::new(e)))
}
}

View File

@@ -3,8 +3,8 @@ use axum::{
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use chrono::{DateTime, NaiveDate}; use chrono::{DateTime, NaiveDate};
use rusqlite::{Connection, OptionalExtension, ffi::SQLITE_CONSTRAINT_UNIQUE};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{PgConnection, Row};
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
@@ -45,65 +45,87 @@ pub enum UserError {
} }
impl User { impl User {
pub fn total_count(conn: &Connection) -> Result<i64, UserError> { pub async fn total_count(conn: &mut PgConnection) -> Result<i64, UserError> {
Ok(conn.query_row("SELECT COUNT(*) FROM users", (), |r| r.get(0))?) Ok(sqlx::query_scalar("SELECT COUNT(*) FROM users")
.fetch_one(conn)
.await?)
} }
pub fn get_by_id(conn: &Connection, id: Uuid) -> Result<User, UserError> {
let res = conn pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> Result<User, UserError> {
.prepare("SELECT handle FROM users WHERE id = ?1")? let res = sqlx::query("SELECT handle FROM users WHERE id = $1")
.query_one((&id,), |r| { .bind(id)
.fetch_optional(conn)
.await?;
match res {
Some(r) => {
let handle_str: String = r.try_get("handle")?;
Ok(User { Ok(User {
id, id,
handle: r.get(0)?, handle: UserHandle::new(&handle_str)?,
}) })
}) }
.optional()?;
match res {
Some(u) => Ok(u),
None => Err(UserError::NoUserWithId(id)), None => Err(UserError::NoUserWithId(id)),
} }
} }
pub fn get_by_handle(conn: &Connection, handle: UserHandle) -> Result<User, UserError> {
let res = conn pub async fn get_by_handle(
.prepare("SELECT id, handle FROM users WHERE handle = ?1")? conn: &mut PgConnection,
.query_one((&handle,), |r| { handle: UserHandle,
Ok(User { ) -> Result<User, UserError> {
id: r.get(0)?, let res = sqlx::query("SELECT id FROM users WHERE handle = $1")
handle: r.get(1)?, .bind(handle.as_str())
}) .fetch_optional(conn)
}) .await?;
.optional()?;
match res { match res {
Some(u) => Ok(u), Some(r) => Ok(User {
id: r.try_get("id")?,
handle,
}),
None => Err(UserError::NoUserWithHandle(handle)), None => Err(UserError::NoUserWithHandle(handle)),
} }
} }
pub fn get_all(conn: &Connection) -> Result<Vec<User>, UserError> {
Ok(conn pub async fn get_all(conn: &mut PgConnection) -> Result<Vec<User>, UserError> {
.prepare("SELECT id, handle FROM users")? let rows = sqlx::query("SELECT id, handle FROM users")
.query_map((), |r| { .fetch_all(conn)
Ok(User { .await?;
id: r.get(0)?,
handle: r.get(1)?, let mut users = Vec::with_capacity(rows.len());
}) for r in rows {
})? let handle_str: String = r.try_get("handle")?;
.collect::<Result<Vec<User>, _>>()?) users.push(User {
id: r.try_get("id")?,
handle: UserHandle::new(&handle_str)?,
});
}
Ok(users)
} }
pub fn create(conn: &Connection, handle: UserHandle) -> Result<User, UserError> { pub async fn create(conn: &mut PgConnection, handle: UserHandle) -> Result<User, UserError> {
let id = Uuid::now_v7(); let id = Uuid::now_v7();
conn.prepare("INSERT INTO users(id, handle) VALUES (?1, ?2)")? sqlx::query("INSERT INTO users(id, handle) VALUES ($1, $2)")
.execute((&id, &handle))?; .bind(id)
.bind(handle.as_str())
.execute(conn)
.await?;
Ok(User { id, handle }) Ok(User { id, handle })
} }
pub fn set_handle( pub async fn set_handle(
&mut self, &mut self,
conn: &Connection, conn: &mut PgConnection,
new_handle: UserHandle, new_handle: UserHandle,
) -> Result<(), UserError> { ) -> Result<(), UserError> {
conn.prepare("UPDATE users SET handle = ?1 WHERE id = ?2")? sqlx::query("UPDATE users SET handle = $1 WHERE id = $2")
.execute((&new_handle, self.id))?; .bind(new_handle.as_str())
.bind(self.id)
.execute(conn)
.await?;
self.handle = new_handle; self.handle = new_handle;
Ok(()) Ok(())
} }
@@ -118,21 +140,26 @@ impl User {
// DANGEROUS: AUTH // DANGEROUS: AUTH
impl User { impl User {
pub fn set_password( pub async fn set_password(
&mut self, &mut self,
conn: &Connection, conn: &mut PgConnection,
passw: Option<&str>, passw: Option<&str>,
) -> Result<(), UserError> { ) -> Result<(), UserError> {
match passw { match passw {
None => { None => {
conn.prepare("UPDATE users SET password = NULL WHERE id = ?1")? sqlx::query("UPDATE users SET password = NULL WHERE id = $1")
.execute((self.id,))?; .bind(self.id)
.execute(conn)
.await?;
Ok(()) Ok(())
} }
Some(passw) => { Some(passw) => {
let hashed = User::hash_password(passw)?; let hashed = User::hash_password(passw)?;
conn.prepare("UPDATE users SET password = ?1 WHERE id = ?2")? sqlx::query("UPDATE users SET password = $1 WHERE id = $2")
.execute((hashed, self.id))?; .bind(hashed)
.bind(self.id)
.execute(conn)
.await?;
Ok(()) Ok(())
} }
} }
@@ -148,14 +175,18 @@ impl User {
/// to do everything and probably should not be used as a regular account /// to do everything and probably should not be used as a regular account
/// due to the ramifications of compromise. But it could be used for that, /// due to the ramifications of compromise. But it could be used for that,
/// and have its name changed. /// and have its name changed.
pub fn create_infradmin(conn: &Connection) -> Result<User, UserError> { pub async fn create_infradmin(conn: &mut PgConnection) -> Result<User, UserError> {
let mut u = User { let mut u = User {
id: Uuid::max(), id: Uuid::max(),
handle: UserHandle::new("Infradmin")?, handle: UserHandle::new("Infradmin")?,
}; };
conn.prepare("INSERT INTO users(id, handle) VALUES (?1, ?2)")? sqlx::query("INSERT INTO users(id, handle) VALUES ($1, $2)")
.execute((&u.id, &u.handle))?; .bind(u.id)
u.regenerate_infradmin_password(conn)?; .bind(u.handle.as_str())
.execute(&mut *conn)
.await?;
u.regenerate_infradmin_password(conn).await?;
Ok(u) Ok(u)
} }
@@ -178,9 +209,12 @@ impl User {
/// to do everything and probably should not be used as a regular account /// to do everything and probably should not be used as a regular account
/// due to the ramifications of compromise. But it could be used for that, /// due to the ramifications of compromise. But it could be used for that,
/// and have its name changed. /// and have its name changed.
pub fn regenerate_infradmin_password(&mut self, conn: &Connection) -> Result<(), UserError> { pub async fn regenerate_infradmin_password(
&mut self,
conn: &mut PgConnection,
) -> Result<(), UserError> {
let passw = auth::generate_token(auth::TokenSize::Char16); let passw = auth::generate_token(auth::TokenSize::Char16);
self.set_password(conn, Some(&passw))?; self.set_password(conn, Some(&passw)).await?;
log::info!("[USERS] The infradmin account password has been (re)generated."); log::info!("[USERS] The infradmin account password has been (re)generated.");
log::info!("[USERS] Handle: {}", self.handle.as_str()); log::info!("[USERS] Handle: {}", self.handle.as_str());
log::info!("[USERS] Password: {}", passw); log::info!("[USERS] Password: {}", passw);
@@ -194,13 +228,16 @@ impl User {
/// for actions performed by Mnemosyne internally. /// for actions performed by Mnemosyne internally.
/// It shall not be available for log-in. /// It shall not be available for log-in.
/// It should not have its name changed, and should be protected from that. /// It should not have its name changed, and should be protected from that.
pub fn create_systemuser(conn: &Connection) -> Result<User, UserError> { pub async fn create_systemuser(conn: &mut PgConnection) -> Result<User, UserError> {
let u = User { let u = User {
id: Uuid::nil(), id: Uuid::nil(),
handle: UserHandle::new("Mnemosyne")?, handle: UserHandle::new("Mnemosyne")?,
}; };
conn.prepare("INSERT INTO users(id, handle) VALUES (?1, ?2)")? sqlx::query("INSERT INTO users(id, handle) VALUES ($1, $2)")
.execute((&u.id, &u.handle))?; .bind(u.id)
.bind(u.handle.as_str())
.execute(conn)
.await?;
Ok(u) Ok(u)
} }
@@ -216,22 +253,24 @@ impl User {
} }
} }
impl From<rusqlite::Error> for UserError { impl From<sqlx::Error> for UserError {
fn from(error: rusqlite::Error) -> Self { fn from(error: sqlx::Error) -> Self {
if let rusqlite::Error::SqliteFailure(err, Some(msg)) = &error if let sqlx::Error::Database(err) = &error {
&& err.extended_code == SQLITE_CONSTRAINT_UNIQUE // Check for Postgres unique constraint violation (code 23505)
&& msg.contains("handle") if err.is_unique_violation() && err.message().contains("handle") {
{ return UserError::HandleAlreadyExists;
return UserError::HandleAlreadyExists; }
} }
UserError::DatabaseError(DatabaseError::from(error)) UserError::DatabaseError(DatabaseError::from(error))
} }
} }
impl From<argon2::password_hash::Error> for UserError { impl From<argon2::password_hash::Error> for UserError {
fn from(err: argon2::password_hash::Error) -> Self { fn from(err: argon2::password_hash::Error) -> Self {
UserError::PassHashError(err) UserError::PassHashError(err)
} }
} }
impl IntoResponse for UserError { impl IntoResponse for UserError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
match self { match self {

View File

@@ -1,4 +1,4 @@
use rusqlite::Connection; use sqlx::PgConnection;
use crate::{database::DatabaseError, users::User}; use crate::{database::DatabaseError, users::User};
@@ -17,13 +17,14 @@ pub enum Permission {
RenameTags, RenameTags,
DeleteTags, DeleteTags,
ChangePersonPrimaryName, ChangePersonPrimaryName,
#[allow(unused)]
BrowseServerLogs, BrowseServerLogs,
} }
impl User { impl User {
pub fn has_permission( pub async fn has_permission(
&self, &self,
#[allow(unused)] conn: &Connection, #[allow(unused)] conn: &mut PgConnection,
#[allow(unused)] permission: Permission, #[allow(unused)] permission: Permission,
) -> Result<bool, DatabaseError> { ) -> Result<bool, DatabaseError> {
// Infradmin and systemuser have all permissions // Infradmin and systemuser have all permissions

View File

@@ -3,9 +3,9 @@ use axum::{
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use chrono::{DateTime, Duration, Utc}; use chrono::{DateTime, Duration, Utc};
use rusqlite::{Connection, OptionalExtension};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use sqlx::{PgConnection, Row};
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
@@ -46,11 +46,13 @@ pub enum SessionError {
#[error("No session found with provided token")] #[error("No session found with provided token")]
NoSessionWithToken(String), NoSessionWithToken(String),
} }
impl From<rusqlite::Error> for SessionError {
fn from(error: rusqlite::Error) -> Self { impl From<sqlx::Error> for SessionError {
fn from(error: sqlx::Error) -> Self {
SessionError::DatabaseError(DatabaseError::from(error)) SessionError::DatabaseError(DatabaseError::from(error))
} }
} }
impl IntoResponse for SessionError { impl IntoResponse for SessionError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
match self { match self {
@@ -70,55 +72,89 @@ impl IntoResponse for SessionError {
} }
impl Session { impl Session {
pub fn get_by_id(conn: &Connection, id: Uuid) -> Result<Session, SessionError> { pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> Result<Session, SessionError> {
let res = conn let row = sqlx::query(
.prepare("SELECT user_id, expiry, revoked, revoked_at, revoked_by FROM sessions WHERE id = ?1")? "SELECT user_id, expiry, revoked, revoked_at, revoked_by FROM sessions WHERE id = $1",
.query_one((&id,), |r| Ok(Session { )
id, .bind(id)
user_id: r.get(0)?, .fetch_optional(conn)
expiry: r.get(1)?, .await?;
status: match r.get::<_, bool>(2)? {
false => SessionStatus::Active,
true => {
SessionStatus::Revoked { revoked_at: r.get(3)?, revoked_by: r.get(4)? }
}
}
})).optional()?;
match res { match row {
Some(s) => Ok(s), Some(r) => {
let revoked: bool = r.try_get("revoked")?;
let status = if revoked {
SessionStatus::Revoked {
revoked_at: r.try_get("revoked_at")?,
revoked_by: r.try_get("revoked_by")?,
}
} else {
SessionStatus::Active
};
Ok(Session {
id,
user_id: r.try_get("user_id")?,
expiry: r.try_get("expiry")?,
status,
})
}
None => Err(SessionError::NoSessionWithId(id)), None => Err(SessionError::NoSessionWithId(id)),
} }
} }
pub fn get_by_token(conn: &Connection, token: &str) -> Result<Session, SessionError> {
let hashed = Sha256::digest(token.as_bytes()).to_vec();
let res = conn
.prepare("SELECT id, user_id, expiry, revoked, revoked_at, revoked_by FROM sessions WHERE token = ?1")?
.query_one((hashed,), |r| Ok(Session {
id: r.get(0)?,
user_id: r.get(1)?,
expiry: r.get(2)?,
status: match r.get::<_, bool>(3)? {
false => SessionStatus::Active,
true => {
SessionStatus::Revoked { revoked_at: r.get(4)?, revoked_by: r.get(5)? }
}
}
})).optional()?;
match res { pub async fn get_by_token(
Some(s) => Ok(s), conn: &mut PgConnection,
token: &str,
) -> Result<Session, SessionError> {
let hashed = Sha256::digest(token.as_bytes()).to_vec();
let row = sqlx::query(
"SELECT id, user_id, expiry, revoked, revoked_at, revoked_by FROM sessions WHERE token = $1",
)
.bind(&hashed)
.fetch_optional(conn)
.await?;
match row {
Some(r) => {
let revoked: bool = r.try_get("revoked")?;
let status = if revoked {
SessionStatus::Revoked {
revoked_at: r.try_get("revoked_at")?,
revoked_by: r.try_get("revoked_by")?,
}
} else {
SessionStatus::Active
};
Ok(Session {
id: r.try_get("id")?,
user_id: r.try_get("user_id")?,
expiry: r.try_get("expiry")?,
status,
})
}
None => Err(SessionError::NoSessionWithToken(token.to_string())), None => Err(SessionError::NoSessionWithToken(token.to_string())),
} }
} }
pub fn new_for_user(conn: &Connection, user: &User) -> Result<(Session, String), SessionError> {
pub async fn new_for_user(
conn: &mut PgConnection,
user: &User,
) -> Result<(Session, String), SessionError> {
let id = Uuid::now_v7(); let id = Uuid::now_v7();
let token = auth::generate_token(auth::TokenSize::Char64); let token = auth::generate_token(auth::TokenSize::Char64);
let hashed = Sha256::digest(token.as_bytes()).to_vec(); let hashed = Sha256::digest(token.as_bytes()).to_vec();
let expiry = Utc::now() + Session::DEFAULT_PROLONGATION; let expiry = Utc::now() + Session::DEFAULT_PROLONGATION;
conn.prepare("INSERT INTO sessions(id, token, user_id, expiry) VALUES (?1, ?2, ?3, ?4)")? sqlx::query("INSERT INTO sessions(id, token, user_id, expiry) VALUES ($1, $2, $3, $4)")
.execute((&id, &hashed, user.id, expiry))?; .bind(id)
.bind(hashed)
.bind(user.id)
.bind(expiry)
.execute(conn)
.await?;
let s = Session { let s = Session {
id, id,
user_id: user.id, user_id: user.id,
@@ -130,7 +166,8 @@ impl Session {
pub const DEFAULT_PROLONGATION: Duration = Duration::days(14); pub const DEFAULT_PROLONGATION: Duration = Duration::days(14);
const PROLONGATION_THRESHOLD: Duration = Duration::hours(2); const PROLONGATION_THRESHOLD: Duration = Duration::hours(2);
pub fn prolong(&mut self, conn: &Connection) -> Result<(), SessionError> {
pub async fn prolong(&mut self, conn: &mut PgConnection) -> Result<(), SessionError> {
if self.expiry - Session::DEFAULT_PROLONGATION + Session::PROLONGATION_THRESHOLD if self.expiry - Session::DEFAULT_PROLONGATION + Session::PROLONGATION_THRESHOLD
> Utc::now() > Utc::now()
{ {
@@ -138,22 +175,37 @@ impl Session {
} }
let expiry = Utc::now() + Session::DEFAULT_PROLONGATION; let expiry = Utc::now() + Session::DEFAULT_PROLONGATION;
conn.prepare("UPDATE sessions SET expiry = ?1 WHERE id = ?2")? sqlx::query("UPDATE sessions SET expiry = $1 WHERE id = $2")
.execute((&expiry, &self.id))?; .bind(expiry)
.bind(self.id)
.execute(conn)
.await?;
self.expiry = expiry; self.expiry = expiry;
Ok(()) Ok(())
} }
pub fn revoke(&mut self, conn: &Connection, actor: Option<&User>) -> Result<(), SessionError> { pub async fn revoke(
&mut self,
conn: &mut PgConnection,
actor: Option<&User>,
) -> Result<(), SessionError> {
let now = Utc::now(); let now = Utc::now();
let id = actor.map(|u| u.id).unwrap_or(Uuid::nil()); let actor_id = actor.map(|u| u.id).unwrap_or(Uuid::nil());
conn.prepare(
"UPDATE sessions SET revoked = ?1, revoked_at = ?2, revoked_by = ?3 WHERE id = ?4", sqlx::query(
)? "UPDATE sessions SET revoked = $1, revoked_at = $2, revoked_by = $3 WHERE id = $4",
.execute((&true, &now, &id, &self.id))?; )
.bind(true)
.bind(now)
.bind(actor_id)
.bind(self.id)
.execute(conn)
.await?;
self.status = SessionStatus::Revoked { self.status = SessionStatus::Revoked {
revoked_at: now, revoked_at: now,
revoked_by: id, revoked_by: actor_id,
}; };
Ok(()) Ok(())
} }
@@ -165,9 +217,11 @@ impl Session {
let timestamp = self.id.get_timestamp().unwrap().to_unix(); let timestamp = self.id.get_timestamp().unwrap().to_unix();
DateTime::from_timestamp_secs(timestamp.0 as i64).unwrap() DateTime::from_timestamp_secs(timestamp.0 as i64).unwrap()
} }
pub fn is_expired_or_revoked(&self) -> bool { pub fn is_expired_or_revoked(&self) -> bool {
self.is_expired() || self.status.is_revoked() self.is_expired() || self.status.is_revoked()
} }
pub fn is_expired(&self) -> bool { pub fn is_expired(&self) -> bool {
self.expiry <= Utc::now() self.expiry <= Utc::now()
} }

View File

@@ -1,40 +1,37 @@
use rusqlite::OptionalExtension; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
database,
logs::{LogAction, LogEntry}, logs::{LogAction, LogEntry},
users::{User, UserError}, users::{User, UserError},
}; };
pub fn initialise_reserved_users_if_needed() -> Result<(), UserError> { pub async fn initialise_reserved_users_if_needed(pool: &PgPool) -> Result<(), UserError> {
let mut conn = database::conn()?; let mut tx = pool.begin().await?;
let tx = conn.transaction()?;
if tx let systemuser_exists = sqlx::query("SELECT handle FROM users WHERE id = $1")
.prepare("SELECT handle FROM users WHERE id = ?1")? .bind(Uuid::nil())
.query_one((&Uuid::nil(),), |_| Ok(())) .fetch_optional(&mut *tx)
.optional()? .await?
.is_none() .is_some();
{
let u = User::create_systemuser(&tx)?; if !systemuser_exists {
LogEntry::new(&tx, u, LogAction::Initialize)?; let u = User::create_systemuser(&mut *tx).await?;
LogEntry::new(&mut *tx, u, LogAction::Initialize).await?;
} }
if tx let infradmin_exists = sqlx::query("SELECT handle FROM users WHERE id = $1")
.prepare("SELECT handle FROM users WHERE id = ?1")? .bind(Uuid::max())
.query_one((&Uuid::max(),), |_| Ok(())) .fetch_optional(&mut *tx)
.optional()? .await?
.is_none() .is_some();
{
User::create_infradmin(&tx)?; if !infradmin_exists {
LogEntry::new( User::create_infradmin(&mut *tx).await?;
&tx, let u = User::get_by_id(&mut *tx, Uuid::max()).await?;
User::get_by_id(&tx, Uuid::nil())?, LogEntry::new(&mut *tx, u, LogAction::RegenInfradmin).await?;
LogAction::RegenInfradmin,
)?;
} }
tx.commit()?; tx.commit().await?;
Ok(()) Ok(())
} }

View File

@@ -9,7 +9,9 @@ pub fn quote(quote: &Quote) -> Markup {
(PreEscaped(icons::QUOTE)) (PreEscaped(icons::QUOTE))
} }
@for (i, line) in quote.lines.iter().enumerate() { @for (i, line) in quote.lines.iter().enumerate() {
@let show_author = i == quote.lines.len()-1 || quote.lines[i+1].attribution.id != line.attribution.id; @let is_last = i == quote.lines.len() - 1;
@let show_author = is_last || !line.attribution.iter().map(|a| a.id)
.eq(quote.lines[i + 1].attribution.iter().map(|a| a.id));
div class="mb-2" { div class="mb-2" {
span class="flex flex-row gap-2 relative" { span class="flex flex-row gap-2 relative" {
span class="scale-x-[.65] scale-y-[.5] absolute opacity-[.3]"{ span class="scale-x-[.65] scale-y-[.5] absolute opacity-[.3]"{
@@ -19,7 +21,7 @@ pub fn quote(quote: &Quote) -> Markup {
} }
@if show_author { @if show_author {
p class="text-sm italic ml-3 flex flex-row gap-1.5 text-neutral-400" { p class="text-sm italic ml-3 flex flex-row gap-1.5 text-neutral-400" {
"" (line.attribution.name) "" (line.attribution.iter().map(|a| a.name.clone()).collect::<Vec<_>>().join(", "))
} }
} }
} }

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-git-commit-vertical-icon lucide-git-commit-vertical"><path d="M12 3v6"/><circle cx="12" cy="12" r="3"/><path d="M12 15v6"/></svg>

After

Width:  |  Height:  |  Size: 332 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-line-dot-right-horizontal-icon lucide-line-dot-right-horizontal"><path d="M 3 12 L 15 12"/><circle cx="18" cy="12" r="3"/></svg>

After

Width:  |  Height:  |  Size: 331 B

View File

@@ -8,8 +8,10 @@ pub const CLOCK: &str = include_str!("clock.svg");
pub const CONTACT: &str = include_str!("contact.svg"); pub const CONTACT: &str = include_str!("contact.svg");
pub const EYE: &str = include_str!("eye.svg"); pub const EYE: &str = include_str!("eye.svg");
pub const FILE_IMAGE: &str = include_str!("file-image.svg"); pub const FILE_IMAGE: &str = include_str!("file-image.svg");
pub const GIT_COMMIT_VERTICAL: &str = include_str!("git-commit-vertical.svg");
pub const INFO: &str = include_str!("info.svg"); pub const INFO: &str = include_str!("info.svg");
pub const LAYOUT_DASHBOARD: &str = include_str!("layout-dashboard.svg"); pub const LAYOUT_DASHBOARD: &str = include_str!("layout-dashboard.svg");
pub const LINE_DOT_RIGHT_HORIZONTAL: &str = include_str!("line-dot-right-horizontal.svg");
pub const LOG_OUT: &str = include_str!("log-out.svg"); pub const LOG_OUT: &str = include_str!("log-out.svg");
pub const MAP_PIN: &str = include_str!("map-pin.svg"); pub const MAP_PIN: &str = include_str!("map-pin.svg");
pub const PEN: &str = include_str!("pen.svg"); pub const PEN: &str = include_str!("pen.svg");

View File

@@ -1,17 +1,14 @@
use axum::{ use axum::{Router, http::header, routing::get};
Router,
http::header, use crate::MnemoState;
response::{IntoResponse, Redirect, Response},
routing::get,
};
mod components; mod components;
mod icons; pub mod icons;
mod pages; mod pages;
pub const CSS: &str = include_str!(concat!(env!("OUT_DIR"), "/styles.css")); pub const CSS: &str = include_str!(concat!(env!("OUT_DIR"), "/styles.css"));
pub fn web_router() -> Router { pub fn web_router() -> Router<MnemoState> {
Router::new() Router::new()
.route( .route(
"/styles.css", "/styles.css",
@@ -19,10 +16,3 @@ pub fn web_router() -> Router {
) )
.merge(pages::pages()) .merge(pages::pages())
} }
pub struct RedirectViaError(Redirect);
impl IntoResponse for RedirectViaError {
fn into_response(self) -> Response {
self.0.into_response()
}
}

View File

@@ -1,9 +1,12 @@
use axum::extract::Request; use axum::{
extract::{Request, State},
response::{IntoResponse, Redirect, Response},
};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use maud::{Markup, PreEscaped, html}; use maud::{PreEscaped, html};
use crate::{ use crate::{
database::{self}, MnemoState,
error::CompositeError, error::CompositeError,
persons::Person, persons::Person,
quotes::Quote, quotes::Quote,
@@ -21,19 +24,30 @@ const LINKS: &[(&str, &str, &str)] = &[
("Add Person", "/persons", icons::CONTACT), ("Add Person", "/persons", icons::CONTACT),
]; ];
pub async fn page(req: Request) -> Result<Markup, CompositeError> { pub async fn page(
let u = User::authenticate(req.headers()).ok().flatten(); State(state): State<MnemoState>,
let conn = database::conn()?; req: Request,
) -> Result<Response, CompositeError> {
let mut conn = state.pool.acquire().await?;
let u = match User::authenticate(&mut *conn, req.headers()).await? {
Some(u) => Some(u),
None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()),
};
let newest_quote = match u { let newest_quote = match u {
Some(_) => Quote::get_newest(&conn)?, Some(_) => Quote::get_newest(&mut *conn).await?,
None => Quote::get_newest_public(&conn)?, None => Quote::get_newest_public(&mut *conn).await?,
}; };
let random_quote = match u { let random_quote = match u {
Some(_) => Quote::get_random(&conn)?, Some(_) => Quote::get_random(&mut *conn).await?,
None => None, None => None,
}; };
let quote_count = Quote::total_count(&mut *conn).await;
let person_count = Person::total_count(&mut *conn).await;
let tag_count = Tag::total_count(&mut *conn).await;
let user_count = User::total_count(&mut *conn).await;
Ok(base( Ok(base(
"Dashboard | Mnemosyne", "Dashboard | Mnemosyne",
html!( html!(
@@ -79,25 +93,25 @@ pub async fn page(req: Request) -> Result<Markup, CompositeError> {
} }
div class="mx-auto max-w-4xl px-2 mt-4 flex flex-row gap-2" { div class="mx-auto max-w-4xl px-2 mt-4 flex flex-row gap-2" {
(chip(html!({ (chip(html!({
@match Quote::total_count(&conn) { @match quote_count {
Ok(count) => {(count) " QUOTES TOTAL"}, Ok(count) => {(count) " QUOTES TOTAL"},
Err(_) => span class="text-red-400" {"QUOTE COUNT ERR"}, Err(_) => span class="text-red-400" {"QUOTE COUNT ERR"},
} }
}))) })))
(chip(html!({ (chip(html!({
@match Person::total_count(&conn) { @match person_count {
Ok(count) => {(count) " PERSONS TOTAL"}, Ok(count) => {(count) " PERSONS TOTAL"},
Err(_) => span class="text-red-400" {"PERSON COUNT ERR"}, Err(_) => span class="text-red-400" {"PERSON COUNT ERR"},
} }
}))) })))
(chip(html!({ (chip(html!({
@match Tag::total_count(&conn) { @match tag_count {
Ok(count) => {(count) " TAGS TOTAL"}, Ok(count) => {(count) " TAGS TOTAL"},
Err(_) => span class="text-red-400" {"TAG COUNT ERR"} Err(_) => span class="text-red-400" {"TAG COUNT ERR"}
} }
}))) })))
(chip(html!({ (chip(html!({
@match User::total_count(&conn) { @match user_count {
Ok(count) => {(count) " USERS TOTAL"}, Ok(count) => {(count) " USERS TOTAL"},
Err(_) => span class="text-red-400" {"USER COUNT ERR"} Err(_) => span class="text-red-400" {"USER COUNT ERR"}
} }
@@ -106,7 +120,7 @@ pub async fn page(req: Request) -> Result<Markup, CompositeError> {
div class="text-4xl xs:text-6xl sm:text-8xl text-neutral-800/25 mt-16 text-center font-semibold font-lora select-none" {"Mnemosyne"} div class="text-4xl xs:text-6xl sm:text-8xl text-neutral-800/25 mt-16 text-center font-semibold font-lora select-none" {"Mnemosyne"}
), ),
)) ).into_response())
} }
fn format_time_ago(dt: DateTime<Utc>) -> String { fn format_time_ago(dt: DateTime<Utc>) -> String {

View File

@@ -1,7 +1,7 @@
use axum::response::{IntoResponse, Redirect, Response}; use axum::response::{IntoResponse, Redirect, Response};
use crate::users::auth::AuthError; use crate::error::CompositeError;
pub async fn page() -> Result<Response, AuthError> { pub async fn page() -> Result<Response, CompositeError> {
Ok(Redirect::to("/dashboard").into_response()) Ok(Redirect::to("/dashboard").into_response())
} }

View File

@@ -1,5 +1,5 @@
use axum::{ use axum::{
extract::{Query, Request}, extract::{Query, Request, State},
response::{IntoResponse, Redirect, Response}, response::{IntoResponse, Redirect, Response},
}; };
use maud::{PreEscaped, html}; use maud::{PreEscaped, html};
@@ -7,11 +7,10 @@ use rand::seq::IndexedRandom;
use serde::Deserialize; use serde::Deserialize;
use crate::{ use crate::{
MnemoState,
config::REFERENCE_SPLASHES, config::REFERENCE_SPLASHES,
users::{ error::CompositeError,
User, users::{User, auth::UserAuthenticate},
auth::{AuthError, UserAuthenticate},
},
web::{components::marquee::marquee, icons, pages::base}, web::{components::marquee::marquee, icons, pages::base},
}; };
@@ -20,8 +19,13 @@ pub struct LoginMsg {
msg: Option<String>, msg: Option<String>,
} }
pub async fn page(Query(q): Query<LoginMsg>, req: Request) -> Result<Response, AuthError> { pub async fn page(
let u = User::authenticate(req.headers())?; State(state): State<MnemoState>,
Query(q): Query<LoginMsg>,
req: Request,
) -> Result<Response, CompositeError> {
let mut conn = state.pool.acquire().await?;
let u = User::authenticate(&mut *conn, req.headers()).await?;
if u.is_some() { if u.is_some() {
return Ok(Redirect::to("/dashboard").into_response()); return Ok(Redirect::to("/dashboard").into_response());
} }

View File

@@ -1,40 +1,42 @@
use axum::{ use axum::{
extract::{Query, Request}, extract::{Query, Request, State},
response::{IntoResponse, Redirect, Response}, response::{IntoResponse, Redirect, Response},
}; };
use maud::{PreEscaped, html}; use maud::{PreEscaped, html};
use serde::Deserialize; use serde::Deserialize;
use strum::IntoEnumIterator;
use crate::{ use crate::{
database::{self}, MnemoState,
error::CompositeError, error::CompositeError,
logs::LogEntry, logs::{LogActionDiscriminant, LogEntry},
users::{User, auth::UserAuthenticate}, users::{User, auth::UserAuthenticate},
web::{components::nav::nav, icons, pages::base}, web::{components::nav::nav, icons, pages::base},
}; };
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct PageQuery { pub struct LogsPageQuery {
page: Option<i64>, page: Option<i64>,
action: Option<LogActionDiscriminant>,
} }
pub async fn page( pub async fn page(
Query(query): Query<PageQuery>, State(state): State<MnemoState>,
Query(query): Query<LogsPageQuery>,
req: Request, req: Request,
) -> Result<Response, CompositeError> { ) -> Result<Response, CompositeError> {
let u = match User::authenticate(req.headers())? { let mut tx = state.pool.acquire().await?;
let u = match User::authenticate(&mut *tx, req.headers()).await? {
Some(u) => u, Some(u) => u,
None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()), None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()),
}; };
let mut conn = database::conn()?;
let tx = conn.transaction()?;
let page = query.page.unwrap_or(1).max(1); let page = query.page.unwrap_or(1).max(1);
let per_page = 20; let per_page = 20;
let offset = (page - 1) * per_page; let offset = (page - 1) * per_page;
let logs = LogEntry::get_chronological_offset(&tx, offset, per_page)?; let logs = LogEntry::get_chronological_offset(&mut *tx, query.action, offset, per_page).await?;
let total_logs = LogEntry::total_count(&tx)?; let total_logs = LogEntry::count(&mut *tx, query.action).await?;
let total_pages = (total_logs as f64 / per_page as f64).ceil() as i64; let total_pages = (total_logs as f64 / per_page as f64).ceil() as i64;
Ok(base( Ok(base(
@@ -42,18 +44,31 @@ pub async fn page(
html!( html!(
(nav(Some(&u), req.uri().path())) (nav(Some(&u), req.uri().path()))
@if true {//let Ok(true) = u.has_permission(&tx, Permission::BrowseServerLogs) { @if true {//let Ok(true) = u.has_permission(&mut *tx, Permission::BrowseServerLogs) {
div class="max-w-4xl mx-auto px-2" { div class="max-w-4xl mx-auto px-2" {
div class="my-4" { div class="my-4" {
p class="flex items-center gap-2" { p class="flex items-center gap-2" {
span class="text-neutral-500" {(PreEscaped(icons::CLIPBOARD_CLOCK))} span class="text-neutral-500" {(PreEscaped(icons::CLIPBOARD_CLOCK))}
span class="text-2xl font-semibold font-lora" {"Logs"} span class="text-2xl font-semibold font-lora" {"Logs"}
} }
p class="text-neutral-500 text-sm font-light" { }
"Work in progress." // abcdefghijklmnopqrstuvwxyz
div class="mb-4 flex flex-wrap gap-2 items-center justify-between" {
div class="text-sm text-neutral-400" {
"Showing " (total_logs) " logs"
}
select
class="bg-neutral-900 border border-neutral-200/25 rounded px-2 py-1 text-sm text-neutral-200"
onchange="window.location.search = this.value ? '?action=' + this.value : ''"
{
option value="" { "All Actions" }
@for action in LogActionDiscriminant::iter() {
@let act_str: &'static str = action.into();
option value=(act_str) selected[query.action == Some(action)] { (action.human_readable()) }
}
} }
} }
div class="w-full border border-neutral-200/25 rounded grid grid-cols-[auto_auto_1fr]" { div class="w-full overflow-x-auto border border-neutral-200/25 rounded grid grid-cols-[auto_auto_1fr]" {
@for (txt, ico) in [("Timestamp", icons::CLOCK), ("Actor", icons::USER), ("Action", icons::PEN)] { @for (txt, ico) in [("Timestamp", icons::CLOCK), ("Actor", icons::USER), ("Action", icons::PEN)] {
div class="p-2 flex gap-1 font-semibold border-b border-neutral-200/25" { div class="p-2 flex gap-1 font-semibold border-b border-neutral-200/25" {
span class="text-neutral-500 scale-[.8]" {(PreEscaped(ico))} span class="text-neutral-500 scale-[.8]" {(PreEscaped(ico))}
@@ -76,9 +91,10 @@ pub async fn page(
div class="p-2 font-light" style=(s) {(log.data.get_humanreadable_payload())} div class="p-2 font-light" style=(s) {(log.data.get_humanreadable_payload())}
} }
} }
div class="flex justify-between items-center my-4 text-neutral-400" { @let action_q = query.action.map(|a| { let s: &'static str = a.into(); format!("&action={s}") }).unwrap_or_default();
div class="flex flex-wrap gap-2 justify-between items-center my-4 text-neutral-400" {
@if page > 1 { @if page > 1 {
a href=(format!("/logs?page={}", (page - 1).max(1))) class="px-4 py-2 border border-neutral-200/25 hover:border-neutral-200/45 bg-neutral-200/5 hover:bg-neutral-200/15 rounded" { a href=(format!("/logs?page={}{}", (page - 1).max(1), action_q)) class="px-4 py-2 border border-neutral-200/25 hover:border-neutral-200/45 bg-neutral-200/5 hover:bg-neutral-200/15 rounded" {
"Previous" "Previous"
} }
} @else { } @else {
@@ -86,11 +102,11 @@ pub async fn page(
} }
span { span {
"Page " (page) " of " (total_pages) "Page " (page) " of " (total_pages.max(1))
} }
@if page < total_pages { @if page < total_pages {
a href=(format!("/logs?page={}", page + 1)) class="px-4 py-2 border border-neutral-200/25 hover:border-neutral-200/45 bg-neutral-200/5 hover:bg-neutral-200/15 rounded" { a href=(format!("/logs?page={}{}", page + 1, action_q)) class="px-4 py-2 border border-neutral-200/25 hover:border-neutral-200/45 bg-neutral-200/5 hover:bg-neutral-200/15 rounded" {
"Next" "Next"
} }
} @else { } @else {

View File

@@ -4,6 +4,8 @@ use axum::{
}; };
use maud::{DOCTYPE, Markup, html}; use maud::{DOCTYPE, Markup, html};
use crate::MnemoState;
pub mod dashboard; pub mod dashboard;
pub mod index; pub mod index;
pub mod login; pub mod login;
@@ -15,7 +17,7 @@ pub mod tags;
pub mod users; pub mod users;
pub mod usersettings; pub mod usersettings;
pub fn pages() -> Router { pub fn pages() -> Router<MnemoState> {
Router::new() Router::new()
.route("/", get(index::page)) .route("/", get(index::page))
.route("/login", get(login::page)) .route("/login", get(login::page))

View File

@@ -1,14 +1,19 @@
use axum::extract::Request; use axum::extract::{Request, State};
use maud::{Markup, html}; use maud::{Markup, html};
use crate::{ use crate::{
MnemoState,
error::CompositeError, error::CompositeError,
users::{User, auth::UserAuthenticate}, users::{User, auth::UserAuthenticate},
web::{components::nav::nav, pages::base}, web::{components::nav::nav, pages::base},
}; };
pub async fn page(req: Request) -> Result<Markup, CompositeError> { pub async fn page(State(state): State<MnemoState>, req: Request) -> Result<Markup, CompositeError> {
let u = User::authenticate(req.headers()).ok().flatten(); let mut conn = state.pool.acquire().await?;
let u = User::authenticate(&mut *conn, req.headers())
.await
.ok()
.flatten();
Ok(base( Ok(base(
"Not Found | Mnemosyne", "Not Found | Mnemosyne",
html!( html!(

View File

@@ -1,6 +1,7 @@
use axum::{ use axum::{
Form, Form,
extract::Request, extract::Request,
extract::State,
http::HeaderMap, http::HeaderMap,
response::{IntoResponse, Redirect, Response}, response::{IntoResponse, Redirect, Response},
}; };
@@ -8,26 +9,38 @@ use maud::{PreEscaped, html};
use serde::Deserialize; use serde::Deserialize;
use crate::{ use crate::{
database::{self}, MnemoState,
error::CompositeError, error::CompositeError,
logs::{LogAction, LogEntry}, logs::{LogAction, LogEntry},
persons::Person, persons::Person,
users::{ users::{
User, User,
auth::{AuthError, UserAuthRequired, UserAuthenticate}, auth::{UserAuthRequired, UserAuthenticate},
}, },
web::{components::nav::nav, icons, pages::base}, web::{components::nav::nav, icons, pages::base},
}; };
pub mod profile; pub mod profile;
pub async fn page(req: Request) -> Result<Response, AuthError> { pub async fn page(
let u = match User::authenticate(req.headers())? { State(state): State<MnemoState>,
req: Request,
) -> Result<Response, CompositeError> {
let mut conn = state.pool.acquire().await?;
let u = match User::authenticate(&mut *conn, req.headers()).await? {
Some(u) => u, Some(u) => u,
None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()), None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()),
}; };
let mut conn = database::conn()?;
let tx = conn.transaction()?; let total_count = Person::total_count(&mut *conn).await;
let persons_res = Person::get_all(&mut *conn).await;
let mut person_counts = vec![];
if let Ok(ref persons) = persons_res {
for p in persons {
person_counts.push(p.get_in_quote_count(&mut *conn).await);
}
}
Ok(base( Ok(base(
"Persons | Mnemosyne", "Persons | Mnemosyne",
@@ -40,30 +53,28 @@ pub async fn page(req: Request) -> Result<Response, AuthError> {
span class="text-2xl font-semibold font-lora" {"Persons"} span class="text-2xl font-semibold font-lora" {"Persons"}
} }
p class="text-neutral-500 text-sm font-light" { p class="text-neutral-500 text-sm font-light" {
@if let Ok(c) = Person::total_count(&tx) { @if let Ok(c) = total_count {
(c) " persons in total." (c) " persons in total."
} @else { } @else {
"Could not get total person count." "Could not get total person count."
} }
} }
} }
@if let Ok(persons) = Person::get_all(&tx) { @if let Ok(persons) = persons_res {
div class="max-w-4xl mx-auto px-2 mt-4 flex flex-wrap gap-2" { div class="max-w-4xl mx-auto px-2 mt-4 flex flex-wrap gap-2" {
@for person in &persons { @for (idx, person) in persons.iter().enumerate() {
a href={"/persons/"(person.id)} class="rounded px-4 py-2 bg-neutral-200/5 hover:bg-neutral-200/10 border border-neutral-200/25 hover:border-neutral-200/25 flex items-center" { a href={"/persons/"(person.id)} class="rounded px-4 py-2 bg-neutral-200/5 hover:bg-neutral-200/10 border border-neutral-200/25 hover:border-neutral-200/25 flex items-center" {
span class="text-neutral-400 mr-1 scale-125" {"~"} span class="text-neutral-400 mr-1 scale-125" {"~"}
span class="text-sm" {(person.primary_name)} span class="text-sm" {(person.primary_name)}
div class="w-px h-2/3 my-auto mx-2 bg-neutral-200/15" {} div class="w-px h-2/3 my-auto mx-2 bg-neutral-200/15" {}
div class="text-xs flex items-center" { div class="text-xs flex items-center" {
( (
if let Ok(i) = person.get_in_quote_count(&tx) { if let Ok(i) = person_counts[idx] {
i.to_string() i.to_string()
} else { } else {
"?".to_string() "?".to_string()
} }
) span class="*:size-3 ml-1 text-neutral-400" {(PreEscaped(icons::SCROLL_TEXT))} ) span class="*:size-3 ml-1 text-neutral-400" {(PreEscaped(icons::SCROLL_TEXT))}
// div class="ml-2" {}
// "4" span class="*:size-3 ml-1 text-neutral-400" {(PreEscaped(icons::FILE_IMAGE))}
} }
} }
} }
@@ -72,7 +83,7 @@ pub async fn page(req: Request) -> Result<Response, AuthError> {
p class="text-center p-2" {"No persons yet."} p class="text-center p-2" {"No persons yet."}
} }
div class="mx-auto max-w-4xl mt-4 px-2" { div class="mx-auto max-w-4xl mt-4 px-2" {
h3 class="font-lora font-semibold text-xl" {"Add new person"} h3 class="font-lora font-semibold text-xl mb-1" {"Add new person"}
form action="/persons/create" method="post" { form action="/persons/create" method="post" {
label for="primary_name" class="text-neutral-500 font-light mt-2" {"Primary Name"} label for="primary_name" class="text-neutral-500 font-light mt-2" {"Primary Name"}
div class="flex gap-2" { div class="flex gap-2" {
@@ -96,22 +107,23 @@ pub struct PersonNameForm {
primary_name: String, primary_name: String,
} }
pub async fn create( pub async fn create(
State(state): State<MnemoState>,
headers: HeaderMap, headers: HeaderMap,
Form(form): Form<PersonNameForm>, Form(form): Form<PersonNameForm>,
) -> Result<Response, CompositeError> { ) -> Result<Response, CompositeError> {
let u = User::authenticate(&headers)?.required()?; let mut tx = state.pool.begin().await?;
let mut conn = database::conn()?; let u = User::authenticate(&mut *tx, &headers).await?.required()?;
let tx = conn.transaction()?;
let p = Person::create(&tx, form.primary_name, u.id)?; let p = Person::create(&mut *tx, form.primary_name, u.id).await?;
LogEntry::new( LogEntry::new(
&tx, &mut *tx,
u, u,
LogAction::CreatePerson { LogAction::CreatePerson {
id: p.id, id: p.id,
pname: p.primary_name, pname: p.primary_name,
}, },
)?; )
tx.commit()?; .await?;
tx.commit().await?;
Ok(Redirect::to("/persons").into_response()) Ok(Redirect::to("/persons").into_response())
} }

View File

@@ -1,6 +1,6 @@
use axum::{ use axum::{
Form, Form,
extract::{Path, Request}, extract::{Path, Request, State},
http::HeaderMap, http::HeaderMap,
response::{IntoResponse, Redirect, Response}, response::{IntoResponse, Redirect, Response},
}; };
@@ -9,29 +9,45 @@ use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
database, MnemoState,
error::CompositeError, error::CompositeError,
logs::{LogAction, LogEntry}, logs::{LogAction, LogEntry},
persons::{Name, Person}, persons::{Name, Person},
users::{ users::{
User, User,
auth::{AuthError, UserAuthRequired, UserAuthenticate}, auth::{UserAuthRequired, UserAuthenticate},
}, },
web::{components::nav::nav, pages::base}, web::{components::nav::nav, pages::base},
}; };
pub async fn page(Path(id): Path<Uuid>, req: Request) -> Result<Response, AuthError> { pub async fn page(
let u = match User::authenticate(req.headers())? { State(state): State<MnemoState>,
Path(id): Path<Uuid>,
req: Request,
) -> Result<Response, CompositeError> {
let mut conn = state.pool.acquire().await?;
let u = match User::authenticate(&mut *conn, req.headers()).await? {
Some(u) => u, Some(u) => u,
None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()), None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()),
}; };
let conn = database::conn()?; let p = Person::get_by_id(&mut *conn, id).await;
let p = Person::get_by_id(&conn, id);
let title = match &p { let title = match &p {
Ok(p) => format!("~{} | Mnemosyne", p.primary_name), Ok(p) => format!("~{} | Mnemosyne", p.primary_name),
Err(_) => "Error! | Mnemosyne".into(), Err(_) => "Error! | Mnemosyne".into(),
}; };
let mut names_with_attribution = Vec::new();
let mut names_ok = false;
if let Ok(ref person) = p {
if let Ok(names) = person.get_all_names(&mut *conn).await {
names_ok = true;
for name in names {
let attr = name.times_attributed(&mut *conn).await.unwrap_or(0);
names_with_attribution.push((name, attr));
}
}
}
Ok(base( Ok(base(
&title, &title,
html!( html!(
@@ -46,14 +62,14 @@ pub async fn page(Path(id): Path<Uuid>, req: Request) -> Result<Response, AuthEr
div { div {
h2 class="text-lg font-semibold font-lora mb-2 text-neutral-300" {"Names"} h2 class="text-lg font-semibold font-lora mb-2 text-neutral-300" {"Names"}
div class="flex flex-wrap gap-2 mb-4" { div class="flex flex-wrap gap-2 mb-4" {
@if let Ok(names) = p.get_all_names(&conn) { @if names_ok {
@for name in &names { @for (name, attr) in names_with_attribution {
div class="rounded px-3 py-1 bg-neutral-200/5 border border-neutral-200/10 text-sm flex items-center gap-2" { div class="rounded px-3 py-1 bg-neutral-200/5 border border-neutral-200/10 text-sm flex items-center gap-2" {
(name.name) (name.name)
@if name.is_primary { @if name.is_primary {
span class="text-xs text-neutral-500" {"(primary)"} span class="text-xs text-neutral-500" {"(primary)"}
} }
@if let Ok(0) = name.times_attributed(&conn) && !name.is_primary { @if attr == 0 && !name.is_primary {
form action=(format!("/names/{}/delete", name.id)) method="post" class="flex items-center ml-1" { form action=(format!("/names/{}/delete", name.id)) method="post" class="flex items-center ml-1" {
button type="submit" class="text-neutral-500 hover:text-red-400 flex items-center justify-center cursor-pointer" title="Delete" { button type="submit" class="text-neutral-500 hover:text-red-400 flex items-center justify-center cursor-pointer" title="Delete" {
"" ""
@@ -91,19 +107,19 @@ pub struct AddNameForm {
} }
pub async fn add_name( pub async fn add_name(
State(state): State<MnemoState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
headers: HeaderMap, headers: HeaderMap,
Form(form): Form<AddNameForm>, Form(form): Form<AddNameForm>,
) -> Result<Response, CompositeError> { ) -> Result<Response, CompositeError> {
let u = User::authenticate(&headers)?.required()?; let mut tx = state.pool.begin().await?;
let mut conn = database::conn()?; let u = User::authenticate(&mut *tx, &headers).await?.required()?;
let tx = conn.transaction()?;
let p = Person::get_by_id(&tx, id)?; let p = Person::get_by_id(&mut *tx, id).await?;
let n = p.add_name(&tx, form.name, u.id)?; let n = p.add_name(&mut *tx, form.name, u.id).await?;
LogEntry::new( LogEntry::new(
&tx, &mut *tx,
u, u,
LogAction::AddPersonName { LogAction::AddPersonName {
pid: p.id, pid: p.id,
@@ -111,28 +127,29 @@ pub async fn add_name(
pn: p.primary_name, pn: p.primary_name,
nn: n.name, nn: n.name,
}, },
)?; )
tx.commit()?; .await?;
tx.commit().await?;
Ok(Redirect::to(&format!("/persons/{}", p.id)).into_response()) Ok(Redirect::to(&format!("/persons/{}", p.id)).into_response())
} }
pub async fn delete_name( pub async fn delete_name(
State(state): State<MnemoState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
headers: HeaderMap, headers: HeaderMap,
) -> Result<Response, CompositeError> { ) -> Result<Response, CompositeError> {
let u = User::authenticate(&headers)?.required()?; let mut tx = state.pool.begin().await?;
let mut conn = database::conn()?; let u = User::authenticate(&mut *tx, &headers).await?.required()?;
let tx = conn.transaction()?;
let n = Name::get_by_id(&tx, id)?; let n = Name::get_by_id(&mut *tx, id).await?;
let p = Person::get_by_id(&tx, n.person_id)?; let p = Person::get_by_id(&mut *tx, n.person_id).await?;
let nn = n.name.clone(); let nn = n.name.clone();
n.delete(&tx)?; n.delete(&mut *tx).await?;
LogEntry::new( LogEntry::new(
&tx, &mut *tx,
u, u,
LogAction::DeletePersonName { LogAction::DeletePersonName {
pid: p.id, pid: p.id,
@@ -140,8 +157,9 @@ pub async fn delete_name(
pn: p.primary_name, pn: p.primary_name,
n: nn, n: nn,
}, },
)?; )
tx.commit()?; .await?;
tx.commit().await?;
Ok(Redirect::to(&format!("/persons/{}", p.id)).into_response()) Ok(Redirect::to(&format!("/persons/{}", p.id)).into_response())
} }

View File

@@ -1,12 +1,12 @@
use axum::{ use axum::{
extract::{Query, Request}, extract::{Query, Request, State},
response::{IntoResponse, Redirect, Response}, response::{IntoResponse, Redirect, Response},
}; };
use maud::{PreEscaped, html}; use maud::{PreEscaped, html};
use serde::Deserialize; use serde::Deserialize;
use crate::{ use crate::{
database, MnemoState,
error::CompositeError, error::CompositeError,
quotes::Quote, quotes::Quote,
users::{User, auth::UserAuthenticate}, users::{User, auth::UserAuthenticate},
@@ -22,26 +22,41 @@ pub mod add;
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct PageQuery { pub struct PageQuery {
page: Option<i64>, page: Option<i64>,
s: Option<String>,
} }
pub async fn page( pub async fn page(
State(state): State<MnemoState>,
Query(query): Query<PageQuery>, Query(query): Query<PageQuery>,
req: Request, req: Request,
) -> Result<Response, CompositeError> { ) -> Result<Response, CompositeError> {
let u = match User::authenticate(req.headers())? { let mut conn = state.pool.acquire().await?;
let u = match User::authenticate(&mut *conn, req.headers()).await? {
Some(u) => u, Some(u) => u,
None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()), None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()),
}; };
let conn = database::conn()?;
let page = query.page.unwrap_or(1).max(1); let page = query.page.unwrap_or(1).max(1);
let per_page = 10; let per_page = 10;
let offset = (page - 1) * per_page; let offset = (page - 1) * per_page;
let quotes = Quote::get_chronological_offset(&conn, offset, per_page)?; let search = query.s.as_deref().unwrap_or("");
let total_quotes = Quote::total_count(&conn)?; let quotes = match search {
"" => Quote::get_chronological_offset(&mut *conn, offset, per_page).await?,
_ => Quote::get_by_search_query(&mut *conn, search, offset, per_page).await?,
};
let total_quotes = match search {
"" => Quote::total_count(&mut *conn).await?,
_ => Quote::search_query_count(&mut *conn, search).await?,
};
let total_pages = (total_quotes as f64 / per_page as f64).ceil() as i64; let total_pages = (total_quotes as f64 / per_page as f64).ceil() as i64;
let s_qs = if search.is_empty() {
String::new()
} else {
format!("&s={}", search)
};
Ok(base( Ok(base(
"Quotes | Mnemosyne", "Quotes | Mnemosyne",
html!( html!(
@@ -58,8 +73,10 @@ pub async fn page(
span class="text-neutral-300 group-hover:text-neutral-200" {"Add quote"} span class="text-neutral-300 group-hover:text-neutral-200" {"Add quote"}
} }
} }
input class="border w-full border-neutral-200/25 hover:border-neutral-200/45 bg-neutral-950/50 p-2 rounded" form method="get" action="/quotes" {
placeholder="Search not yet implemented."; input type="text" name="s" class="border w-full border-neutral-200/25 hover:border-neutral-200/45 bg-neutral-950/50 p-2 rounded"
placeholder="Search quotes..." value={(search)};
}
div class="my-2 w-full" { div class="my-2 w-full" {
p class="ml-auto w-fit text-neutral-500 text-sm flex items-center" { p class="ml-auto w-fit text-neutral-500 text-sm flex items-center" {
span class="scale-[.6]" {(PreEscaped(icons::CALENDAR_ARROW_DOWN))} span class="scale-[.6]" {(PreEscaped(icons::CALENDAR_ARROW_DOWN))}
@@ -73,7 +90,7 @@ pub async fn page(
div class="flex justify-between items-center mt-4 text-neutral-400" { div class="flex justify-between items-center mt-4 text-neutral-400" {
@if page > 1 { @if page > 1 {
a href=(format!("/quotes?page={}", (page - 1).min(1))) class="px-4 py-2 border border-neutral-200/25 hover:border-neutral-200/45 bg-neutral-200/5 hover:bg-neutral-200/15 rounded" { a href=(format!("/quotes?page={}{}", (page - 1).max(1), s_qs)) class="px-4 py-2 border border-neutral-200/25 hover:border-neutral-200/45 bg-neutral-200/5 hover:bg-neutral-200/15 rounded" {
"Previous" "Previous"
} }
} @else { } @else {
@@ -81,11 +98,11 @@ pub async fn page(
} }
span { span {
"Page " (page) " of " (total_pages) "Page " (page) " of " (total_pages.max(1))
} }
@if page < total_pages { @if page < total_pages {
a href=(format!("/quotes?page={}", page + 1)) class="px-4 py-2 border border-neutral-200/25 hover:border-neutral-200/45 bg-neutral-200/5 hover:bg-neutral-200/15 rounded" { a href=(format!("/quotes?page={}{}", page + 1, s_qs)) class="px-4 py-2 border border-neutral-200/25 hover:border-neutral-200/45 bg-neutral-200/5 hover:bg-neutral-200/15 rounded" {
"Next" "Next"
} }
} @else { } @else {

View File

@@ -1,17 +1,16 @@
use axum::{ use axum::{
extract::Request, extract::{Request, State},
http::HeaderMap, http::HeaderMap,
response::{IntoResponse, Redirect, Response}, response::{IntoResponse, Redirect, Response},
}; };
use axum_extra::extract::Form; use axum_extra::extract::Form;
use chrono::{TimeZone, Utc}; use chrono::NaiveDateTime;
use chrono_tz::Europe::Warsaw;
use maud::{Markup, PreEscaped, html}; use maud::{Markup, PreEscaped, html};
use serde::Deserialize; use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
database, MnemoState,
error::CompositeError, error::CompositeError,
logs::{LogAction, LogEntry}, logs::{LogAction, LogEntry},
persons::Name, persons::Name,
@@ -26,13 +25,16 @@ use crate::{
const LINE_ADD_RM_SCRIPT: &str = include_str!("line-add-rm.js"); const LINE_ADD_RM_SCRIPT: &str = include_str!("line-add-rm.js");
const PREFILL_TIME_SCRIPT: &str = include_str!("prefill-time.js"); const PREFILL_TIME_SCRIPT: &str = include_str!("prefill-time.js");
pub async fn page(req: Request) -> Result<Response, CompositeError> { pub async fn page(
let u = match User::authenticate(req.headers())? { State(state): State<MnemoState>,
req: Request,
) -> Result<Response, CompositeError> {
let mut conn = state.pool.acquire().await?;
let u = match User::authenticate(&mut *conn, req.headers()).await? {
Some(u) => u, Some(u) => u,
None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()), None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()),
}; };
let conn = database::conn()?; let names = Name::get_all(&mut *conn).await?;
let names = Name::get_all(&conn)?;
Ok(base( Ok(base(
"Add Quote | Mnemosyne", "Add Quote | Mnemosyne",
@@ -137,30 +139,32 @@ pub struct IncomingQuote {
authors: Vec<Uuid>, authors: Vec<Uuid>,
location: String, location: String,
time: String, time: String,
tz_offset: Option<i32>,
context: String, context: String,
} }
pub async fn form( pub async fn form(
State(state): State<MnemoState>,
headers: HeaderMap, headers: HeaderMap,
Form(form): Form<IncomingQuote>, Form(form): Form<IncomingQuote>,
) -> Result<Response, CompositeError> { ) -> Result<Response, CompositeError> {
let u = User::authenticate(&headers)?.required()?; let mut tx = state.pool.begin().await?;
let mut conn = database::conn()?; let u = User::authenticate(&mut *tx, &headers).await?.required()?;
let tx = conn.transaction()?;
let authors = form let mut authors = Vec::new();
.authors for nid in form.authors {
authors.push(Name::get_by_id(&mut *tx, nid).await.unwrap());
}
let lines = form
.lines
.into_iter() .into_iter()
.map(|nid| Name::get_by_id(&tx, nid).unwrap()); .zip(authors)
let lines = form.lines.into_iter().zip(authors).collect(); .map(|(l, a)| (l, vec![a]))
let offset = form .collect();
.tz_offset
.and_then(|mins| chrono::FixedOffset::west_opt(mins * 60)) let timestamp = match NaiveDateTime::parse_from_str(&form.time, "%Y-%m-%dT%H:%M") {
.unwrap_or_else(|| chrono::FixedOffset::west_opt(0).unwrap()); Ok(ts) => ts,
Err(_) => return Ok("Time was formatted wrong.".into_response()),
};
let timestamp = chrono::NaiveDateTime::parse_from_str(&form.time, "%Y-%m-%dT%H:%M")
.map(|ndt| offset.from_local_datetime(&ndt).unwrap())
.unwrap_or_else(|_| Utc::now().with_timezone(&Warsaw).fixed_offset());
let context = match form.context.trim() { let context = match form.context.trim() {
"" => None, "" => None,
s => Some(s.to_string()), s => Some(s.to_string()),
@@ -170,9 +174,9 @@ pub async fn form(
s => Some(s.to_string()), s => Some(s.to_string()),
}; };
let q = Quote::create(&tx, lines, timestamp, context, location, u.id, false)?; let q = Quote::create(&mut *tx, lines, timestamp, context, location, u.id, false).await?;
LogEntry::new(&tx, u, LogAction::CreateQuote { id: q.id })?; LogEntry::new(&mut *tx, u, LogAction::CreateQuote { id: q.id }).await?;
tx.commit()?; tx.commit().await?;
Ok(Redirect::to("/dashboard").into_response()) Ok(Redirect::to("/dashboard").into_response())
} }

View File

@@ -1,6 +1,6 @@
use axum::{ use axum::{
Form, Form,
extract::{Path, Request}, extract::{Path, Request, State},
http::HeaderMap, http::HeaderMap,
response::{IntoResponse, Redirect, Response}, response::{IntoResponse, Redirect, Response},
}; };
@@ -9,23 +9,39 @@ use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
database::{self}, MnemoState,
error::CompositeError, error::CompositeError,
logs::{LogAction, LogEntry}, logs::{LogAction, LogEntry},
tags::{Tag, TagName}, tags::{Tag, TagName},
users::{ users::{
User, User,
auth::{AuthError, UserAuthRequired, UserAuthenticate}, auth::{UserAuthRequired, UserAuthenticate},
}, },
web::{components::nav::nav, icons, pages::base}, web::{components::nav::nav, icons, pages::base},
}; };
pub async fn page(req: Request) -> Result<Response, AuthError> { pub async fn page(
let u = match User::authenticate(req.headers())? { State(state): State<MnemoState>,
req: Request,
) -> Result<Response, CompositeError> {
let mut conn = state.pool.acquire().await?;
let u = match User::authenticate(&mut *conn, req.headers()).await? {
Some(u) => u, Some(u) => u,
None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()), None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()),
}; };
let conn = database::conn()?; let total_tags = Tag::total_count(&mut *conn).await;
let mut tags_with_counts = Vec::new();
let tags = Tag::get_all(&mut *conn).await;
let mut is_tags_ok = false;
let mut is_tags_empty = true;
if let Ok(ts) = tags {
is_tags_ok = true;
is_tags_empty = ts.is_empty();
for tag in ts {
let count = tag.get_tagged_quotes_count(&mut *conn).await;
tags_with_counts.push((tag, count));
}
}
Ok(base( Ok(base(
"Tags | Mnemosyne", "Tags | Mnemosyne",
@@ -38,23 +54,23 @@ pub async fn page(req: Request) -> Result<Response, AuthError> {
span class="text-2xl font-semibold font-lora" {"Tags"} span class="text-2xl font-semibold font-lora" {"Tags"}
} }
p class="text-neutral-500 text-sm font-light" { p class="text-neutral-500 text-sm font-light" {
@if let Ok(c) = Tag::total_count(&conn) { @if let Ok(c) = total_tags {
(c) " tags in total." (c) " tags in total."
} @else { } @else {
"Could not get total tag count." "Could not get total tag count."
} }
} }
} }
@if let Ok(tags) = Tag::get_all(&conn) { @if is_tags_ok {
div class="max-w-4xl mx-auto px-2 mt-4 flex flex-wrap gap-2" { div class="max-w-4xl mx-auto px-2 mt-4 flex flex-wrap gap-2" {
@for tag in &tags { @for (tag, count) in tags_with_counts {
div class="rounded-full px-3 py-1 bg-neutral-200/10 border border-neutral-200/15 flex" { div class="rounded-full px-3 py-1 bg-neutral-200/10 border border-neutral-200/15 flex" {
span class="text-neutral-400 text-sm" {"#"} span class="text-neutral-400 text-sm" {"#"}
span class="text-sm" {(tag.name)} span class="text-sm" {(tag.name)}
div class="w-px h-2/3 my-auto mx-2 bg-neutral-200/15" {} div class="w-px h-2/3 my-auto mx-2 bg-neutral-200/15" {}
div class="text-xs flex items-center" { div class="text-xs flex items-center" {
( (
if let Ok(i) = tag.get_tagged_quotes_count(&conn) { if let Ok(i) = &count {
i.to_string() i.to_string()
} else { } else {
"?".to_string() "?".to_string()
@@ -63,7 +79,7 @@ pub async fn page(req: Request) -> Result<Response, AuthError> {
// div class="ml-2" {} // div class="ml-2" {}
// "0" span class="*:size-3 ml-1 text-neutral-400" {(PreEscaped(icons::FILE_IMAGE))} // "0" span class="*:size-3 ml-1 text-neutral-400" {(PreEscaped(icons::FILE_IMAGE))}
} }
@if let Ok(0) = tag.get_tagged_quotes_count(&conn) { @if let Ok(0) = count {
form action=(format!("/tags/{}/delete", tag.id)) method="post" class="flex items-center ml-1" { form action=(format!("/tags/{}/delete", tag.id)) method="post" class="flex items-center ml-1" {
button type="submit" class="text-neutral-500 hover:text-red-400 text-sm flex items-center justify-center cursor-pointer" title="Delete" { button type="submit" class="text-neutral-500 hover:text-red-400 text-sm flex items-center justify-center cursor-pointer" title="Delete" {
"" ""
@@ -73,11 +89,11 @@ pub async fn page(req: Request) -> Result<Response, AuthError> {
} }
} }
} }
@if tags.is_empty() { @if is_tags_empty {
p class="text-center p-2" {"No tags yet. How about making one?"} p class="text-center p-2" {"No tags yet. How about making one?"}
} }
div class="mx-auto max-w-4xl mt-4 px-2" { div class="mx-auto max-w-4xl mt-4 px-2" {
h3 class="font-lora font-semibold text-xl" {"Add new tag"} h3 class="font-lora font-semibold text-xl mb-1" {"Add new tag"}
form action="/tags/create" method="post" { form action="/tags/create" method="post" {
label for="tagname" class="text-neutral-500 font-light mt-2" {"Tag Name"} label for="tagname" class="text-neutral-500 font-light mt-2" {"Tag Name"}
div class="flex gap-2" { div class="flex gap-2" {
@@ -101,40 +117,41 @@ pub struct TagForm {
tagname: TagName, tagname: TagName,
} }
pub async fn create( pub async fn create(
State(state): State<MnemoState>,
headers: HeaderMap, headers: HeaderMap,
Form(form): Form<TagForm>, Form(form): Form<TagForm>,
) -> Result<Response, CompositeError> { ) -> Result<Response, CompositeError> {
let u = User::authenticate(&headers)?.required()?; let mut tx = state.pool.begin().await?;
let mut conn = database::conn()?; let u = User::authenticate(&mut *tx, &headers).await?.required()?;
let tx = conn.transaction()?;
let t = Tag::create(&tx, form.tagname)?; let t = Tag::create(&mut *tx, form.tagname).await?;
LogEntry::new( LogEntry::new(
&tx, &mut *tx,
u, u,
LogAction::CreateTag { LogAction::CreateTag {
id: t.id, id: t.id,
name: t.name.to_string(), name: t.name.to_string(),
}, },
)?; )
tx.commit()?; .await?;
tx.commit().await?;
Ok(Redirect::to("/tags").into_response()) Ok(Redirect::to("/tags").into_response())
} }
pub async fn delete_tag( pub async fn delete_tag(
State(state): State<MnemoState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
headers: HeaderMap, headers: HeaderMap,
) -> Result<Response, CompositeError> { ) -> Result<Response, CompositeError> {
let u = User::authenticate(&headers)?.required()?; let mut tx = state.pool.begin().await?;
let mut conn = database::conn()?; let u = User::authenticate(&mut *tx, &headers).await?.required()?;
let tx = conn.transaction()?;
let t = Tag::get_by_id(&tx, id)?; let t = Tag::get_by_id(&mut *tx, id).await?;
let name = t.name.as_str().to_string(); let name = t.name.as_str().to_string();
t.delete(&tx)?; t.delete(&mut *tx).await?;
LogEntry::new(&tx, u, LogAction::DeleteTag { id, name })?; LogEntry::new(&mut *tx, u, LogAction::DeleteTag { id, name }).await?;
tx.commit()?; tx.commit().await?;
Ok(Redirect::to("/tags").into_response()) Ok(Redirect::to("/tags").into_response())
} }

View File

@@ -1,16 +1,13 @@
use axum::{ use axum::{
extract::Request, extract::{Request, State},
response::{IntoResponse, Redirect, Response}, response::{IntoResponse, Redirect, Response},
}; };
use maud::{PreEscaped, html}; use maud::{PreEscaped, html};
use crate::{ use crate::{
database, MnemoState,
users::{ error::CompositeError,
User, users::{User, auth::UserAuthenticate, permissions::Permission},
auth::{AuthError, UserAuthenticate},
permissions::Permission,
},
web::{ web::{
components::{nav::nav, user_miniprofile::user_miniprofile}, components::{nav::nav, user_miniprofile::user_miniprofile},
icons, icons,
@@ -21,13 +18,19 @@ use crate::{
pub mod create; pub mod create;
pub mod profile; pub mod profile;
pub async fn page(req: Request) -> Result<Response, AuthError> { pub async fn page(
let u = match User::authenticate(req.headers())? { State(state): State<MnemoState>,
req: Request,
) -> Result<Response, CompositeError> {
let mut conn = state.pool.acquire().await?;
let u = match User::authenticate(&mut *conn, req.headers()).await? {
Some(u) => u, Some(u) => u,
None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()), None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()),
}; };
let conn = database::conn()?; let us = User::get_all(&mut *conn).await;
let us = User::get_all(&conn); let can_create_users = u
.has_permission(&mut *conn, Permission::ManuallyCreateUsers)
.await;
Ok(base( Ok(base(
"Users | Mnemosyne", "Users | Mnemosyne",
@@ -45,7 +48,7 @@ pub async fn page(req: Request) -> Result<Response, AuthError> {
} @else { } @else {
"Could not fetch user count." "Could not fetch user count."
} }
@if let Ok(true) = u.has_permission(&conn, Permission::ManuallyCreateUsers) { @if let Ok(true) = can_create_users {
" " " "
a href="/users/create" class="text-blue-500 hover:text-blue-400 hover:underline" { a href="/users/create" class="text-blue-500 hover:text-blue-400 hover:underline" {
"Create a new user" "Create a new user"

View File

@@ -1,6 +1,6 @@
use axum::{ use axum::{
Form, Form,
extract::Request, extract::{Request, State},
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode},
response::{IntoResponse, Redirect, Response}, response::{IntoResponse, Redirect, Response},
}; };
@@ -8,24 +8,30 @@ use maud::{PreEscaped, html};
use serde::Deserialize; use serde::Deserialize;
use crate::{ use crate::{
database::{self}, MnemoState,
error::CompositeError, error::CompositeError,
logs::{LogAction, LogEntry}, logs::{LogAction, LogEntry},
users::{ users::{
User, User,
auth::{AuthError, UserAuthRequired, UserAuthenticate}, auth::{UserAuthRequired, UserAuthenticate},
handle::UserHandle, handle::UserHandle,
permissions::Permission, permissions::Permission,
}, },
web::{components::nav::nav, icons, pages::base}, web::{components::nav::nav, icons, pages::base},
}; };
pub async fn page(req: Request) -> Result<Response, AuthError> { pub async fn page(
let u = match User::authenticate(req.headers())? { State(state): State<MnemoState>,
req: Request,
) -> Result<Response, CompositeError> {
let mut conn = state.pool.acquire().await?;
let u = match User::authenticate(&mut *conn, req.headers()).await? {
Some(u) => u, Some(u) => u,
None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()), None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()),
}; };
let conn = database::conn()?; let can_create = u
.has_permission(&mut *conn, Permission::ManuallyCreateUsers)
.await;
Ok(base( Ok(base(
"Create User | Mnemosyne", "Create User | Mnemosyne",
@@ -38,7 +44,7 @@ pub async fn page(req: Request) -> Result<Response, AuthError> {
span class="text-2xl font-semibold font-lora" {"Create a new user"} span class="text-2xl font-semibold font-lora" {"Create a new user"}
} }
} }
@if let Ok(true) = u.has_permission(&conn, Permission::ManuallyCreateUsers) { @if let Ok(true) = can_create {
div class="mx-auto max-w-4xl px-2 mt-4" { div class="mx-auto max-w-4xl px-2 mt-4" {
form action="/users/create-form" method="post" class="flex flex-col" { form action="/users/create-form" method="post" class="flex flex-col" {
label for="handle" class="font-light text-neutral-500" {"Handle"} label for="handle" class="font-light text-neutral-500" {"Handle"}
@@ -68,26 +74,30 @@ pub struct CreateUserWithPasswordForm {
password: String, password: String,
} }
pub async fn create_user( pub async fn create_user(
State(state): State<MnemoState>,
headers: HeaderMap, headers: HeaderMap,
Form(form): Form<CreateUserWithPasswordForm>, Form(form): Form<CreateUserWithPasswordForm>,
) -> Result<Response, CompositeError> { ) -> Result<Response, CompositeError> {
let u = User::authenticate(&headers)?.required()?; let mut tx = state.pool.begin().await?;
let mut conn = database::conn()?; let u = User::authenticate(&mut *tx, &headers).await?.required()?;
let tx = conn.transaction()?;
if !u.has_permission(&tx, Permission::ManuallyCreateUsers)? { if !u
.has_permission(&mut *tx, Permission::ManuallyCreateUsers)
.await?
{
return Ok((StatusCode::FORBIDDEN).into_response()); return Ok((StatusCode::FORBIDDEN).into_response());
} }
let mut nu = User::create(&tx, form.handle)?; let mut nu = User::create(&mut *tx, form.handle).await?;
nu.set_password(&tx, Some(&form.password))?; nu.set_password(&mut *tx, Some(&form.password)).await?;
LogEntry::new( LogEntry::new(
&tx, &mut *tx,
u, u,
LogAction::CreateUser { LogAction::CreateUser {
id: nu.id, id: nu.id,
handle: nu.handle.as_str().to_string(), handle: nu.handle.as_str().to_string(),
}, },
)?; )
tx.commit()?; .await?;
tx.commit().await?;
Ok(Redirect::to("/users").into_response()) Ok(Redirect::to("/users").into_response())
} }

View File

@@ -1,16 +1,13 @@
use axum::{ use axum::{
extract::{Path, Request}, extract::{Path, Request, State},
response::{IntoResponse, Redirect, Response}, response::{IntoResponse, Redirect, Response},
}; };
use chrono::{DateTime, Utc};
use maud::{PreEscaped, html}; use maud::{PreEscaped, html};
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
database::{self}, MnemoState,
error::CompositeError, error::CompositeError,
persons::Name,
quotes::{Quote, QuoteLine},
users::{User, UserError, auth::UserAuthenticate}, users::{User, UserError, auth::UserAuthenticate},
web::{ web::{
components::{nav::nav, quote::quote}, components::{nav::nav, quote::quote},
@@ -19,15 +16,18 @@ use crate::{
}, },
}; };
pub async fn page(Path(id): Path<Uuid>, req: Request) -> Result<Response, CompositeError> { pub async fn page(
let u = match User::authenticate(req.headers())? { State(state): State<MnemoState>,
Path(id): Path<Uuid>,
req: Request,
) -> Result<Response, CompositeError> {
let mut tx = state.pool.acquire().await?;
let u = match User::authenticate(&mut *tx, req.headers()).await? {
Some(u) => u, Some(u) => u,
None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()), None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()),
}; };
let mut conn = database::conn()?;
let tx = conn.transaction()?;
let user = match User::get_by_id(&tx, id) { let user = match User::get_by_id(&mut *tx, id).await {
Ok(u) => u, Ok(u) => u,
Err(UserError::NoUserWithId(_)) => { Err(UserError::NoUserWithId(_)) => {
return Ok(base( return Ok(base(
@@ -65,7 +65,7 @@ pub async fn page(Path(id): Path<Uuid>, req: Request) -> Result<Response, Compos
.to_uppercase() .to_uppercase()
.to_string(); .to_string();
let joined_str = user.created_at().map(|d| d.format("%Y-%m-%d").to_string()); let joined_str = user.created_at().map(|d| d.format("%Y-%m-%d").to_string());
let sample_quotes = sample_quotes_for_display(); let sample_quotes = vec![];
Ok(base( Ok(base(
&format!("@{} | Mnemosyne", user.handle), &format!("@{} | Mnemosyne", user.handle),
@@ -199,72 +199,3 @@ pub async fn page(Path(id): Path<Uuid>, req: Request) -> Result<Response, Compos
) )
.into_response()) .into_response())
} }
fn sample_quotes_for_display() -> Vec<Quote> {
vec![
Quote {
id: Uuid::now_v7(),
public: true,
location: Some(String::from("Poznań")),
context: Some(String::from("Wykład z językoznawstwa")),
created_by: Uuid::max(),
timestamp: DateTime::from(Utc::now()),
lines: vec![
QuoteLine {
id: Uuid::now_v7(),
content: String::from("Nie wiem, czy są tutaj osoby fanowskie zipline-ów?"),
attribution: Name {
id: Uuid::nil(),
created_by: Uuid::max(),
person_id: Uuid::now_v7(),
is_primary: true,
name: String::from("dr. Barbara Konat"),
},
},
QuoteLine {
id: Uuid::now_v7(),
content: String::from("Taka uprząż co robi pziuuum!"),
attribution: Name {
id: Uuid::nil(),
created_by: Uuid::max(),
person_id: Uuid::now_v7(),
is_primary: true,
name: String::from("dr. Barbara Konat"),
},
},
],
},
Quote {
id: Uuid::now_v7(),
public: true,
location: Some(String::from("Discord VC")),
context: Some(String::from("O narysowanej dziewczynie")),
created_by: Uuid::max(),
timestamp: DateTime::from(Utc::now()),
lines: vec![
QuoteLine {
id: Uuid::now_v7(),
content: String::from("Czy tu proporcje są zachowane?"),
attribution: Name {
id: Uuid::now_v7(),
created_by: Uuid::max(),
person_id: Uuid::now_v7(),
is_primary: true,
name: String::from("Adam"),
},
},
QuoteLine {
id: Uuid::now_v7(),
content: String::from("Adam, ona nie ma kolan."),
attribution: Name {
id: Uuid::nil(),
created_by: Uuid::max(),
person_id: Uuid::now_v7(),
is_primary: true,
name: String::from("Mollin"),
},
},
],
},
]
}

View File

@@ -1,6 +1,6 @@
use axum::{ use axum::{
Form, Form,
extract::Request, extract::{Request, State},
http::HeaderMap, http::HeaderMap,
response::{IntoResponse, Redirect, Response}, response::{IntoResponse, Redirect, Response},
}; };
@@ -8,19 +8,23 @@ use maud::{PreEscaped, html};
use serde::Deserialize; use serde::Deserialize;
use crate::{ use crate::{
database::{self}, MnemoState,
error::CompositeError, error::CompositeError,
logs::{LogAction, LogEntry}, logs::{LogAction, LogEntry},
users::{ users::{
User, User,
auth::{AuthError, UserAuthRequired, UserAuthenticate}, auth::{UserAuthRequired, UserAuthenticate},
handle::UserHandle, handle::UserHandle,
}, },
web::{components::nav::nav, icons, pages::base}, web::{components::nav::nav, icons, pages::base},
}; };
pub async fn page(req: Request) -> Result<Response, AuthError> { pub async fn page(
let u = match User::authenticate(req.headers())? { State(state): State<MnemoState>,
req: Request,
) -> Result<Response, CompositeError> {
let mut conn = state.pool.acquire().await?;
let u = match User::authenticate(&mut *conn, req.headers()).await? {
Some(u) => u, Some(u) => u,
None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()), None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()),
}; };
@@ -77,25 +81,26 @@ pub struct HandleForm {
handle: UserHandle, handle: UserHandle,
} }
pub async fn change_handle( pub async fn change_handle(
State(state): State<MnemoState>,
headers: HeaderMap, headers: HeaderMap,
Form(form): Form<HandleForm>, Form(form): Form<HandleForm>,
) -> Result<Response, CompositeError> { ) -> Result<Response, CompositeError> {
let mut u = User::authenticate(&headers)?.required()?; let mut tx = state.pool.begin().await?;
let mut conn = database::conn()?; let mut u = User::authenticate(&mut *tx, &headers).await?.required()?;
let tx = conn.transaction()?;
let oldhandle = u.handle.as_str().to_string(); let oldhandle = u.handle.as_str().to_string();
u.set_handle(&tx, form.handle)?; u.set_handle(&mut *tx, form.handle).await?;
LogEntry::new( LogEntry::new(
&tx, &mut *tx,
u.clone(), u.clone(),
LogAction::ChangeUserHandle { LogAction::ChangeUserHandle {
id: u.id, id: u.id,
old: oldhandle, old: oldhandle,
new: u.handle.as_str().to_string(), new: u.handle.as_str().to_string(),
}, },
)?; )
tx.commit()?; .await?;
tx.commit().await?;
Ok(Redirect::to("/user-settings").into_response()) Ok(Redirect::to("/user-settings").into_response())
} }
@@ -104,13 +109,22 @@ pub struct PasswordForm {
password: String, password: String,
} }
pub async fn change_password( pub async fn change_password(
State(state): State<MnemoState>,
headers: HeaderMap, headers: HeaderMap,
Form(form): Form<PasswordForm>, Form(form): Form<PasswordForm>,
) -> Result<Response, CompositeError> { ) -> Result<Response, CompositeError> {
let mut u = User::authenticate(&headers)?.required()?; if form.password.trim().is_empty() {
let mut conn = database::conn()?; return Ok((
let tx = conn.transaction()?; axum::http::StatusCode::BAD_REQUEST,
u.set_password(&tx, Some(&form.password))?; "Password cannot be empty or consist only of whitespace.",
tx.commit()?; )
.into_response());
}
let mut tx = state.pool.begin().await?;
let mut u = User::authenticate(&mut *tx, &headers).await?.required()?;
u.set_password(&mut *tx, Some(&form.password)).await?;
tx.commit().await?;
Ok(Redirect::to("/user-settings").into_response()) Ok(Redirect::to("/user-settings").into_response())
} }