Compare commits
125 Commits
bc44efad1a
...
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
|
|||
| 9163e38cec | |||
| 4ae0e0ddf1 | |||
| 7c1cc1dcf9 | |||
| dff6e3dd91 | |||
| 00d34f23b0 | |||
| ac37058d9f | |||
| a5180e4ee9 | |||
|
3eb1da8319
|
|||
|
30254864a9
|
|||
|
18f420c7d4
|
|||
| b9632e55d5 | |||
|
3a811db715
|
|||
|
1b5d625b9c
|
|||
| b1e713fd18 | |||
|
f2eab97c15
|
|||
|
d56fcc3f4c
|
|||
|
6906cec2c3
|
|||
| d6e68ac8f7 | |||
|
386118de7d
|
|||
| 1adb4d9e33 | |||
|
673ea40fa6
|
|||
| dc326dfd94 | |||
|
2dd4c8ac47
|
|||
|
777027d471
|
|||
|
f09af791e2
|
|||
|
26be03ba31
|
|||
|
b0d86efae6
|
|||
|
f6337104cf
|
|||
|
3ab3567ac3
|
|||
| 7514e98f1b | |||
|
3f10b51da9
|
|||
|
947db47fdf
|
|||
|
f49fb9df6f
|
|||
|
252f7b164b
|
|||
| f588f3cf27 | |||
|
e08f169074
|
|||
|
d8fb561bca
|
|||
| 0fb8dafd09 | |||
|
449136ce37
|
|||
| a811727dd3 | |||
|
e4cccde466
|
|||
| dc7d16babe | |||
|
20efa90e5d
|
|||
| bd9b24cdbb | |||
|
f16da888d5
|
31
.dockerignore
Normal file
@@ -0,0 +1,31 @@
|
||||
**/.DS_Store
|
||||
**/.classpath
|
||||
**/.dockerignore
|
||||
**/.env
|
||||
**/.git
|
||||
**/.gitignore
|
||||
**/.project
|
||||
**/.settings
|
||||
**/.toolstarget
|
||||
**/.vs
|
||||
**/.vscode
|
||||
**/.zed
|
||||
**/*.*proj.user
|
||||
**/*.dbmdl
|
||||
**/*.jfm
|
||||
**/charts
|
||||
**/docker-compose*
|
||||
**/compose.y*ml
|
||||
**/Dockerfile*
|
||||
**/node_modules
|
||||
**/npm-debug.log
|
||||
**/secrets.dev.yaml
|
||||
**/values.dev.yaml
|
||||
/bin
|
||||
/target
|
||||
LICENSE
|
||||
README.md
|
||||
readme
|
||||
**/*.db*
|
||||
**/*.db3*
|
||||
/mnemodata
|
||||
@@ -26,3 +26,8 @@ jobs:
|
||||
password: ${{secrets.TOKEN2}}
|
||||
- name: publish
|
||||
run: docker push git.gractwo.pl/gractwo/mnemosyne:${{env.short_sha}}
|
||||
- uses: actions-hub/kubectl@master
|
||||
env:
|
||||
KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }}
|
||||
with:
|
||||
args: patch statefulset -n cytaty mnemosyne -p '{"spec":{"template":{"spec":{"containers":[{"name":"mnemosyne","image":"git.gractwo.pl/gractwo/mnemosyne:${{env.short_sha}}"}]}}}}'
|
||||
2
.gitignore
vendored
@@ -1,6 +1,8 @@
|
||||
/target
|
||||
.DS_Store
|
||||
/database
|
||||
/mnemodata
|
||||
/scripts
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"languages": {
|
||||
"Rust": {
|
||||
"language_servers": ["rust-analyzer", "tailwindcss-language-server"],
|
||||
"language_servers": [
|
||||
"rust-analyzer",
|
||||
//
|
||||
"tailwindcss-language-server",
|
||||
],
|
||||
},
|
||||
},
|
||||
"lsp": {
|
||||
|
||||
1808
Cargo.lock
generated
@@ -6,23 +6,27 @@ edition = "2024"
|
||||
[dependencies]
|
||||
argon2 = "0.5.3"
|
||||
axum = "0.8.8"
|
||||
axum-extra = { version = "0.12.5", features = ["form"] }
|
||||
base32 = "0.5.1"
|
||||
base64 = "0.22.1"
|
||||
chrono = { version = "0.4.43", features = ["serde"] }
|
||||
chrono-tz = "0.10.4"
|
||||
dotenvy = "0.15.7"
|
||||
env_logger = "0.11.9"
|
||||
http = "1.4.0"
|
||||
log = "0.4.29"
|
||||
maud = { version = "0.27.0", features = ["axum"] }
|
||||
rand = "0.10.0"
|
||||
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_json = "1.0.149"
|
||||
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"] }
|
||||
thiserror = "2.0.18"
|
||||
tokio = { version = "1.49.0", features = ["full"] }
|
||||
tower = { version = "0.5.3", 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"] }
|
||||
|
||||
@@ -21,7 +21,7 @@ RUN apk add --no-cache clang lld musl-dev git
|
||||
# source code into the container. Once built, copy the executable to an
|
||||
# output directory before the cache mounted /app/target is unmounted.
|
||||
RUN --mount=type=bind,source=src,target=src \
|
||||
--mount=type=bind,source=web,target=web \
|
||||
--mount=type=bind,source=build.rs,target=build.rs \
|
||||
--mount=type=bind,source=Cargo.toml,target=Cargo.toml \
|
||||
--mount=type=bind,source=Cargo.lock,target=Cargo.lock \
|
||||
--mount=type=cache,target=/app/target/ \
|
||||
@@ -54,7 +54,7 @@ RUN adduser \
|
||||
--uid "${UID}" \
|
||||
appuser
|
||||
|
||||
RUN mkdir -p /app && chown appuser:appuser /app
|
||||
RUN mkdir -p /app/data && chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
WORKDIR /app
|
||||
ENV IN_DOCKER=true
|
||||
|
||||
31
build.rs
@@ -6,18 +6,25 @@ use std::process::Command;
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
println!("cargo:rerun-if-changed=src/web");
|
||||
println!("cargo:rerun-if-changed=src/database/migrations");
|
||||
|
||||
if std::env::var("IN_DOCKER").is_err() {
|
||||
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 download_url = match (os.as_str(), arch.as_str()) {
|
||||
("macos", "aarch64") => {
|
||||
let env = env::var("CARGO_CFG_TARGET_ENV").unwrap_or_else(|_| String::from("unknown"));
|
||||
let download_url = match (os.as_str(), arch.as_str(), env.as_str()) {
|
||||
("macos", "aarch64", _) => {
|
||||
"https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-macos-arm64"
|
||||
}
|
||||
("linux", "x86_64") => {
|
||||
("linux", "x86_64", "musl") => {
|
||||
"https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64-musl"
|
||||
}
|
||||
("linux", "x86_64", _) => {
|
||||
"https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64"
|
||||
}
|
||||
("linux", "aarch64") => {
|
||||
("linux", "aarch64", "musl") => {
|
||||
"https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-arm64-musl"
|
||||
}
|
||||
("linux", "aarch64", _) => {
|
||||
"https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-arm64"
|
||||
}
|
||||
_ => return Err(format!("Unsupported platform: {} {}", os, arch).into()),
|
||||
@@ -30,7 +37,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
download_tailwind(&download_url, &tailwind_binary)?;
|
||||
println!("cargo:rustc-env=TAILWIND_BIN={}", tailwind_binary.display());
|
||||
run_tailwind(&tailwind_binary)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -43,19 +49,18 @@ fn run_tailwind(bin: &PathBuf) -> Result<(), Box<dyn std::error::Error>> {
|
||||
.join("web")
|
||||
.join("input.css");
|
||||
let inputstr = input.to_str().unwrap();
|
||||
let output = Path::new(&basedir)
|
||||
.join("src")
|
||||
.join("web")
|
||||
.join("styles.css");
|
||||
let output = PathBuf::from(env::var("OUT_DIR")?).join("styles.css");
|
||||
let outputstr = output.to_str().unwrap();
|
||||
let args = vec!["-i", inputstr, "-o", outputstr, "--minify"];
|
||||
|
||||
let run = Command::new(&bin).args(args).status()?;
|
||||
match run.success() {
|
||||
true => println!("Tailwind CSS build complete."),
|
||||
false => println!("Tailwind CSS build failed."),
|
||||
};
|
||||
true => {
|
||||
println!("Tailwind CSS build complete.");
|
||||
Ok(())
|
||||
}
|
||||
false => panic!("Tailwind CSS build failed."),
|
||||
}
|
||||
}
|
||||
|
||||
fn download_tailwind(url: &str, target_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
39
compose.yaml
Normal file
@@ -0,0 +1,39 @@
|
||||
services:
|
||||
core:
|
||||
build:
|
||||
context: .
|
||||
target: final
|
||||
ports:
|
||||
- 39321:39321
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./mnemodata:/app/data
|
||||
environment:
|
||||
# - PORT=39321 # Mnemosyne uses port 39321 for HTTP by default;
|
||||
- DATABASE_URL=postgres://mnemo:syne@postgres:5432/mnemosyne
|
||||
networks:
|
||||
- mnemosyne
|
||||
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
|
||||
|
||||
networks:
|
||||
mnemosyne:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
pg_volume:
|
||||
driver: local
|
||||
6
readme
@@ -1,6 +0,0 @@
|
||||
Mnemosyne
|
||||
|
||||
|
||||
Mnemosyne is a work-in-progress project which aims to satisfy all the quote-collecting needs of your community, all the while making them queryable and well notarized.
|
||||
|
||||
Note on tailwind and styles.css: The styles.css file, which is generated by a standalone Tailwind binary downloaded and executed automatically within build.rs, is to be committed any time it changes, as without it styling will be broken on the frontend part of the page. This is in contrast to a perhaps expected approach of gitignoring them and just generating them every build; this is incompatible with building docker images in an efficient way, however, as docker exposes no way to mount directories while retaining ability to write files and execute binaries.
|
||||
4
readme.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
Mnemosyne
|
||||
|
||||
|
||||
Mnemosyne is a work-in-progress project which aims to satisfy all the quote-collecting needs of your community, all the while making them queryable and well notarized.
|
||||
@@ -1,17 +1,22 @@
|
||||
use axum::{
|
||||
Form, Json,
|
||||
extract::State,
|
||||
http::{HeaderMap, header},
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::users::{
|
||||
use crate::{
|
||||
MnemoState,
|
||||
users::{
|
||||
User,
|
||||
auth::{
|
||||
AuthError, COOKIE_NAME, SessionAuthRequired, SessionAuthenticate, UserAuthRequired,
|
||||
implementation::authenticate_via_credentials,
|
||||
},
|
||||
sessions::Session,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -20,9 +25,14 @@ pub struct LoginForm {
|
||||
password: String,
|
||||
}
|
||||
|
||||
fn login_common(creds: LoginForm) -> Result<(String, String), AuthError> {
|
||||
let u = authenticate_via_credentials(&creds.handle, &creds.password)?.required()?;
|
||||
let (_, token) = Session::new_for_user(&u)?;
|
||||
async fn login_common(pool: &PgPool, creds: LoginForm) -> Result<(String, String), AuthError> {
|
||||
let mut conn = pool.acquire().await?;
|
||||
let u = authenticate_via_credentials(&mut conn, &creds.handle, &creds.password)
|
||||
.await?
|
||||
.required()?;
|
||||
|
||||
let (_, token) = Session::new_for_user(&mut conn, &u).await?;
|
||||
|
||||
let secure = match cfg!(debug_assertions) {
|
||||
false => "; Secure",
|
||||
true => "",
|
||||
@@ -34,12 +44,20 @@ fn login_common(creds: LoginForm) -> Result<(String, String), AuthError> {
|
||||
);
|
||||
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())
|
||||
}
|
||||
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(([(header::SET_COOKIE, cookie)], Redirect::to("/dashboard")).into_response())
|
||||
}
|
||||
@@ -47,15 +65,32 @@ pub async fn login_form(Form(creds): Form<LoginForm>) -> Result<Response, AuthEr
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn logout(headers: HeaderMap) -> Result<Response, AuthError> {
|
||||
let mut s = Session::authenticate(&headers)?.required()?;
|
||||
s.revoke(Some(&User::get_by_id(s.user_id)?))?;
|
||||
pub async fn logout(
|
||||
State(state): State<MnemoState>,
|
||||
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");
|
||||
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()?;
|
||||
s.revoke(Some(&User::get_by_id(s.user_id)?))?;
|
||||
|
||||
pub async fn logout_form(
|
||||
State(state): State<MnemoState>,
|
||||
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");
|
||||
Ok(([(header::SET_COOKIE, cookie)], Redirect::to("/")).into_response())
|
||||
}
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
use axum::{
|
||||
Router,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{delete, get, patch, post},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
database::DatabaseError,
|
||||
persons::PersonError,
|
||||
quotes::QuoteError,
|
||||
tags::TagError,
|
||||
users::{UserError, auth::AuthError, sessions::SessionError},
|
||||
web::RedirectViaError,
|
||||
};
|
||||
use crate::MnemoState;
|
||||
|
||||
mod auth;
|
||||
mod persons;
|
||||
@@ -20,7 +12,7 @@ mod sessions;
|
||||
mod tags;
|
||||
mod users;
|
||||
|
||||
pub fn api_router() -> Router {
|
||||
pub fn api_router() -> Router<MnemoState> {
|
||||
Router::new()
|
||||
.route("/api/live", get(async || "Mnemosyne lives"))
|
||||
// auth
|
||||
@@ -36,6 +28,10 @@ pub fn api_router() -> Router {
|
||||
.route("/api/users/@{handle}", get(users::get_by_handle))
|
||||
.route("/api/users/{id}/setpassw", post(users::change_password))
|
||||
.route("/api/users/{id}/sethandle", post(users::change_handle))
|
||||
.route(
|
||||
"/api/users/{id}/permissions/{perm}",
|
||||
get(users::get_permission).put(users::put_permission),
|
||||
)
|
||||
// sessions
|
||||
.route("/api/sessions/{id}", get(sessions::get_by_id))
|
||||
.route("/api/sessions/{id}/revoke", post(sessions::revoke_by_id))
|
||||
@@ -52,38 +48,11 @@ pub fn api_router() -> Router {
|
||||
.route("/api/persons/{id}", get(persons::get_by_id))
|
||||
.route("/api/persons/{id}/names", get(persons::pid_names))
|
||||
.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}/setprimary", post(persons::n_setprimary))
|
||||
// quotes
|
||||
.route("/api/quotes", post(quotes::create))
|
||||
.route("/api/quotes/{id}", get(quotes::get_by_id))
|
||||
.route("/api/quotes/search", get(quotes::get_by_query))
|
||||
}
|
||||
|
||||
pub struct CompositeError(Response);
|
||||
impl IntoResponse for CompositeError {
|
||||
fn into_response(self) -> Response {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! composite_from {
|
||||
($($t:ty),+ $(,)?) => {
|
||||
$(
|
||||
impl From<$t> for CompositeError {
|
||||
fn from(e: $t) -> Self {
|
||||
CompositeError(e.into_response())
|
||||
}
|
||||
}
|
||||
)+
|
||||
};
|
||||
}
|
||||
composite_from!(
|
||||
AuthError,
|
||||
UserError,
|
||||
SessionError,
|
||||
TagError,
|
||||
PersonError,
|
||||
QuoteError,
|
||||
DatabaseError,
|
||||
RedirectViaError
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::Path,
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
@@ -8,7 +8,9 @@ use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
api::CompositeError,
|
||||
MnemoState,
|
||||
error::CompositeError,
|
||||
logs::{LogAction, LogEntry},
|
||||
persons::{Name, Person},
|
||||
users::{
|
||||
User,
|
||||
@@ -19,23 +21,34 @@ use crate::{
|
||||
|
||||
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> {
|
||||
User::authenticate(&headers)?.required()?;
|
||||
Ok(Json(Person::get_all()?).into_response())
|
||||
pub async fn get_all(
|
||||
State(state): State<MnemoState>,
|
||||
headers: HeaderMap,
|
||||
) -> 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(
|
||||
State(state): State<MnemoState>,
|
||||
Path(id): Path<Uuid>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, CompositeError> {
|
||||
User::authenticate(&headers)?.required()?;
|
||||
Ok(Json(Person::get_by_id(id)?).into_response())
|
||||
let mut conn = state.pool.acquire().await?;
|
||||
User::authenticate(&mut conn, &headers).await?.required()?;
|
||||
Ok(Json(Person::get_by_id(&mut conn, id).await?).into_response())
|
||||
}
|
||||
|
||||
pub async fn pid_names(
|
||||
State(state): State<MnemoState>,
|
||||
Path(id): Path<Uuid>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, CompositeError> {
|
||||
User::authenticate(&headers)?.required()?;
|
||||
Ok(Json(Person::get_by_id(id)?.get_all_names()?).into_response())
|
||||
let mut conn = state.pool.acquire().await?;
|
||||
User::authenticate(&mut conn, &headers).await?.required()?;
|
||||
let person = Person::get_by_id(&mut conn, id).await?;
|
||||
Ok(Json(person.get_all_names(&mut conn).await?).into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -44,40 +57,106 @@ pub struct PersonNameForm {
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(state): State<MnemoState>,
|
||||
headers: HeaderMap,
|
||||
Json(form): Json<PersonNameForm>,
|
||||
) -> Result<Response, CompositeError> {
|
||||
let u = User::authenticate(&headers)?.required()?;
|
||||
let p = Person::create(form.name, u.id)?;
|
||||
let mut tx = state.pool.begin().await?;
|
||||
let u = User::authenticate(&mut tx, &headers).await?.required()?;
|
||||
|
||||
let p = Person::create(&mut tx, form.name, u.id).await?;
|
||||
LogEntry::new(
|
||||
&mut tx,
|
||||
u,
|
||||
LogAction::CreatePerson {
|
||||
id: p.id,
|
||||
pname: p.primary_name.clone(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(p)).into_response())
|
||||
}
|
||||
|
||||
pub async fn add_name(
|
||||
State(state): State<MnemoState>,
|
||||
Path(id): Path<Uuid>,
|
||||
headers: HeaderMap,
|
||||
Json(form): Json<PersonNameForm>,
|
||||
) -> Result<Response, CompositeError> {
|
||||
let u = User::authenticate(&headers)?.required()?;
|
||||
let p = Person::get_by_id(id)?;
|
||||
let n = p.add_name(form.name, u.id)?;
|
||||
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.clone(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(n)).into_response())
|
||||
}
|
||||
|
||||
pub async fn n_by_id(Path(id): Path<Uuid>, headers: HeaderMap) -> Result<Response, CompositeError> {
|
||||
User::authenticate(&headers)?.required()?;
|
||||
Ok(Json(Name::get_by_id(id)?).into_response())
|
||||
pub async fn n_all(
|
||||
State(state): State<MnemoState>,
|
||||
headers: HeaderMap,
|
||||
) -> 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>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, CompositeError> {
|
||||
let u = User::authenticate(&headers)?.required()?;
|
||||
if !u.has_permission(Permission::ChangePersonPrimaryName)? {
|
||||
let mut conn = state.pool.acquire().await?;
|
||||
User::authenticate(&mut conn, &headers).await?.required()?;
|
||||
Ok(Json(Name::get_by_id(&mut conn, id).await?).into_response())
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
let mut n = Name::get_by_id(id)?;
|
||||
n.set_primary()?;
|
||||
let mut n = Name::get_by_id(&mut tx, id).await?;
|
||||
let p = Person::get_by_id(&mut tx, n.person_id).await?;
|
||||
n.set_primary(&mut tx).await?;
|
||||
n.is_primary = true;
|
||||
|
||||
LogEntry::new(
|
||||
&mut tx,
|
||||
u,
|
||||
LogAction::SetPersonPrimaryName {
|
||||
pid: p.id,
|
||||
nid: n.id,
|
||||
on: p.primary_name,
|
||||
nn: n.name.clone(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(n).into_response())
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::Path,
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use chrono::{DateTime, FixedOffset};
|
||||
use chrono::NaiveDateTime;
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
api::CompositeError,
|
||||
MnemoState,
|
||||
error::CompositeError,
|
||||
logs::{LogAction, LogEntry},
|
||||
persons::Name,
|
||||
quotes::Quote,
|
||||
users::{
|
||||
@@ -19,48 +21,78 @@ use crate::{
|
||||
};
|
||||
|
||||
pub async fn get_by_id(
|
||||
State(state): State<MnemoState>,
|
||||
Path(id): Path<Uuid>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, CompositeError> {
|
||||
User::authenticate(&headers)?.required()?;
|
||||
Ok(Json(Quote::get_by_id(id)?).into_response())
|
||||
let mut conn = state.pool.acquire().await?;
|
||||
User::authenticate(&mut conn, &headers).await?.required()?;
|
||||
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)]
|
||||
pub struct QuoteLineForm {
|
||||
pub content: String,
|
||||
pub name_id: Uuid,
|
||||
pub name_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct QuoteCreateForm {
|
||||
pub lines: Vec<QuoteLineForm>,
|
||||
pub timestamp: DateTime<FixedOffset>,
|
||||
pub timestamp: NaiveDateTime,
|
||||
pub context: Option<String>,
|
||||
pub location: Option<String>,
|
||||
pub public: bool,
|
||||
#[serde(default)]
|
||||
pub discord_webhook: bool,
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(state): State<MnemoState>,
|
||||
headers: HeaderMap,
|
||||
Json(form): Json<QuoteCreateForm>,
|
||||
) -> Result<Response, CompositeError> {
|
||||
let u = User::authenticate(&headers)?.required()?;
|
||||
let mut tx = state.pool.begin().await?;
|
||||
let u = User::authenticate(&mut tx, &headers).await?.required()?;
|
||||
|
||||
let lines = form
|
||||
.lines
|
||||
.into_iter()
|
||||
.map(|l| Ok((l.content, Name::get_by_id(l.name_id)?)))
|
||||
.collect::<Result<Vec<(String, Name)>, CompositeError>>()?;
|
||||
let mut lines = Vec::with_capacity(form.lines.len());
|
||||
for l in form.lines {
|
||||
let mut names = Vec::with_capacity(l.name_ids.len());
|
||||
for id in l.name_ids {
|
||||
names.push(Name::get_by_id(&mut tx, id).await?);
|
||||
}
|
||||
lines.push((l.content, names));
|
||||
}
|
||||
|
||||
let q = Quote::create(
|
||||
&mut tx,
|
||||
lines,
|
||||
form.timestamp,
|
||||
form.context,
|
||||
form.location,
|
||||
u.id,
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
Ok((StatusCode::CREATED, Json(q)).into_response())
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::Path,
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
api::CompositeError,
|
||||
MnemoState,
|
||||
error::CompositeError,
|
||||
logs::{LogAction, LogEntry},
|
||||
users::{
|
||||
User,
|
||||
auth::{UserAuthRequired, UserAuthenticate},
|
||||
@@ -16,17 +18,20 @@ use crate::{
|
||||
},
|
||||
};
|
||||
|
||||
const CANT_REVOKE: &str = "You don't have permission to change this user's password.";
|
||||
const CANT_REVOKE: &str = "You don't have permission to revoke this user's sessions.";
|
||||
|
||||
pub async fn get_by_id(
|
||||
State(state): State<MnemoState>,
|
||||
Path(id): Path<Uuid>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, CompositeError> {
|
||||
let u = User::authenticate(&headers)?.required()?;
|
||||
let s = Session::get_by_id(id)?;
|
||||
let mut conn = state.pool.acquire().await?;
|
||||
let u = User::authenticate(&mut conn, &headers).await?.required()?;
|
||||
let s = Session::get_by_id(&mut conn, id).await?;
|
||||
|
||||
match s.user_id == u.id
|
||||
|| u.has_permission(Permission::ListOthersSessions)
|
||||
|| u.has_permission(&mut conn, Permission::ListOthersSessions)
|
||||
.await
|
||||
.is_ok_and(|v| v)
|
||||
{
|
||||
true => Ok(Json(s).into_response()),
|
||||
@@ -35,21 +40,29 @@ pub async fn get_by_id(
|
||||
}
|
||||
|
||||
pub async fn revoke_by_id(
|
||||
State(state): State<MnemoState>,
|
||||
Path(id): Path<Uuid>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, CompositeError> {
|
||||
let u = User::authenticate(&headers)?.required()?;
|
||||
let mut s = Session::get_by_id(id)?;
|
||||
let mut tx = state.pool.begin().await?;
|
||||
let u = User::authenticate(&mut tx, &headers).await?.required()?;
|
||||
|
||||
let mut s = Session::get_by_id(&mut tx, id).await?;
|
||||
match s.user_id == u.id
|
||||
|| u.has_permission(Permission::RevokeOthersSessions)
|
||||
|| u.has_permission(&mut tx, Permission::RevokeOthersSessions)
|
||||
.await
|
||||
.is_ok_and(|v| v)
|
||||
{
|
||||
true => {
|
||||
s.revoke(Some(&u))?;
|
||||
s.revoke(&mut tx, Some(&u)).await?;
|
||||
LogEntry::new(&mut tx, u, LogAction::ManuallyRevokeSession { id }).await?;
|
||||
tx.commit().await?;
|
||||
Ok(Json(s).into_response())
|
||||
}
|
||||
false => match u.has_permission(Permission::ListOthersSessions)? {
|
||||
false => match u
|
||||
.has_permission(&mut tx, Permission::ListOthersSessions)
|
||||
.await?
|
||||
{
|
||||
true => Ok((StatusCode::FORBIDDEN, CANT_REVOKE).into_response()),
|
||||
false => Err(SessionError::NoSessionWithId(id))?,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::Path,
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
@@ -8,7 +8,9 @@ use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
api::CompositeError,
|
||||
MnemoState,
|
||||
error::CompositeError,
|
||||
logs::{LogAction, LogEntry},
|
||||
tags::{Tag, TagName},
|
||||
users::{
|
||||
User,
|
||||
@@ -22,61 +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 TAG_DELETED: &str = "Tag deleted successfully.";
|
||||
|
||||
pub async fn get_all(headers: HeaderMap) -> Result<Response, CompositeError> {
|
||||
User::authenticate(&headers)?.required()?;
|
||||
Ok(Json(Tag::get_all()?).into_response())
|
||||
pub async fn get_all(
|
||||
State(state): State<MnemoState>,
|
||||
headers: HeaderMap,
|
||||
) -> 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(
|
||||
State(state): State<MnemoState>,
|
||||
Path(id): Path<Uuid>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, CompositeError> {
|
||||
User::authenticate(&headers)?.required()?;
|
||||
Ok(Json(Tag::get_by_id(id)?).into_response())
|
||||
let mut conn = state.pool.acquire().await?;
|
||||
User::authenticate(&mut conn, &headers).await?.required()?;
|
||||
Ok(Json(Tag::get_by_id(&mut conn, id).await?).into_response())
|
||||
}
|
||||
|
||||
pub async fn get_by_name(
|
||||
State(state): State<MnemoState>,
|
||||
Path(name): Path<TagName>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, CompositeError> {
|
||||
User::authenticate(&headers)?.required()?;
|
||||
Ok(Json(Tag::get_by_name(name)?).into_response())
|
||||
let mut conn = state.pool.acquire().await?;
|
||||
User::authenticate(&mut conn, &headers).await?.required()?;
|
||||
Ok(Json(Tag::get_by_name(&mut conn, name).await?).into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TagNameForm {
|
||||
name: TagName,
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(state): State<MnemoState>,
|
||||
headers: HeaderMap,
|
||||
Json(form): Json<TagNameForm>,
|
||||
) -> Result<Response, CompositeError> {
|
||||
let u = User::authenticate(&headers)?.required()?;
|
||||
if !u.has_permission(Permission::CreateTags)? {
|
||||
let mut tx = state.pool.begin().await?;
|
||||
let u = User::authenticate(&mut tx, &headers).await?.required()?;
|
||||
|
||||
if !u.has_permission(&mut tx, Permission::CreateTags).await? {
|
||||
return Ok((StatusCode::FORBIDDEN, CANT_MAKE_TAGS).into_response());
|
||||
}
|
||||
Ok(Json(Tag::create(form.name)?).into_response())
|
||||
|
||||
let t = Tag::create(&mut tx, form.name).await?;
|
||||
LogEntry::new(
|
||||
&mut tx,
|
||||
u,
|
||||
LogAction::CreateTag {
|
||||
id: t.id,
|
||||
name: t.name.as_str().to_string(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(Json(t).into_response())
|
||||
}
|
||||
|
||||
pub async fn rename(
|
||||
State(state): State<MnemoState>,
|
||||
Path(id): Path<Uuid>,
|
||||
headers: HeaderMap,
|
||||
Json(form): Json<TagNameForm>,
|
||||
) -> Result<Response, CompositeError> {
|
||||
let u = User::authenticate(&headers)?.required()?;
|
||||
if !u.has_permission(Permission::RenameTags)? {
|
||||
let mut tx = state.pool.begin().await?;
|
||||
let u = User::authenticate(&mut tx, &headers).await?.required()?;
|
||||
|
||||
if !u.has_permission(&mut tx, Permission::RenameTags).await? {
|
||||
return Ok((StatusCode::FORBIDDEN, CANT_RENAME_TAGS).into_response());
|
||||
}
|
||||
let mut tag = Tag::get_by_id(id)?;
|
||||
tag.rename(form.name)?;
|
||||
let mut tag = Tag::get_by_id(&mut tx, id).await?;
|
||||
let on = tag.name.as_str().to_string();
|
||||
tag.rename(&mut tx, form.name).await?;
|
||||
LogEntry::new(
|
||||
&mut tx,
|
||||
u,
|
||||
LogAction::RenameTag {
|
||||
id,
|
||||
on,
|
||||
nn: tag.name.as_str().to_string(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(tag).into_response())
|
||||
}
|
||||
|
||||
pub async fn delete(Path(id): Path<Uuid>, headers: HeaderMap) -> Result<Response, CompositeError> {
|
||||
let u = User::authenticate(&headers)?.required()?;
|
||||
if !u.has_permission(Permission::DeleteTags)? {
|
||||
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::DeleteTags).await? {
|
||||
return Ok((StatusCode::FORBIDDEN, CANT_DEL_TAGS).into_response());
|
||||
}
|
||||
Tag::get_by_id(id)?.delete()?;
|
||||
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((StatusCode::OK, TAG_DELETED).into_response())
|
||||
}
|
||||
|
||||
199
src/api/users.rs
@@ -1,6 +1,6 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::Path,
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
@@ -8,44 +8,59 @@ use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
api::CompositeError,
|
||||
MnemoState,
|
||||
error::CompositeError,
|
||||
logs::{LogAction, LogEntry},
|
||||
users::{
|
||||
User,
|
||||
auth::{UserAuthRequired, UserAuthenticate},
|
||||
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_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 GO_AWAY: &str = "You don't have permission to look into permissions!";
|
||||
const HANDLE_CHANGED_SUCCESS: &str = "Handle changed successfully.";
|
||||
const PASSW_CHANGED_SUCCESS: &str = "Password changed successfully.";
|
||||
|
||||
pub async fn get_me(headers: HeaderMap) -> Result<Response, CompositeError> {
|
||||
Ok(Json(User::authenticate(&headers)?.required()?).into_response())
|
||||
pub async fn get_me(
|
||||
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(
|
||||
State(state): State<MnemoState>,
|
||||
Path(id): Path<Uuid>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, CompositeError> {
|
||||
User::authenticate(&headers)?.required()?;
|
||||
Ok(Json(User::get_by_id(id)?).into_response())
|
||||
let mut conn = state.pool.acquire().await?;
|
||||
User::authenticate(&mut conn, &headers).await?.required()?;
|
||||
Ok(Json(User::get_by_id(&mut conn, id).await?).into_response())
|
||||
}
|
||||
|
||||
pub async fn get_by_handle(
|
||||
State(state): State<MnemoState>,
|
||||
Path(handle): Path<UserHandle>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, CompositeError> {
|
||||
User::authenticate(&headers)?.required()?;
|
||||
Ok(Json(User::get_by_handle(handle)?).into_response())
|
||||
let mut conn = state.pool.acquire().await?;
|
||||
User::authenticate(&mut conn, &headers).await?.required()?;
|
||||
Ok(Json(User::get_by_handle(&mut conn, handle).await?).into_response())
|
||||
}
|
||||
|
||||
pub async fn get_all(headers: HeaderMap) -> Result<Response, CompositeError> {
|
||||
User::authenticate(&headers)?.required()?;
|
||||
Ok(Json(User::get_all()?).into_response())
|
||||
pub async fn get_all(
|
||||
State(state): State<MnemoState>,
|
||||
headers: HeaderMap,
|
||||
) -> 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)]
|
||||
@@ -53,30 +68,70 @@ pub struct HandleForm {
|
||||
handle: UserHandle,
|
||||
}
|
||||
pub async fn create(
|
||||
State(state): State<MnemoState>,
|
||||
headers: HeaderMap,
|
||||
Json(form): Json<HandleForm>,
|
||||
) -> Result<Response, CompositeError> {
|
||||
let u = User::authenticate(&headers)?.required()?;
|
||||
if !u.has_permission(Permission::ManuallyCreateUsers)? {
|
||||
let mut tx = state.pool.begin().await?;
|
||||
let u = User::authenticate(&mut tx, &headers).await?.required()?;
|
||||
|
||||
if !u
|
||||
.has_permission(&mut tx, Permission::ManuallyCreateUsers)
|
||||
.await?
|
||||
{
|
||||
return Ok((StatusCode::FORBIDDEN, CANT_MANUALLY_MAKE_USERS).into_response());
|
||||
}
|
||||
Ok(Json(User::create(form.handle)?).into_response())
|
||||
|
||||
let nu = User::create(&mut tx, form.handle).await?;
|
||||
LogEntry::new(
|
||||
&mut tx,
|
||||
u,
|
||||
LogAction::CreateUser {
|
||||
id: nu.id,
|
||||
handle: nu.handle.as_str().to_string(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(nu).into_response())
|
||||
}
|
||||
|
||||
pub async fn change_handle(
|
||||
State(state): State<MnemoState>,
|
||||
Path(id): Path<Uuid>,
|
||||
headers: HeaderMap,
|
||||
Json(form): Json<HandleForm>,
|
||||
) -> Result<Response, CompositeError> {
|
||||
let u = User::authenticate(&headers)?.required()?;
|
||||
let mut tx = state.pool.begin().await?;
|
||||
let u = User::authenticate(&mut tx, &headers).await?.required()?;
|
||||
|
||||
let mut target = if u.id == id {
|
||||
u
|
||||
u.clone()
|
||||
} else {
|
||||
if !u.has_permission(Permission::ChangeOthersHandles)? {
|
||||
if !u
|
||||
.has_permission(&mut tx, Permission::ChangeOthersHandles)
|
||||
.await?
|
||||
{
|
||||
return Ok((StatusCode::FORBIDDEN, CANT_CHANGE_OTHERS_HANDLE).into_response());
|
||||
}
|
||||
User::get_by_id(id)?
|
||||
User::get_by_id(&mut tx, id).await?
|
||||
};
|
||||
target.set_handle(form.handle)?;
|
||||
|
||||
let old_handle = target.handle.as_str().to_string();
|
||||
target.set_handle(&mut tx, form.handle).await?;
|
||||
LogEntry::new(
|
||||
&mut tx,
|
||||
u,
|
||||
LogAction::ChangeUserHandle {
|
||||
id: target.id,
|
||||
old: old_handle,
|
||||
new: target.handle.as_str().to_string(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(HANDLE_CHANGED_SUCCESS.into_response())
|
||||
}
|
||||
|
||||
@@ -85,19 +140,113 @@ pub struct ChangePasswordForm {
|
||||
password: String,
|
||||
}
|
||||
pub async fn change_password(
|
||||
State(state): State<MnemoState>,
|
||||
Path(id): Path<Uuid>,
|
||||
headers: HeaderMap,
|
||||
Json(form): Json<ChangePasswordForm>,
|
||||
) -> Result<Response, CompositeError> {
|
||||
let u = User::authenticate(&headers)?.required()?;
|
||||
let mut tx = state.pool.begin().await?;
|
||||
let u = User::authenticate(&mut tx, &headers).await?.required()?;
|
||||
|
||||
let mut target = if u.id == id {
|
||||
u
|
||||
u.clone()
|
||||
} else {
|
||||
if !u.has_permission(Permission::ChangeOthersPasswords)? {
|
||||
if !u
|
||||
.has_permission(&mut tx, Permission::ChangeOthersPasswords)
|
||||
.await?
|
||||
{
|
||||
return Ok((StatusCode::FORBIDDEN, CANT_CHANGE_OTHERS_PASSW).into_response());
|
||||
}
|
||||
User::get_by_id(id)?
|
||||
User::get_by_id(&mut tx, id).await?
|
||||
};
|
||||
target.set_password(Some(&form.password))?;
|
||||
|
||||
target.set_password(&mut tx, Some(&form.password)).await?;
|
||||
LogEntry::new(
|
||||
&mut tx,
|
||||
u,
|
||||
LogAction::ManuallyChangeUsersPassword { id: target.id },
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
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 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] = &[
|
||||
"quote engine",
|
||||
@@ -14,8 +68,47 @@ pub const REFERENCE_SPLASHES: &[&str] = &[
|
||||
"memory palace",
|
||||
"take a break sometimes",
|
||||
"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<()> {
|
||||
let level_string = format!("{}", 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 -- link probably
|
||||
);
|
||||
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, response::IntoResponse};
|
||||
use rusqlite::{Connection, OptionalExtension};
|
||||
|
||||
macro_rules! migration {
|
||||
($name:literal) => {
|
||||
($name, include_str!(concat!("./migrations/", $name, ".sql")))
|
||||
};
|
||||
}
|
||||
const MIGRATIONS: &[(&str, &str)] = &[migration!("2026-03-07--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())
|
||||
);
|
||||
"#;
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("{0}")]
|
||||
pub struct DatabaseError(#[from] rusqlite::Error);
|
||||
pub struct DatabaseError(#[from] sqlx::Error);
|
||||
impl IntoResponse for DatabaseError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
fn into_response(self) -> Response {
|
||||
log::error!("[DB ERROR] {}", self);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error.").into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn conn() -> Result<Connection, rusqlite::Error> {
|
||||
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(())
|
||||
}
|
||||
|
||||
38
src/error.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use axum::response::{IntoResponse, Response};
|
||||
|
||||
use crate::database::DatabaseError;
|
||||
|
||||
pub struct CompositeError(Response);
|
||||
impl IntoResponse for CompositeError {
|
||||
fn into_response(self) -> Response {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! composite_from {
|
||||
($($t:ty),+ $(,)?) => {
|
||||
$(
|
||||
impl From<$t> for CompositeError {
|
||||
fn from(e: $t) -> Self {
|
||||
CompositeError(e.into_response())
|
||||
}
|
||||
}
|
||||
)+
|
||||
};
|
||||
}
|
||||
composite_from!(
|
||||
crate::users::auth::AuthError,
|
||||
crate::users::UserError,
|
||||
crate::users::sessions::SessionError,
|
||||
crate::tags::TagError,
|
||||
crate::persons::PersonError,
|
||||
crate::quotes::QuoteError,
|
||||
DatabaseError,
|
||||
// RedirectViaError,
|
||||
);
|
||||
|
||||
impl From<sqlx::Error> for CompositeError {
|
||||
fn from(value: sqlx::Error) -> Self {
|
||||
CompositeError(DatabaseError::from(value).into_response())
|
||||
}
|
||||
}
|
||||
289
src/logs.rs
@@ -1,10 +1,17 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use strum::IntoStaticStr;
|
||||
use sqlx::{PgConnection, Row};
|
||||
use strum::{EnumDiscriminants, EnumIter, IntoStaticStr, VariantNames};
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
database::{self, DatabaseError},
|
||||
users::User,
|
||||
database::DatabaseError,
|
||||
quotes::Quote,
|
||||
users::{
|
||||
User,
|
||||
permissions::{Permission, PermissionState},
|
||||
},
|
||||
web::icons,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -15,62 +22,190 @@ pub struct LogEntry {
|
||||
}
|
||||
|
||||
impl LogEntry {
|
||||
pub fn new(actor: User, data: LogAction) -> Result<LogEntry, DatabaseError> {
|
||||
pub async fn new(
|
||||
conn: &mut PgConnection,
|
||||
actor: User,
|
||||
data: LogAction,
|
||||
) -> Result<LogEntry, DatabaseError> {
|
||||
let log = LogEntry {
|
||||
id: Uuid::now_v7(),
|
||||
actor,
|
||||
data,
|
||||
};
|
||||
let conn = database::conn()?;
|
||||
let actiontype: &'static str = (&log.data).into();
|
||||
let payload = serde_json::to_string(&log.data).unwrap();
|
||||
conn.prepare(
|
||||
"INSERT INTO logs(id, actor, target, actiontype, payload) VALUES (?1,?2,?3,?4,?5)",
|
||||
)?
|
||||
.execute((
|
||||
&log.id,
|
||||
&log.actor.id,
|
||||
log.data.get_target_id(),
|
||||
actiontype,
|
||||
payload,
|
||||
))?;
|
||||
let payload = serde_json::to_value(&log.data).unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO logs(id, actor, target, actiontype, payload) VALUES ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(log.id)
|
||||
.bind(log.actor.id)
|
||||
.bind(log.data.get_target_id())
|
||||
.bind(actiontype)
|
||||
.bind(payload)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
Ok(log)
|
||||
}
|
||||
pub fn get_all() -> Result<Vec<LogEntry>, DatabaseError> {
|
||||
Ok(database::conn()?
|
||||
.prepare("SELECT id, actor, target, actiontype, payload FROM logs ORDER BY id DESC")?
|
||||
.query_map((), |r| {
|
||||
let payload: String = r.get(4)?;
|
||||
Ok(LogEntry {
|
||||
id: r.get(0)?,
|
||||
actor: User::get_by_id(r.get(1)?).unwrap(),
|
||||
data: serde_json::from_str(&payload).unwrap(),
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<LogEntry>, _>>()?)
|
||||
|
||||
pub async fn count(
|
||||
conn: &mut PgConnection,
|
||||
action_type: Option<LogActionDiscriminant>,
|
||||
) -> Result<i64, DatabaseError> {
|
||||
let count = match action_type {
|
||||
Some(at) => {
|
||||
let atstr: &'static str = at.into();
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM logs WHERE actiontype = $1")
|
||||
.bind(atstr)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM logs")
|
||||
.fetch_one(&mut *conn)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub 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)]
|
||||
// pub enum LogError {}
|
||||
|
||||
#[derive(Debug, IntoStaticStr, Serialize, Deserialize)]
|
||||
#[derive(Debug, IntoStaticStr, Serialize, Deserialize, VariantNames, EnumDiscriminants)]
|
||||
#[strum_discriminants(derive(EnumIter, IntoStaticStr, Serialize, Deserialize))]
|
||||
#[strum_discriminants(name(LogActionDiscriminant))]
|
||||
pub enum LogAction {
|
||||
Initialize,
|
||||
RegenInfradmin,
|
||||
CreateUser { id: Uuid, handle: String },
|
||||
CreateTag { id: Uuid, name: String },
|
||||
CreatePerson { id: Uuid, pname: String },
|
||||
ChangeUserHandle { id: Uuid, old: String, new: String },
|
||||
CreateUser {
|
||||
id: Uuid,
|
||||
handle: String,
|
||||
},
|
||||
UpdatePermission {
|
||||
id: Uuid,
|
||||
os: PermissionState,
|
||||
ns: PermissionState,
|
||||
p: Permission,
|
||||
},
|
||||
ManuallyChangeUsersPassword {
|
||||
id: Uuid,
|
||||
},
|
||||
CreateTag {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
},
|
||||
RenameTag {
|
||||
id: Uuid,
|
||||
on: String,
|
||||
nn: String,
|
||||
},
|
||||
DeleteTag {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
},
|
||||
CreatePerson {
|
||||
id: Uuid,
|
||||
pname: String,
|
||||
},
|
||||
ChangeUserHandle {
|
||||
id: Uuid,
|
||||
old: String,
|
||||
new: String,
|
||||
},
|
||||
AddPersonName {
|
||||
pid: Uuid, // person id
|
||||
nid: Uuid, // name id
|
||||
pn: String, // primary name
|
||||
nn: String, // new name
|
||||
},
|
||||
DeletePersonName {
|
||||
pid: Uuid,
|
||||
nid: Uuid,
|
||||
pn: String,
|
||||
n: String,
|
||||
},
|
||||
SetPersonPrimaryName {
|
||||
pid: Uuid, // person id
|
||||
nid: Uuid, // name id
|
||||
on: String, // old name
|
||||
nn: String, // new name
|
||||
},
|
||||
CreateQuote {
|
||||
id: Uuid,
|
||||
},
|
||||
DeleteQuote {
|
||||
quote: Quote,
|
||||
},
|
||||
ManuallyRevokeSession {
|
||||
id: Uuid,
|
||||
},
|
||||
ChangeInstanceName {
|
||||
old: String,
|
||||
new: String,
|
||||
},
|
||||
ChangeDiscordWebhookUrl {
|
||||
old: Option<Url>,
|
||||
new: Option<Url>,
|
||||
},
|
||||
}
|
||||
impl LogAction {
|
||||
pub fn get_target_id(&self) -> Option<Uuid> {
|
||||
match self {
|
||||
Self::Initialize | Self::RegenInfradmin => None,
|
||||
|
||||
Self::CreateUser { id, .. }
|
||||
| Self::CreateTag { id, .. }
|
||||
| Self::CreatePerson { id, .. }
|
||||
| Self::ChangeUserHandle { id, .. } => Some(*id),
|
||||
| Self::ChangeUserHandle { id, .. }
|
||||
| Self::CreateQuote { id }
|
||||
| Self::ManuallyRevokeSession { id }
|
||||
| Self::RenameTag { id, .. }
|
||||
| Self::DeleteTag { id, .. }
|
||||
| Self::UpdatePermission { id, .. }
|
||||
| Self::ManuallyChangeUsersPassword { id } => Some(*id),
|
||||
|
||||
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 {
|
||||
@@ -80,15 +215,95 @@ impl LogAction {
|
||||
LogAction::CreateUser { id, handle } => {
|
||||
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 } => {
|
||||
format!("Manually changed password of user with id: {id}")
|
||||
}
|
||||
LogAction::CreateTag { id, name } => {
|
||||
format!("Created tag #{name} (id: {id})")
|
||||
}
|
||||
LogAction::RenameTag { id, on, nn } => {
|
||||
format!("Renamed tag #{on} -> #{nn} (id: {id})")
|
||||
}
|
||||
LogAction::DeleteTag { id, name } => {
|
||||
format!("Deleted tag #{name} (id: {id})")
|
||||
}
|
||||
LogAction::CreatePerson { id, pname } => {
|
||||
format!("Created person ~{pname} (id: {id})")
|
||||
}
|
||||
LogAction::ChangeUserHandle { id, old, new } => {
|
||||
format!("Changed user handle @{old} -> @{new} (uid: {id})")
|
||||
}
|
||||
LogAction::AddPersonName { pid, nid, pn, nn } => {
|
||||
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 } => {
|
||||
format!("~{on} now has primary name \"{nn}\" (pid: {pid}; nid: {nid})")
|
||||
}
|
||||
LogAction::CreateQuote { id } => {
|
||||
format!("Created quote of ID {id}")
|
||||
}
|
||||
LogAction::DeleteQuote { quote } => {
|
||||
format!("Deleted quote of ID {}", quote.id)
|
||||
}
|
||||
LogAction::ManuallyRevokeSession { 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
49
src/main.rs
@@ -1,11 +1,15 @@
|
||||
use std::error::Error;
|
||||
use std::{error::Error, sync::Arc};
|
||||
|
||||
use axum::Router;
|
||||
use tokio::net::TcpListener;
|
||||
use sqlx::PgPool;
|
||||
use tokio::{net::TcpListener, sync::RwLock};
|
||||
|
||||
use crate::config::MnemoConf;
|
||||
|
||||
mod api;
|
||||
mod config;
|
||||
mod database;
|
||||
mod error;
|
||||
mod logs;
|
||||
mod persons;
|
||||
mod quotes;
|
||||
@@ -13,39 +17,34 @@ mod tags;
|
||||
mod users;
|
||||
mod web;
|
||||
|
||||
/// Mnemosyne, the mother of the nine muses
|
||||
const DEFAULT_PORT: u16 = 0x9999; // 39321
|
||||
|
||||
/// The string to be returned alongside HTTP 500
|
||||
const ISE_MSG: &str = "Internal server error";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MnemoState {
|
||||
pool: PgPool,
|
||||
conf: Arc<RwLock<MnemoConf>>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn Error>> {
|
||||
if let Err(e) = dotenvy::dotenv()
|
||||
&& !e.not_found()
|
||||
{
|
||||
return Err(e.into());
|
||||
}
|
||||
env_logger::builder()
|
||||
.filter_level(log::LevelFilter::Info)
|
||||
.parse_default_env()
|
||||
.format(config::envlogger_write_format)
|
||||
.init();
|
||||
config::dotenv()?;
|
||||
config::env_logger()?;
|
||||
|
||||
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::setup::initialise_reserved_users_if_needed()?;
|
||||
users::setup::initialise_reserved_users_if_needed(&pool).await?;
|
||||
|
||||
let port = match std::env::var("PORT") {
|
||||
Ok(p) => p.parse::<u16>()?,
|
||||
Err(e) => match e {
|
||||
std::env::VarError::NotPresent => DEFAULT_PORT,
|
||||
_ => return Err(e)?,
|
||||
},
|
||||
};
|
||||
let port = config::port()?;
|
||||
let r = Router::new()
|
||||
.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?;
|
||||
log::info!("Listener bound to {}", l.local_addr()?);
|
||||
|
||||
|
||||
@@ -2,25 +2,23 @@ use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use rusqlite::OptionalExtension;
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{PgConnection, Row};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::database::{self, DatabaseError};
|
||||
use crate::database::DatabaseError;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Person {
|
||||
pub id: Uuid,
|
||||
pub primary_name: String,
|
||||
pub created_by: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Name {
|
||||
pub id: Uuid,
|
||||
pub is_primary: bool,
|
||||
pub person_id: Uuid,
|
||||
pub created_by: Uuid,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
@@ -39,149 +37,214 @@ pub enum PersonError {
|
||||
}
|
||||
|
||||
impl Person {
|
||||
pub fn total_count() -> Result<i64, PersonError> {
|
||||
let conn = database::conn()?;
|
||||
let count: i64 = conn.query_row("SELECT COUNT(*) FROM persons", (), |r| r.get(0))?;
|
||||
pub async fn total_count(conn: &mut PgConnection) -> Result<i64, PersonError> {
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM persons")
|
||||
.fetch_one(conn)
|
||||
.await?;
|
||||
Ok(count)
|
||||
}
|
||||
pub fn get_all() -> Result<Vec<Person>, PersonError> {
|
||||
Ok(database::conn()?
|
||||
.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 async fn get_all(conn: &mut PgConnection) -> Result<Vec<Person>, PersonError> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT p.id, n.name FROM persons p JOIN names n ON p.id = n.person_id AND n.is_primary = true"
|
||||
)
|
||||
.fetch_all(conn)
|
||||
.await?;
|
||||
|
||||
let mut persons = Vec::with_capacity(rows.len());
|
||||
for r in rows {
|
||||
persons.push(Person {
|
||||
id: r.try_get("id")?,
|
||||
primary_name: r.try_get("name")?,
|
||||
});
|
||||
}
|
||||
Ok(persons)
|
||||
}
|
||||
|
||||
pub fn get_by_id(id: Uuid) -> Result<Person, PersonError> {
|
||||
let res = database::conn()?
|
||||
.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")?
|
||||
.query_one((&id,), |r| {
|
||||
Ok(Person {
|
||||
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,
|
||||
created_by: r.get(0)?,
|
||||
primary_name: r.get(1)?,
|
||||
})
|
||||
})
|
||||
.optional()?;
|
||||
match res {
|
||||
Some(p) => Ok(p),
|
||||
primary_name: r.try_get("name")?,
|
||||
}),
|
||||
None => Err(PersonError::NoPersonWithId(id)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_in_quote_count(&self) -> Result<i64, PersonError> {
|
||||
Ok(database::conn()?
|
||||
.prepare(
|
||||
pub async fn get_in_quote_count(&self, conn: &mut PgConnection) -> Result<i64, PersonError> {
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT COUNT(DISTINCT l.quote_id) AS quote_count
|
||||
FROM lines l WHERE l.name_id IN (
|
||||
SELECT id FROM names WHERE person_id = ?1
|
||||
SELECT COUNT(DISTINCT l.quote_id)
|
||||
FROM lines l JOIN line_authors la ON l.id = la.line_id
|
||||
WHERE la.name_id IN (
|
||||
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) -> Result<Vec<Name>, PersonError> {
|
||||
Ok(database::conn()?
|
||||
.prepare("SELECT id, is_primary, person_id, created_by, name FROM names WHERE person_id = ?1")?
|
||||
.query_map((&self.id,), |r| {
|
||||
Ok(Name {
|
||||
id: r.get(0)?,
|
||||
is_primary: r.get(1)?,
|
||||
person_id: r.get(2)?,
|
||||
created_by: r.get(3)?,
|
||||
name: r.get(4)?,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<Name>, _>>()?)
|
||||
pub async fn get_all_names(&self, conn: &mut PgConnection) -> Result<Vec<Name>, PersonError> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, is_primary, person_id, name FROM names WHERE person_id = $1 ORDER BY id",
|
||||
)
|
||||
.bind(self.id)
|
||||
.fetch_all(conn)
|
||||
.await?;
|
||||
|
||||
let mut names = Vec::with_capacity(rows.len());
|
||||
for r in rows {
|
||||
names.push(Name {
|
||||
id: r.try_get("id")?,
|
||||
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(&self, name: String, created_by: Uuid) -> Result<Name, PersonError> {
|
||||
pub async fn add_name(
|
||||
&self,
|
||||
conn: &mut PgConnection,
|
||||
name: String,
|
||||
_created_by: Uuid,
|
||||
) -> Result<Name, PersonError> {
|
||||
let id = Uuid::now_v7();
|
||||
database::conn()?
|
||||
.prepare("INSERT INTO names VALUES (?1, ?2, ?3, ?4, ?5)")?
|
||||
.execute((id, 0, self.id, created_by, &name))?;
|
||||
sqlx::query("INSERT INTO names (id, is_primary, person_id, name) VALUES ($1, $2, $3, $4)")
|
||||
.bind(id)
|
||||
.bind(false)
|
||||
.bind(self.id)
|
||||
.bind(&name)
|
||||
.execute(conn)
|
||||
.await?;
|
||||
|
||||
Ok(Name {
|
||||
id,
|
||||
is_primary: false,
|
||||
person_id: self.id,
|
||||
created_by,
|
||||
name,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create(primary_name: String, created_by: Uuid) -> Result<Person, PersonError> {
|
||||
pub async fn create(
|
||||
conn: &mut PgConnection,
|
||||
primary_name: String,
|
||||
_created_by: Uuid,
|
||||
) -> Result<Person, PersonError> {
|
||||
let person_id = Uuid::now_v7();
|
||||
let name_id = Uuid::now_v7();
|
||||
|
||||
let conn = database::conn()?;
|
||||
conn.execute("BEGIN TRANSACTION", ())?;
|
||||
sqlx::query("INSERT INTO persons(id) VALUES ($1)")
|
||||
.bind(person_id)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
conn.prepare("INSERT INTO persons(id, created_by) VALUES (?1, ?2)")?
|
||||
.execute((person_id, created_by))?;
|
||||
conn.prepare("INSERT INTO names VALUES (?1, ?2, ?3, ?4, ?5)")?
|
||||
.execute((name_id, 1, person_id, created_by, &primary_name))?;
|
||||
conn.execute("COMMIT", ())?;
|
||||
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 {
|
||||
id: person_id,
|
||||
primary_name,
|
||||
created_by,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Name {
|
||||
pub fn get_by_id(id: Uuid) -> Result<Name, PersonError> {
|
||||
let res = database::conn()?
|
||||
.prepare("SELECT id, is_primary, person_id, created_by, name FROM names WHERE id = ?1")?
|
||||
.query_one((&id,), |r| {
|
||||
Ok(Name {
|
||||
id: r.get(0)?,
|
||||
is_primary: r.get(1)?,
|
||||
person_id: r.get(2)?,
|
||||
created_by: r.get(3)?,
|
||||
name: r.get(4)?,
|
||||
})
|
||||
})
|
||||
.optional()?;
|
||||
match res {
|
||||
Some(n) => Ok(n),
|
||||
pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> Result<Name, PersonError> {
|
||||
let row = sqlx::query("SELECT id, is_primary, person_id, name FROM names WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(conn)
|
||||
.await?;
|
||||
|
||||
match row {
|
||||
Some(r) => Ok(Name {
|
||||
id: r.try_get("id")?,
|
||||
is_primary: r.try_get("is_primary")?,
|
||||
person_id: r.try_get("person_id")?,
|
||||
name: r.try_get("name")?,
|
||||
}),
|
||||
None => Err(PersonError::NoNameWithId(id)),
|
||||
}
|
||||
}
|
||||
pub fn set_primary(&mut self) -> Result<(), PersonError> {
|
||||
|
||||
pub async fn get_all(conn: &mut PgConnection) -> Result<Vec<Name>, PersonError> {
|
||||
let rows = sqlx::query("SELECT id, is_primary, person_id, name FROM names")
|
||||
.fetch_all(conn)
|
||||
.await?;
|
||||
|
||||
let mut names = Vec::with_capacity(rows.len());
|
||||
for r in rows {
|
||||
names.push(Name {
|
||||
id: r.try_get("id")?,
|
||||
is_primary: r.try_get("is_primary")?,
|
||||
person_id: r.try_get("person_id")?,
|
||||
name: r.try_get("name")?,
|
||||
});
|
||||
}
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
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 {
|
||||
return Err(PersonError::AlreadyPrimary);
|
||||
}
|
||||
|
||||
let conn = database::conn()?;
|
||||
conn.execute("BEGIN TRANSACTION", ())?;
|
||||
sqlx::query(
|
||||
"UPDATE names SET is_primary = false WHERE person_id = $1 AND is_primary = true",
|
||||
)
|
||||
.bind(self.person_id)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
conn.prepare("UPDATE names SET is_primary = 0 WHERE person_id = ?1 AND is_primary = 1")?
|
||||
.execute((&self.person_id,))?;
|
||||
conn.prepare("UPDATE names SET is_primary = 1 WHERE id = ?1")?
|
||||
.execute((&self.id,))?;
|
||||
sqlx::query("UPDATE names SET is_primary = true WHERE id = $1")
|
||||
.bind(self.id)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
conn.execute("COMMIT", ())?;
|
||||
self.is_primary = true;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rusqlite::Error> for PersonError {
|
||||
fn from(error: rusqlite::Error) -> Self {
|
||||
if let rusqlite::Error::SqliteFailure(e, Some(msg)) = &error
|
||||
&& e.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE
|
||||
&& msg.contains("name")
|
||||
{
|
||||
impl From<sqlx::Error> for PersonError {
|
||||
fn from(error: sqlx::Error) -> Self {
|
||||
if let sqlx::Error::Database(err) = &error {
|
||||
if err.is_unique_violation() && err.message().contains("name") {
|
||||
return PersonError::NameAlreadyExists;
|
||||
}
|
||||
}
|
||||
PersonError::DatabaseError(DatabaseError::from(error))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
use axum::{http::StatusCode, response::IntoResponse};
|
||||
use chrono::{DateTime, FixedOffset};
|
||||
use rusqlite::OptionalExtension;
|
||||
use serde::Serialize;
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{PgConnection, Row};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
database::{self, DatabaseError},
|
||||
persons::Name,
|
||||
};
|
||||
use crate::{database::DatabaseError, persons::Name};
|
||||
|
||||
#[derive(Serialize)]
|
||||
mod webhook;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Quote {
|
||||
pub id: Uuid,
|
||||
pub lines: Vec<QuoteLine>,
|
||||
pub timestamp: DateTime<FixedOffset>,
|
||||
pub timestamp: NaiveDateTime,
|
||||
pub location: Option<String>,
|
||||
pub context: Option<String>,
|
||||
pub created_by: Uuid,
|
||||
pub public: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct QuoteLine {
|
||||
pub id: Uuid,
|
||||
pub attribution: Name,
|
||||
pub attribution: Vec<Name>,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
@@ -38,56 +40,74 @@ pub enum QuoteError {
|
||||
}
|
||||
|
||||
impl Quote {
|
||||
pub fn total_count() -> Result<i64, QuoteError> {
|
||||
let conn = database::conn()?;
|
||||
let count: i64 = conn.query_row("SELECT COUNT(*) FROM quotes", (), |r| r.get(0))?;
|
||||
pub fn get_creation_timestamp(&self) -> DateTime<Utc> {
|
||||
// unwrap here because all IDs use UUIDv7
|
||||
let (s, n) = self.id.get_timestamp().unwrap().to_unix();
|
||||
// unwrap here because timestamps held by UUIDs are valid by spec
|
||||
DateTime::from_timestamp(s as i64, n).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl Quote {
|
||||
pub async fn total_count(conn: &mut PgConnection) -> Result<i64, QuoteError> {
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM quotes")
|
||||
.fetch_one(conn)
|
||||
.await?;
|
||||
Ok(count)
|
||||
}
|
||||
pub fn get_by_id(id: Uuid) -> Result<Quote, QuoteError> {
|
||||
let conn = database::conn()?;
|
||||
|
||||
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()?;
|
||||
pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> Result<Quote, QuoteError> {
|
||||
let quotemain = sqlx::query(
|
||||
"SELECT timestamp, location, context, created_by, public FROM quotes WHERE id = $1",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?;
|
||||
|
||||
let (timestamp, location, context, created_by, public) = match quotemain {
|
||||
Some(data) => data,
|
||||
let row = match quotemain {
|
||||
Some(row) => row,
|
||||
None => return Err(QuoteError::NoQuoteWithId(id)),
|
||||
};
|
||||
|
||||
let lines = conn
|
||||
.prepare(
|
||||
let timestamp: NaiveDateTime = row.try_get("timestamp")?;
|
||||
let location: Option<String> = row.try_get("location")?;
|
||||
let context: Option<String> = row.try_get("context")?;
|
||||
let created_by: Uuid = row.try_get("created_by")?;
|
||||
let public: bool = row.try_get("public")?;
|
||||
|
||||
let line_rows = sqlx::query(
|
||||
r#"
|
||||
SELECT l.id, l.content, n.id, n.is_primary, n.person_id, n.created_by, n.name
|
||||
FROM lines AS l JOIN names AS n ON l.name_id = n.id
|
||||
WHERE l.quote_id = ?1 ORDER BY l.ordering
|
||||
SELECT l.id, l.content, n.id as name_id, n.is_primary, n.person_id, n.name
|
||||
FROM lines AS l
|
||||
JOIN line_authors AS la ON l.id = la.line_id
|
||||
JOIN names AS n ON la.name_id = n.id
|
||||
WHERE l.quote_id = $1 ORDER BY l.ordering
|
||||
"#,
|
||||
)?
|
||||
.query_map((id,), |r| {
|
||||
Ok(QuoteLine {
|
||||
id: r.get(0)?,
|
||||
content: r.get(1)?,
|
||||
attribution: Name {
|
||||
id: r.get(2)?,
|
||||
is_primary: r.get(3)?,
|
||||
person_id: r.get(4)?,
|
||||
created_by: r.get(5)?,
|
||||
name: r.get(6)?,
|
||||
},
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<QuoteLine>, _>>()?;
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_all(&mut *conn)
|
||||
.await?;
|
||||
|
||||
let mut lines: Vec<QuoteLine> = Vec::new();
|
||||
for r in line_rows {
|
||||
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 {
|
||||
id,
|
||||
@@ -99,9 +119,102 @@ impl Quote {
|
||||
public,
|
||||
})
|
||||
}
|
||||
pub fn create(
|
||||
lines: Vec<(String, Name)>,
|
||||
timestamp: DateTime<FixedOffset>,
|
||||
|
||||
pub async fn get_newest(conn: &mut PgConnection) -> Result<Option<Quote>, QuoteError> {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
limit: i64,
|
||||
) -> Result<Vec<Quote>, QuoteError> {
|
||||
let ids: Vec<Uuid> =
|
||||
sqlx::query_scalar("SELECT id FROM quotes ORDER BY timestamp DESC LIMIT $1 OFFSET $2")
|
||||
.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 search_query_count(
|
||||
conn: &mut PgConnection,
|
||||
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>,
|
||||
location: Option<String>,
|
||||
created_by: Uuid,
|
||||
@@ -111,34 +224,55 @@ impl Quote {
|
||||
return Err(QuoteError::EmptyQuote);
|
||||
}
|
||||
|
||||
let conn = database::conn()?;
|
||||
let quote_id = Uuid::now_v7();
|
||||
let lines: Vec<(Uuid, String, Name)> = lines
|
||||
let lines: Vec<(Uuid, String, Vec<Name>)> = lines
|
||||
.into_iter()
|
||||
.map(|(c, a)| (Uuid::now_v7(), c, a))
|
||||
.collect();
|
||||
|
||||
conn.execute("BEGIN TRANSACTION", ())?;
|
||||
|
||||
let mut quote_stmt = conn.prepare(
|
||||
sqlx::query(
|
||||
r#"
|
||||
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() {
|
||||
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?;
|
||||
}
|
||||
}
|
||||
|
||||
conn.execute("COMMIT", ())?;
|
||||
Ok(Quote {
|
||||
id: quote_id,
|
||||
lines: lines
|
||||
@@ -156,16 +290,50 @@ impl Quote {
|
||||
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 {
|
||||
fn from(error: rusqlite::Error) -> Self {
|
||||
impl From<sqlx::Error> for QuoteError {
|
||||
fn from(error: sqlx::Error) -> Self {
|
||||
QuoteError::DatabaseError(DatabaseError::from(error))
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for QuoteError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
fn into_response(self) -> Response {
|
||||
match self {
|
||||
Self::DatabaseError(e) => e.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}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
154
src/tags.rs
@@ -4,15 +4,11 @@ use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use rusqlite::{
|
||||
OptionalExtension, Result as RusqliteResult, ToSql,
|
||||
ffi::SQLITE_CONSTRAINT_UNIQUE,
|
||||
types::{FromSql, FromSqlError, FromSqlResult, ToSqlOutput, ValueRef},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{PgConnection, Row};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::database::{self, DatabaseError};
|
||||
use crate::database::DatabaseError;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Tag {
|
||||
@@ -21,75 +17,95 @@ pub struct Tag {
|
||||
}
|
||||
|
||||
impl Tag {
|
||||
pub fn total_count() -> Result<i64, TagError> {
|
||||
let conn = database::conn()?;
|
||||
let count: i64 = conn.query_row("SELECT COUNT(*) FROM tags", (), |r| r.get(0))?;
|
||||
pub async fn total_count(conn: &mut PgConnection) -> Result<i64, TagError> {
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM tags")
|
||||
.fetch_one(conn)
|
||||
.await?;
|
||||
Ok(count)
|
||||
}
|
||||
pub fn get_all() -> Result<Vec<Tag>, TagError> {
|
||||
Ok(database::conn()?
|
||||
.prepare("SELECT id, tagname FROM tags")?
|
||||
.query_map((), |r| {
|
||||
Ok(Tag {
|
||||
id: r.get(0)?,
|
||||
name: r.get(1)?,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<Tag>, _>>()?)
|
||||
|
||||
pub async fn get_all(conn: &mut PgConnection) -> Result<Vec<Tag>, TagError> {
|
||||
let rows = sqlx::query("SELECT id, name FROM tags")
|
||||
.fetch_all(conn)
|
||||
.await?;
|
||||
|
||||
let mut tags = Vec::with_capacity(rows.len());
|
||||
for r in rows {
|
||||
let name_str: String = r.try_get("name")?;
|
||||
tags.push(Tag {
|
||||
id: r.try_get("id")?,
|
||||
name: TagName::new(name_str)?,
|
||||
});
|
||||
}
|
||||
pub fn get_by_id(id: Uuid) -> Result<Tag, TagError> {
|
||||
let res = database::conn()?
|
||||
.prepare("SELECT tagname FROM tags WHERE id = ?1")?
|
||||
.query_one((&id,), |r| {
|
||||
Ok(tags)
|
||||
}
|
||||
|
||||
pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> Result<Tag, TagError> {
|
||||
let res = sqlx::query("SELECT name FROM tags WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(conn)
|
||||
.await?;
|
||||
|
||||
match res {
|
||||
Some(r) => {
|
||||
let name_str: String = r.try_get("name")?;
|
||||
Ok(Tag {
|
||||
id,
|
||||
name: r.get(0)?,
|
||||
name: TagName::new(name_str)?,
|
||||
})
|
||||
})
|
||||
.optional()?;
|
||||
match res {
|
||||
Some(t) => Ok(t),
|
||||
}
|
||||
None => Err(TagError::NoTagWithId(id)),
|
||||
}
|
||||
}
|
||||
pub fn get_tagged_quotes_count(&self) -> Result<i64, TagError> {
|
||||
Ok(database::conn()?
|
||||
.prepare("SELECT COUNT(*) FROM quote_tags WHERE tag_id = ?1")?
|
||||
.query_one((self.id,), |r| Ok(r.get(0)?))?)
|
||||
|
||||
pub async fn get_tagged_quotes_count(&self, conn: &mut PgConnection) -> Result<i64, TagError> {
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM quote_tags WHERE tag_id = $1")
|
||||
.bind(self.id)
|
||||
.fetch_one(conn)
|
||||
.await?;
|
||||
Ok(count)
|
||||
}
|
||||
pub fn get_by_name(name: TagName) -> Result<Tag, TagError> {
|
||||
let res = database::conn()?
|
||||
.prepare("SELECT id, tagname FROM tags WHERE tagname = ?1")?
|
||||
.query_one((&name,), |r| {
|
||||
Ok(Tag {
|
||||
id: r.get(0)?,
|
||||
name: r.get(1)?,
|
||||
})
|
||||
})
|
||||
.optional()?;
|
||||
|
||||
pub async fn get_by_name(conn: &mut PgConnection, name: TagName) -> Result<Tag, TagError> {
|
||||
let res = sqlx::query("SELECT id FROM tags WHERE name = $1")
|
||||
.bind(name.as_str())
|
||||
.fetch_optional(conn)
|
||||
.await?;
|
||||
|
||||
match res {
|
||||
Some(u) => Ok(u),
|
||||
Some(r) => Ok(Tag {
|
||||
id: r.try_get("id")?,
|
||||
name,
|
||||
}),
|
||||
None => Err(TagError::NoTagWithName(name)),
|
||||
}
|
||||
}
|
||||
pub fn create(name: TagName) -> Result<Tag, TagError> {
|
||||
|
||||
pub async fn create(conn: &mut PgConnection, name: TagName) -> Result<Tag, TagError> {
|
||||
let id = Uuid::now_v7();
|
||||
database::conn()?
|
||||
.prepare("INSERT INTO tags(id, tagname) VALUES (?1, ?2)")?
|
||||
.execute((id, &name))?;
|
||||
sqlx::query("INSERT INTO tags(id, name) VALUES ($1, $2)")
|
||||
.bind(id)
|
||||
.bind(name.as_str())
|
||||
.execute(conn)
|
||||
.await?;
|
||||
Ok(Tag { id, name })
|
||||
}
|
||||
pub fn rename(&mut self, name: TagName) -> Result<(), TagError> {
|
||||
database::conn()?
|
||||
.prepare("UPDATE tags SET tagname = ?1 WHERE id = ?2")?
|
||||
.execute((&name, self.id))?;
|
||||
|
||||
pub async fn rename(&mut self, conn: &mut PgConnection, name: TagName) -> Result<(), TagError> {
|
||||
sqlx::query("UPDATE tags SET name = $1 WHERE id = $2")
|
||||
.bind(name.as_str())
|
||||
.bind(self.id)
|
||||
.execute(conn)
|
||||
.await?;
|
||||
self.name = name;
|
||||
Ok(())
|
||||
}
|
||||
pub fn delete(self) -> Result<(), TagError> {
|
||||
database::conn()?
|
||||
.prepare("DELETE FROM tags WHERE id = ?1")?
|
||||
.execute((self.id,))?;
|
||||
|
||||
pub async fn delete(self, conn: &mut PgConnection) -> Result<(), TagError> {
|
||||
sqlx::query("DELETE FROM tags WHERE id = $1")
|
||||
.bind(self.id)
|
||||
.execute(conn)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -107,17 +123,18 @@ pub enum TagError {
|
||||
#[error("Database error: {0}")]
|
||||
DatabaseError(#[from] DatabaseError),
|
||||
}
|
||||
impl From<rusqlite::Error> for TagError {
|
||||
fn from(error: rusqlite::Error) -> Self {
|
||||
if let rusqlite::Error::SqliteFailure(e, Some(msg)) = &error
|
||||
&& e.extended_code == SQLITE_CONSTRAINT_UNIQUE
|
||||
&& msg.contains("tagname")
|
||||
{
|
||||
|
||||
impl From<sqlx::Error> for TagError {
|
||||
fn from(error: sqlx::Error) -> Self {
|
||||
if let sqlx::Error::Database(err) = &error {
|
||||
if err.is_unique_violation() && err.message().contains("tagname") {
|
||||
return TagError::TagAlreadyExists;
|
||||
}
|
||||
}
|
||||
TagError::DatabaseError(DatabaseError::from(error))
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for TagError {
|
||||
fn into_response(self) -> Response {
|
||||
match self {
|
||||
@@ -130,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(try_from = "String")]
|
||||
pub struct TagName(String);
|
||||
@@ -229,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]
|
||||
#[should_panic]
|
||||
fn tagname_leading_dash_fail() {
|
||||
|
||||
@@ -7,12 +7,12 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use base64::{Engine, prelude::BASE64_STANDARD};
|
||||
use rusqlite::OptionalExtension;
|
||||
use sqlx::{PgConnection, Row};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
ISE_MSG,
|
||||
database::{self, DatabaseError},
|
||||
database::DatabaseError,
|
||||
users::{
|
||||
User,
|
||||
auth::{
|
||||
@@ -53,8 +53,8 @@ impl IntoResponse for AuthError {
|
||||
}
|
||||
}
|
||||
}
|
||||
impl From<rusqlite::Error> for AuthError {
|
||||
fn from(value: rusqlite::Error) -> Self {
|
||||
impl From<sqlx::Error> for AuthError {
|
||||
fn from(value: sqlx::Error) -> Self {
|
||||
AuthError::DatabaseError(DatabaseError::from(value))
|
||||
}
|
||||
}
|
||||
@@ -122,21 +122,27 @@ impl<'a> AuthScheme<'a> {
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
match (basic_auth, bearer_auth) {
|
||||
(Some(creds), _) => authenticate_basic(&creds),
|
||||
(None, Some(token)) => authenticate_bearer(&token),
|
||||
(Some(creds), _) => authenticate_basic(conn, &creds).await,
|
||||
(None, Some(token)) => authenticate_bearer(conn, &token).await,
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
if let Some(token) = bearer_auth {
|
||||
authenticate_bearer_with_session(&token)
|
||||
authenticate_bearer_with_session(conn, &token).await
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
@@ -181,50 +187,71 @@ fn auth_common(headers: &HeaderMap) -> (Option<String>, Option<String>) {
|
||||
(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 credentials_str = String::from_utf8(decoded)?;
|
||||
|
||||
let Some((handle, password)) = credentials_str.split_once(':') else {
|
||||
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,
|
||||
password: &str,
|
||||
) -> Result<Option<User>, AuthError> {
|
||||
let conn = database::conn()?;
|
||||
let user: Option<(Uuid, Option<String>)> = conn
|
||||
.prepare("SELECT id, password FROM users WHERE handle = ?1")?
|
||||
.query_row([handle], |r| Ok((r.get(0)?, r.get(1)?)))
|
||||
.optional()?;
|
||||
let row = sqlx::query("SELECT id, password FROM users WHERE handle = $1")
|
||||
.bind(handle)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?;
|
||||
|
||||
match user {
|
||||
Some((id, Some(passhash))) => match User::match_hash_password(password, &passhash)? {
|
||||
true => Ok(Some(User::get_by_id(id)?)),
|
||||
match row {
|
||||
Some(r) => {
|
||||
let id: Uuid = r.try_get("id")?;
|
||||
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)?;
|
||||
Err(AuthError::InvalidCredentials)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn authenticate_bearer(token: &str) -> Result<Option<User>, AuthError> {
|
||||
let mut s = Session::get_by_token(token)?;
|
||||
async fn authenticate_bearer(
|
||||
conn: &mut PgConnection,
|
||||
token: &str,
|
||||
) -> Result<Option<User>, AuthError> {
|
||||
let mut s = Session::get_by_token(&mut *conn, token).await?;
|
||||
if s.is_expired_or_revoked() {
|
||||
return Err(AuthError::InvalidCredentials);
|
||||
}
|
||||
s.prolong()?;
|
||||
Ok(Some(User::get_by_id(s.user_id)?))
|
||||
s.prolong(&mut *conn).await?;
|
||||
Ok(Some(User::get_by_id(conn, s.user_id).await?))
|
||||
}
|
||||
fn authenticate_bearer_with_session(token: &str) -> Result<Option<Session>, AuthError> {
|
||||
let mut s = Session::get_by_token(token)?;
|
||||
|
||||
async fn authenticate_bearer_with_session(
|
||||
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() {
|
||||
return Err(AuthError::InvalidCredentials);
|
||||
}
|
||||
s.prolong()?;
|
||||
s.prolong(conn).await?;
|
||||
Ok(Some(s))
|
||||
}
|
||||
|
||||
@@ -16,14 +16,22 @@ pub mod implementation;
|
||||
|
||||
pub const COOKIE_NAME: &str = "mnemohash";
|
||||
|
||||
use sqlx::PgConnection;
|
||||
|
||||
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 {
|
||||
fn required(self) -> Result<User, AuthError>;
|
||||
}
|
||||
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 {
|
||||
fn required(self) -> Result<Session, AuthError>;
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
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};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type)]
|
||||
#[sqlx(transparent)]
|
||||
#[serde(into = "String")]
|
||||
#[serde(try_from = "String")]
|
||||
pub struct UserHandle(String);
|
||||
@@ -90,15 +87,3 @@ impl From<UserHandle> for String {
|
||||
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)))
|
||||
}
|
||||
}
|
||||
|
||||
174
src/users/mod.rs
@@ -3,13 +3,13 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use chrono::{DateTime, NaiveDate};
|
||||
use rusqlite::{OptionalExtension, ffi::SQLITE_CONSTRAINT_UNIQUE};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{PgConnection, Row};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
ISE_MSG,
|
||||
database::{self, DatabaseError},
|
||||
database::DatabaseError,
|
||||
users::{
|
||||
auth::UserPasswordHashing,
|
||||
handle::{UserHandle, UserHandleError},
|
||||
@@ -45,65 +45,87 @@ pub enum UserError {
|
||||
}
|
||||
|
||||
impl User {
|
||||
pub fn total_count() -> Result<i64, UserError> {
|
||||
let conn = database::conn()?;
|
||||
let count: i64 = conn.query_row("SELECT COUNT(*) FROM users", (), |r| r.get(0))?;
|
||||
Ok(count)
|
||||
pub async fn total_count(conn: &mut PgConnection) -> Result<i64, UserError> {
|
||||
Ok(sqlx::query_scalar("SELECT COUNT(*) FROM users")
|
||||
.fetch_one(conn)
|
||||
.await?)
|
||||
}
|
||||
pub fn get_by_id(id: Uuid) -> Result<User, UserError> {
|
||||
let res = database::conn()?
|
||||
.prepare("SELECT handle FROM users WHERE id = ?1")?
|
||||
.query_one((&id,), |r| {
|
||||
|
||||
pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> Result<User, UserError> {
|
||||
let res = sqlx::query("SELECT handle FROM users WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(conn)
|
||||
.await?;
|
||||
|
||||
match res {
|
||||
Some(r) => {
|
||||
let handle_str: String = r.try_get("handle")?;
|
||||
Ok(User {
|
||||
id,
|
||||
handle: r.get(0)?,
|
||||
handle: UserHandle::new(&handle_str)?,
|
||||
})
|
||||
})
|
||||
.optional()?;
|
||||
match res {
|
||||
Some(u) => Ok(u),
|
||||
}
|
||||
None => Err(UserError::NoUserWithId(id)),
|
||||
}
|
||||
}
|
||||
pub fn get_by_handle(handle: UserHandle) -> Result<User, UserError> {
|
||||
let res = database::conn()?
|
||||
.prepare("SELECT id, handle FROM users WHERE handle = ?1")?
|
||||
.query_one((&handle,), |r| {
|
||||
Ok(User {
|
||||
id: r.get(0)?,
|
||||
handle: r.get(1)?,
|
||||
})
|
||||
})
|
||||
.optional()?;
|
||||
|
||||
pub async fn get_by_handle(
|
||||
conn: &mut PgConnection,
|
||||
handle: UserHandle,
|
||||
) -> Result<User, UserError> {
|
||||
let res = sqlx::query("SELECT id FROM users WHERE handle = $1")
|
||||
.bind(handle.as_str())
|
||||
.fetch_optional(conn)
|
||||
.await?;
|
||||
|
||||
match res {
|
||||
Some(u) => Ok(u),
|
||||
Some(r) => Ok(User {
|
||||
id: r.try_get("id")?,
|
||||
handle,
|
||||
}),
|
||||
None => Err(UserError::NoUserWithHandle(handle)),
|
||||
}
|
||||
}
|
||||
pub fn get_all() -> Result<Vec<User>, UserError> {
|
||||
Ok(database::conn()?
|
||||
.prepare("SELECT id, handle FROM users")?
|
||||
.query_map((), |r| {
|
||||
Ok(User {
|
||||
id: r.get(0)?,
|
||||
handle: r.get(1)?,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<User>, _>>()?)
|
||||
|
||||
pub async fn get_all(conn: &mut PgConnection) -> Result<Vec<User>, UserError> {
|
||||
let rows = sqlx::query("SELECT id, handle FROM users ORDER BY id")
|
||||
.fetch_all(conn)
|
||||
.await?;
|
||||
|
||||
let mut users = Vec::with_capacity(rows.len());
|
||||
for r in rows {
|
||||
let handle_str: String = r.try_get("handle")?;
|
||||
users.push(User {
|
||||
id: r.try_get("id")?,
|
||||
handle: UserHandle::new(&handle_str)?,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn create(handle: UserHandle) -> Result<User, UserError> {
|
||||
let conn = database::conn()?;
|
||||
Ok(users)
|
||||
}
|
||||
|
||||
pub async fn create(conn: &mut PgConnection, handle: UserHandle) -> Result<User, UserError> {
|
||||
let id = Uuid::now_v7();
|
||||
conn.prepare("INSERT INTO users(id, handle) VALUES (?1, ?2)")?
|
||||
.execute((&id, &handle))?;
|
||||
sqlx::query("INSERT INTO users(id, handle) VALUES ($1, $2)")
|
||||
.bind(id)
|
||||
.bind(handle.as_str())
|
||||
.execute(conn)
|
||||
.await?;
|
||||
|
||||
Ok(User { id, handle })
|
||||
}
|
||||
|
||||
pub fn set_handle(&mut self, new_handle: UserHandle) -> Result<(), UserError> {
|
||||
let conn = database::conn()?;
|
||||
conn.prepare("UPDATE users SET handle = ?1 WHERE id = ?2")?
|
||||
.execute((&new_handle, self.id))?;
|
||||
pub async fn set_handle(
|
||||
&mut self,
|
||||
conn: &mut PgConnection,
|
||||
new_handle: UserHandle,
|
||||
) -> Result<(), UserError> {
|
||||
sqlx::query("UPDATE users SET handle = $1 WHERE id = $2")
|
||||
.bind(new_handle.as_str())
|
||||
.bind(self.id)
|
||||
.execute(conn)
|
||||
.await?;
|
||||
|
||||
self.handle = new_handle;
|
||||
Ok(())
|
||||
}
|
||||
@@ -118,18 +140,26 @@ impl User {
|
||||
|
||||
// DANGEROUS: AUTH
|
||||
impl User {
|
||||
pub fn set_password(&mut self, passw: Option<&str>) -> Result<(), UserError> {
|
||||
let conn = database::conn()?;
|
||||
pub async fn set_password(
|
||||
&mut self,
|
||||
conn: &mut PgConnection,
|
||||
passw: Option<&str>,
|
||||
) -> Result<(), UserError> {
|
||||
match passw {
|
||||
None => {
|
||||
conn.prepare("UPDATE users SET password = NULL WHERE id = ?1")?
|
||||
.execute((self.id,))?;
|
||||
sqlx::query("UPDATE users SET password = NULL WHERE id = $1")
|
||||
.bind(self.id)
|
||||
.execute(conn)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
Some(passw) => {
|
||||
let hashed = User::hash_password(passw)?;
|
||||
conn.prepare("UPDATE users SET password = ?1 WHERE id = ?2")?
|
||||
.execute((hashed, self.id))?;
|
||||
sqlx::query("UPDATE users SET password = $1 WHERE id = $2")
|
||||
.bind(hashed)
|
||||
.bind(self.id)
|
||||
.execute(conn)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -145,15 +175,18 @@ impl User {
|
||||
/// 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,
|
||||
/// and have its name changed.
|
||||
pub fn create_infradmin() -> Result<User, UserError> {
|
||||
pub async fn create_infradmin(conn: &mut PgConnection) -> Result<User, UserError> {
|
||||
let mut u = User {
|
||||
id: Uuid::max(),
|
||||
handle: UserHandle::new("Infradmin")?,
|
||||
};
|
||||
database::conn()?
|
||||
.prepare("INSERT INTO users(id, handle) VALUES (?1, ?2)")?
|
||||
.execute((&u.id, &u.handle))?;
|
||||
u.regenerate_infradmin_password()?;
|
||||
sqlx::query("INSERT INTO users(id, handle) VALUES ($1, $2)")
|
||||
.bind(u.id)
|
||||
.bind(u.handle.as_str())
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
u.regenerate_infradmin_password(conn).await?;
|
||||
|
||||
Ok(u)
|
||||
}
|
||||
@@ -176,9 +209,12 @@ impl User {
|
||||
/// 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,
|
||||
/// and have its name changed.
|
||||
pub fn regenerate_infradmin_password(&mut self) -> Result<(), UserError> {
|
||||
pub async fn regenerate_infradmin_password(
|
||||
&mut self,
|
||||
conn: &mut PgConnection,
|
||||
) -> Result<(), UserError> {
|
||||
let passw = auth::generate_token(auth::TokenSize::Char16);
|
||||
self.set_password(Some(&passw))?;
|
||||
self.set_password(conn, Some(&passw)).await?;
|
||||
log::info!("[USERS] The infradmin account password has been (re)generated.");
|
||||
log::info!("[USERS] Handle: {}", self.handle.as_str());
|
||||
log::info!("[USERS] Password: {}", passw);
|
||||
@@ -192,14 +228,16 @@ impl User {
|
||||
/// for actions performed by Mnemosyne internally.
|
||||
/// It shall not be available for log-in.
|
||||
/// It should not have its name changed, and should be protected from that.
|
||||
pub fn create_systemuser() -> Result<User, UserError> {
|
||||
pub async fn create_systemuser(conn: &mut PgConnection) -> Result<User, UserError> {
|
||||
let u = User {
|
||||
id: Uuid::nil(),
|
||||
handle: UserHandle::new("Mnemosyne")?,
|
||||
};
|
||||
database::conn()?
|
||||
.prepare("INSERT INTO users(id, handle) VALUES (?1, ?2)")?
|
||||
.execute((&u.id, &u.handle))?;
|
||||
sqlx::query("INSERT INTO users(id, handle) VALUES ($1, $2)")
|
||||
.bind(u.id)
|
||||
.bind(u.handle.as_str())
|
||||
.execute(conn)
|
||||
.await?;
|
||||
|
||||
Ok(u)
|
||||
}
|
||||
@@ -215,22 +253,24 @@ impl User {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rusqlite::Error> for UserError {
|
||||
fn from(error: rusqlite::Error) -> Self {
|
||||
if let rusqlite::Error::SqliteFailure(err, Some(msg)) = &error
|
||||
&& err.extended_code == SQLITE_CONSTRAINT_UNIQUE
|
||||
&& msg.contains("handle")
|
||||
{
|
||||
impl From<sqlx::Error> for UserError {
|
||||
fn from(error: sqlx::Error) -> Self {
|
||||
if let sqlx::Error::Database(err) = &error {
|
||||
// Check for Postgres unique constraint violation (code 23505)
|
||||
if err.is_unique_violation() && err.message().contains("handle") {
|
||||
return UserError::HandleAlreadyExists;
|
||||
}
|
||||
}
|
||||
UserError::DatabaseError(DatabaseError::from(error))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<argon2::password_hash::Error> for UserError {
|
||||
fn from(err: argon2::password_hash::Error) -> Self {
|
||||
UserError::PassHashError(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for UserError {
|
||||
fn into_response(self) -> Response {
|
||||
match self {
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
use sqlx::PgConnection;
|
||||
use strum::Display;
|
||||
|
||||
use crate::{database::DatabaseError, users::User};
|
||||
|
||||
/// Infradmin and systemuser have all permissions.
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
PartialEq,
|
||||
strum::IntoStaticStr,
|
||||
Display,
|
||||
serde::Deserialize,
|
||||
serde::Serialize,
|
||||
)]
|
||||
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
|
||||
ListOthersSessions,
|
||||
// All Users have the right to revoke their own sessions
|
||||
@@ -14,20 +30,130 @@ pub enum Permission {
|
||||
CreateTags,
|
||||
RenameTags,
|
||||
DeleteTags,
|
||||
CreateQuotes,
|
||||
DeleteQuotes,
|
||||
ChangePersonPrimaryName,
|
||||
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 {
|
||||
pub fn has_permission(
|
||||
pub async fn permission_dbstate(
|
||||
&self,
|
||||
#[allow(unused)] permission: Permission,
|
||||
conn: &mut PgConnection,
|
||||
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> {
|
||||
// Infradmin and systemuser have all permissions
|
||||
if self.is_infradmin() || self.is_systemuser() {
|
||||
return Ok(true);
|
||||
}
|
||||
if let Some(true) = self.permission_dbstate(conn, Permission::Admin).await? {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
todo!("Do the permission checking here once permissions are modeled in the DB")
|
||||
Ok(self
|
||||
.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,13 +3,13 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use rusqlite::OptionalExtension;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::{PgConnection, Row};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
database::{self, DatabaseError},
|
||||
database::DatabaseError,
|
||||
users::{
|
||||
User,
|
||||
auth::{self, COOKIE_NAME},
|
||||
@@ -46,11 +46,13 @@ pub enum SessionError {
|
||||
#[error("No session found with provided token")]
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for SessionError {
|
||||
fn into_response(self) -> Response {
|
||||
match self {
|
||||
@@ -70,56 +72,89 @@ impl IntoResponse for SessionError {
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn get_by_id(id: Uuid) -> Result<Session, SessionError> {
|
||||
let res = database::conn()?
|
||||
.prepare("SELECT user_id, expiry, revoked, revoked_at, revoked_by FROM sessions WHERE id = ?1")?
|
||||
.query_one((&id,), |r| Ok(Session {
|
||||
id,
|
||||
user_id: r.get(0)?,
|
||||
expiry: r.get(1)?,
|
||||
status: match r.get::<_, bool>(2)? {
|
||||
false => SessionStatus::Active,
|
||||
true => {
|
||||
SessionStatus::Revoked { revoked_at: r.get(3)?, revoked_by: r.get(4)? }
|
||||
}
|
||||
}
|
||||
})).optional()?;
|
||||
pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> Result<Session, SessionError> {
|
||||
let row = sqlx::query(
|
||||
"SELECT user_id, expiry, revoked, revoked_at, revoked_by FROM sessions WHERE id = $1",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(conn)
|
||||
.await?;
|
||||
|
||||
match res {
|
||||
Some(s) => Ok(s),
|
||||
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,
|
||||
user_id: r.try_get("user_id")?,
|
||||
expiry: r.try_get("expiry")?,
|
||||
status,
|
||||
})
|
||||
}
|
||||
None => Err(SessionError::NoSessionWithId(id)),
|
||||
}
|
||||
}
|
||||
pub fn get_by_token(token: &str) -> Result<Session, SessionError> {
|
||||
let hashed = Sha256::digest(token.as_bytes()).to_vec();
|
||||
let res = database::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 {
|
||||
Some(s) => Ok(s),
|
||||
pub async fn get_by_token(
|
||||
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())),
|
||||
}
|
||||
}
|
||||
pub fn new_for_user(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 token = auth::generate_token(auth::TokenSize::Char64);
|
||||
let hashed = Sha256::digest(token.as_bytes()).to_vec();
|
||||
let expiry = Utc::now() + Session::DEFAULT_PROLONGATION;
|
||||
|
||||
database::conn()?
|
||||
.prepare("INSERT INTO sessions(id, token, user_id, expiry) VALUES (?1, ?2, ?3, ?4)")?
|
||||
.execute((&id, &hashed, user.id, expiry))?;
|
||||
sqlx::query("INSERT INTO sessions(id, token, user_id, expiry) VALUES ($1, $2, $3, $4)")
|
||||
.bind(id)
|
||||
.bind(hashed)
|
||||
.bind(user.id)
|
||||
.bind(expiry)
|
||||
.execute(conn)
|
||||
.await?;
|
||||
|
||||
let s = Session {
|
||||
id,
|
||||
user_id: user.id,
|
||||
@@ -131,7 +166,8 @@ impl Session {
|
||||
|
||||
pub const DEFAULT_PROLONGATION: Duration = Duration::days(14);
|
||||
const PROLONGATION_THRESHOLD: Duration = Duration::hours(2);
|
||||
pub fn prolong(&mut self) -> Result<(), SessionError> {
|
||||
|
||||
pub async fn prolong(&mut self, conn: &mut PgConnection) -> Result<(), SessionError> {
|
||||
if self.expiry - Session::DEFAULT_PROLONGATION + Session::PROLONGATION_THRESHOLD
|
||||
> Utc::now()
|
||||
{
|
||||
@@ -139,24 +175,37 @@ impl Session {
|
||||
}
|
||||
|
||||
let expiry = Utc::now() + Session::DEFAULT_PROLONGATION;
|
||||
database::conn()?
|
||||
.prepare("UPDATE sessions SET expiry = ?1 WHERE id = ?2")?
|
||||
.execute((&expiry, &self.id))?;
|
||||
sqlx::query("UPDATE sessions SET expiry = $1 WHERE id = $2")
|
||||
.bind(expiry)
|
||||
.bind(self.id)
|
||||
.execute(conn)
|
||||
.await?;
|
||||
|
||||
self.expiry = expiry;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn revoke(&mut self, actor: Option<&User>) -> Result<(), SessionError> {
|
||||
pub async fn revoke(
|
||||
&mut self,
|
||||
conn: &mut PgConnection,
|
||||
actor: Option<&User>,
|
||||
) -> Result<(), SessionError> {
|
||||
let now = Utc::now();
|
||||
let id = actor.map(|u| u.id).unwrap_or(Uuid::nil());
|
||||
database::conn()?
|
||||
.prepare(
|
||||
"UPDATE sessions SET revoked = ?1, revoked_at = ?2, revoked_by = ?3 WHERE id = ?4",
|
||||
)?
|
||||
.execute((&true, &now, &id, &self.id))?;
|
||||
let actor_id = actor.map(|u| u.id).unwrap_or(Uuid::nil());
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE sessions SET revoked = $1, revoked_at = $2, revoked_by = $3 WHERE id = $4",
|
||||
)
|
||||
.bind(true)
|
||||
.bind(now)
|
||||
.bind(actor_id)
|
||||
.bind(self.id)
|
||||
.execute(conn)
|
||||
.await?;
|
||||
|
||||
self.status = SessionStatus::Revoked {
|
||||
revoked_at: now,
|
||||
revoked_by: id,
|
||||
revoked_by: actor_id,
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
@@ -168,9 +217,11 @@ impl Session {
|
||||
let timestamp = self.id.get_timestamp().unwrap().to_unix();
|
||||
DateTime::from_timestamp_secs(timestamp.0 as i64).unwrap()
|
||||
}
|
||||
|
||||
pub fn is_expired_or_revoked(&self) -> bool {
|
||||
self.is_expired() || self.status.is_revoked()
|
||||
}
|
||||
|
||||
pub fn is_expired(&self) -> bool {
|
||||
self.expiry <= Utc::now()
|
||||
}
|
||||
|
||||
@@ -1,34 +1,37 @@
|
||||
use rusqlite::OptionalExtension;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
database,
|
||||
logs::{LogAction, LogEntry},
|
||||
users::{User, UserError},
|
||||
};
|
||||
|
||||
pub fn initialise_reserved_users_if_needed() -> Result<(), UserError> {
|
||||
let conn = database::conn()?;
|
||||
pub async fn initialise_reserved_users_if_needed(pool: &PgPool) -> Result<(), UserError> {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
if conn
|
||||
.prepare("SELECT handle FROM users WHERE id = ?1")?
|
||||
.query_one((&Uuid::nil(),), |_| Ok(()))
|
||||
.optional()?
|
||||
.is_none()
|
||||
{
|
||||
let u = User::create_systemuser()?;
|
||||
LogEntry::new(u, LogAction::Initialize)?;
|
||||
let systemuser_exists = sqlx::query("SELECT handle FROM users WHERE id = $1")
|
||||
.bind(Uuid::nil())
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.is_some();
|
||||
|
||||
if !systemuser_exists {
|
||||
let u = User::create_systemuser(&mut *tx).await?;
|
||||
LogEntry::new(&mut *tx, u, LogAction::Initialize).await?;
|
||||
}
|
||||
|
||||
if conn
|
||||
.prepare("SELECT handle FROM users WHERE id = ?1")?
|
||||
.query_one((&Uuid::max(),), |_| Ok(()))
|
||||
.optional()?
|
||||
.is_none()
|
||||
{
|
||||
User::create_infradmin()?;
|
||||
LogEntry::new(User::get_by_id(Uuid::nil())?, LogAction::RegenInfradmin)?;
|
||||
let infradmin_exists = sqlx::query("SELECT handle FROM users WHERE id = $1")
|
||||
.bind(Uuid::max())
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.is_some();
|
||||
|
||||
if !infradmin_exists {
|
||||
User::create_infradmin(&mut *tx).await?;
|
||||
let u = User::get_by_id(&mut *tx, Uuid::max()).await?;
|
||||
LogEntry::new(&mut *tx, u, LogAction::RegenInfradmin).await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
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)
|
||||
const LINKS: &[(&str, &str, &str, bool)] = &[
|
||||
("Dashboard", "/dashboard", icons::LAYOUT_DASHBOARD, false),
|
||||
("Quotes", "#quotes", icons::SCROLL_TEXT, false),
|
||||
("Quotes", "/quotes", icons::SCROLL_TEXT, false),
|
||||
("Photos", "#photos", icons::FILE_IMAGE, false),
|
||||
("Persons", "/persons", icons::CONTACT, false),
|
||||
("Tags", "/tags", icons::TAG, false),
|
||||
@@ -13,14 +17,19 @@ const LINKS: &[(&str, &str, &str, bool)] = &[
|
||||
("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!(
|
||||
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"}
|
||||
div class="w-px h-5 bg-neutral-200/15 hidden sm:block" {}
|
||||
div class="flex flex-row" {
|
||||
@for link in LINKS {
|
||||
@if !link.3 || user.is_some() {
|
||||
@if (!link.3 || user.is_some()) && link.0 != "Photos" {
|
||||
a href={(link.1)} class="flex flex-row px-2 py-1 rounded items-center gap-2 hover:bg-neutral-200/5 border border-transparent hover:border-neutral-200/25" {
|
||||
@if uri.starts_with(link.1) {
|
||||
div class="scale-[.75] text-neutral-300" {(PreEscaped(link.2))}
|
||||
@@ -46,15 +55,22 @@ pub fn nav(user: Option<&User>, uri: &str) -> Markup {
|
||||
span class="hidden sm:block"{(u.handle)}
|
||||
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" {
|
||||
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))}
|
||||
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" {
|
||||
div class="scale-[.7]" {(PreEscaped(icons::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 {"Settings"}
|
||||
p {"Instance Config"}
|
||||
}
|
||||
}
|
||||
div class="h-px w-full bg-neutral-200/15" {}
|
||||
form action="/api/auth/logout-form" method="post" {
|
||||
|
||||
@@ -4,12 +4,14 @@ use crate::{quotes::Quote, web::icons};
|
||||
|
||||
pub fn quote(quote: &Quote) -> Markup {
|
||||
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]" {
|
||||
(PreEscaped(icons::QUOTE))
|
||||
}
|
||||
@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" {
|
||||
span class="flex flex-row gap-2 relative" {
|
||||
span class="scale-x-[.65] scale-y-[.5] absolute opacity-[.3]"{
|
||||
@@ -19,13 +21,13 @@ pub fn quote(quote: &Quote) -> Markup {
|
||||
}
|
||||
@if show_author {
|
||||
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(", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div class="flex flex-row text-neutral-400 mt-auto pt-4 items-center font-light text-xs" {
|
||||
p {(quote.timestamp.format("%d/%m/%Y %H:%M"))}
|
||||
p {(quote.timestamp.format("%Y-%m-%d %H:%M"))}
|
||||
@if let Some(loc) = "e.location {
|
||||
span class="ml-3 scale-[.5]"{(PreEscaped(icons::MAP_PIN))} p { (loc) }
|
||||
}
|
||||
|
||||
1
src/web/icons/calendar-arrow-down.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-calendar-arrow-down-icon lucide-calendar-arrow-down"><path d="m14 18 4 4 4-4"/><path d="M16 2v4"/><path d="M18 14v8"/><path d="M21 11.354V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h7.343"/><path d="M3 10h18"/><path d="M8 2v4"/></svg>
|
||||
|
After Width: | Height: | Size: 442 B |
1
src/web/icons/circle-minus.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-circle-minus-icon lucide-circle-minus"><circle cx="12" cy="12" r="10"/><path d="M8 12h8"/></svg>
|
||||
|
After Width: | Height: | Size: 299 B |
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,21 +1,33 @@
|
||||
#![allow(unused)]
|
||||
// Below icons sourced from https://lucide.dev
|
||||
pub const ARROW_RIGHT: &str = include_str!("arrow-right.svg");
|
||||
pub const CALENDAR_1: &str = include_str!("calendar-1.svg");
|
||||
pub const CALENDAR_ARROW_DOWN: &str = include_str!("calendar-arrow-down.svg");
|
||||
pub const CIRCLE_MINUS: &str = include_str!("circle-minus.svg");
|
||||
pub const CLIPBOARD_CLOCK: &str = include_str!("clipboard-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 EYE: &str = include_str!("eye.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 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 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 PLUS: &str = include_str!("plus.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 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 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_KEY: &str = include_str!("user-key.svg");
|
||||
pub const USER_PLUS: &str = include_str!("user-plus.svg");
|
||||
|
||||
1
src/web/icons/plus.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-plus-icon lucide-plus"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
|
||||
|
After Width: | Height: | Size: 272 B |
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,22 +1,18 @@
|
||||
use axum::{
|
||||
Router,
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
};
|
||||
use tower_http::services::ServeFile;
|
||||
use axum::{Router, http::header, routing::get};
|
||||
|
||||
use crate::MnemoState;
|
||||
|
||||
mod components;
|
||||
mod icons;
|
||||
pub mod icons;
|
||||
mod pages;
|
||||
|
||||
pub fn web_router() -> Router {
|
||||
pub const CSS: &str = include_str!(concat!(env!("OUT_DIR"), "/styles.css"));
|
||||
|
||||
pub fn web_router() -> Router<MnemoState> {
|
||||
Router::new()
|
||||
.route_service("/styles.css", ServeFile::new("src/web/styles.css"))
|
||||
.route(
|
||||
"/styles.css",
|
||||
get(([(header::CONTENT_TYPE, "text/css")], CSS)),
|
||||
)
|
||||
.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,11 +1,15 @@
|
||||
use axum::extract::Request;
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use maud::{Markup, PreEscaped, html};
|
||||
use uuid::Uuid;
|
||||
use maud::{PreEscaped, html};
|
||||
|
||||
use crate::{
|
||||
persons::{Name, Person},
|
||||
quotes::{Quote, QuoteLine},
|
||||
MnemoState,
|
||||
error::CompositeError,
|
||||
persons::Person,
|
||||
quotes::Quote,
|
||||
tags::Tag,
|
||||
users::{User, auth::UserAuthenticate},
|
||||
web::{
|
||||
@@ -17,29 +21,70 @@ use crate::{
|
||||
|
||||
const LINKS: &[(&str, &str, &str)] = &[
|
||||
("Add Quote", "/quotes/add", icons::QUOTE),
|
||||
("Add Person", "/persons/add", icons::CONTACT),
|
||||
("Add Person", "/persons", icons::CONTACT),
|
||||
];
|
||||
|
||||
pub async fn page(req: Request) -> Markup {
|
||||
let u = User::authenticate(req.headers()).ok().flatten();
|
||||
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) => Some(u),
|
||||
None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()),
|
||||
};
|
||||
|
||||
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(
|
||||
"Dashboard | Mnemosyne",
|
||||
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 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" {
|
||||
p {"Newest Quote"}
|
||||
p class="text-neutral-500 font-light mb-4" {"This just in! This quote was added 15s ago."}
|
||||
div class="flex-1 [&>div]:h-full" {(quote(&sample_quote_1()))}
|
||||
@if let Some(q) = &newest_quote {
|
||||
p class="text-neutral-500 font-light mb-4" {
|
||||
"This just in! 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))}
|
||||
}
|
||||
} @else {
|
||||
p class="text-neutral-500 font-light mb-4" {"No quotes yet."}
|
||||
}
|
||||
}
|
||||
@if let Some(q) = random_quote {
|
||||
div class="flex flex-col" {
|
||||
p {"Quote of the Day"}
|
||||
p class="text-neutral-500 font-light mb-4" {"This quote was voiced a year ago today."}
|
||||
div class="flex-1 [&>div]:h-full" {(quote(&sample_quote_2()))}
|
||||
div class="flex gap-1" {
|
||||
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 mt-4" {
|
||||
}
|
||||
}
|
||||
div class="mx-auto max-w-4xl px-2 mt-4" {
|
||||
p class="mb-2" {"Quick access"}
|
||||
div class="flex gap-4" {
|
||||
@for (title, url, icon) in LINKS {
|
||||
@@ -53,27 +98,27 @@ pub async fn page(req: Request) -> Markup {
|
||||
}
|
||||
|
||||
}
|
||||
div class="mx-auto max-w-4xl 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!({
|
||||
@match Quote::total_count() {
|
||||
@match quote_count {
|
||||
Ok(count) => {(count) " QUOTES TOTAL"},
|
||||
Err(_) => span class="text-red-400" {"QUOTE COUNT ERR"},
|
||||
}
|
||||
})))
|
||||
(chip(html!({
|
||||
@match Person::total_count() {
|
||||
@match person_count {
|
||||
Ok(count) => {(count) " PERSONS TOTAL"},
|
||||
Err(_) => span class="text-red-400" {"PERSON COUNT ERR"},
|
||||
}
|
||||
})))
|
||||
(chip(html!({
|
||||
@match Tag::total_count() {
|
||||
@match tag_count {
|
||||
Ok(count) => {(count) " TAGS TOTAL"},
|
||||
Err(_) => span class="text-red-400" {"TAG COUNT ERR"}
|
||||
}
|
||||
})))
|
||||
(chip(html!({
|
||||
@match User::total_count() {
|
||||
@match user_count {
|
||||
Ok(count) => {(count) " USERS TOTAL"},
|
||||
Err(_) => span class="text-red-400" {"USER COUNT ERR"}
|
||||
}
|
||||
@@ -82,75 +127,15 @@ pub async fn page(req: Request) -> Markup {
|
||||
|
||||
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 sample_quote_1() -> Quote {
|
||||
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"),
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_quote_2() -> Quote {
|
||||
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"),
|
||||
},
|
||||
},
|
||||
],
|
||||
fn format_time_ago(dt: DateTime<Utc>) -> String {
|
||||
let secs = Utc::now().signed_duration_since(dt).num_seconds();
|
||||
match secs {
|
||||
..60 => format!("{}s", secs),
|
||||
60..3600 => format!("{}m", secs / 60),
|
||||
3600..86400 => format!("{}h", secs / 3600),
|
||||
_ => format!("{}d", secs / 86400),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use axum::{
|
||||
extract::{Query, Request},
|
||||
extract::{Query, Request, State},
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
};
|
||||
use maud::{PreEscaped, html};
|
||||
@@ -7,11 +7,10 @@ use rand::seq::IndexedRandom;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
MnemoState,
|
||||
config::REFERENCE_SPLASHES,
|
||||
users::{
|
||||
User,
|
||||
auth::{AuthError, UserAuthenticate},
|
||||
},
|
||||
error::CompositeError,
|
||||
users::{User, auth::UserAuthenticate},
|
||||
web::{components::marquee::marquee, icons, pages::base},
|
||||
};
|
||||
|
||||
@@ -20,8 +19,13 @@ pub struct LoginMsg {
|
||||
msg: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn page(Query(q): Query<LoginMsg>, req: Request) -> Result<Response, AuthError> {
|
||||
let u = User::authenticate(req.headers())?;
|
||||
pub async fn page(
|
||||
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() {
|
||||
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)
|
||||
script defer {(PreEscaped(r#"
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
@@ -89,7 +97,12 @@ pub async fn page(Query(q): Query<LoginMsg>, req: Request) -> Result<Response, A
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const r = new URL(window.location.href).searchParams.get('r');
|
||||
if (r && r.startsWith('/')) {
|
||||
window.location.href = r;
|
||||
} else {
|
||||
window.location.href = '/dashboard';
|
||||
}
|
||||
} else {
|
||||
const text = await res.text();
|
||||
err.textContent = text || 'Login failed';
|
||||
|
||||
@@ -1,38 +1,74 @@
|
||||
use axum::{
|
||||
extract::Request,
|
||||
extract::{Query, Request, State},
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
};
|
||||
use maud::{PreEscaped, html};
|
||||
use serde::Deserialize;
|
||||
use strum::IntoEnumIterator;
|
||||
|
||||
use crate::{
|
||||
api::CompositeError,
|
||||
logs::LogEntry,
|
||||
users::{User, auth::UserAuthenticate, permissions::Permission},
|
||||
web::{RedirectViaError, components::nav::nav, icons, pages::base},
|
||||
MnemoState,
|
||||
error::CompositeError,
|
||||
logs::{LogActionDiscriminant, LogEntry},
|
||||
users::{User, auth::UserAuthenticate},
|
||||
web::{components::nav::nav, icons, pages::base},
|
||||
};
|
||||
|
||||
pub async fn page(req: Request) -> Result<Response, CompositeError> {
|
||||
let u = User::authenticate(req.headers())?
|
||||
.ok_or(RedirectViaError(Redirect::to("/login?re=/logs")))?;
|
||||
let logs = LogEntry::get_all()?;
|
||||
#[derive(Deserialize)]
|
||||
pub struct LogsPageQuery {
|
||||
page: Option<i64>,
|
||||
action: Option<LogActionDiscriminant>,
|
||||
}
|
||||
|
||||
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(
|
||||
"Persons | Mnemosyne",
|
||||
"Logs | Mnemosyne",
|
||||
html!(
|
||||
(nav(Some(&u), req.uri().path()))
|
||||
(nav(&mut tx, Some(&u), req.uri().path()).await)
|
||||
|
||||
@if let Ok(true) = u.has_permission(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="my-4" {
|
||||
p class="flex items-center gap-2" {
|
||||
span class="text-neutral-500" {(PreEscaped(icons::CLIPBOARD_CLOCK))}
|
||||
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)] {
|
||||
div class="p-2 flex gap-1 font-semibold border-b border-neutral-200/25" {
|
||||
span class="text-neutral-500 scale-[.8]" {(PreEscaped(ico))}
|
||||
@@ -54,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.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 {
|
||||
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,32 +4,61 @@ use axum::{
|
||||
};
|
||||
use maud::{DOCTYPE, Markup, html};
|
||||
|
||||
use crate::MnemoState;
|
||||
|
||||
pub mod conf;
|
||||
pub mod dashboard;
|
||||
pub mod index;
|
||||
pub mod login;
|
||||
pub mod logs;
|
||||
pub mod notfound;
|
||||
pub mod persons;
|
||||
pub mod quotes;
|
||||
pub mod tags;
|
||||
pub mod users;
|
||||
pub mod usersettings;
|
||||
|
||||
pub fn pages() -> Router {
|
||||
pub fn pages() -> Router<MnemoState> {
|
||||
Router::new()
|
||||
.route("/", get(index::page))
|
||||
.route("/login", get(login::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/handle", post(usersettings::change_handle))
|
||||
.route("/user-settings/passwd", post(usersettings::change_password))
|
||||
//
|
||||
.route("/users", get(users::page))
|
||||
.route("/users/{id}", get(users::profile::page))
|
||||
.route("/users/create", get(users::create::page))
|
||||
.route("/users/create-form", post(users::create::create_user))
|
||||
//
|
||||
.route("/tags", get(tags::page))
|
||||
.route("/tags/create", post(tags::create))
|
||||
.route("/tags/{id}/delete", post(tags::delete_tag))
|
||||
//
|
||||
.route("/persons", get(persons::page))
|
||||
.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("/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-form", post(quotes::add::form))
|
||||
//
|
||||
.fallback(notfound::page)
|
||||
}
|
||||
|
||||
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::{
|
||||
Form,
|
||||
extract::Request,
|
||||
extract::State,
|
||||
http::HeaderMap,
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
};
|
||||
@@ -8,54 +9,72 @@ use maud::{PreEscaped, html};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
api::CompositeError,
|
||||
MnemoState,
|
||||
error::CompositeError,
|
||||
logs::{LogAction, LogEntry},
|
||||
persons::Person,
|
||||
users::{
|
||||
User,
|
||||
auth::{AuthError, UserAuthRequired, UserAuthenticate},
|
||||
auth::{UserAuthRequired, UserAuthenticate},
|
||||
},
|
||||
web::{components::nav::nav, icons, pages::base},
|
||||
};
|
||||
|
||||
pub async fn page(req: Request) -> Result<Response, AuthError> {
|
||||
let u = User::authenticate(req.headers())?;
|
||||
pub mod profile;
|
||||
|
||||
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()),
|
||||
};
|
||||
|
||||
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(
|
||||
"Persons | Mnemosyne",
|
||||
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" {
|
||||
p class="flex items-center gap-2" {
|
||||
span class="text-neutral-500" {(PreEscaped(icons::CONTACT))}
|
||||
span class="text-2xl font-semibold font-lora" {"Persons"}
|
||||
}
|
||||
p class="text-neutral-500 text-sm font-light" {
|
||||
@if let Ok(c) = Person::total_count() {
|
||||
@if let Ok(c) = total_count {
|
||||
(c) " persons in total."
|
||||
} @else {
|
||||
"Could not get total person count."
|
||||
}
|
||||
}
|
||||
}
|
||||
@if let Ok(persons) = Person::get_all() {
|
||||
@if let Ok(persons) = persons_res {
|
||||
div class="max-w-4xl mx-auto px-2 mt-4 flex flex-wrap gap-2" {
|
||||
@for person in &persons {
|
||||
div class="rounded px-4 py-2 bg-neutral-200/10 border border-neutral-200/15 flex items-center" {
|
||||
@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.get_in_quote_count() {
|
||||
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))}
|
||||
// div class="ml-2" {}
|
||||
// "4" span class="*:size-3 ml-1 text-neutral-400" {(PreEscaped(icons::FILE_IMAGE))}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,7 +83,7 @@ pub async fn page(req: Request) -> Result<Response, AuthError> {
|
||||
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"}
|
||||
h3 class="font-lora font-semibold text-xl mb-1" {"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" {
|
||||
@@ -78,9 +97,6 @@ pub async fn page(req: Request) -> Result<Response, AuthError> {
|
||||
} @else {
|
||||
p class="text-red-400 text-center" {"Failed to load persons."}
|
||||
}
|
||||
} @else {
|
||||
p class="text-center p-2" {"You must be logged in to view this page."}
|
||||
}
|
||||
),
|
||||
)
|
||||
.into_response())
|
||||
@@ -91,10 +107,23 @@ pub struct PersonNameForm {
|
||||
primary_name: String,
|
||||
}
|
||||
pub async fn create(
|
||||
State(state): State<MnemoState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<PersonNameForm>,
|
||||
) -> Result<Response, CompositeError> {
|
||||
let u = User::authenticate(&headers)?.required()?;
|
||||
Person::create(form.primary_name, u.id)?;
|
||||
let mut tx = state.pool.begin().await?;
|
||||
let u = User::authenticate(&mut *tx, &headers).await?.required()?;
|
||||
|
||||
let p = Person::create(&mut *tx, form.primary_name, u.id).await?;
|
||||
LogEntry::new(
|
||||
&mut *tx,
|
||||
u,
|
||||
LogAction::CreatePerson {
|
||||
id: p.id,
|
||||
pname: p.primary_name,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
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())
|
||||
}
|
||||
118
src/web/pages/quotes.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
use axum::{
|
||||
extract::{Query, Request, State},
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
};
|
||||
use maud::{PreEscaped, html};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
MnemoState,
|
||||
error::CompositeError,
|
||||
quotes::Quote,
|
||||
users::{User, auth::UserAuthenticate},
|
||||
web::{
|
||||
components::{nav::nav, quote::quote},
|
||||
icons,
|
||||
pages::base,
|
||||
},
|
||||
};
|
||||
|
||||
pub mod add;
|
||||
pub mod id;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PageQuery {
|
||||
page: Option<i64>,
|
||||
s: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn page(
|
||||
State(state): State<MnemoState>,
|
||||
Query(query): Query<PageQuery>,
|
||||
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 page = query.page.unwrap_or(1).max(1);
|
||||
let per_page = 10;
|
||||
let offset = (page - 1) * per_page;
|
||||
|
||||
let search = query.s.as_deref().unwrap_or("");
|
||||
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 s_qs = if search.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("&s={}", search)
|
||||
};
|
||||
|
||||
Ok(base(
|
||||
"Quotes | 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" {
|
||||
span class="text-neutral-500" {(PreEscaped(icons::SCROLL_TEXT))}
|
||||
span class="text-2xl font-semibold font-lora" {"Quotes"}
|
||||
}
|
||||
a href="/quotes/add" class="group border rounded flex items-center gap-1 px-2 border-neutral-200/25 hover:border-neutral-200/45 bg-neutral-400/5 hover:bg-neutral-400/10" {
|
||||
span class="text-neutral-300 group-hover:text-neutral-200" {(PreEscaped(icons::PLUS))}
|
||||
span class="text-neutral-300 group-hover:text-neutral-200" {"Add quote"}
|
||||
}
|
||||
}
|
||||
form method="get" action="/quotes" {
|
||||
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" {
|
||||
p class="ml-auto w-fit text-neutral-500 text-sm flex items-center" {
|
||||
span class="scale-[.6]" {(PreEscaped(icons::CALENDAR_ARROW_DOWN))}
|
||||
"Chronological"
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-4 mb-8" {
|
||||
@for q in "es {
|
||||
a href=(format!("/quotes/{}", q.id)) class="group/a" {(quote(q))}
|
||||
}
|
||||
|
||||
div class="flex justify-between items-center mt-4 text-neutral-400" {
|
||||
@if page > 1 {
|
||||
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"
|
||||
}
|
||||
} @else {
|
||||
div {}
|
||||
}
|
||||
|
||||
span {
|
||||
"Page " (page) " of " (total_pages.max(1))
|
||||
}
|
||||
|
||||
@if page < total_pages {
|
||||
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"
|
||||
}
|
||||
} @else {
|
||||
div {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
200
src/web/pages/quotes/add.rs
Normal file
@@ -0,0 +1,200 @@
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::HeaderMap,
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
};
|
||||
use axum_extra::extract::Form;
|
||||
use chrono::NaiveDateTime;
|
||||
use maud::{Markup, PreEscaped, html};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
MnemoState,
|
||||
error::CompositeError,
|
||||
logs::{LogAction, LogEntry},
|
||||
persons::Name,
|
||||
quotes::Quote,
|
||||
users::{
|
||||
User,
|
||||
auth::{UserAuthRequired, UserAuthenticate},
|
||||
},
|
||||
web::{components::nav::nav, icons, pages::base},
|
||||
};
|
||||
|
||||
const LINE_ADD_RM_SCRIPT: &str = include_str!("line-add-rm.js");
|
||||
const PREFILL_TIME_SCRIPT: &str = include_str!("prefill-time.js");
|
||||
|
||||
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()),
|
||||
};
|
||||
let names = Name::get_all(&mut *conn).await?;
|
||||
let feature_webhooks = state.conf.read().await.discord_webhook.is_some();
|
||||
|
||||
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" {
|
||||
span class="text-neutral-500" {(PreEscaped(icons::SCROLL_TEXT))}
|
||||
span class="text-2xl font-semibold font-lora" {"Quote Maker"}
|
||||
}
|
||||
}
|
||||
form method="post" action="/quotes/add-form"
|
||||
class="border border-neutral-200/25 bg-neutral-200/5 rounded-md p-4 flex flex-col" {
|
||||
div quotelines class="flex flex-col" {
|
||||
@for i in 1..=3 {(maker_line_row(i==1, &names))}
|
||||
}
|
||||
template quotelinetemplate {
|
||||
(maker_line_row(false, &names))
|
||||
}
|
||||
div class="flex flex-row gap-2" {
|
||||
hr class="border-neutral-200/25 flex-1 my-4";
|
||||
button addlinebtn type="button"
|
||||
class="w-fit text-neutral-400 hover:text-neutral-300 cursor-pointer" {
|
||||
"Add line"
|
||||
}
|
||||
}
|
||||
script {(PreEscaped(LINE_ADD_RM_SCRIPT))}
|
||||
script {(PreEscaped(PREFILL_TIME_SCRIPT))}
|
||||
div class="flex gap-4 justify-between" {
|
||||
div class="flex flex-col flex-1" {
|
||||
label class="w-full"{
|
||||
p class="mb-1" {"Location"}
|
||||
input type="text" name="location" autocomplete="off" placeholder="Right there!"
|
||||
class="px-2 py-1 w-full mb-2 bg-neutral-950/50 rounded border border-neutral-200/25";
|
||||
}
|
||||
}
|
||||
div class="flex flex-col flex-1" {
|
||||
label class="w-full" {
|
||||
p class="mb-1" {"Time of utterance"}
|
||||
input type="hidden" name="tz_offset" id="tz_offset" value="0";
|
||||
script { (PreEscaped("document.getElementById('tz_offset').value = new Date().getTimezoneOffset();")) }
|
||||
input type="datetime-local" name="time" autocomplete="off"
|
||||
class="px-2 py-1 w-full mb-2 bg-neutral-950/50 rounded border border-neutral-200/25";
|
||||
}
|
||||
}
|
||||
}
|
||||
div class="flex gap-4 justify-between" {
|
||||
div class="flex flex-col flex-1" {
|
||||
label class="w-full" {
|
||||
p class="mb-1" {"Context"}
|
||||
input type="text" name="context" autocomplete="off" placeholder="It was like this.."
|
||||
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" {
|
||||
"Submit"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
fn maker_line_row(rm_disabled: bool, names: &[Name]) -> Markup {
|
||||
html!(
|
||||
div quoteline class="flex gap-4" {
|
||||
div class="flex flex-col flex-1" {
|
||||
label class="w-full" {
|
||||
p class="mb-1" {"Quote line"}
|
||||
input type="text" name="quoteline" placeholder="They said..." autocomplete="off" required
|
||||
class="px-2 py-1 w-full mb-2 bg-neutral-950/50 rounded border border-neutral-200/25";
|
||||
}
|
||||
}
|
||||
div class="flex flex-col ml-auto" {
|
||||
label {
|
||||
p class="mb-1" {"Attribution"}
|
||||
select name="quoteauthor" autocomplete="off" required
|
||||
class="px-2 py-1.5 w-full mb-2 bg-neutral-950/50 rounded border border-neutral-200/25"{
|
||||
option value="" {"--"}
|
||||
@for name in names {
|
||||
option value=(name.id.to_string()) {(name.name)}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
button rmlinebtn disabled?[rm_disabled] type="button" class="h-fit mt-auto mb-2 p-1 bg-neutral-200/5 hover:bg-neutral-200/15 rounded border border-neutral-200/25 hover:border-neutral-200/45 cursor-pointer disabled:cursor-not-allowed disabled:opacity-[.5]" {
|
||||
(PreEscaped(icons::CIRCLE_MINUS))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct IncomingQuote {
|
||||
#[serde(rename = "quoteline")]
|
||||
lines: Vec<String>,
|
||||
#[serde(rename = "quoteauthor")]
|
||||
authors: Vec<Uuid>,
|
||||
location: String,
|
||||
time: String,
|
||||
context: String,
|
||||
discord_webhook: Option<String>,
|
||||
}
|
||||
pub async fn form(
|
||||
State(state): State<MnemoState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<IncomingQuote>,
|
||||
) -> Result<Response, CompositeError> {
|
||||
let mut tx = state.pool.begin().await?;
|
||||
let u = User::authenticate(&mut *tx, &headers).await?.required()?;
|
||||
|
||||
let mut authors = Vec::new();
|
||||
for nid in form.authors {
|
||||
authors.push(Name::get_by_id(&mut *tx, nid).await.unwrap());
|
||||
}
|
||||
let lines = form
|
||||
.lines
|
||||
.into_iter()
|
||||
.zip(authors)
|
||||
.map(|(l, a)| (l, vec![a]))
|
||||
.collect();
|
||||
|
||||
let timestamp = match NaiveDateTime::parse_from_str(&form.time, "%Y-%m-%dT%H:%M") {
|
||||
Ok(ts) => ts,
|
||||
Err(_) => return Ok("Time was formatted wrong.".into_response()),
|
||||
};
|
||||
|
||||
let context = match form.context.trim() {
|
||||
"" => None,
|
||||
s => Some(s.to_string()),
|
||||
};
|
||||
let location = match form.location.trim() {
|
||||
"" => None,
|
||||
s => Some(s.to_string()),
|
||||
};
|
||||
|
||||
let q = Quote::create(&mut *tx, lines, timestamp, context, location, u.id, false).await?;
|
||||
LogEntry::new(&mut *tx, u, LogAction::CreateQuote { id: q.id }).await?;
|
||||
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())
|
||||
}
|
||||
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())
|
||||
}
|
||||
20
src/web/pages/quotes/line-add-rm.js
Normal file
@@ -0,0 +1,20 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const container = document.querySelector("[quotelines]");
|
||||
const template = document.querySelector("[quotelinetemplate]");
|
||||
const addButton = document.querySelector("[addlinebtn]");
|
||||
|
||||
addButton.addEventListener("click", () => {
|
||||
const clone = template.content.cloneNode(true);
|
||||
container.appendChild(clone);
|
||||
});
|
||||
|
||||
container.addEventListener("click", (e) => {
|
||||
const rmBtn = e.target.closest("[rmlinebtn]");
|
||||
if (rmBtn && !rmBtn.disabled) {
|
||||
const line = rmBtn.closest("[quoteline]");
|
||||
if (line && container.querySelectorAll("[quoteline]").length > 1) {
|
||||
line.remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
15
src/web/pages/quotes/prefill-time.js
Normal file
@@ -0,0 +1,15 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const timeInput = document.querySelector(
|
||||
'input[type="datetime-local"][name="time"]',
|
||||
);
|
||||
if (timeInput && !timeInput.value) {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(now.getDate()).padStart(2, "0");
|
||||
const hours = String(now.getHours()).padStart(2, "0");
|
||||
const minutes = String(now.getMinutes()).padStart(2, "0");
|
||||
|
||||
timeInput.value = `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
}
|
||||
});
|
||||
@@ -1,54 +1,76 @@
|
||||
use axum::{
|
||||
Form,
|
||||
extract::Request,
|
||||
extract::{Path, Request, State},
|
||||
http::HeaderMap,
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
};
|
||||
use maud::{PreEscaped, html};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
api::CompositeError,
|
||||
MnemoState,
|
||||
error::CompositeError,
|
||||
logs::{LogAction, LogEntry},
|
||||
tags::{Tag, TagName},
|
||||
users::{
|
||||
User,
|
||||
auth::{AuthError, UserAuthRequired, UserAuthenticate},
|
||||
auth::{UserAuthRequired, UserAuthenticate},
|
||||
},
|
||||
web::{components::nav::nav, icons, pages::base},
|
||||
};
|
||||
|
||||
pub async fn page(req: Request) -> Result<Response, AuthError> {
|
||||
let u = User::authenticate(req.headers())?;
|
||||
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()),
|
||||
};
|
||||
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(
|
||||
"Tags | Mnemosyne",
|
||||
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" {
|
||||
p class="flex items-center gap-2" {
|
||||
span class="text-neutral-500" {(PreEscaped(icons::TAG))}
|
||||
span class="text-2xl font-semibold font-lora" {"Tags"}
|
||||
}
|
||||
p class="text-neutral-500 text-sm font-light" {
|
||||
@if let Ok(c) = Tag::total_count() {
|
||||
@if let Ok(c) = total_tags {
|
||||
(c) " tags in total."
|
||||
} @else {
|
||||
"Could not get total tag count."
|
||||
}
|
||||
}
|
||||
}
|
||||
@if let Ok(tags) = Tag::get_all() {
|
||||
div class="max-w-4xl mx-auto mt-4 flex flex-wrap gap-2" {
|
||||
@for tag in &tags {
|
||||
@if is_tags_ok {
|
||||
div class="max-w-4xl mx-auto px-2 mt-4 flex flex-wrap gap-2" {
|
||||
@for (tag, count) in tags_with_counts {
|
||||
div class="rounded-full px-3 py-1 bg-neutral-200/10 border border-neutral-200/15 flex" {
|
||||
span class="text-neutral-400 text-sm" {"#"}
|
||||
span class="text-sm" {(tag.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) = tag.get_tagged_quotes_count() {
|
||||
if let Ok(i) = &count {
|
||||
i.to_string()
|
||||
} else {
|
||||
"?".to_string()
|
||||
@@ -57,14 +79,21 @@ pub async fn page(req: Request) -> Result<Response, AuthError> {
|
||||
// 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() {
|
||||
}
|
||||
}
|
||||
}
|
||||
@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"}
|
||||
h3 class="font-lora font-semibold text-xl mb-1" {"Add new tag"}
|
||||
form action="/tags/create" method="post" {
|
||||
label for="tagname" class="text-neutral-500 font-light mt-2" {"Tag Name"}
|
||||
div class="flex gap-2" {
|
||||
@@ -78,9 +107,6 @@ pub async fn page(req: Request) -> Result<Response, AuthError> {
|
||||
} @else {
|
||||
p class="text-red-400 text-center" {"Failed to load tags."}
|
||||
}
|
||||
} @else {
|
||||
p class="text-center p-2" {"You must be logged in to view this page."}
|
||||
}
|
||||
),
|
||||
)
|
||||
.into_response())
|
||||
@@ -91,10 +117,41 @@ pub struct TagForm {
|
||||
tagname: TagName,
|
||||
}
|
||||
pub async fn create(
|
||||
State(state): State<MnemoState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<TagForm>,
|
||||
) -> Result<Response, CompositeError> {
|
||||
User::authenticate(&headers)?.required()?;
|
||||
Tag::create(form.tagname)?;
|
||||
let mut tx = state.pool.begin().await?;
|
||||
let u = User::authenticate(&mut *tx, &headers).await?.required()?;
|
||||
|
||||
let t = Tag::create(&mut *tx, form.tagname).await?;
|
||||
LogEntry::new(
|
||||
&mut *tx,
|
||||
u,
|
||||
LogAction::CreateTag {
|
||||
id: t.id,
|
||||
name: t.name.to_string(),
|
||||
},
|
||||
)
|
||||
.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())
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
use axum::{
|
||||
extract::Request,
|
||||
response::{IntoResponse, Response},
|
||||
extract::{Request, State},
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
};
|
||||
use maud::{PreEscaped, html};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
users::{
|
||||
User,
|
||||
auth::{AuthError, UserAuthenticate},
|
||||
permissions::Permission,
|
||||
},
|
||||
MnemoState,
|
||||
error::CompositeError,
|
||||
users::{User, auth::UserAuthenticate, permissions::Permission},
|
||||
web::{
|
||||
components::{nav::nav, user_miniprofile::user_miniprofile},
|
||||
icons,
|
||||
@@ -20,19 +19,32 @@ use crate::{
|
||||
pub mod create;
|
||||
pub mod profile;
|
||||
|
||||
pub async fn page(req: Request) -> Result<Response, AuthError> {
|
||||
let u = User::authenticate(req.headers())?;
|
||||
let us = match u.is_some() {
|
||||
true => User::get_all(),
|
||||
false => Ok(vec![]),
|
||||
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()),
|
||||
};
|
||||
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(
|
||||
"Users | Mnemosyne",
|
||||
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" {
|
||||
p class="flex items-center gap-2" {
|
||||
span class="text-neutral-500" {(PreEscaped(icons::USERS))}
|
||||
@@ -44,7 +56,7 @@ pub async fn page(req: Request) -> Result<Response, AuthError> {
|
||||
} @else {
|
||||
"Could not fetch user count."
|
||||
}
|
||||
@if let Ok(true) = u.has_permission(Permission::ManuallyCreateUsers) {
|
||||
@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"
|
||||
@@ -61,9 +73,6 @@ pub async fn page(req: Request) -> Result<Response, AuthError> {
|
||||
p class="text-center py-4 text-light text-red-500" {"Failed to load users."}
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
p class="text-center p-2" {"You must be logged in to view this page."}
|
||||
}
|
||||
),
|
||||
)
|
||||
.into_response())
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::{
|
||||
Form,
|
||||
extract::Request,
|
||||
extract::{Request, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
};
|
||||
@@ -8,32 +8,43 @@ use maud::{PreEscaped, html};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
api::CompositeError,
|
||||
MnemoState,
|
||||
error::CompositeError,
|
||||
logs::{LogAction, LogEntry},
|
||||
users::{
|
||||
User,
|
||||
auth::{AuthError, UserAuthRequired, UserAuthenticate},
|
||||
auth::{UserAuthRequired, UserAuthenticate},
|
||||
handle::UserHandle,
|
||||
permissions::Permission,
|
||||
},
|
||||
web::{components::nav::nav, icons, pages::base},
|
||||
};
|
||||
|
||||
pub async fn page(req: Request) -> Result<Response, AuthError> {
|
||||
let u = User::authenticate(req.headers())?;
|
||||
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()),
|
||||
};
|
||||
let can_create = u
|
||||
.has_permission(&mut *conn, Permission::ManuallyCreateUsers)
|
||||
.await;
|
||||
|
||||
Ok(base(
|
||||
"Users | Mnemosyne",
|
||||
"Create User | Mnemosyne",
|
||||
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" {
|
||||
p class="flex items-center gap-2" {
|
||||
span class="text-neutral-500" {(PreEscaped(icons::USER_PLUS))}
|
||||
span class="text-2xl font-semibold font-lora" {"Create a new user"}
|
||||
}
|
||||
}
|
||||
@if let Ok(true) = u.has_permission(Permission::ManuallyCreateUsers) {
|
||||
@if let Ok(true) = can_create {
|
||||
div class="mx-auto max-w-4xl px-2 mt-4" {
|
||||
form action="/users/create-form" method="post" class="flex flex-col" {
|
||||
label for="handle" class="font-light text-neutral-500" {"Handle"}
|
||||
@@ -52,9 +63,6 @@ pub async fn page(req: Request) -> Result<Response, AuthError> {
|
||||
} @else {
|
||||
p class="text-center p-2" {"You must have permission to view this page."}
|
||||
}
|
||||
} @else {
|
||||
p class="text-center p-2" {"You must be logged in to view this page."}
|
||||
}
|
||||
),
|
||||
)
|
||||
.into_response())
|
||||
@@ -66,14 +74,30 @@ pub struct CreateUserWithPasswordForm {
|
||||
password: String,
|
||||
}
|
||||
pub async fn create_user(
|
||||
State(state): State<MnemoState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<CreateUserWithPasswordForm>,
|
||||
) -> Result<Response, CompositeError> {
|
||||
let u = User::authenticate(&headers)?.required()?;
|
||||
if !u.has_permission(Permission::ManuallyCreateUsers)? {
|
||||
let mut tx = state.pool.begin().await?;
|
||||
let u = User::authenticate(&mut *tx, &headers).await?.required()?;
|
||||
|
||||
if !u
|
||||
.has_permission(&mut *tx, Permission::ManuallyCreateUsers)
|
||||
.await?
|
||||
{
|
||||
return Ok((StatusCode::FORBIDDEN).into_response());
|
||||
}
|
||||
let mut nu = User::create(form.handle)?;
|
||||
nu.set_password(Some(&form.password))?;
|
||||
let mut nu = User::create(&mut *tx, form.handle).await?;
|
||||
nu.set_password(&mut *tx, Some(&form.password)).await?;
|
||||
LogEntry::new(
|
||||
&mut *tx,
|
||||
u,
|
||||
LogAction::CreateUser {
|
||||
id: nu.id,
|
||||
handle: nu.handle.as_str().to_string(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(Redirect::to("/users").into_response())
|
||||
}
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
use axum::{
|
||||
extract::{Path, Request},
|
||||
extract::{Path, Request, State},
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use maud::{PreEscaped, html};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
persons::Name,
|
||||
quotes::{Quote, QuoteLine},
|
||||
users::{
|
||||
User, UserError,
|
||||
auth::{AuthError, UserAuthenticate},
|
||||
},
|
||||
MnemoState,
|
||||
error::CompositeError,
|
||||
users::{User, UserError, auth::UserAuthenticate},
|
||||
web::{
|
||||
components::{nav::nav, quote::quote},
|
||||
icons,
|
||||
@@ -20,18 +16,24 @@ use crate::{
|
||||
},
|
||||
};
|
||||
|
||||
pub async fn page(Path(id): Path<Uuid>, req: Request) -> Result<Response, AuthError> {
|
||||
let u = match User::authenticate(req.headers())? {
|
||||
pub async fn page(
|
||||
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,
|
||||
None => return Ok(Redirect::to("/users").into_response()),
|
||||
None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()),
|
||||
};
|
||||
let user = match User::get_by_id(id) {
|
||||
|
||||
let user = match User::get_by_id(&mut *tx, id).await {
|
||||
Ok(u) => u,
|
||||
Err(UserError::NoUserWithId(_)) => {
|
||||
return Ok(base(
|
||||
"No such user | Mnemosyne",
|
||||
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="text-6xl mb-4" { "?" }
|
||||
p class="text-red-400 text-lg" { "No such user found." }
|
||||
@@ -46,7 +48,7 @@ pub async fn page(Path(id): Path<Uuid>, req: Request) -> Result<Response, AuthEr
|
||||
}
|
||||
_ => {
|
||||
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." }
|
||||
)).into_response());
|
||||
}
|
||||
@@ -63,12 +65,12 @@ pub async fn page(Path(id): Path<Uuid>, req: Request) -> Result<Response, AuthEr
|
||||
.to_uppercase()
|
||||
.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(
|
||||
&format!("@{} | Mnemosyne", user.handle),
|
||||
html!(
|
||||
(nav(Some(&u), req.uri().path()))
|
||||
(nav(&mut tx, Some(&u), req.uri().path()).await)
|
||||
|
||||
// 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" {
|
||||
@@ -197,72 +199,3 @@ pub async fn page(Path(id): Path<Uuid>, req: Request) -> Result<Response, AuthEr
|
||||
)
|
||||
.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::{
|
||||
Form,
|
||||
extract::Request,
|
||||
extract::{Request, State},
|
||||
http::HeaderMap,
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
};
|
||||
@@ -8,34 +8,40 @@ use maud::{PreEscaped, html};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
api::CompositeError,
|
||||
MnemoState,
|
||||
error::CompositeError,
|
||||
logs::{LogAction, LogEntry},
|
||||
users::{
|
||||
User,
|
||||
auth::{AuthError, UserAuthRequired, UserAuthenticate},
|
||||
auth::{UserAuthRequired, UserAuthenticate},
|
||||
handle::UserHandle,
|
||||
},
|
||||
web::{components::nav::nav, icons, pages::base},
|
||||
};
|
||||
|
||||
pub async fn page(req: Request) -> Result<Response, AuthError> {
|
||||
let u = User::authenticate(req.headers())?;
|
||||
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()),
|
||||
};
|
||||
|
||||
Ok(base(
|
||||
"Persons | Mnemosyne",
|
||||
"User Settings | Mnemosyne",
|
||||
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="mx-auto max-w-4xl my-4" {
|
||||
p class="flex items-center gap-2" {
|
||||
span class="text-neutral-500" {(PreEscaped(icons::SERVER))}
|
||||
span class="text-neutral-500" {(PreEscaped(icons::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."}
|
||||
"Hi, " (u.handle) "!" " " "This is your user settings page."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,9 +69,6 @@ pub async fn page(req: Request) -> Result<Response, AuthError> {
|
||||
}
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
p class="text-center p-2" {"You must be logged in to view this page."}
|
||||
}
|
||||
),
|
||||
)
|
||||
.into_response())
|
||||
@@ -76,11 +79,26 @@ pub struct HandleForm {
|
||||
handle: UserHandle,
|
||||
}
|
||||
pub async fn change_handle(
|
||||
State(state): State<MnemoState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<HandleForm>,
|
||||
) -> Result<Response, CompositeError> {
|
||||
let mut u = User::authenticate(&headers)?.required()?;
|
||||
u.set_handle(form.handle)?;
|
||||
let mut tx = state.pool.begin().await?;
|
||||
let mut u = User::authenticate(&mut *tx, &headers).await?.required()?;
|
||||
|
||||
let oldhandle = u.handle.as_str().to_string();
|
||||
u.set_handle(&mut *tx, form.handle).await?;
|
||||
LogEntry::new(
|
||||
&mut *tx,
|
||||
u.clone(),
|
||||
LogAction::ChangeUserHandle {
|
||||
id: u.id,
|
||||
old: oldhandle,
|
||||
new: u.handle.as_str().to_string(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(Redirect::to("/user-settings").into_response())
|
||||
}
|
||||
|
||||
@@ -89,10 +107,22 @@ pub struct PasswordForm {
|
||||
password: String,
|
||||
}
|
||||
pub async fn change_password(
|
||||
State(state): State<MnemoState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<PasswordForm>,
|
||||
) -> Result<Response, CompositeError> {
|
||||
let mut u = User::authenticate(&headers)?.required()?;
|
||||
u.set_password(Some(&form.password))?;
|
||||
if form.password.trim().is_empty() {
|
||||
return Ok((
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
"Password cannot be empty or consist only of whitespace.",
|
||||
)
|
||||
.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())
|
||||
}
|
||||
|
||||