Compare commits
80 Commits
9163e38cec
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
256d12c9c8
|
|||
|
65edef47b2
|
|||
|
9b69a0a5ee
|
|||
|
e2e9a3efb5
|
|||
|
1f07952973
|
|||
|
b1ccd21068
|
|||
|
7d284f0777
|
|||
|
84dde9cc4b
|
|||
|
e7c0523841
|
|||
|
7fe1b6f8be
|
|||
|
ca726c8e8b
|
|||
|
a08ba568cb
|
|||
|
dd75d89472
|
|||
|
9eb3332576
|
|||
|
032d450af2
|
|||
|
76ac36c4fb
|
|||
|
29804e75e5
|
|||
|
0be4f11f66
|
|||
|
f876ff3f00
|
|||
|
47cd13f734
|
|||
|
cdd296ea84
|
|||
|
4d49a5c0b3
|
|||
| 4aa96dca01 | |||
|
be462dc662
|
|||
| 05d4aca741 | |||
|
ffe1a4d8d2
|
|||
|
24df6054ea
|
|||
|
4229444f96
|
|||
| 202b81e517 | |||
|
1578c3a708
|
|||
| ccc1be0d07 | |||
|
851f73f639
|
|||
| 55c7ad6d6a | |||
|
7a0ef9a3ad
|
|||
| 021489c740 | |||
|
f119b87965
|
|||
|
6f334f3825
|
|||
|
f83d34a50b
|
|||
|
a282e4f445
|
|||
|
f239de1ca0
|
|||
|
914d8a48c1
|
|||
|
efb6226421
|
|||
|
7d418c91e4
|
|||
|
fac1959193
|
|||
|
a80a64ceec
|
|||
|
41711dd7fb
|
|||
|
84e2ad3918
|
|||
|
983e1ae88f
|
|||
|
48e14a5830
|
|||
|
26576ec31a
|
|||
|
d20387c2c1
|
|||
|
93b3083d2b
|
|||
|
d14a587794
|
|||
|
aab9d87df2
|
|||
|
879c5ee3d3
|
|||
| 665915f61b | |||
|
acfd8a6d72
|
|||
| 35932da2f7 | |||
|
34f48c2b3d
|
|||
|
66c4065059
|
|||
| fe03b17cb9 | |||
|
fc13b166a0
|
|||
| 13759498ff | |||
|
3f98a10df8
|
|||
| 9b24e68691 | |||
|
ab01d0d275
|
|||
| 060fe7a3a3 | |||
|
8fe1f52644
|
|||
| a0cd0ad633 | |||
|
14abdc9e4a
|
|||
| a3f5ccfcb7 | |||
|
9fa19d6caf
|
|||
|
a326d5f17d
|
|||
| e35da127aa | |||
|
b93cdfba63
|
|||
| 23595e8008 | |||
|
3c111212f0
|
|||
| 1f9a854122 | |||
|
b3fac1f9ec
|
|||
|
cec765bcf3
|
1
.gitignore
vendored
@@ -2,6 +2,7 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
/database
|
/database
|
||||||
/mnemodata
|
/mnemodata
|
||||||
|
/scripts
|
||||||
*.db
|
*.db
|
||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
{
|
{
|
||||||
"languages": {
|
"languages": {
|
||||||
"Rust": {
|
"Rust": {
|
||||||
"language_servers": ["rust-analyzer", "tailwindcss-language-server"],
|
"language_servers": [
|
||||||
|
"rust-analyzer",
|
||||||
|
//
|
||||||
|
"tailwindcss-language-server",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"lsp": {
|
"lsp": {
|
||||||
|
|||||||
1769
Cargo.lock
generated
@@ -13,17 +13,20 @@ chrono = { version = "0.4.43", features = ["serde"] }
|
|||||||
chrono-tz = "0.10.4"
|
chrono-tz = "0.10.4"
|
||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
env_logger = "0.11.9"
|
env_logger = "0.11.9"
|
||||||
|
http = "1.4.0"
|
||||||
log = "0.4.29"
|
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"] }
|
reqwest = { version = "0.13.3", features = ["json"] }
|
||||||
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"] }
|
||||||
tower = { version = "0.5.3", features = ["full"] }
|
tower = { version = "0.5.3", features = ["full"] }
|
||||||
tower-http = { version = "0.6.8", features = ["full"] }
|
tower-http = { version = "0.6.8", features = ["full"] }
|
||||||
|
url = { version = "2.5.8", features = ["serde"] }
|
||||||
uuid = { version = "1.21.0", features = ["serde", "v7"] }
|
uuid = { version = "1.21.0", features = ["serde", "v7"] }
|
||||||
|
|||||||
1
build.rs
@@ -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"));
|
||||||
|
|||||||
43
compose.yaml
@@ -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
|
||||||
|
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
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -26,6 +28,10 @@ pub fn api_router() -> Router {
|
|||||||
.route("/api/users/@{handle}", get(users::get_by_handle))
|
.route("/api/users/@{handle}", get(users::get_by_handle))
|
||||||
.route("/api/users/{id}/setpassw", post(users::change_password))
|
.route("/api/users/{id}/setpassw", post(users::change_password))
|
||||||
.route("/api/users/{id}/sethandle", post(users::change_handle))
|
.route("/api/users/{id}/sethandle", post(users::change_handle))
|
||||||
|
.route(
|
||||||
|
"/api/users/{id}/permissions/{perm}",
|
||||||
|
get(users::get_permission).put(users::put_permission),
|
||||||
|
)
|
||||||
// sessions
|
// sessions
|
||||||
.route("/api/sessions/{id}", get(sessions::get_by_id))
|
.route("/api/sessions/{id}", get(sessions::get_by_id))
|
||||||
.route("/api/sessions/{id}/revoke", post(sessions::revoke_by_id))
|
.route("/api/sessions/{id}/revoke", post(sessions::revoke_by_id))
|
||||||
@@ -42,9 +48,11 @@ pub fn api_router() -> Router {
|
|||||||
.route("/api/persons/{id}", get(persons::get_by_id))
|
.route("/api/persons/{id}", get(persons::get_by_id))
|
||||||
.route("/api/persons/{id}/names", get(persons::pid_names))
|
.route("/api/persons/{id}/names", get(persons::pid_names))
|
||||||
.route("/api/persons/{id}/addname", post(persons::add_name))
|
.route("/api/persons/{id}/addname", post(persons::add_name))
|
||||||
|
.route("/api/names", get(persons::n_all))
|
||||||
.route("/api/names/{id}", get(persons::n_by_id))
|
.route("/api/names/{id}", get(persons::n_by_id))
|
||||||
.route("/api/names/{id}/setprimary", post(persons::n_setprimary))
|
.route("/api/names/{id}/setprimary", post(persons::n_setprimary))
|
||||||
// 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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,34 +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_by_id(Path(id): Path<Uuid>, 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_by_id(&conn, id)?).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_setprimary(
|
|
||||||
|
pub async fn n_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 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,
|
||||||
@@ -123,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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,78 @@ 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,
|
||||||
|
#[serde(default)]
|
||||||
|
pub discord_webhook: 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(&mut tx, u, LogAction::CreateQuote { id: q.id }).await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
if form.discord_webhook {
|
||||||
|
if let Some(ref url) = state.conf.read().await.discord_webhook {
|
||||||
|
q.post_msg_webhook(url.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LogEntry::new(&tx, u, LogAction::CreateQuote { id: q.id })?;
|
|
||||||
tx.commit()?;
|
|
||||||
Ok((StatusCode::CREATED, Json(q)).into_response())
|
Ok((StatusCode::CREATED, Json(q)).into_response())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))?,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
184
src/api/users.rs
@@ -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,49 +8,59 @@ 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::{
|
||||||
User,
|
User,
|
||||||
auth::{UserAuthRequired, UserAuthenticate},
|
auth::{UserAuthRequired, UserAuthenticate},
|
||||||
handle::UserHandle,
|
handle::UserHandle,
|
||||||
permissions::Permission,
|
permissions::{Permission, PermissionState},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const CANT_CHANGE_OTHERS_HANDLE: &str = "You don't have permission to change this user's handle.";
|
const CANT_CHANGE_OTHERS_HANDLE: &str = "You don't have permission to change this user's handle.";
|
||||||
const CANT_CHANGE_OTHERS_PASSW: &str = "You don't have permission to change this user's password.";
|
const CANT_CHANGE_OTHERS_PASSW: &str = "You don't have permission to change this user's password.";
|
||||||
const CANT_MANUALLY_MAKE_USERS: &str = "You don't have permission to manually create new users.";
|
const CANT_MANUALLY_MAKE_USERS: &str = "You don't have permission to manually create new users.";
|
||||||
|
const GO_AWAY: &str = "You don't have permission to look into permissions!";
|
||||||
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 +68,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 +140,113 @@ 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())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_permission(
|
||||||
|
State(state): State<MnemoState>,
|
||||||
|
Path((uid, perm)): Path<(Uuid, Permission)>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Response, CompositeError> {
|
||||||
|
let mut conn = state.pool.acquire().await?;
|
||||||
|
let u = User::authenticate(&mut conn, &headers).await?.required()?;
|
||||||
|
if !u.has_permission(&mut conn, Permission::Admin).await? {
|
||||||
|
return Ok((StatusCode::FORBIDDEN, GO_AWAY).into_response());
|
||||||
|
}
|
||||||
|
let target = User::get_by_id(&mut conn, uid).await?;
|
||||||
|
let has: PermissionState = target.permission_dbstate(&mut conn, perm).await?.into();
|
||||||
|
|
||||||
|
Ok((StatusCode::OK, Json(has)).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn put_permission(
|
||||||
|
State(state): State<MnemoState>,
|
||||||
|
Path((uid, perm)): Path<(Uuid, Permission)>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(newstate): Json<PermissionState>,
|
||||||
|
) -> 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::Admin).await? {
|
||||||
|
return Ok((StatusCode::FORBIDDEN, GO_AWAY).into_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = User::get_by_id(&mut tx, uid).await?;
|
||||||
|
let os: PermissionState = target.permission_dbstate(&mut tx, perm).await?.into();
|
||||||
|
match newstate {
|
||||||
|
PermissionState::ExplicitlyGranted => {
|
||||||
|
target.grant_permission(&mut tx, perm).await?;
|
||||||
|
LogEntry::new(
|
||||||
|
&mut tx,
|
||||||
|
u,
|
||||||
|
LogAction::UpdatePermission {
|
||||||
|
id: target.id,
|
||||||
|
os,
|
||||||
|
ns: newstate,
|
||||||
|
p: perm,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
PermissionState::ExplicitlyRevoked => {
|
||||||
|
target.revoke_permission(&mut tx, perm).await?;
|
||||||
|
LogEntry::new(
|
||||||
|
&mut tx,
|
||||||
|
u,
|
||||||
|
LogAction::UpdatePermission {
|
||||||
|
id: target.id,
|
||||||
|
os,
|
||||||
|
ns: newstate,
|
||||||
|
p: perm,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
PermissionState::Implicit => {
|
||||||
|
target.reset_permission(&mut tx, perm).await?;
|
||||||
|
LogEntry::new(
|
||||||
|
&mut tx,
|
||||||
|
u,
|
||||||
|
LogAction::UpdatePermission {
|
||||||
|
id: target.id,
|
||||||
|
os,
|
||||||
|
ns: newstate,
|
||||||
|
p: perm,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok((StatusCode::OK, Json(newstate)).into_response())
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,61 @@
|
|||||||
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;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
/// Mnemosyne, the mother of the nine muses
|
||||||
|
pub const DEFAULT_PORT: u16 = 0x9999; // 39321
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct MnemoConf {
|
||||||
|
pub instance_name: String,
|
||||||
|
pub discord_webhook: Option<Url>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MnemoConf {
|
||||||
|
pub async fn load(conn: &mut sqlx::PgConnection) -> Result<Self, sqlx::Error> {
|
||||||
|
let row: Option<serde_json::Value> =
|
||||||
|
sqlx::query_scalar("SELECT config FROM mnemoconf LIMIT 1")
|
||||||
|
.fetch_optional(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(match row {
|
||||||
|
Some(val) => serde_json::from_value(val).unwrap_or_default(),
|
||||||
|
None => {
|
||||||
|
let conf = MnemoConf::default();
|
||||||
|
conf.save(conn).await?;
|
||||||
|
conf
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save(&self, conn: &mut sqlx::PgConnection) -> Result<(), sqlx::Error> {
|
||||||
|
let val = serde_json::to_value(self).unwrap();
|
||||||
|
sqlx::query("DELETE FROM mnemoconf")
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("INSERT INTO mnemoconf (config) VALUES ($1)")
|
||||||
|
.bind(val)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Default for MnemoConf {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
instance_name: String::from("Mnemosyne"),
|
||||||
|
discord_webhook: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub const REFERENCE_SPLASHES: &[&str] = &[
|
pub const REFERENCE_SPLASHES: &[&str] = &[
|
||||||
"quote engine",
|
"quote engine",
|
||||||
@@ -14,8 +68,47 @@ pub const REFERENCE_SPLASHES: &[&str] = &[
|
|||||||
"memory palace",
|
"memory palace",
|
||||||
"take a break sometimes",
|
"take a break sometimes",
|
||||||
"segmentation fault (jk)",
|
"segmentation fault (jk)",
|
||||||
|
"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());
|
||||||
|
|||||||
134
src/database/migrations/0001_init.sql
Normal 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();
|
||||||
3
src/database/migrations/0002_conf.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
CREATE TABLE mnemoconf (
|
||||||
|
config JSONB NOT NULL
|
||||||
|
);
|
||||||
@@ -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
|
|
||||||
@@ -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(())
|
|
||||||
}
|
|
||||||
|
|||||||
29
src/error.rs
@@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
212
src/logs.rs
@@ -1,9 +1,18 @@
|
|||||||
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 url::Url;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{database::DatabaseError, users::User};
|
use crate::{
|
||||||
|
database::DatabaseError,
|
||||||
|
quotes::Quote,
|
||||||
|
users::{
|
||||||
|
User,
|
||||||
|
permissions::{Permission, PermissionState},
|
||||||
|
},
|
||||||
|
web::icons,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct LogEntry {
|
pub struct LogEntry {
|
||||||
@@ -13,45 +22,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 get_all(conn: &Connection) -> Result<Vec<LogEntry>, DatabaseError> {
|
|
||||||
Ok(conn
|
pub async fn count(
|
||||||
.prepare("SELECT id, actor, target, actiontype, payload FROM logs ORDER BY id DESC")?
|
conn: &mut PgConnection,
|
||||||
.query_map((), |r| {
|
action_type: Option<LogActionDiscriminant>,
|
||||||
let payload: String = r.get(4)?;
|
) -> Result<i64, DatabaseError> {
|
||||||
Ok(LogEntry {
|
let count = match action_type {
|
||||||
id: r.get(0)?,
|
Some(at) => {
|
||||||
actor: User::get_by_id(conn, r.get(1)?).unwrap(),
|
let atstr: &'static str = at.into();
|
||||||
data: serde_json::from_str(&payload).unwrap(),
|
sqlx::query_scalar("SELECT COUNT(*) FROM logs WHERE actiontype = $1")
|
||||||
})
|
.bind(atstr)
|
||||||
})?
|
.fetch_one(&mut *conn)
|
||||||
.collect::<Result<Vec<LogEntry>, _>>()?)
|
.await?
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
sqlx::query_scalar("SELECT COUNT(*) FROM logs")
|
||||||
|
.fetch_one(&mut *conn)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_chronological_offset(
|
||||||
|
conn: &mut PgConnection,
|
||||||
|
action_type: Option<LogActionDiscriminant>,
|
||||||
|
offset: i64,
|
||||||
|
limit: i64,
|
||||||
|
) -> Result<Vec<LogEntry>, DatabaseError> {
|
||||||
|
let mut qstr = String::from("SELECT id, actor, payload FROM logs ");
|
||||||
|
if action_type.is_some() {
|
||||||
|
qstr += "WHERE actiontype = $3 "
|
||||||
|
}
|
||||||
|
qstr += "ORDER BY id DESC LIMIT $1 OFFSET $2";
|
||||||
|
|
||||||
|
let q = sqlx::query(&qstr).bind(limit).bind(offset);
|
||||||
|
|
||||||
|
let rows = match action_type {
|
||||||
|
Some(at) => {
|
||||||
|
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,
|
||||||
@@ -59,6 +114,12 @@ pub enum LogAction {
|
|||||||
id: Uuid,
|
id: Uuid,
|
||||||
handle: String,
|
handle: String,
|
||||||
},
|
},
|
||||||
|
UpdatePermission {
|
||||||
|
id: Uuid,
|
||||||
|
os: PermissionState,
|
||||||
|
ns: PermissionState,
|
||||||
|
p: Permission,
|
||||||
|
},
|
||||||
ManuallyChangeUsersPassword {
|
ManuallyChangeUsersPassword {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
},
|
},
|
||||||
@@ -90,6 +151,12 @@ pub enum LogAction {
|
|||||||
pn: String, // primary name
|
pn: String, // primary name
|
||||||
nn: String, // new name
|
nn: String, // new name
|
||||||
},
|
},
|
||||||
|
DeletePersonName {
|
||||||
|
pid: Uuid,
|
||||||
|
nid: Uuid,
|
||||||
|
pn: String,
|
||||||
|
n: String,
|
||||||
|
},
|
||||||
SetPersonPrimaryName {
|
SetPersonPrimaryName {
|
||||||
pid: Uuid, // person id
|
pid: Uuid, // person id
|
||||||
nid: Uuid, // name id
|
nid: Uuid, // name id
|
||||||
@@ -99,14 +166,26 @@ pub enum LogAction {
|
|||||||
CreateQuote {
|
CreateQuote {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
},
|
},
|
||||||
|
DeleteQuote {
|
||||||
|
quote: Quote,
|
||||||
|
},
|
||||||
ManuallyRevokeSession {
|
ManuallyRevokeSession {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
},
|
},
|
||||||
|
ChangeInstanceName {
|
||||||
|
old: String,
|
||||||
|
new: String,
|
||||||
|
},
|
||||||
|
ChangeDiscordWebhookUrl {
|
||||||
|
old: Option<Url>,
|
||||||
|
new: Option<Url>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
impl LogAction {
|
impl LogAction {
|
||||||
pub fn get_target_id(&self) -> Option<Uuid> {
|
pub fn get_target_id(&self) -> Option<Uuid> {
|
||||||
match self {
|
match self {
|
||||||
Self::Initialize | Self::RegenInfradmin => None,
|
Self::Initialize | Self::RegenInfradmin => None,
|
||||||
|
|
||||||
Self::CreateUser { id, .. }
|
Self::CreateUser { id, .. }
|
||||||
| Self::CreateTag { id, .. }
|
| Self::CreateTag { id, .. }
|
||||||
| Self::CreatePerson { id, .. }
|
| Self::CreatePerson { id, .. }
|
||||||
@@ -115,8 +194,18 @@ impl LogAction {
|
|||||||
| Self::ManuallyRevokeSession { id }
|
| Self::ManuallyRevokeSession { id }
|
||||||
| Self::RenameTag { id, .. }
|
| Self::RenameTag { id, .. }
|
||||||
| Self::DeleteTag { id, .. }
|
| Self::DeleteTag { id, .. }
|
||||||
|
| Self::UpdatePermission { id, .. }
|
||||||
| Self::ManuallyChangeUsersPassword { id } => Some(*id),
|
| Self::ManuallyChangeUsersPassword { id } => Some(*id),
|
||||||
Self::AddPersonName { pid, .. } | Self::SetPersonPrimaryName { pid, .. } => Some(*pid),
|
|
||||||
|
Self::DeleteQuote { quote } => Some(quote.id),
|
||||||
|
|
||||||
|
Self::AddPersonName { pid, .. }
|
||||||
|
| Self::DeletePersonName { pid, .. }
|
||||||
|
| Self::SetPersonPrimaryName { pid, .. } => Some(*pid),
|
||||||
|
|
||||||
|
Self::ChangeInstanceName { .. } | Self::ChangeDiscordWebhookUrl { .. } => {
|
||||||
|
Some(Uuid::nil())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn get_humanreadable_payload(&self) -> String {
|
pub fn get_humanreadable_payload(&self) -> String {
|
||||||
@@ -126,6 +215,9 @@ impl LogAction {
|
|||||||
LogAction::CreateUser { id, handle } => {
|
LogAction::CreateUser { id, handle } => {
|
||||||
format!("Created user @{handle} (uid: {id})")
|
format!("Created user @{handle} (uid: {id})")
|
||||||
}
|
}
|
||||||
|
LogAction::UpdatePermission { id, os, ns, p } => {
|
||||||
|
format!("Updated permission {p} of user with id {id} from {os} to {ns}")
|
||||||
|
}
|
||||||
LogAction::ManuallyChangeUsersPassword { id } => {
|
LogAction::ManuallyChangeUsersPassword { id } => {
|
||||||
format!("Manually changed password of user with id: {id}")
|
format!("Manually changed password of user with id: {id}")
|
||||||
}
|
}
|
||||||
@@ -147,15 +239,71 @@ impl LogAction {
|
|||||||
LogAction::AddPersonName { pid, nid, pn, nn } => {
|
LogAction::AddPersonName { pid, nid, pn, nn } => {
|
||||||
format!("Added name \"{nn}\" to ~{pn} (pid: {pid}; nid: {nid})")
|
format!("Added name \"{nn}\" to ~{pn} (pid: {pid}; nid: {nid})")
|
||||||
}
|
}
|
||||||
|
LogAction::DeletePersonName { pid, nid, pn, n } => {
|
||||||
|
format!("Deleted name \"{n}\" from ~{pn} (pid: {pid}; nid: {nid})")
|
||||||
|
}
|
||||||
LogAction::SetPersonPrimaryName { pid, nid, on, nn } => {
|
LogAction::SetPersonPrimaryName { pid, nid, on, nn } => {
|
||||||
format!("~{on} now has primary name \"{nn}\" (pid: {pid}; nid: {nid})")
|
format!("~{on} now has primary name \"{nn}\" (pid: {pid}; nid: {nid})")
|
||||||
}
|
}
|
||||||
LogAction::CreateQuote { id } => {
|
LogAction::CreateQuote { id } => {
|
||||||
format!("Created quote of ID {id}")
|
format!("Created quote of ID {id}")
|
||||||
}
|
}
|
||||||
|
LogAction::DeleteQuote { quote } => {
|
||||||
|
format!("Deleted quote of ID {}", quote.id)
|
||||||
|
}
|
||||||
LogAction::ManuallyRevokeSession { id } => {
|
LogAction::ManuallyRevokeSession { id } => {
|
||||||
format!("Revoked session of ID {id}")
|
format!("Revoked session of ID {id}")
|
||||||
}
|
}
|
||||||
|
LogAction::ChangeInstanceName { old, new } => {
|
||||||
|
format!("Changed instance name from \"{old}\" to \"{new}\"")
|
||||||
|
}
|
||||||
|
LogAction::ChangeDiscordWebhookUrl { .. } => "Changed Discord webhook URL".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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::UpdatePermission => "Permission Update",
|
||||||
|
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::DeleteQuote => "Quote Deletion",
|
||||||
|
LAD::ManuallyRevokeSession => "Manual Session Revocation",
|
||||||
|
LAD::ChangeInstanceName => "Instance Name Change",
|
||||||
|
LAD::ChangeDiscordWebhookUrl => "Discord Webhook URL Change",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
48
src/main.rs
@@ -1,7 +1,10 @@
|
|||||||
use std::error::Error;
|
use std::{error::Error, sync::Arc};
|
||||||
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use tokio::net::TcpListener;
|
use sqlx::PgPool;
|
||||||
|
use tokio::{net::TcpListener, sync::RwLock};
|
||||||
|
|
||||||
|
use crate::config::MnemoConf;
|
||||||
|
|
||||||
mod api;
|
mod api;
|
||||||
mod config;
|
mod config;
|
||||||
@@ -14,39 +17,34 @@ 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,
|
||||||
|
conf: Arc<RwLock<MnemoConf>>,
|
||||||
|
}
|
||||||
|
|
||||||
#[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.");
|
||||||
|
let conf = Arc::new(RwLock::new(
|
||||||
|
MnemoConf::load(&mut *pool.acquire().await?).await?,
|
||||||
|
));
|
||||||
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, conf });
|
||||||
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()?);
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ use axum::{
|
|||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
use rusqlite::{Connection, OptionalExtension};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde::Serialize;
|
use sqlx::{PgConnection, Row};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::database::DatabaseError;
|
use crate::database::DatabaseError;
|
||||||
@@ -12,15 +12,13 @@ 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(Debug, Serialize, Deserialize, Clone)]
|
||||||
pub struct Name {
|
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,159 +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")?
|
"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 set_primary(&mut self, conn: &Connection) -> Result<(), PersonError> {
|
|
||||||
|
pub async fn times_attributed(&self, conn: &mut PgConnection) -> Result<i64, PersonError> {
|
||||||
|
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM line_authors WHERE name_id = $1")
|
||||||
|
.bind(self.id)
|
||||||
|
.fetch_one(conn)
|
||||||
|
.await?;
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(self, conn: &mut PgConnection) -> Result<(), PersonError> {
|
||||||
|
sqlx::query("DELETE FROM names WHERE id = $1")
|
||||||
|
.bind(self.id)
|
||||||
|
.execute(conn)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,31 @@
|
|||||||
use axum::{http::StatusCode, response::IntoResponse};
|
use axum::{
|
||||||
use chrono::{DateTime, FixedOffset, Utc};
|
http::StatusCode,
|
||||||
use rusqlite::{Connection, OptionalExtension};
|
response::{IntoResponse, Response},
|
||||||
use serde::Serialize;
|
};
|
||||||
|
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::{PgConnection, Row};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{database::DatabaseError, persons::Name};
|
use crate::{database::DatabaseError, persons::Name};
|
||||||
|
|
||||||
#[derive(Serialize)]
|
mod webhook;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
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,
|
||||||
pub public: bool,
|
pub public: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
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 +49,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,34 +119,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_chronological_offset(
|
|
||||||
conn: &Connection,
|
pub async fn get_newest_public(conn: &mut PgConnection) -> Result<Option<Quote>, QuoteError> {
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_random(conn: &mut PgConnection) -> Result<Option<Quote>, QuoteError> {
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
@@ -139,27 +225,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 {
|
||||||
@@ -179,16 +290,50 @@ impl Quote {
|
|||||||
public,
|
public,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn delete(self, conn: &mut PgConnection) -> Result<(), QuoteError> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
DELETE FROM line_authors
|
||||||
|
WHERE line_id IN (SELECT id FROM lines WHERE quote_id = $1)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(self.id)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM lines WHERE quote_id = $1")
|
||||||
|
.bind(self.id)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM quote_tags WHERE quote_id = $1")
|
||||||
|
.bind(self.id)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM user_quote_likes WHERE quote_id = $1")
|
||||||
|
.bind(self.id)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM quotes WHERE id = $1")
|
||||||
|
.bind(self.id)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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(),
|
||||||
|
|||||||
56
src/quotes/webhook.rs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
use reqwest::Url;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::quotes::Quote;
|
||||||
|
|
||||||
|
impl Quote {
|
||||||
|
pub fn post_msg_webhook(&self, url: Url) {
|
||||||
|
let mut message = String::new();
|
||||||
|
for line in &self.lines {
|
||||||
|
let authors = line
|
||||||
|
.attribution
|
||||||
|
.iter()
|
||||||
|
.map(|n| n.name.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
message.push_str(&format!("> {}\n", line.content));
|
||||||
|
message.push_str(&format!("~ {}\n", authors));
|
||||||
|
}
|
||||||
|
|
||||||
|
message.push_str(&format!("\n-# {}", self.timestamp));
|
||||||
|
let escape_md = |s: &str| s.replace('*', "\\*").replace('_', "\\_");
|
||||||
|
|
||||||
|
if let Some(ctx) = &self.location {
|
||||||
|
message.push_str(&format!(" | Location: {}", escape_md(ctx)));
|
||||||
|
}
|
||||||
|
if let Some(ctx) = &self.context {
|
||||||
|
message.push_str(&format!(" | Context: *{}*", escape_md(ctx)));
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let client = match reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(10))
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to construct reqwest Client while sending webhook! {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"content": message
|
||||||
|
});
|
||||||
|
match client.post(url).json(&body).send().await {
|
||||||
|
Ok(response) => {
|
||||||
|
if let Err(e) = response.error_for_status() {
|
||||||
|
log::error!("Webhook responded with an HTTP error! {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to POST webhook! {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
151
src/tags.rs
@@ -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() {
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
163
src/users/mod.rs
@@ -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 ORDER BY id")
|
||||||
.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 {
|
||||||
|
|||||||
@@ -1,9 +1,23 @@
|
|||||||
use rusqlite::Connection;
|
use sqlx::PgConnection;
|
||||||
|
use strum::Display;
|
||||||
|
|
||||||
use crate::{database::DatabaseError, users::User};
|
use crate::{database::DatabaseError, users::User};
|
||||||
|
|
||||||
/// Infradmin and systemuser have all permissions.
|
/// Infradmin and systemuser have all permissions.
|
||||||
|
#[derive(
|
||||||
|
Debug,
|
||||||
|
Clone,
|
||||||
|
Copy,
|
||||||
|
PartialEq,
|
||||||
|
strum::IntoStaticStr,
|
||||||
|
Display,
|
||||||
|
serde::Deserialize,
|
||||||
|
serde::Serialize,
|
||||||
|
)]
|
||||||
pub enum Permission {
|
pub enum Permission {
|
||||||
|
// Pass all the permission checks
|
||||||
|
// Additionally, only Admins can manage others' permissions.
|
||||||
|
Admin,
|
||||||
// All Users have the right to observe their own sessions
|
// All Users have the right to observe their own sessions
|
||||||
ListOthersSessions,
|
ListOthersSessions,
|
||||||
// All Users have the right to revoke their own sessions
|
// All Users have the right to revoke their own sessions
|
||||||
@@ -16,22 +30,130 @@ pub enum Permission {
|
|||||||
CreateTags,
|
CreateTags,
|
||||||
RenameTags,
|
RenameTags,
|
||||||
DeleteTags,
|
DeleteTags,
|
||||||
|
CreateQuotes,
|
||||||
|
DeleteQuotes,
|
||||||
ChangePersonPrimaryName,
|
ChangePersonPrimaryName,
|
||||||
BrowseServerLogs,
|
BrowseServerLogs,
|
||||||
|
ConfigureInstance,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Permission {
|
||||||
|
pub fn is_default_permission(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
Self::CreateTags | Self::CreateQuotes => true,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(
|
||||||
|
Debug,
|
||||||
|
Clone,
|
||||||
|
Copy,
|
||||||
|
PartialEq,
|
||||||
|
strum::IntoStaticStr,
|
||||||
|
Display,
|
||||||
|
serde::Deserialize,
|
||||||
|
serde::Serialize,
|
||||||
|
)]
|
||||||
|
pub enum PermissionState {
|
||||||
|
ExplicitlyGranted,
|
||||||
|
ExplicitlyRevoked,
|
||||||
|
Implicit,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Option<bool>> for PermissionState {
|
||||||
|
fn from(state: Option<bool>) -> Self {
|
||||||
|
match state {
|
||||||
|
Some(true) => Self::ExplicitlyGranted,
|
||||||
|
Some(false) => Self::ExplicitlyRevoked,
|
||||||
|
None => Self::Implicit,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl User {
|
impl User {
|
||||||
pub fn has_permission(
|
pub async fn permission_dbstate(
|
||||||
&self,
|
&self,
|
||||||
#[allow(unused)] conn: &Connection,
|
conn: &mut PgConnection,
|
||||||
#[allow(unused)] permission: Permission,
|
permission: Permission,
|
||||||
|
) -> Result<Option<bool>, DatabaseError> {
|
||||||
|
let permission_key: &'static str = (&permission).into();
|
||||||
|
let state: Option<bool> = sqlx::query_scalar(
|
||||||
|
"SELECT state FROM user_permissions WHERE user_id = $1 AND permission = $2",
|
||||||
|
)
|
||||||
|
.bind(self.id)
|
||||||
|
.bind(permission_key)
|
||||||
|
.fetch_optional(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn has_permission(
|
||||||
|
&self,
|
||||||
|
conn: &mut PgConnection,
|
||||||
|
permission: Permission,
|
||||||
) -> Result<bool, DatabaseError> {
|
) -> Result<bool, DatabaseError> {
|
||||||
// Infradmin and systemuser have all permissions
|
|
||||||
if self.is_infradmin() || self.is_systemuser() {
|
if self.is_infradmin() || self.is_systemuser() {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
|
if let Some(true) = self.permission_dbstate(conn, Permission::Admin).await? {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(false)
|
Ok(self
|
||||||
// todo!("Do the permission checking here once permissions are modeled in the DB")
|
.permission_dbstate(conn, permission)
|
||||||
|
.await?
|
||||||
|
.unwrap_or(permission.is_default_permission()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn grant_permission(
|
||||||
|
&self,
|
||||||
|
conn: &mut PgConnection,
|
||||||
|
permission: Permission,
|
||||||
|
) -> Result<(), DatabaseError> {
|
||||||
|
let permission_key: &'static str = (&permission).into();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO user_permissions (user_id, permission, state) VALUES ($1, $2, TRUE) ON CONFLICT (user_id, permission) DO UPDATE SET state = EXCLUDED.state",
|
||||||
|
)
|
||||||
|
.bind(self.id)
|
||||||
|
.bind(permission_key)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn revoke_permission(
|
||||||
|
&self,
|
||||||
|
conn: &mut PgConnection,
|
||||||
|
permission: Permission,
|
||||||
|
) -> Result<(), DatabaseError> {
|
||||||
|
let permission_key: &'static str = (&permission).into();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO user_permissions (user_id, permission, state) VALUES ($1, $2, FALSE) ON CONFLICT (user_id, permission) DO UPDATE SET state = EXCLUDED.state",
|
||||||
|
)
|
||||||
|
.bind(self.id)
|
||||||
|
.bind(permission_key)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn reset_permission(
|
||||||
|
&self,
|
||||||
|
conn: &mut PgConnection,
|
||||||
|
permission: Permission,
|
||||||
|
) -> Result<(), DatabaseError> {
|
||||||
|
let permission_key: &'static str = (&permission).into();
|
||||||
|
sqlx::query("DELETE FROM user_permissions WHERE user_id = $1 AND permission = $2")
|
||||||
|
.bind(self.id)
|
||||||
|
.bind(permission_key)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
use maud::{Markup, PreEscaped, html};
|
use maud::{Markup, PreEscaped, html};
|
||||||
|
use sqlx::PgConnection;
|
||||||
|
|
||||||
use crate::{users::User, web::icons};
|
use crate::{
|
||||||
|
users::{User, permissions::Permission},
|
||||||
|
web::icons,
|
||||||
|
};
|
||||||
|
|
||||||
// (SHOWTEXT, LINK, ICON, REQUIRES_LOG_IN)
|
// (SHOWTEXT, LINK, ICON, REQUIRES_LOG_IN)
|
||||||
const LINKS: &[(&str, &str, &str, bool)] = &[
|
const LINKS: &[(&str, &str, &str, bool)] = &[
|
||||||
@@ -13,7 +17,12 @@ const LINKS: &[(&str, &str, &str, bool)] = &[
|
|||||||
("Logs", "/logs", icons::CLIPBOARD_CLOCK, true),
|
("Logs", "/logs", icons::CLIPBOARD_CLOCK, true),
|
||||||
];
|
];
|
||||||
|
|
||||||
pub fn nav(user: Option<&User>, uri: &str) -> Markup {
|
pub async fn nav(conn: &mut PgConnection, user: Option<&User>, uri: &str) -> Markup {
|
||||||
|
#[rustfmt::skip]
|
||||||
|
let show_instance_conf = match user {
|
||||||
|
Some(u) if u.has_permission(conn, Permission::ConfigureInstance).await.is_ok_and(|r| r) => true,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
html!(
|
html!(
|
||||||
div class="flex items-center text-sm gap-4 border-b border-neutral-200/25 bg-neutral-200/5 px-4 py-2" {
|
div class="flex items-center text-sm gap-4 border-b border-neutral-200/25 bg-neutral-200/5 px-4 py-2" {
|
||||||
a href="/dashboard" class="font-lora font-semibold hidden xs:block md:text-xl sm:mr-2" {"Mnemosyne"}
|
a href="/dashboard" class="font-lora font-semibold hidden xs:block md:text-xl sm:mr-2" {"Mnemosyne"}
|
||||||
@@ -46,15 +55,22 @@ pub fn nav(user: Option<&User>, uri: &str) -> Markup {
|
|||||||
span class="hidden sm:block"{(u.handle)}
|
span class="hidden sm:block"{(u.handle)}
|
||||||
div class="scale-[.75]" {(PreEscaped(icons::USER))}
|
div class="scale-[.75]" {(PreEscaped(icons::USER))}
|
||||||
}
|
}
|
||||||
div class="absolute right-0 top-full pt-1 w-40 opacity-0 invisible group-focus-within:opacity-100 group-focus-within:visible transition-all duration-100 z-50" {
|
div class="absolute right-0 top-full pt-1 w-44 opacity-0 invisible group-focus-within:opacity-100 group-focus-within:visible transition-all duration-100 z-50" {
|
||||||
div class="rounded bg-neutral-900 border border-neutral-200/25 shadow-lg flex flex-col overflow-hidden" {
|
div class="rounded bg-neutral-900 border border-neutral-200/25 shadow-lg flex flex-col overflow-hidden" {
|
||||||
a href=(format!("/users/{}", u.id)) class="px-4 py-2 flex items-center gap-2 hover:bg-neutral-200/10 font-lexend text-sm text-neutral-200 transition-colors" {
|
a href=(format!("/users/{}", u.id)) class="px-4 py-2 flex items-center gap-2 hover:bg-neutral-200/10 font-lexend text-sm text-neutral-200 transition-colors" {
|
||||||
div class="scale-[.7]" {(PreEscaped(icons::USER))}
|
div class="scale-[.7]" {(PreEscaped(icons::USER))}
|
||||||
p {"Profile"}
|
p {"Profile"}
|
||||||
}
|
}
|
||||||
a href="/user-settings" class="px-4 py-2 flex items-center gap-2 hover:bg-neutral-200/10 font-lexend text-sm text-neutral-200 transition-colors" {
|
a href="/user-settings" class="px-4 py-2 flex items-center gap-2 hover:bg-neutral-200/10 font-lexend text-sm text-neutral-200 transition-colors" {
|
||||||
div class="scale-[.7]" {(PreEscaped(icons::SERVER))}
|
div class="scale-[.7]" {(PreEscaped(icons::SETTINGS))}
|
||||||
p {"Settings"}
|
p {"User Settings"}
|
||||||
|
}
|
||||||
|
@if show_instance_conf {
|
||||||
|
div class="h-px w-full bg-neutral-200/15" {}
|
||||||
|
a href="/instance-config" class="px-4 py-2 flex items-center gap-2 hover:bg-neutral-200/10 font-lexend text-sm text-neutral-200 transition-colors" {
|
||||||
|
div class="scale-[.7]" {(PreEscaped(icons::SERVER))}
|
||||||
|
p {"Instance Config"}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
div class="h-px w-full bg-neutral-200/15" {}
|
div class="h-px w-full bg-neutral-200/15" {}
|
||||||
form action="/api/auth/logout-form" method="post" {
|
form action="/api/auth/logout-form" method="post" {
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ use crate::{quotes::Quote, web::icons};
|
|||||||
|
|
||||||
pub fn quote(quote: &Quote) -> Markup {
|
pub fn quote(quote: &Quote) -> Markup {
|
||||||
html!(
|
html!(
|
||||||
div class="border border-neutral-200/25 bg-neutral-200/5 p-3 pb-1 overflow-clip rounded-md relative flex flex-col" {
|
div class="border border-neutral-200/25 bg-neutral-200/5 p-3 pb-1 overflow-clip rounded-md relative flex flex-col transition-colors group-hover/a:border-neutral-200/35 group-hover/a:bg-neutral-200/10" {
|
||||||
div class="absolute top-4 right-6 -rotate-12 opacity-[.025] scale-x-[4.5] scale-y-[4]" {
|
div class="absolute top-4 right-6 -rotate-12 opacity-[.025] scale-x-[4.5] scale-y-[4]" {
|
||||||
(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(", "))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
src/web/icons/code.svg
Normal 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-code-icon lucide-code"><path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/></svg>
|
||||||
|
After Width: | Height: | Size: 282 B |
1
src/web/icons/git-commit-vertical.svg
Normal 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 |
1
src/web/icons/line-dot-right-horizontal.svg
Normal 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 |
1
src/web/icons/message-square-code.svg
Normal 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-message-square-code-icon lucide-message-square-code"><path d="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z"/><path d="m10 8-3 3 3 3"/><path d="m14 14 3-3-3-3"/></svg>
|
||||||
|
After Width: | Height: | Size: 440 B |
@@ -1,3 +1,4 @@
|
|||||||
|
#![allow(unused)]
|
||||||
// Below icons sourced from https://lucide.dev
|
// Below icons sourced from https://lucide.dev
|
||||||
pub const ARROW_RIGHT: &str = include_str!("arrow-right.svg");
|
pub const ARROW_RIGHT: &str = include_str!("arrow-right.svg");
|
||||||
pub const CALENDAR_1: &str = include_str!("calendar-1.svg");
|
pub const CALENDAR_1: &str = include_str!("calendar-1.svg");
|
||||||
@@ -5,20 +6,28 @@ pub const CALENDAR_ARROW_DOWN: &str = include_str!("calendar-arrow-down.svg");
|
|||||||
pub const CIRCLE_MINUS: &str = include_str!("circle-minus.svg");
|
pub const CIRCLE_MINUS: &str = include_str!("circle-minus.svg");
|
||||||
pub const CLIPBOARD_CLOCK: &str = include_str!("clipboard-clock.svg");
|
pub const CLIPBOARD_CLOCK: &str = include_str!("clipboard-clock.svg");
|
||||||
pub const CLOCK: &str = include_str!("clock.svg");
|
pub const CLOCK: &str = include_str!("clock.svg");
|
||||||
|
pub const CODE: &str = include_str!("code.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 MESSAGE_SQUARE_CODE: &str = include_str!("message-square-code.svg");
|
||||||
pub const PEN: &str = include_str!("pen.svg");
|
pub const PEN: &str = include_str!("pen.svg");
|
||||||
pub const PLUS: &str = include_str!("plus.svg");
|
pub const PLUS: &str = include_str!("plus.svg");
|
||||||
pub const QUOTE: &str = include_str!("quote.svg");
|
pub const QUOTE: &str = include_str!("quote.svg");
|
||||||
|
pub const REFRESH_CW: &str = include_str!("refresh-cw.svg");
|
||||||
pub const SCROLL_TEXT: &str = include_str!("scroll-text.svg");
|
pub const SCROLL_TEXT: &str = include_str!("scroll-text.svg");
|
||||||
pub const SERVER: &str = include_str!("server.svg");
|
pub const SERVER: &str = include_str!("server.svg");
|
||||||
|
pub const SETTINGS: &str = include_str!("settings.svg");
|
||||||
pub const SHIELD_USER: &str = include_str!("shield-user.svg");
|
pub const SHIELD_USER: &str = include_str!("shield-user.svg");
|
||||||
pub const TAG: &str = include_str!("tag.svg");
|
pub const TAG: &str = include_str!("tag.svg");
|
||||||
|
pub const TRASH: &str = include_str!("trash.svg");
|
||||||
|
pub const TYPE: &str = include_str!("type.svg");
|
||||||
pub const USER: &str = include_str!("user.svg");
|
pub const USER: &str = include_str!("user.svg");
|
||||||
pub const USER_KEY: &str = include_str!("user-key.svg");
|
pub const USER_KEY: &str = include_str!("user-key.svg");
|
||||||
pub const USER_PLUS: &str = include_str!("user-plus.svg");
|
pub const USER_PLUS: &str = include_str!("user-plus.svg");
|
||||||
|
|||||||
1
src/web/icons/refresh-cw.svg
Normal 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-refresh-cw-icon lucide-refresh-cw"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
|
||||||
|
After Width: | Height: | Size: 412 B |
1
src/web/icons/settings.svg
Normal 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-settings-icon lucide-settings"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
|
After Width: | Height: | Size: 610 B |
1
src/web/icons/trash.svg
Normal 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-trash-icon lucide-trash"><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||||
|
After Width: | Height: | Size: 355 B |
1
src/web/icons/type.svg
Normal 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-type-icon lucide-type"><path d="M12 4v16"/><path d="M4 7V5a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2"/><path d="M9 20h6"/></svg>
|
||||||
|
After Width: | Height: | Size: 322 B |
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
232
src/web/pages/conf.rs
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
use axum::{
|
||||||
|
Form,
|
||||||
|
extract::{Request, State},
|
||||||
|
http::HeaderMap,
|
||||||
|
response::{IntoResponse, Redirect, Response},
|
||||||
|
};
|
||||||
|
use http::StatusCode;
|
||||||
|
use maud::{Markup, PreEscaped, html};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
MnemoState,
|
||||||
|
error::CompositeError,
|
||||||
|
logs::{LogAction, LogEntry},
|
||||||
|
users::{
|
||||||
|
User,
|
||||||
|
auth::{UserAuthRequired, UserAuthenticate},
|
||||||
|
permissions::Permission,
|
||||||
|
},
|
||||||
|
web::{components::nav::nav, icons, pages::base},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn page(
|
||||||
|
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,
|
||||||
|
None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !u
|
||||||
|
.has_permission(&mut *conn, Permission::ConfigureInstance)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
return Ok((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"You do not have permission to view this page.",
|
||||||
|
)
|
||||||
|
.into_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
let (current_name, current_webhook) = {
|
||||||
|
let conf = state.conf.read().await;
|
||||||
|
let current_name = conf.instance_name.clone();
|
||||||
|
let current_webhook = conf
|
||||||
|
.discord_webhook
|
||||||
|
.as_ref()
|
||||||
|
.map(|u| u.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
(current_name, current_webhook)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(base(
|
||||||
|
"Instance Config | Mnemosyne",
|
||||||
|
html!(
|
||||||
|
(nav(&mut conn, Some(&u), req.uri().path()).await)
|
||||||
|
|
||||||
|
div class="max-w-4xl mx-auto px-2" {
|
||||||
|
div class="mx-auto max-w-4xl my-4 mb-8" {
|
||||||
|
p class="flex items-center gap-2" {
|
||||||
|
span class="text-neutral-500" {(PreEscaped(icons::SERVER))}
|
||||||
|
span class="text-2xl font-semibold font-lora" {"Mnemosyne Instance Settings"}
|
||||||
|
}
|
||||||
|
p class="text-neutral-500 text-sm font-light mt-1" {
|
||||||
|
"Manage global configuration for your Mnemosyne instance."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(setting_block(
|
||||||
|
"Instance Name",
|
||||||
|
"The name of this instance. This is displayed on the dashboard, in page titles, and used in webhook payloads to identify this server.",
|
||||||
|
icons::PEN,
|
||||||
|
"/instance-config/name",
|
||||||
|
"instance_name",
|
||||||
|
"text",
|
||||||
|
"e.g. Mnemosyne",
|
||||||
|
¤t_name,
|
||||||
|
))
|
||||||
|
|
||||||
|
hr class="mt-6 mb-4 border-neutral-600";
|
||||||
|
|
||||||
|
(setting_block(
|
||||||
|
"Discord Webhook URL",
|
||||||
|
"Mnemosyne will attempt to send a message to this webhook for all newly created quotes, regardless of whether they are public or not. Leave empty to disable.",
|
||||||
|
icons::MESSAGE_SQUARE_CODE,
|
||||||
|
"/instance-config/dsc-webhook",
|
||||||
|
"webhook_url",
|
||||||
|
"url",
|
||||||
|
"https://discord.com/api/webhooks/...",
|
||||||
|
¤t_webhook,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setting_block(
|
||||||
|
title: &str,
|
||||||
|
description: &str,
|
||||||
|
icon: &str,
|
||||||
|
form_action: &str,
|
||||||
|
input_name: &str,
|
||||||
|
input_type: &str,
|
||||||
|
input_placeholder: &str,
|
||||||
|
current_value: &str,
|
||||||
|
) -> Markup {
|
||||||
|
html! {
|
||||||
|
div class="mb-6" {
|
||||||
|
p class="flex items-center gap-1" {
|
||||||
|
span class="text-neutral-500 scale-[.8]" {(PreEscaped(icon))}
|
||||||
|
span class="text-lg font-semibold font-lora" {(title)}
|
||||||
|
}
|
||||||
|
p class="text-neutral-500 text-sm font-light mb-3" {
|
||||||
|
(description)
|
||||||
|
}
|
||||||
|
form action=(form_action) method="post" class="flex gap-2" {
|
||||||
|
input id=(input_name) name=(input_name) type=(input_type) placeholder=(input_placeholder) autocomplete="off" value=(current_value)
|
||||||
|
class="w-full max-w-md px-2 py-1 border border-neutral-200/25 bg-neutral-950/50 rounded outline-none focus:border-neutral-200/50";
|
||||||
|
button type="submit" class="px-4 py-1 border border-neutral-200/25 bg-neutral-200/5 rounded cursor-pointer hover:border-neutral-200/40" {
|
||||||
|
"Save"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct InstanceNameForm {
|
||||||
|
instance_name: String,
|
||||||
|
}
|
||||||
|
pub async fn change_name(
|
||||||
|
State(state): State<MnemoState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Form(form): Form<InstanceNameForm>,
|
||||||
|
) -> 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::ConfigureInstance)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
return Ok((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"You do not have permission to change this.",
|
||||||
|
)
|
||||||
|
.into_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.instance_name.trim().is_empty() {
|
||||||
|
return Ok((StatusCode::BAD_REQUEST, "Instance name cannot be empty.").into_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_name = form.instance_name.trim().to_string();
|
||||||
|
|
||||||
|
LogEntry::new(
|
||||||
|
&mut tx,
|
||||||
|
u,
|
||||||
|
LogAction::ChangeInstanceName {
|
||||||
|
old: state.conf.read().await.instance_name.clone(),
|
||||||
|
new: new_name.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut new_conf = state.conf.read().await.clone();
|
||||||
|
new_conf.instance_name = new_name.clone();
|
||||||
|
new_conf.save(&mut tx).await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
state.conf.write().await.instance_name = new_name;
|
||||||
|
Ok(Redirect::to("/instance-config").into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct WebhookForm {
|
||||||
|
webhook_url: String,
|
||||||
|
}
|
||||||
|
pub async fn change_webhook(
|
||||||
|
State(state): State<MnemoState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Form(form): Form<WebhookForm>,
|
||||||
|
) -> 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::ConfigureInstance)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
return Ok((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"You do not have permission to change this.",
|
||||||
|
)
|
||||||
|
.into_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_webhook = if form.webhook_url.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
match Url::parse(form.webhook_url.trim()) {
|
||||||
|
Ok(url) => Some(url),
|
||||||
|
Err(_) => {
|
||||||
|
return Ok(
|
||||||
|
(axum::http::StatusCode::BAD_REQUEST, "Invalid URL format.").into_response()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
LogEntry::new(
|
||||||
|
&mut tx,
|
||||||
|
u,
|
||||||
|
LogAction::ChangeDiscordWebhookUrl {
|
||||||
|
old: state.conf.read().await.discord_webhook.clone(),
|
||||||
|
new: new_webhook.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut new_conf = state.conf.read().await.clone();
|
||||||
|
new_conf.discord_webhook = new_webhook.clone();
|
||||||
|
new_conf.save(&mut tx).await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
state.conf.write().await.discord_webhook = new_webhook;
|
||||||
|
Ok(Redirect::to("/instance-config").into_response())
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
@@ -18,38 +21,68 @@ use crate::{
|
|||||||
|
|
||||||
const LINKS: &[(&str, &str, &str)] = &[
|
const LINKS: &[(&str, &str, &str)] = &[
|
||||||
("Add Quote", "/quotes/add", icons::QUOTE),
|
("Add Quote", "/quotes/add", icons::QUOTE),
|
||||||
("Add Person", "/persons/add", 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 = Quote::get_newest(&conn)?;
|
let newest_quote = match u {
|
||||||
|
Some(_) => Quote::get_newest(&mut *conn).await?,
|
||||||
|
None => Quote::get_newest_public(&mut *conn).await?,
|
||||||
|
};
|
||||||
|
let random_quote = match u {
|
||||||
|
Some(_) => Quote::get_random(&mut *conn).await?,
|
||||||
|
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!(
|
||||||
(nav(u.as_ref(), req.uri().path()))
|
(nav(&mut conn, u.as_ref(), req.uri().path()).await)
|
||||||
|
|
||||||
div class="mx-auto max-w-4xl px-2 mt-4 grid grid-cols-1 --sm:grid-cols-2 gap-4" {
|
div class="mx-auto max-w-4xl px-2 mt-4 grid grid-cols-1 --sm:grid-cols-2 gap-4" {
|
||||||
div class="flex flex-col" {
|
div class="flex flex-col" {
|
||||||
p {"Newest Quote"}
|
p {"Newest Quote"}
|
||||||
@if let Some(q) = newest_quote {
|
@if let Some(q) = &newest_quote {
|
||||||
p class="text-neutral-500 font-light mb-4" {
|
p class="text-neutral-500 font-light mb-4" {
|
||||||
"This just in! This quote was added "
|
"This just in! This quote was added "
|
||||||
(format_time_ago(q.get_creation_timestamp())) " ago."
|
(format_time_ago(q.get_creation_timestamp())) " ago."
|
||||||
}
|
}
|
||||||
div class="flex-1 [&>div]:h-full" {(quote(&q))}
|
div class="flex-1 [&>div]:h-full" {
|
||||||
|
a href=(format!("/quotes/{}", q.id)) class="group/a" {(quote(q))}
|
||||||
|
}
|
||||||
} @else {
|
} @else {
|
||||||
p class="text-neutral-500 font-light mb-4" {"No quotes yet."}
|
p class="text-neutral-500 font-light mb-4" {"No quotes yet."}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// div class="flex flex-col" {
|
@if let Some(q) = random_quote {
|
||||||
// p {"Quote of the Day"} // maybe "Quote of the Moment" instead? idk, this algorithm needs to be crazy
|
div class="flex flex-col" {
|
||||||
// p class="text-neutral-500 font-light mb-4" {"This quote was voiced a year ago today."}
|
div class="flex gap-1" {
|
||||||
// div class="flex-1 [&>div]:h-full" {(quote(&sample_quote_2()))}
|
p {"Random Quote"}
|
||||||
// }
|
a href="/dashboard" class="text-neutral-500 scale-[.65] hover:text-neutral-200 focus:text-neutral-200" {(PreEscaped(icons::REFRESH_CW))}
|
||||||
|
}
|
||||||
|
p class="text-neutral-500 font-light mb-4" {
|
||||||
|
"This quote was added "
|
||||||
|
(format_time_ago(q.get_creation_timestamp())) " ago."
|
||||||
|
}
|
||||||
|
div class="flex-1 [&>div]:h-full" {
|
||||||
|
a href=(format!("/quotes/{}", q.id)) class="group/a" {(quote(&q))}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
div class="mx-auto max-w-4xl px-2 mt-4" {
|
div class="mx-auto max-w-4xl px-2 mt-4" {
|
||||||
p class="mb-2" {"Quick access"}
|
p class="mb-2" {"Quick access"}
|
||||||
@@ -67,25 +100,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"}
|
||||||
}
|
}
|
||||||
@@ -94,7 +127,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 {
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
@@ -71,7 +75,11 @@ pub async fn page(Query(q): Query<LoginMsg>, req: Request) -> Result<Response, A
|
|||||||
// (if javascript is disabled, login via form still works)
|
// (if javascript is disabled, login via form still works)
|
||||||
script defer {(PreEscaped(r#"
|
script defer {(PreEscaped(r#"
|
||||||
if (window.location.search) {
|
if (window.location.search) {
|
||||||
history.replaceState(null, '', window.location.pathname);
|
const url = new URL(window.location.href);
|
||||||
|
const r = url.searchParams.get('r');
|
||||||
|
url.search = '';
|
||||||
|
if (r) url.searchParams.set('r', r);
|
||||||
|
history.replaceState(null, '', url.pathname + url.search);
|
||||||
}
|
}
|
||||||
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -89,7 +97,12 @@ pub async fn page(Query(q): Query<LoginMsg>, req: Request) -> Result<Response, A
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
window.location.href = '/dashboard';
|
const r = new URL(window.location.href).searchParams.get('r');
|
||||||
|
if (r && r.startsWith('/')) {
|
||||||
|
window.location.href = r;
|
||||||
|
} else {
|
||||||
|
window.location.href = '/dashboard';
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
err.textContent = text || 'Login failed';
|
err.textContent = text || 'Login failed';
|
||||||
|
|||||||
@@ -1,41 +1,74 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::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 strum::IntoEnumIterator;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
database::{self},
|
MnemoState,
|
||||||
error::CompositeError,
|
error::CompositeError,
|
||||||
logs::LogEntry,
|
logs::{LogActionDiscriminant, LogEntry},
|
||||||
users::{User, auth::UserAuthenticate, permissions::Permission},
|
users::{User, auth::UserAuthenticate},
|
||||||
web::{RedirectViaError, components::nav::nav, icons, pages::base},
|
web::{components::nav::nav, icons, pages::base},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn page(req: Request) -> Result<Response, CompositeError> {
|
#[derive(Deserialize)]
|
||||||
let u = User::authenticate(req.headers())?
|
pub struct LogsPageQuery {
|
||||||
.ok_or(RedirectViaError(Redirect::to("/login?re=/logs")))?;
|
page: Option<i64>,
|
||||||
let mut conn = database::conn()?;
|
action: Option<LogActionDiscriminant>,
|
||||||
let tx = conn.transaction()?;
|
}
|
||||||
let logs = LogEntry::get_all(&tx)?;
|
|
||||||
|
pub async fn page(
|
||||||
|
State(state): State<MnemoState>,
|
||||||
|
Query(query): Query<LogsPageQuery>,
|
||||||
|
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,
|
||||||
|
None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let page = query.page.unwrap_or(1).max(1);
|
||||||
|
let per_page = 20;
|
||||||
|
let offset = (page - 1) * per_page;
|
||||||
|
|
||||||
|
let logs = LogEntry::get_chronological_offset(&mut *tx, query.action, offset, per_page).await?;
|
||||||
|
let total_logs = LogEntry::count(&mut *tx, query.action).await?;
|
||||||
|
let total_pages = (total_logs as f64 / per_page as f64).ceil() as i64;
|
||||||
|
|
||||||
Ok(base(
|
Ok(base(
|
||||||
"Logs | Mnemosyne",
|
"Logs | Mnemosyne",
|
||||||
html!(
|
html!(
|
||||||
(nav(Some(&u), req.uri().path()))
|
(nav(&mut tx, Some(&u), req.uri().path()).await)
|
||||||
|
|
||||||
@if 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))}
|
||||||
@@ -57,13 +90,32 @@ pub async fn page(req: Request) -> Result<Response, CompositeError> {
|
|||||||
div class="p-2 font-light" style=(s) {(log.actor.handle)}
|
div class="p-2 font-light" style=(s) {(log.actor.handle)}
|
||||||
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())}
|
||||||
}
|
}
|
||||||
@if true {
|
}
|
||||||
div class="p-2 col-span-3 text-center font-light text-neutral-400" {"You've reached the end of all logs."}
|
@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 {
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
div {}
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
"Page " (page) " of " (total_pages.max(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
@if page < total_pages {
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
div {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} @else {
|
} @else {
|
||||||
p class="text-center p-2" {"You must have permission to view this page."}
|
p class="text-center p-2" {"You must have permission to view logs."}
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,37 +4,61 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use maud::{DOCTYPE, Markup, html};
|
use maud::{DOCTYPE, Markup, html};
|
||||||
|
|
||||||
|
use crate::MnemoState;
|
||||||
|
|
||||||
|
pub mod conf;
|
||||||
pub mod dashboard;
|
pub mod dashboard;
|
||||||
pub mod index;
|
pub mod index;
|
||||||
pub mod login;
|
pub mod login;
|
||||||
pub mod logs;
|
pub mod logs;
|
||||||
|
pub mod notfound;
|
||||||
pub mod persons;
|
pub mod persons;
|
||||||
pub mod quotes;
|
pub mod quotes;
|
||||||
pub mod tags;
|
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))
|
||||||
.route("/dashboard", get(dashboard::page))
|
.route("/dashboard", get(dashboard::page))
|
||||||
|
//
|
||||||
|
.route("/instance-config", get(conf::page))
|
||||||
|
.route("/instance-config/name", post(conf::change_name))
|
||||||
|
.route("/instance-config/dsc-webhook", post(conf::change_webhook))
|
||||||
|
//
|
||||||
.route("/user-settings", get(usersettings::page))
|
.route("/user-settings", get(usersettings::page))
|
||||||
.route("/user-settings/handle", post(usersettings::change_handle))
|
.route("/user-settings/handle", post(usersettings::change_handle))
|
||||||
.route("/user-settings/passwd", post(usersettings::change_password))
|
.route("/user-settings/passwd", post(usersettings::change_password))
|
||||||
|
//
|
||||||
.route("/users", get(users::page))
|
.route("/users", get(users::page))
|
||||||
.route("/users/{id}", get(users::profile::page))
|
.route("/users/{id}", get(users::profile::page))
|
||||||
.route("/users/create", get(users::create::page))
|
.route("/users/create", get(users::create::page))
|
||||||
.route("/users/create-form", post(users::create::create_user))
|
.route("/users/create-form", post(users::create::create_user))
|
||||||
|
//
|
||||||
.route("/tags", get(tags::page))
|
.route("/tags", get(tags::page))
|
||||||
.route("/tags/create", post(tags::create))
|
.route("/tags/create", post(tags::create))
|
||||||
|
.route("/tags/{id}/delete", post(tags::delete_tag))
|
||||||
|
//
|
||||||
.route("/persons", get(persons::page))
|
.route("/persons", get(persons::page))
|
||||||
.route("/persons/create", post(persons::create))
|
.route("/persons/create", post(persons::create))
|
||||||
|
.route("/persons/{id}", get(persons::profile::page))
|
||||||
|
.route("/persons/{id}/add-name", post(persons::profile::add_name))
|
||||||
|
.route("/names/{id}/delete", post(persons::profile::delete_name))
|
||||||
|
//
|
||||||
.route("/logs", get(logs::page))
|
.route("/logs", get(logs::page))
|
||||||
//
|
//
|
||||||
.route("/quotes", get(quotes::page))
|
.route("/quotes", get(quotes::page))
|
||||||
|
.route("/quotes/{id}", get(quotes::id::page))
|
||||||
|
.route(
|
||||||
|
"/quotes/{id}/delete",
|
||||||
|
get(quotes::id::delete_confirm).post(quotes::id::delete),
|
||||||
|
)
|
||||||
.route("/quotes/add", get(quotes::add::page))
|
.route("/quotes/add", get(quotes::add::page))
|
||||||
.route("/quotes/add-form", post(quotes::add::form))
|
.route("/quotes/add-form", post(quotes::add::form))
|
||||||
|
//
|
||||||
|
.fallback(notfound::page)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn base(title: &str, inner: Markup) -> Markup {
|
pub fn base(title: &str, inner: Markup) -> Markup {
|
||||||
|
|||||||
38
src/web/pages/notfound.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Request, State},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use http::StatusCode;
|
||||||
|
use maud::html;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
MnemoState,
|
||||||
|
error::CompositeError,
|
||||||
|
users::{User, auth::UserAuthenticate},
|
||||||
|
web::{components::nav::nav, pages::base},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn page(
|
||||||
|
State(state): State<MnemoState>,
|
||||||
|
req: Request,
|
||||||
|
) -> Result<Response, CompositeError> {
|
||||||
|
let mut conn = state.pool.acquire().await?;
|
||||||
|
let u = User::authenticate(&mut *conn, req.headers())
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
Ok((StatusCode::NOT_FOUND, base(
|
||||||
|
"Not Found | Mnemosyne",
|
||||||
|
html!(
|
||||||
|
(nav(&mut conn, u.as_ref(), req.uri().path()).await)
|
||||||
|
|
||||||
|
div class="mx-auto max-w-4xl px-2 mt-8 mb-2" {
|
||||||
|
h1 class="text-4xl font-lora font-semibold mb-1" { "Not Found" }
|
||||||
|
p class="text-neutral-400 font-light" {
|
||||||
|
"No page found under"
|
||||||
|
span class="font-mono mx-1 px-1 py-.5 border border-neutral-200/25 rounded bg-neutral-950/25" {(req.uri().path())}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)).into_response())
|
||||||
|
}
|
||||||
@@ -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,82 +9,93 @@ 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 async fn page(req: Request) -> Result<Response, AuthError> {
|
pub mod profile;
|
||||||
let u = User::authenticate(req.headers())?;
|
|
||||||
let mut conn = database::conn()?;
|
pub async fn page(
|
||||||
let tx = conn.transaction()?;
|
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,
|
||||||
|
None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()),
|
||||||
|
};
|
||||||
|
|
||||||
|
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",
|
||||||
html!(
|
html!(
|
||||||
(nav(u.as_ref(), req.uri().path()))
|
(nav(&mut conn, Some(&u), req.uri().path()).await)
|
||||||
|
|
||||||
@if let Some(_) = u {
|
div class="mx-auto max-w-4xl px-2 my-4" {
|
||||||
div class="mx-auto max-w-4xl px-2 my-4" {
|
p class="flex items-center gap-2" {
|
||||||
p class="flex items-center gap-2" {
|
span class="text-neutral-500" {(PreEscaped(icons::CONTACT))}
|
||||||
span class="text-neutral-500" {(PreEscaped(icons::CONTACT))}
|
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" {
|
||||||
|
@if let Ok(c) = total_count {
|
||||||
|
(c) " persons in total."
|
||||||
|
} @else {
|
||||||
|
"Could not get total person count."
|
||||||
}
|
}
|
||||||
p class="text-neutral-500 text-sm font-light" {
|
}
|
||||||
@if let Ok(c) = Person::total_count(&tx) {
|
}
|
||||||
(c) " persons in total."
|
@if let Ok(persons) = persons_res {
|
||||||
} @else {
|
div class="max-w-4xl mx-auto px-2 mt-4 flex flex-wrap gap-2" {
|
||||||
"Could not get total person count."
|
@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" {
|
||||||
|
span class="text-neutral-400 mr-1 scale-125" {"~"}
|
||||||
|
span class="text-sm" {(person.primary_name)}
|
||||||
|
div class="w-px h-2/3 my-auto mx-2 bg-neutral-200/15" {}
|
||||||
|
div class="text-xs flex items-center" {
|
||||||
|
(
|
||||||
|
if let Ok(i) = person_counts[idx] {
|
||||||
|
i.to_string()
|
||||||
|
} else {
|
||||||
|
"?".to_string()
|
||||||
|
}
|
||||||
|
) span class="*:size-3 ml-1 text-neutral-400" {(PreEscaped(icons::SCROLL_TEXT))}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@if let Ok(persons) = Person::get_all(&tx) {
|
@if persons.is_empty() {
|
||||||
div class="max-w-4xl mx-auto px-2 mt-4 flex flex-wrap gap-2" {
|
p class="text-center p-2" {"No persons yet."}
|
||||||
@for person in &persons {
|
}
|
||||||
div class="rounded px-4 py-2 bg-neutral-200/10 border border-neutral-200/15 flex items-center" {
|
div class="mx-auto max-w-4xl mt-4 px-2" {
|
||||||
span class="text-neutral-400 mr-1 scale-125" {"~"}
|
h3 class="font-lora font-semibold text-xl mb-1" {"Add new person"}
|
||||||
span class="text-sm" {(person.primary_name)}
|
form action="/persons/create" method="post" {
|
||||||
div class="w-px h-2/3 my-auto mx-2 bg-neutral-200/15" {}
|
label for="primary_name" class="text-neutral-500 font-light mt-2" {"Primary Name"}
|
||||||
div class="text-xs flex items-center" {
|
div class="flex gap-2" {
|
||||||
(
|
input type="text" autocomplete="off" id="primary_name" name="primary_name" placeholder="e.g. Frank"
|
||||||
if let Ok(i) = person.get_in_quote_count(&tx) {
|
class="px-2 py-1 border border-neutral-200/25 bg-neutral-950/50 rounded";
|
||||||
i.to_string()
|
button type="submit"
|
||||||
} else {
|
class="px-4 py-1 border border-neutral-200/25 bg-neutral-200/5 rounded cursor-pointer hover:border-neutral-200/40" {"Submit"}
|
||||||
"?".to_string()
|
|
||||||
}
|
|
||||||
) 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))}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@if persons.is_empty() {
|
|
||||||
p class="text-center p-2" {"No persons yet."}
|
|
||||||
}
|
|
||||||
div class="mx-auto max-w-4xl mt-4 px-2" {
|
|
||||||
h3 class="font-lora font-semibold text-xl" {"Add new person"}
|
|
||||||
form action="/persons/create" method="post" {
|
|
||||||
label for="primary_name" class="text-neutral-500 font-light mt-2" {"Primary Name"}
|
|
||||||
div class="flex gap-2" {
|
|
||||||
input type="text" autocomplete="off" id="primary_name" name="primary_name" placeholder="e.g. Frank"
|
|
||||||
class="px-2 py-1 border border-neutral-200/25 bg-neutral-950/50 rounded";
|
|
||||||
button type="submit"
|
|
||||||
class="px-4 py-1 border border-neutral-200/25 bg-neutral-200/5 rounded cursor-pointer hover:border-neutral-200/40" {"Submit"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} @else {
|
|
||||||
p class="text-red-400 text-center" {"Failed to load persons."}
|
|
||||||
}
|
}
|
||||||
} @else {
|
} @else {
|
||||||
p class="text-center p-2" {"You must be logged in to view this page."}
|
p class="text-red-400 text-center" {"Failed to load persons."}
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -95,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())
|
||||||
}
|
}
|
||||||
|
|||||||
165
src/web/pages/persons/profile.rs
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
use axum::{
|
||||||
|
Form,
|
||||||
|
extract::{Path, Request, State},
|
||||||
|
http::HeaderMap,
|
||||||
|
response::{IntoResponse, Redirect, Response},
|
||||||
|
};
|
||||||
|
use maud::html;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
MnemoState,
|
||||||
|
error::CompositeError,
|
||||||
|
logs::{LogAction, LogEntry},
|
||||||
|
persons::{Name, Person},
|
||||||
|
users::{
|
||||||
|
User,
|
||||||
|
auth::{UserAuthRequired, UserAuthenticate},
|
||||||
|
},
|
||||||
|
web::{components::nav::nav, pages::base},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn page(
|
||||||
|
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,
|
||||||
|
None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()),
|
||||||
|
};
|
||||||
|
let p = Person::get_by_id(&mut *conn, id).await;
|
||||||
|
let title = match &p {
|
||||||
|
Ok(p) => format!("~{} | Mnemosyne", p.primary_name),
|
||||||
|
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(
|
||||||
|
&title,
|
||||||
|
html!(
|
||||||
|
(nav(&mut conn, Some(&u), req.uri().path()).await)
|
||||||
|
|
||||||
|
div class="mx-auto max-w-4xl px-2 my-4" {
|
||||||
|
@if let Ok(p) = p {
|
||||||
|
div class="flex items-center gap-2 mb-6" {
|
||||||
|
span class="text-neutral-500 scale-150" {"~"}
|
||||||
|
h1 class="text-3xl font-semibold font-lora" {(p.primary_name)}
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
h2 class="text-lg font-semibold font-lora mb-2 text-neutral-300" {"Names"}
|
||||||
|
div class="flex flex-wrap gap-2 mb-4" {
|
||||||
|
@if names_ok {
|
||||||
|
@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" {
|
||||||
|
(name.name)
|
||||||
|
@if name.is_primary {
|
||||||
|
span class="text-xs text-neutral-500" {"(primary)"}
|
||||||
|
}
|
||||||
|
@if attr == 0 && !name.is_primary {
|
||||||
|
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" {
|
||||||
|
"✕"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
"Failed to get names."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
form action=(format!("/persons/{}/add-name", p.id)) method="post" {
|
||||||
|
label for="name" class="text-neutral-500 font-light text-sm" {"Add Name"}
|
||||||
|
div class="flex gap-2 mt-1" {
|
||||||
|
input type="text" autocomplete="off" id="name" name="name" placeholder="e.g. Frank"
|
||||||
|
class="px-2 py-1 border border-neutral-200/25 bg-neutral-950/50 rounded w-full sm:w-auto";
|
||||||
|
button type="submit"
|
||||||
|
class="px-4 py-1 border border-neutral-200/25 bg-neutral-200/5 rounded cursor-pointer hover:bg-neutral-200/10 hover:border-neutral-200/45" {"Add"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
p class="text-center p-2 my-4 text-red-400" {"Person not found."}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct AddNameForm {
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_name(
|
||||||
|
State(state): State<MnemoState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Form(form): Form<AddNameForm>,
|
||||||
|
) -> Result<Response, CompositeError> {
|
||||||
|
let mut tx = state.pool.begin().await?;
|
||||||
|
let u = User::authenticate(&mut *tx, &headers).await?.required()?;
|
||||||
|
|
||||||
|
let p = Person::get_by_id(&mut *tx, id).await?;
|
||||||
|
let n = p.add_name(&mut *tx, form.name, u.id).await?;
|
||||||
|
|
||||||
|
LogEntry::new(
|
||||||
|
&mut *tx,
|
||||||
|
u,
|
||||||
|
LogAction::AddPersonName {
|
||||||
|
pid: p.id,
|
||||||
|
nid: n.id,
|
||||||
|
pn: p.primary_name,
|
||||||
|
nn: n.name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok(Redirect::to(&format!("/persons/{}", p.id)).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_name(
|
||||||
|
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()?;
|
||||||
|
|
||||||
|
let n = Name::get_by_id(&mut *tx, id).await?;
|
||||||
|
let p = Person::get_by_id(&mut *tx, n.person_id).await?;
|
||||||
|
|
||||||
|
let nn = n.name.clone();
|
||||||
|
n.delete(&mut *tx).await?;
|
||||||
|
|
||||||
|
LogEntry::new(
|
||||||
|
&mut *tx,
|
||||||
|
u,
|
||||||
|
LogAction::DeletePersonName {
|
||||||
|
pid: p.id,
|
||||||
|
nid: id,
|
||||||
|
pn: p.primary_name,
|
||||||
|
n: nn,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok(Redirect::to(&format!("/persons/{}", p.id)).into_response())
|
||||||
|
}
|
||||||
@@ -1,18 +1,15 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::{Query, Request},
|
extract::{Query, Request, State},
|
||||||
response::{IntoResponse, 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::{
|
users::{User, auth::UserAuthenticate},
|
||||||
User,
|
|
||||||
auth::{UserAuthRequired, UserAuthenticate},
|
|
||||||
},
|
|
||||||
web::{
|
web::{
|
||||||
components::{nav::nav, quote::quote},
|
components::{nav::nav, quote::quote},
|
||||||
icons,
|
icons,
|
||||||
@@ -21,31 +18,50 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
pub mod add;
|
pub mod add;
|
||||||
|
pub mod id;
|
||||||
|
|
||||||
#[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 = User::authenticate(req.headers())?.required()?;
|
let mut conn = state.pool.acquire().await?;
|
||||||
let conn = database::conn()?;
|
let u = match User::authenticate(&mut *conn, req.headers()).await? {
|
||||||
|
Some(u) => u,
|
||||||
|
None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()),
|
||||||
|
};
|
||||||
|
|
||||||
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!(
|
||||||
(nav(Some(&u), req.uri().path()))
|
(nav(&mut conn, Some(&u), req.uri().path()).await)
|
||||||
|
|
||||||
div class="max-w-4xl mx-auto px-2" {
|
div class="max-w-4xl mx-auto px-2" {
|
||||||
div class="my-4 flex justify-between" {
|
div class="my-4 flex justify-between" {
|
||||||
@@ -58,8 +74,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))}
|
||||||
@@ -68,12 +86,12 @@ pub async fn page(
|
|||||||
}
|
}
|
||||||
div class="flex flex-col gap-4 mb-8" {
|
div class="flex flex-col gap-4 mb-8" {
|
||||||
@for q in "es {
|
@for q in "es {
|
||||||
(quote(q))
|
a href=(format!("/quotes/{}", q.id)) class="group/a" {(quote(q))}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 +99,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 {
|
||||||
|
|||||||
@@ -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,15 +25,22 @@ 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 = User::authenticate(req.headers())?;
|
State(state): State<MnemoState>,
|
||||||
let conn = database::conn()?;
|
req: Request,
|
||||||
let names = Name::get_all(&conn)?;
|
) -> Result<Response, CompositeError> {
|
||||||
|
let mut conn = state.pool.acquire().await?;
|
||||||
|
let u = match User::authenticate(&mut *conn, req.headers()).await? {
|
||||||
|
Some(u) => u,
|
||||||
|
None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()),
|
||||||
|
};
|
||||||
|
let names = Name::get_all(&mut *conn).await?;
|
||||||
|
let feature_webhooks = state.conf.read().await.discord_webhook.is_some();
|
||||||
|
|
||||||
Ok(base(
|
Ok(base(
|
||||||
"Add Quote | Mnemosyne",
|
"Add Quote | Mnemosyne",
|
||||||
html!(
|
html!(
|
||||||
(nav(u.as_ref(), req.uri().path()))
|
(nav(&mut conn, Some(&u), req.uri().path()).await)
|
||||||
|
|
||||||
div class="max-w-4xl mx-auto px-2" {
|
div class="max-w-4xl mx-auto px-2" {
|
||||||
div class="my-4 flex justify-between" {
|
div class="my-4 flex justify-between" {
|
||||||
@@ -86,6 +92,15 @@ pub async fn page(req: Request) -> Result<Response, CompositeError> {
|
|||||||
class="px-2 py-1 w-full mb-2 bg-neutral-950/50 rounded border border-neutral-200/25";
|
class="px-2 py-1 w-full mb-2 bg-neutral-950/50 rounded border border-neutral-200/25";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@if feature_webhooks {
|
||||||
|
div class="flex flex-col justify-center mt-5" {
|
||||||
|
label class="flex items-center gap-2 cursor-pointer" {
|
||||||
|
input type="checkbox" name="discord_webhook" value="true" checked
|
||||||
|
class="w-4 h-4 cursor-pointer";
|
||||||
|
span {"Send to Discord"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
button type="submit" class="border mt-auto mb-2 cursor-pointer rounded h-fit px-2 py-1 bg-neutral-200/5 border-neutral-200/25 hover:border-neutral-200/45 hover:bg-neutral-200/15" {
|
button type="submit" class="border mt-auto mb-2 cursor-pointer rounded h-fit px-2 py-1 bg-neutral-200/5 border-neutral-200/25 hover:border-neutral-200/45 hover:bg-neutral-200/15" {
|
||||||
"Submit"
|
"Submit"
|
||||||
}
|
}
|
||||||
@@ -134,30 +149,33 @@ 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,
|
||||||
|
discord_webhook: Option<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()),
|
||||||
@@ -167,9 +185,16 @@ 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?;
|
||||||
|
|
||||||
|
let should_send_webhook = form.discord_webhook.as_deref() == Some("true");
|
||||||
|
if should_send_webhook {
|
||||||
|
if let Some(ref url) = state.conf.read().await.discord_webhook {
|
||||||
|
q.post_msg_webhook(url.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Redirect::to("/dashboard").into_response())
|
Ok(Redirect::to("/dashboard").into_response())
|
||||||
}
|
}
|
||||||
|
|||||||
146
src/web/pages/quotes/id.rs
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, Request, State},
|
||||||
|
http::HeaderMap,
|
||||||
|
response::{IntoResponse, Redirect, Response},
|
||||||
|
};
|
||||||
|
use http::StatusCode;
|
||||||
|
use maud::{PreEscaped, html};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
MnemoState,
|
||||||
|
error::CompositeError,
|
||||||
|
logs::{LogAction, LogEntry},
|
||||||
|
quotes::Quote,
|
||||||
|
users::{
|
||||||
|
User,
|
||||||
|
auth::{UserAuthRequired, UserAuthenticate},
|
||||||
|
permissions::Permission,
|
||||||
|
},
|
||||||
|
web::{
|
||||||
|
components::{nav::nav, quote::quote},
|
||||||
|
icons,
|
||||||
|
pages::base,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn page(
|
||||||
|
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,
|
||||||
|
None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()),
|
||||||
|
};
|
||||||
|
let q = Quote::get_by_id(&mut conn, id).await;
|
||||||
|
let can_delete = u
|
||||||
|
.has_permission(&mut conn, Permission::DeleteQuotes)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(base(
|
||||||
|
"Add Quote | Mnemosyne",
|
||||||
|
html!(
|
||||||
|
(nav(&mut conn, Some(&u), req.uri().path()).await)
|
||||||
|
|
||||||
|
div class="max-w-4xl mx-auto px-2" {
|
||||||
|
div class="my-4 flex justify-between" {
|
||||||
|
p class="flex items-center gap-2 text-neutral-500" {
|
||||||
|
(PreEscaped(icons::SCROLL_TEXT))
|
||||||
|
span class="font-lora" {"Quote of ID " (id)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if let Ok(q) = q {
|
||||||
|
(quote(&q))
|
||||||
|
div class="flex flex-row w-full flex-wrap justify-end gap-2 mt-2" {
|
||||||
|
a href="#" disabled class="opacity-[.5] cursor-not-allowed px-2 py-1 border rounded flex flex-row gap-1 bg-neutral-200/5 border-neutral-200/25 hover:bg-neutral-200/15 hover:border-neutral-200/45" {
|
||||||
|
span class="scale-[.75]" {(PreEscaped(icons::PEN))}
|
||||||
|
"Edit"
|
||||||
|
}
|
||||||
|
@if can_delete {
|
||||||
|
a href=(format!("/quotes/{id}/delete")) class="px-2 py-1 cursor-pointer border rounded flex flex-row gap-1 bg-pink-400/10 border-pink-400/25 hover:bg-pink-400/20 hover:border-pink-400/45" {
|
||||||
|
span class="scale-[.75]" {(PreEscaped(icons::TRASH))}
|
||||||
|
"Delete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
"Failed to fetch quote. Are you sure it exists?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_confirm(
|
||||||
|
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,
|
||||||
|
None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()),
|
||||||
|
};
|
||||||
|
let q = Quote::get_by_id(&mut conn, id).await;
|
||||||
|
|
||||||
|
Ok(base(
|
||||||
|
"Delete Quote | Mnemosyne",
|
||||||
|
html!(
|
||||||
|
(nav(&mut conn, Some(&u), req.uri().path()).await)
|
||||||
|
|
||||||
|
div class="max-w-4xl mx-auto px-2" {
|
||||||
|
div class="my-4 flex justify-between" {
|
||||||
|
p class="flex items-center gap-2 text-neutral-500" {
|
||||||
|
(PreEscaped(icons::TRASH))
|
||||||
|
span class="font-lora" {"Deleting quote of ID " (id)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if let Ok(q) = q {
|
||||||
|
div class="border border-pink-400/25 bg-pink-400/10 rounded-md p-3 mb-4" {
|
||||||
|
p class="flex flex-wrap items-center gap-2 text-pink-200" {
|
||||||
|
span class="font-semibold" {"Are you sure you want to delete this quote?"}
|
||||||
|
span class="text-pink-300/80" {"This cannot be undone."}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(quote(&q))
|
||||||
|
div class="flex flex-row w-full flex-wrap justify-start gap-2 mt-2" {
|
||||||
|
form method="post" action=(format!("/quotes/{id}/delete")) {
|
||||||
|
button type="submit" class="px-2 py-1 cursor-pointer border rounded flex flex-row gap-1 bg-pink-400/10 border-pink-400/25 hover:bg-pink-400/20 hover:border-pink-400/45" {
|
||||||
|
span class="scale-[.75]" {(PreEscaped(icons::TRASH))}
|
||||||
|
"Delete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a href=(format!("/quotes/{id}")) class="px-2 py-1 border rounded flex flex-row gap-1 bg-neutral-200/5 border-neutral-200/25 hover:bg-neutral-200/15 hover:border-neutral-200/45" {
|
||||||
|
"Cancel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
"Failed to fetch quote. Are you sure it exists?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(
|
||||||
|
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::DeleteQuotes).await? {
|
||||||
|
return Ok((StatusCode::FORBIDDEN, "No permission.").into_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
let q = Quote::get_by_id(&mut *tx, id).await?;
|
||||||
|
LogEntry::new(&mut *tx, u, LogAction::DeleteQuote { quote: q.clone() }).await?;
|
||||||
|
q.delete(&mut tx).await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok(Redirect::to("/quotes").into_response())
|
||||||
|
}
|
||||||
@@ -1,88 +1,111 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Form,
|
Form,
|
||||||
extract::Request,
|
extract::{Path, Request, State},
|
||||||
http::HeaderMap,
|
http::HeaderMap,
|
||||||
response::{IntoResponse, Redirect, Response},
|
response::{IntoResponse, Redirect, Response},
|
||||||
};
|
};
|
||||||
use maud::{PreEscaped, html};
|
use maud::{PreEscaped, html};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
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 = User::authenticate(req.headers())?;
|
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) => u,
|
||||||
|
None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()),
|
||||||
|
};
|
||||||
|
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",
|
||||||
html!(
|
html!(
|
||||||
(nav(u.as_ref(), req.uri().path()))
|
(nav(&mut conn, Some(&u), req.uri().path()).await)
|
||||||
|
|
||||||
@if let Some(_) = u {
|
div class="mx-auto max-w-4xl px-2 my-4" {
|
||||||
div class="mx-auto max-w-4xl px-2 my-4" {
|
p class="flex items-center gap-2" {
|
||||||
p class="flex items-center gap-2" {
|
span class="text-neutral-500" {(PreEscaped(icons::TAG))}
|
||||||
span class="text-neutral-500" {(PreEscaped(icons::TAG))}
|
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) = total_tags {
|
||||||
@if let Ok(c) = Tag::total_count(&conn) {
|
(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) {
|
}
|
||||||
div class="max-w-4xl mx-auto px-2 mt-4 flex flex-wrap gap-2" {
|
@if is_tags_ok {
|
||||||
@for tag in &tags {
|
div class="max-w-4xl mx-auto px-2 mt-4 flex flex-wrap gap-2" {
|
||||||
div class="rounded-full px-3 py-1 bg-neutral-200/10 border border-neutral-200/15 flex" {
|
@for (tag, count) in tags_with_counts {
|
||||||
span class="text-neutral-400 text-sm" {"#"}
|
div class="rounded-full px-3 py-1 bg-neutral-200/10 border border-neutral-200/15 flex" {
|
||||||
span class="text-sm" {(tag.name)}
|
span class="text-neutral-400 text-sm" {"#"}
|
||||||
div class="w-px h-2/3 my-auto mx-2 bg-neutral-200/15" {}
|
span class="text-sm" {(tag.name)}
|
||||||
div class="text-xs flex items-center" {
|
div class="w-px h-2/3 my-auto mx-2 bg-neutral-200/15" {}
|
||||||
(
|
div class="text-xs flex items-center" {
|
||||||
if let Ok(i) = tag.get_tagged_quotes_count(&conn) {
|
(
|
||||||
i.to_string()
|
if let Ok(i) = &count {
|
||||||
} else {
|
i.to_string()
|
||||||
"?".to_string()
|
} else {
|
||||||
}
|
"?".to_string()
|
||||||
) span class="*:size-3 ml-1 text-neutral-400" {(PreEscaped(icons::SCROLL_TEXT))}
|
}
|
||||||
// div class="ml-2" {}
|
) span class="*:size-3 ml-1 text-neutral-400" {(PreEscaped(icons::SCROLL_TEXT))}
|
||||||
// "0" span class="*:size-3 ml-1 text-neutral-400" {(PreEscaped(icons::FILE_IMAGE))}
|
// div class="ml-2" {}
|
||||||
|
// "0" span class="*:size-3 ml-1 text-neutral-400" {(PreEscaped(icons::FILE_IMAGE))}
|
||||||
|
}
|
||||||
|
@if let Ok(0) = count {
|
||||||
|
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" {
|
||||||
|
"✕"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@if tags.is_empty() {
|
}
|
||||||
p class="text-center p-2" {"No tags yet. How about making one?"}
|
@if is_tags_empty {
|
||||||
}
|
p class="text-center p-2" {"No tags yet. How about making one?"}
|
||||||
div class="mx-auto max-w-4xl mt-4 px-2" {
|
}
|
||||||
h3 class="font-lora font-semibold text-xl" {"Add new tag"}
|
div class="mx-auto max-w-4xl mt-4 px-2" {
|
||||||
form action="/tags/create" method="post" {
|
h3 class="font-lora font-semibold text-xl mb-1" {"Add new tag"}
|
||||||
label for="tagname" class="text-neutral-500 font-light mt-2" {"Tag Name"}
|
form action="/tags/create" method="post" {
|
||||||
div class="flex gap-2" {
|
label for="tagname" class="text-neutral-500 font-light mt-2" {"Tag Name"}
|
||||||
input type="text" autocomplete="off" id="tagname" name="tagname" placeholder="e.g. fashion"
|
div class="flex gap-2" {
|
||||||
class="px-2 py-1 border border-neutral-200/25 bg-neutral-950/50 rounded";
|
input type="text" autocomplete="off" id="tagname" name="tagname" placeholder="e.g. fashion"
|
||||||
button type="submit"
|
class="px-2 py-1 border border-neutral-200/25 bg-neutral-950/50 rounded";
|
||||||
class="px-4 py-1 border border-neutral-200/25 bg-neutral-200/5 rounded cursor-pointer hover:border-neutral-200/40" {"Submit"}
|
button type="submit"
|
||||||
}
|
class="px-4 py-1 border border-neutral-200/25 bg-neutral-200/5 rounded cursor-pointer hover:border-neutral-200/40" {"Submit"}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} @else {
|
|
||||||
p class="text-red-400 text-center" {"Failed to load tags."}
|
|
||||||
}
|
}
|
||||||
} @else {
|
} @else {
|
||||||
p class="text-center p-2" {"You must be logged in to view this page."}
|
p class="text-red-400 text-center" {"Failed to load tags."}
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -94,22 +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())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_tag(
|
||||||
|
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()?;
|
||||||
|
|
||||||
|
let t = Tag::get_by_id(&mut *tx, id).await?;
|
||||||
|
let name = t.name.as_str().to_string();
|
||||||
|
t.delete(&mut *tx).await?;
|
||||||
|
|
||||||
|
LogEntry::new(&mut *tx, u, LogAction::DeleteTag { id, name }).await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
Ok(Redirect::to("/tags").into_response())
|
Ok(Redirect::to("/tags").into_response())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::Request,
|
extract::{Request, State},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Redirect, Response},
|
||||||
};
|
};
|
||||||
use maud::{PreEscaped, html};
|
use maud::{PreEscaped, html};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
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,50 +19,59 @@ 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 = User::authenticate(req.headers())?;
|
State(state): State<MnemoState>,
|
||||||
let conn = database::conn()?;
|
req: Request,
|
||||||
let us = match u.is_some() {
|
) -> Result<Response, CompositeError> {
|
||||||
true => User::get_all(&conn),
|
let mut conn = state.pool.acquire().await?;
|
||||||
false => Ok(vec![]),
|
let u = match User::authenticate(&mut *conn, req.headers()).await? {
|
||||||
|
Some(u) => u,
|
||||||
|
None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()),
|
||||||
};
|
};
|
||||||
|
let us = User::get_all(&mut *conn).await.map(|mut v| {
|
||||||
|
v.sort_by_key(|p| match p.id {
|
||||||
|
id if id == Uuid::nil() => (0, p.id),
|
||||||
|
id if id == Uuid::max() => (1, p.id),
|
||||||
|
_ => (2, p.id),
|
||||||
|
});
|
||||||
|
v
|
||||||
|
});
|
||||||
|
let can_create_users = u
|
||||||
|
.has_permission(&mut *conn, Permission::ManuallyCreateUsers)
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(base(
|
Ok(base(
|
||||||
"Users | Mnemosyne",
|
"Users | Mnemosyne",
|
||||||
html!(
|
html!(
|
||||||
(nav(u.as_ref(), req.uri().path()))
|
(nav(&mut conn, Some(&u), req.uri().path()).await)
|
||||||
|
|
||||||
@if let Some(u) = u {
|
div class="mx-auto max-w-4xl px-2 my-4" {
|
||||||
div class="mx-auto max-w-4xl px-2 my-4" {
|
p class="flex items-center gap-2" {
|
||||||
p class="flex items-center gap-2" {
|
span class="text-neutral-500" {(PreEscaped(icons::USERS))}
|
||||||
span class="text-neutral-500" {(PreEscaped(icons::USERS))}
|
span class="text-2xl font-semibold font-lora" {"Users"}
|
||||||
span class="text-2xl font-semibold font-lora" {"Users"}
|
|
||||||
}
|
|
||||||
p class="text-neutral-500 text-sm font-light" {
|
|
||||||
@if let Ok(v) = &us {
|
|
||||||
(v.len()) " users registered with Mnemosyne."
|
|
||||||
} @else {
|
|
||||||
"Could not fetch user count."
|
|
||||||
}
|
|
||||||
@if let Ok(true) = u.has_permission(&conn, Permission::ManuallyCreateUsers) {
|
|
||||||
" "
|
|
||||||
a href="/users/create" class="text-blue-500 hover:text-blue-400 hover:underline" {
|
|
||||||
"Create a new user"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
div class="mx-auto max-w-4xl flex flex-wrap gap-4" {
|
p class="text-neutral-500 text-sm font-light" {
|
||||||
@if let Ok(vec) = &us {
|
@if let Ok(v) = &us {
|
||||||
@for user in vec {
|
(v.len()) " users registered with Mnemosyne."
|
||||||
(user_miniprofile(user))
|
|
||||||
}
|
|
||||||
} @else {
|
} @else {
|
||||||
p class="text-center py-4 text-light text-red-500" {"Failed to load users."}
|
"Could not fetch user count."
|
||||||
|
}
|
||||||
|
@if let Ok(true) = can_create_users {
|
||||||
|
" "
|
||||||
|
a href="/users/create" class="text-blue-500 hover:text-blue-400 hover:underline" {
|
||||||
|
"Create a new user"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} @else {
|
}
|
||||||
p class="text-center p-2" {"You must be logged in to view this page."}
|
div class="mx-auto max-w-4xl flex flex-wrap gap-4" {
|
||||||
|
@if let Ok(vec) = &us {
|
||||||
|
@for user in vec {
|
||||||
|
(user_miniprofile(user))
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
p class="text-center py-4 text-light text-red-500" {"Failed to load users."}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,55 +8,60 @@ 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 = User::authenticate(req.headers())?;
|
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) => u,
|
||||||
|
None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()),
|
||||||
|
};
|
||||||
|
let can_create = u
|
||||||
|
.has_permission(&mut *conn, Permission::ManuallyCreateUsers)
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(base(
|
Ok(base(
|
||||||
"Create User | Mnemosyne",
|
"Create User | Mnemosyne",
|
||||||
html!(
|
html!(
|
||||||
(nav(u.as_ref(), req.uri().path()))
|
(nav(&mut conn, Some(&u), req.uri().path()).await)
|
||||||
|
|
||||||
@if let Some(u) = u {
|
div class="mx-auto max-w-4xl px-2 my-4" {
|
||||||
div class="mx-auto max-w-4xl px-2 my-4" {
|
p class="flex items-center gap-2" {
|
||||||
p class="flex items-center gap-2" {
|
span class="text-neutral-500" {(PreEscaped(icons::USER_PLUS))}
|
||||||
span class="text-neutral-500" {(PreEscaped(icons::USER_PLUS))}
|
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) {
|
}
|
||||||
div class="mx-auto max-w-4xl px-2 mt-4" {
|
@if let Ok(true) = can_create {
|
||||||
form action="/users/create-form" method="post" class="flex flex-col" {
|
div class="mx-auto max-w-4xl px-2 mt-4" {
|
||||||
label for="handle" class="font-light text-neutral-500" {"Handle"}
|
form action="/users/create-form" method="post" class="flex flex-col" {
|
||||||
div class="flex w-64 items-center border border-neutral-200/25 rounded bg-neutral-950/50" {
|
label for="handle" class="font-light text-neutral-500" {"Handle"}
|
||||||
span class="pl-2 text-neutral-500 select-none" {"@"}
|
div class="flex w-64 items-center border border-neutral-200/25 rounded bg-neutral-950/50" {
|
||||||
input id="handle" name="handle" type="text" autocomplete="off"
|
span class="pl-2 text-neutral-500 select-none" {"@"}
|
||||||
class="w-fit pl-0.5 pr-1 py-1 outline-none";
|
input id="handle" name="handle" type="text" autocomplete="off"
|
||||||
}
|
class="w-fit pl-0.5 pr-1 py-1 outline-none";
|
||||||
label for="password" class="font-light text-neutral-500 mt-4" {"Password"} br;
|
|
||||||
input id="password" name="password" type="password" autocomplete="off"
|
|
||||||
class="px-2 w-64 py-1 border border-neutral-200/25 bg-neutral-950/50 rounded";
|
|
||||||
input type="submit" value="Create"
|
|
||||||
class="px-4 mt-4 w-64 py-1 border border-neutral-200/25 bg-neutral-200/5 rounded cursor-pointer hover:border-neutral-200/40";
|
|
||||||
}
|
}
|
||||||
|
label for="password" class="font-light text-neutral-500 mt-4" {"Password"} br;
|
||||||
|
input id="password" name="password" type="password" autocomplete="off"
|
||||||
|
class="px-2 w-64 py-1 border border-neutral-200/25 bg-neutral-950/50 rounded";
|
||||||
|
input type="submit" value="Create"
|
||||||
|
class="px-4 mt-4 w-64 py-1 border border-neutral-200/25 bg-neutral-200/5 rounded cursor-pointer hover:border-neutral-200/40";
|
||||||
}
|
}
|
||||||
} @else {
|
|
||||||
p class="text-center p-2" {"You must have permission to view this page."}
|
|
||||||
}
|
}
|
||||||
} @else {
|
} @else {
|
||||||
p class="text-center p-2" {"You must be logged in to view this page."}
|
p class="text-center p-2" {"You must have permission to view this page."}
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -69,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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,21 +16,24 @@ 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("/users").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(
|
||||||
"No such user | Mnemosyne",
|
"No such user | Mnemosyne",
|
||||||
html!(
|
html!(
|
||||||
(nav(Some(&u), req.uri().path()))
|
(nav(&mut tx, Some(&u), req.uri().path()).await)
|
||||||
div class="mx-auto max-w-4xl mt-16 text-center" {
|
div class="mx-auto max-w-4xl mt-16 text-center" {
|
||||||
div class="text-6xl mb-4" { "?" }
|
div class="text-6xl mb-4" { "?" }
|
||||||
p class="text-red-400 text-lg" { "No such user found." }
|
p class="text-red-400 text-lg" { "No such user found." }
|
||||||
@@ -48,7 +48,7 @@ pub async fn page(Path(id): Path<Uuid>, req: Request) -> Result<Response, Compos
|
|||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
return Ok(base("Error | Mnemosyne", html!(
|
return Ok(base("Error | Mnemosyne", html!(
|
||||||
(nav(Some(&u), req.uri().path()))
|
(nav(&mut tx, Some(&u), req.uri().path()).await)
|
||||||
p class="text-red-400 text-center my-4" { "An error occurred while loading this profile." }
|
p class="text-red-400 text-center my-4" { "An error occurred while loading this profile." }
|
||||||
)).into_response());
|
)).into_response());
|
||||||
}
|
}
|
||||||
@@ -65,12 +65,12 @@ 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),
|
||||||
html!(
|
html!(
|
||||||
(nav(Some(&u), req.uri().path()))
|
(nav(&mut tx, Some(&u), req.uri().path()).await)
|
||||||
|
|
||||||
// banner
|
// banner
|
||||||
div class="relative w-full h-48 sm:h-56 md:h-64 bg-linear-to-b from-neutral-800 from-25% to-emerald-950 overflow-hidden" {
|
div class="relative w-full h-48 sm:h-56 md:h-64 bg-linear-to-b from-neutral-800 from-25% to-emerald-950 overflow-hidden" {
|
||||||
@@ -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"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,65 +8,66 @@ 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 = 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,
|
||||||
|
None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()),
|
||||||
|
};
|
||||||
|
|
||||||
Ok(base(
|
Ok(base(
|
||||||
"User Settings | Mnemosyne",
|
"User Settings | Mnemosyne",
|
||||||
html!(
|
html!(
|
||||||
(nav(u.as_ref(), req.uri().path()))
|
(nav(&mut conn, Some(&u), req.uri().path()).await)
|
||||||
|
|
||||||
@if let Some(u) = u {
|
div class="max-w-4xl mx-auto px-2" {
|
||||||
div class="max-w-4xl mx-auto px-2" {
|
div class="mx-auto max-w-4xl my-4" {
|
||||||
div class="mx-auto max-w-4xl my-4" {
|
p class="flex items-center gap-2" {
|
||||||
p class="flex items-center gap-2" {
|
span class="text-neutral-500" {(PreEscaped(icons::SETTINGS))}
|
||||||
span class="text-neutral-500" {(PreEscaped(icons::SERVER))}
|
span class="text-2xl font-semibold font-lora" {"Your User Settings"}
|
||||||
span class="text-2xl font-semibold font-lora" {"Your User Settings"}
|
|
||||||
}
|
|
||||||
p class="text-neutral-500 text-sm font-light" {
|
|
||||||
// "Hi, " (u.handle) "!" " " "This is your user settings page." br;
|
|
||||||
"Looking for Mnemosyne settings?" " "
|
|
||||||
a class="text-blue-500 hover:text-blue-400 hover:underline" href="/mnemosyne-settings" {"Here."}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
p class="text-neutral-500 text-sm font-light" {
|
||||||
label for="handle" class="font-light text-neutral-500" {"Handle"}
|
"Hi, " (u.handle) "!" " " "This is your user settings page."
|
||||||
form action="/user-settings/handle" method="post" class="flex gap-2" {
|
}
|
||||||
div class="flex items-center border border-neutral-200/25 rounded bg-neutral-950/50" {
|
}
|
||||||
span class="pl-2 text-neutral-500 select-none" {"@"}
|
|
||||||
input id="handle" name="handle" type="text" autocomplete="off" value={(u.handle)}
|
label for="handle" class="font-light text-neutral-500" {"Handle"}
|
||||||
class="w-full bg-transparent pl-0.5 pr-1 py-1 outline-none";
|
form action="/user-settings/handle" method="post" class="flex gap-2" {
|
||||||
}
|
div class="flex items-center border border-neutral-200/25 rounded bg-neutral-950/50" {
|
||||||
button type="submit" class="px-4 py-1 border border-neutral-200/25 bg-neutral-200/5 rounded cursor-pointer hover:border-neutral-200/40" {
|
span class="pl-2 text-neutral-500 select-none" {"@"}
|
||||||
"Save"
|
input id="handle" name="handle" type="text" autocomplete="off" value={(u.handle)}
|
||||||
}
|
class="w-full bg-transparent pl-0.5 pr-1 py-1 outline-none";
|
||||||
}
|
}
|
||||||
hr class="mt-6 mb-4 border-neutral-600";
|
button type="submit" class="px-4 py-1 border border-neutral-200/25 bg-neutral-200/5 rounded cursor-pointer hover:border-neutral-200/40" {
|
||||||
p class="flex items-center gap-1" {
|
"Save"
|
||||||
span class="text-neutral-500 scale-[.8]" {(PreEscaped(icons::USER_KEY))}
|
}
|
||||||
span class="text-lg font-semibold font-lora" {"Change Password"}
|
}
|
||||||
}
|
hr class="mt-6 mb-4 border-neutral-600";
|
||||||
label for="password" class="font-light text-neutral-500" {"New password"}
|
p class="flex items-center gap-1" {
|
||||||
form action="/user-settings/passwd" method="post" class="flex gap-2" {
|
span class="text-neutral-500 scale-[.8]" {(PreEscaped(icons::USER_KEY))}
|
||||||
input id="password" name="password" type="password" autocomplete="off" class="px-2 py-1 border border-neutral-200/25 bg-neutral-950/50 rounded";
|
span class="text-lg font-semibold font-lora" {"Change Password"}
|
||||||
button type="submit" class="px-4 py-1 border border-neutral-200/25 bg-neutral-200/5 rounded cursor-pointer hover:border-neutral-200/40" {
|
}
|
||||||
"Submit"
|
label for="password" class="font-light text-neutral-500" {"New password"}
|
||||||
}
|
form action="/user-settings/passwd" method="post" class="flex gap-2" {
|
||||||
|
input id="password" name="password" type="password" autocomplete="off" class="px-2 py-1 border border-neutral-200/25 bg-neutral-950/50 rounded";
|
||||||
|
button type="submit" class="px-4 py-1 border border-neutral-200/25 bg-neutral-200/5 rounded cursor-pointer hover:border-neutral-200/40" {
|
||||||
|
"Submit"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} @else {
|
|
||||||
p class="text-center p-2" {"You must be logged in to view this page."}
|
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -78,25 +79,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())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,13 +107,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())
|
||||||
}
|
}
|
||||||
|
|||||||