Compare commits

...

119 Commits

Author SHA1 Message Date
256d12c9c8 Merge branch 'master' into gractwo
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 1m11s
2026-05-12 00:55:10 +02:00
65edef47b2 user permission management over api 2026-05-12 00:54:52 +02:00
9b69a0a5ee actually use default permissions, misc 2026-05-12 00:08:20 +02:00
e2e9a3efb5 Admin permission, grant/revoke/reset permission helpers 2026-05-06 18:19:09 +02:00
1f07952973 proper permission checking 2026-05-06 03:07:03 +02:00
b1ccd21068 Merge branch 'master' into gractwo
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 1m14s
2026-05-06 02:50:44 +02:00
7d284f0777 prioritize special uuids in user page display 2026-05-06 02:49:42 +02:00
84dde9cc4b require permission to delete quotes 2026-05-06 02:24:05 +02:00
e7c0523841 quotelink hover, also make dashboard quotes into links 2026-05-06 02:00:23 +02:00
7fe1b6f8be Merge branch 'master' into gractwo
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 1m13s
2026-05-06 00:58:28 +02:00
ca726c8e8b quote deletion confirmation 2026-05-06 00:58:07 +02:00
a08ba568cb Merge branch 'master' into gractwo
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 1m13s
2026-05-06 00:43:48 +02:00
dd75d89472 quote deletion 🎉😮 2026-05-06 00:43:20 +02:00
9eb3332576 forgot to make the 404 page return status 404 2026-05-06 00:13:38 +02:00
032d450af2 barebones quote-specific page 2026-05-05 23:52:09 +02:00
76ac36c4fb remove MnemoConf::new 2026-05-05 15:23:49 +02:00
29804e75e5 allow unused icons 2026-05-05 14:40:17 +02:00
0be4f11f66 Merge branch 'master' into gractwo
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 1m13s
2026-05-05 11:01:29 +02:00
f876ff3f00 only show instance config link for permitted users, make nav markup
component async
2026-05-04 14:11:31 +02:00
47cd13f734 gitignore scripts for local work 2026-05-04 14:07:48 +02:00
cdd296ea84 Merge branch 'master' into gractwo
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 1m11s
2026-05-01 16:33:03 +02:00
4d49a5c0b3 add options to choose if quote should be sent via discord webhook 2026-05-01 16:13:51 +02:00
4aa96dca01 merge upstream
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 1m7s
2026-04-30 20:02:34 +00:00
be462dc662 actually do db readwrites this time 2026-04-30 22:01:28 +02:00
05d4aca741 merge upstream
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 1m38s
2026-04-30 17:05:43 +00:00
ffe1a4d8d2 split language server entries for easier toggling 2026-04-30 19:01:15 +02:00
24df6054ea patch postgres mount location/type
this stops the db instances from being anonymous and from not
persevering between compose up-downs (i think?)
2026-04-30 17:53:59 +02:00
4229444f96 Add instance configuration UI and backend 2026-04-30 17:45:05 +02:00
202b81e517 merge upstream
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 3m3s
2026-04-28 22:55:44 +00:00
1578c3a708 feature: post discord webhook on quote creation 2026-04-29 00:54:44 +02:00
ccc1be0d07 merge upstream
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 1m4s
2026-04-28 15:48:20 +00:00
851f73f639 add dashboard random quote refresh by linking to dashboard page 2026-04-28 13:54:36 +02:00
55c7ad6d6a merge upstream
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 1m5s
2026-04-27 22:54:08 +00:00
7a0ef9a3ad order users by id in User::get_all 2026-04-28 00:42:57 +02:00
021489c740 merge upstream
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 3m21s
2026-04-27 21:52:00 +00:00
f119b87965 fix 'page 1 of 0' on empty log lists 2026-04-27 22:41:07 +02:00
6f334f3825 logs page filtering UI 2026-04-27 19:06:57 +02:00
f83d34a50b jaskier's contribution 2026-04-27 16:29:35 +02:00
a282e4f445 logs filtering functionality 2026-04-27 16:23:21 +02:00
f239de1ca0 also require login for /dashboard 2026-04-27 14:01:35 +02:00
914d8a48c1 show accurate page counts and buttons for quote search results 2026-04-26 23:58:19 +02:00
efb6226421 fix 'Page 1 of 0' on empty quote lists 2026-04-26 23:43:47 +02:00
7d418c91e4 block empty passwords in the web handler 2026-04-26 16:08:31 +02:00
fac1959193 Merge branch 'fts' 2026-04-26 11:48:06 +02:00
a80a64ceec swap quote search to trigram ilike, search ui stub, more 2026-04-26 11:44:53 +02:00
41711dd7fb move config things to config.rs 2026-04-25 17:10:44 +02:00
84e2ad3918 fts wip: trigger, Quote::get_by_search_query, expose to API 2026-04-25 01:00:18 +02:00
983e1ae88f catch model up to multi-author lines
now the only thing missing is the quote adding UI support - multiauthor
lines can already be added via API
2026-04-23 22:48:51 +02:00
48e14a5830 decrement postgres version because the docker image did an oopsie 2026-04-23 18:29:32 +02:00
26576ec31a don't do like count views for now 2026-04-22 00:28:37 +02:00
d20387c2c1 page ui misc 2026-04-22 00:09:52 +02:00
93b3083d2b kill some fully qualified paths 2026-04-21 20:51:55 +02:00
d14a587794 don't acquire useless transasctions, misc 2026-04-20 22:07:49 +02:00
aab9d87df2 make previous button in quote pagination actually subtract page instead
of resetting to page1
2026-04-20 19:16:04 +02:00
879c5ee3d3 postgres via sqlx - workable? 2026-04-20 01:17:30 +02:00
665915f61b merge upstream
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 43s
2026-04-09 20:20:13 +00:00
acfd8a6d72 tag deletion 2026-04-09 22:19:41 +02:00
35932da2f7 merge upstream
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 44s
2026-04-09 18:39:33 +00:00
34f48c2b3d random quote for the dashboard 2026-04-09 20:30:00 +02:00
66c4065059 small boast 2026-04-09 20:20:36 +02:00
fe03b17cb9 merge upstream
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 44s
2026-04-09 17:34:41 +00:00
fc13b166a0 don't show delete button on primary names 2026-04-09 19:33:44 +02:00
13759498ff merge upstream
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 45s
2026-04-09 17:29:37 +00:00
3f98a10df8 name delete if 0 attributions 2026-04-09 19:29:12 +02:00
9b24e68691 merge upstream
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 42s
2026-04-09 16:38:35 +00:00
ab01d0d275 paginate logs 2026-04-09 18:35:54 +02:00
060fe7a3a3 merge upstream
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 40s
2026-04-09 16:03:59 +00:00
8fe1f52644 don't check for log permissions yet.. 2026-04-09 18:03:36 +02:00
a0cd0ad633 merge upstream
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 40s
2026-04-09 15:55:30 +00:00
14abdc9e4a person profile page, adding names 2026-04-09 17:55:04 +02:00
a3f5ccfcb7 merge upstream
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 43s
2026-04-09 14:08:36 +00:00
9fa19d6caf primitive not found page (better than nothing) 2026-04-09 16:00:52 +02:00
a326d5f17d don't dead link 2026-04-09 15:31:21 +02:00
e35da127aa merge upstream
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 40s
2026-04-09 13:23:39 +00:00
b93cdfba63 redirect to /login and redirect back, instead of showing small msg 2026-04-09 15:20:08 +02:00
23595e8008 merge upstream
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 14s
2026-04-09 12:38:36 +00:00
3c111212f0 i don't know how to write SQL 2026-04-09 14:38:05 +02:00
1f9a854122 merge upstream
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 44s
2026-04-09 12:32:00 +00:00
b3fac1f9ec only show public quotes to logged out users 2026-04-09 14:31:06 +02:00
cec765bcf3 all names endpoint 2026-04-09 14:30:56 +02:00
9163e38cec Update .gitea/workflows/build-and-publish.yaml
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 10s
2026-04-09 11:43:25 +00:00
4ae0e0ddf1 Update .gitea/workflows/build-and-publish.yaml
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 10s
2026-04-09 11:41:07 +00:00
7c1cc1dcf9 Update .gitea/workflows/build-and-publish.yaml
Some checks failed
mnemo-build-and-publish / gractwo-mnemo-build (push) Failing after 14s
2026-04-09 11:40:06 +00:00
dff6e3dd91 Update .gitea/workflows/build-and-publish.yaml 2026-04-09 11:39:52 +00:00
00d34f23b0 Update .gitea/workflows/build-and-publish.yaml 2026-04-09 11:39:35 +00:00
ac37058d9f Update .gitea/workflows/build-and-publish.yaml 2026-04-09 11:39:02 +00:00
a5180e4ee9 merge upstream
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 44s
2026-04-09 11:17:01 +00:00
3eb1da8319 don't show photos link on navbar 2026-04-09 13:07:40 +02:00
30254864a9 add page margin to tag listing 2026-04-09 13:04:17 +02:00
18f420c7d4 remove quote cursorscroll, chronological: sort by ts instead of id 2026-04-09 13:01:39 +02:00
b9632e55d5 merge upstream
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 44s
2026-04-08 23:15:48 +00:00
3a811db715 don't throw on permission check lol 2026-04-09 00:55:38 +02:00
1b5d625b9c paginate the quotes, actually 2026-04-09 00:53:39 +02:00
b1e713fd18 merge upstream
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 42s
2026-04-08 18:36:37 +00:00
f2eab97c15 quote chronological cursor scroll, icon 2026-04-08 03:04:06 +02:00
d56fcc3f4c pre-fill time input when entering /quote/add 2026-04-08 02:34:54 +02:00
6906cec2c3 make dashboard only hold newest quote for now 2026-04-08 02:22:45 +02:00
d6e68ac8f7 merge upstream
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 40s
2026-04-07 23:16:50 +00:00
386118de7d we don't need to commit styles.css, actually! 2026-04-08 01:12:25 +02:00
1adb4d9e33 merge upstream
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 32s
2026-04-06 19:29:05 +00:00
673ea40fa6 finalize quote adding functionality (for now?) 2026-04-06 20:55:11 +02:00
dc326dfd94 merge upstream
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 34s
2026-04-05 22:26:49 +00:00
2dd4c8ac47 don't normalize quote timestamp to UTC 2026-04-06 00:24:41 +02:00
777027d471 use YMD date display format so i don't have to fight americans 2026-04-05 23:37:09 +02:00
f09af791e2 fetch newest quote for dashboard, helpers 2026-04-05 23:14:49 +02:00
26be03ba31 working quote submission (with limits for now)
- must have two lines
- can only submit timestamp in system timezone
2026-04-05 22:11:43 +02:00
b0d86efae6 why was readme not given a file extension 2026-04-05 18:12:51 +02:00
f6337104cf oops, forgot about page titles 2026-04-05 15:51:22 +02:00
3ab3567ac3 Name::get_all, quotes page stub, quote add UI work, icons 2026-04-05 15:48:30 +02:00
7514e98f1b merge upstream
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 8s
2026-04-04 21:52:29 +00:00
3f10b51da9 compose.yaml comments, make docker:/app/data writable 2026-04-04 23:50:30 +02:00
947db47fdf finalize migration 2026-04-04--01 2026-04-04 23:46:40 +02:00
f49fb9df6f example compose.yaml, includestr styles, ignore default bind mount 2026-04-04 21:42:08 +02:00
252f7b164b remove all that .map_err nonsense at once 2026-04-04 14:23:42 +02:00
f588f3cf27 merge upstream
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 35s
2026-04-04 08:26:51 +00:00
e08f169074 commit new tailwind artifact to make it stop crying 2026-04-04 02:45:01 +02:00
d8fb561bca log everything 2026-04-04 02:44:17 +02:00
0fb8dafd09 merge upstream
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 31s
2026-04-03 17:06:32 +00:00
449136ce37 make transactions higher level (pass them everywhere) 2026-04-03 19:05:37 +02:00
72 changed files with 5420 additions and 1387 deletions

View File

@@ -28,3 +28,4 @@ README.md
readme
**/*.db*
**/*.db3*
/mnemodata

View File

@@ -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
View File

@@ -1,6 +1,8 @@
/target
.DS_Store
/database
/mnemodata
/scripts
*.db
*.db-shm
*.db-wal

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"] }

View File

@@ -21,6 +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=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/ \
@@ -53,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

View File

@@ -6,31 +6,37 @@ 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") => {
"https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-macos-arm64"
}
("linux", "x86_64") => {
"https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64"
}
("linux", "aarch64") => {
"https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-arm64"
}
_ => return Err(format!("Unsupported platform: {} {}", os, arch).into()),
};
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 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", "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", "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()),
};
let out_dir = PathBuf::from(env::var("OUT_DIR")?);
let tailwind_binary = out_dir.join("tailwind");
fs::create_dir_all(&out_dir)?;
let out_dir = PathBuf::from(env::var("OUT_DIR")?);
let tailwind_binary = out_dir.join("tailwind");
fs::create_dir_all(&out_dir)?;
download_tailwind(&download_url, &tailwind_binary)?;
println!("cargo:rustc-env=TAILWIND_BIN={}", tailwind_binary.display());
run_tailwind(&tailwind_binary)?;
}
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."),
};
Ok(())
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
View 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
View File

@@ -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
View 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.

View File

@@ -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::{
User,
auth::{
AuthError, COOKIE_NAME, SessionAuthRequired, SessionAuthenticate, UserAuthRequired,
implementation::authenticate_via_credentials,
use crate::{
MnemoState,
users::{
User,
auth::{
AuthError, COOKIE_NAME, SessionAuthRequired, SessionAuthenticate, UserAuthRequired,
implementation::authenticate_via_credentials,
},
sessions::Session,
},
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())
}

View File

@@ -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
);

View File

@@ -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())
}

View File

@@ -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())
}

View File

@@ -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))?,
},

View File

@@ -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())
}

View File

@@ -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())
}

View File

@@ -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());

View File

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

View File

@@ -0,0 +1,3 @@
CREATE TABLE mnemoconf (
config JSONB NOT NULL
);

View File

@@ -1,100 +0,0 @@
CREATE TABLE users (
id BLOB NOT NULL UNIQUE PRIMARY KEY, -- UUIDv7 as bytes
handle TEXT NOT NULL UNIQUE COLLATE NOCASE,
password TEXT, -- hashed, nullable in case of OAuth2-only login
prof_pic TEXT -- 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

View File

@@ -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
View 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())
}
}

View File

@@ -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,
}
}
}

View File

@@ -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()?);

View File

@@ -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,148 +37,213 @@ 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 {
id,
created_by: r.get(0)?,
primary_name: r.get(1)?,
})
})
.optional()?;
match res {
Some(p) => Ok(p),
pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> Result<Person, PersonError> {
let row = sqlx::query(
"SELECT n.name FROM persons p JOIN names n ON p.id = n.person_id AND n.is_primary = true WHERE p.id = $1"
)
.bind(id)
.fetch_optional(conn)
.await?;
match row {
Some(r) => Ok(Person {
id,
primary_name: r.try_get("name")?,
}),
None => Err(PersonError::NoPersonWithId(id)),
}
}
pub fn get_in_quote_count(&self) -> Result<i64, PersonError> {
Ok(database::conn()?
.prepare(
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
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)
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")
{
return PersonError::NameAlreadyExists;
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))
}

View File

@@ -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(
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
"#,
)?
.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>, _>>()?;
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 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
"#,
)
.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
View 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}");
}
}
});
}
}

View File

@@ -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)?,
});
}
Ok(tags)
}
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| {
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")
{
return TagError::TagAlreadyExists;
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() {

View File

@@ -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)?)),
false => Err(AuthError::InvalidCredentials),
},
_ => {
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))
}

View File

@@ -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>;

View File

@@ -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)))
}
}

View File

@@ -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)?,
});
}
Ok(users)
}
pub fn create(handle: UserHandle) -> Result<User, UserError> {
let conn = database::conn()?;
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")
{
return UserError::HandleAlreadyExists;
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 {

View File

@@ -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(())
}
}

View File

@@ -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()
}

View File

@@ -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(())
}

View File

@@ -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::SERVER))}
p {"Settings"}
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 {"Instance Config"}
}
}
div class="h-px w-full bg-neutral-200/15" {}
form action="/api/auth/logout-form" method="post" {

View File

@@ -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) = &quote.location {
span class="ml-3 scale-[.5]"{(PreEscaped(icons::MAP_PIN))} p { (loc) }
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-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

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-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
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-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

View File

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

After

Width:  |  Height:  |  Size: 332 B

View File

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

After

Width:  |  Height:  |  Size: 331 B

View File

@@ -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

View File

@@ -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
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-plus-icon lucide-plus"><path d="M5 12h14"/><path d="M12 5v14"/></svg>

After

Width:  |  Height:  |  Size: 272 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-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

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-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
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-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
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-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

View File

@@ -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
View 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",
&current_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/...",
&current_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())
}

View File

@@ -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."}
}
}
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()))}
@if let Some(q) = random_quote {
div class="flex flex-col" {
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),
}
}

View File

@@ -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())
}

View File

@@ -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) {
window.location.href = '/dashboard';
const r = new URL(window.location.href).searchParams.get('r');
if (r && r.startsWith('/')) {
window.location.href = r;
} else {
window.location.href = '/dashboard';
}
} else {
const text = await res.text();
err.textContent = text || 'Login failed';

View File

@@ -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."}
}
),
)

View File

@@ -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
View 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())
}

View File

@@ -1,6 +1,7 @@
use axum::{
Form,
extract::Request,
extract::State,
http::HeaderMap,
response::{IntoResponse, Redirect, Response},
};
@@ -8,79 +9,93 @@ 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"}
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) = total_count {
(c) " persons in total."
} @else {
"Could not get total person count."
}
p class="text-neutral-500 text-sm font-light" {
@if let Ok(c) = Person::total_count() {
(c) " persons in total."
} @else {
"Could not get total person count."
}
}
@if let Ok(persons) = persons_res {
div class="max-w-4xl mx-auto px-2 mt-4 flex flex-wrap gap-2" {
@for (idx, person) in persons.iter().enumerate() {
a href={"/persons/"(person.id)} class="rounded px-4 py-2 bg-neutral-200/5 hover:bg-neutral-200/10 border border-neutral-200/25 hover:border-neutral-200/25 flex items-center" {
span class="text-neutral-400 mr-1 scale-125" {"~"}
span class="text-sm" {(person.primary_name)}
div class="w-px h-2/3 my-auto mx-2 bg-neutral-200/15" {}
div class="text-xs flex items-center" {
(
if let Ok(i) = person_counts[idx] {
i.to_string()
} else {
"?".to_string()
}
) span class="*:size-3 ml-1 text-neutral-400" {(PreEscaped(icons::SCROLL_TEXT))}
}
}
}
}
@if let Ok(persons) = Person::get_all() {
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" {
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() {
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))}
}
}
@if persons.is_empty() {
p class="text-center p-2" {"No persons yet."}
}
div class="mx-auto max-w-4xl mt-4 px-2" {
h3 class="font-lora font-semibold text-xl 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" {
input type="text" autocomplete="off" id="primary_name" name="primary_name" placeholder="e.g. Frank"
class="px-2 py-1 border border-neutral-200/25 bg-neutral-950/50 rounded";
button type="submit"
class="px-4 py-1 border border-neutral-200/25 bg-neutral-200/5 rounded cursor-pointer hover:border-neutral-200/40" {"Submit"}
}
}
@if persons.is_empty() {
p class="text-center p-2" {"No persons yet."}
}
div class="mx-auto max-w-4xl mt-4 px-2" {
h3 class="font-lora font-semibold text-xl" {"Add new person"}
form action="/persons/create" method="post" {
label for="primary_name" class="text-neutral-500 font-light mt-2" {"Primary Name"}
div class="flex gap-2" {
input type="text" autocomplete="off" id="primary_name" name="primary_name" placeholder="e.g. Frank"
class="px-2 py-1 border border-neutral-200/25 bg-neutral-950/50 rounded";
button type="submit"
class="px-4 py-1 border border-neutral-200/25 bg-neutral-200/5 rounded cursor-pointer hover:border-neutral-200/40" {"Submit"}
}
}
}
} @else {
p class="text-red-400 text-center" {"Failed to load persons."}
}
} @else {
p class="text-center p-2" {"You must be logged in to view this page."}
p class="text-red-400 text-center" {"Failed to load persons."}
}
),
)
@@ -92,17 +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()?;
let p = 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())
}

View 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
View 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 &quotes {
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
View 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
View 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())
}

View 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();
}
}
});
});

View 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}`;
}
});

View File

@@ -1,86 +1,111 @@
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() {
(c) " tags in total."
} @else {
"Could not get total tag count."
}
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) = 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 {
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() {
i.to_string()
} else {
"?".to_string()
}
) span class="*:size-3 ml-1 text-neutral-400" {(PreEscaped(icons::SCROLL_TEXT))}
// div class="ml-2" {}
// "0" span class="*:size-3 ml-1 text-neutral-400" {(PreEscaped(icons::FILE_IMAGE))}
}
@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) = &count {
i.to_string()
} else {
"?".to_string()
}
) span class="*:size-3 ml-1 text-neutral-400" {(PreEscaped(icons::SCROLL_TEXT))}
// div class="ml-2" {}
// "0" span class="*:size-3 ml-1 text-neutral-400" {(PreEscaped(icons::FILE_IMAGE))}
}
@if let Ok(0) = count {
form action=(format!("/tags/{}/delete", tag.id)) method="post" class="flex items-center ml-1" {
button type="submit" class="text-neutral-500 hover:text-red-400 text-sm flex items-center justify-center cursor-pointer" title="Delete" {
""
}
}
}
}
}
@if tags.is_empty() {
p class="text-center p-2" {"No tags yet. How about making one?"}
}
div class="mx-auto max-w-4xl mt-4 px-2" {
h3 class="font-lora font-semibold text-xl" {"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" {
input type="text" autocomplete="off" id="tagname" name="tagname" placeholder="e.g. fashion"
class="px-2 py-1 border border-neutral-200/25 bg-neutral-950/50 rounded";
button type="submit"
class="px-4 py-1 border border-neutral-200/25 bg-neutral-200/5 rounded cursor-pointer hover:border-neutral-200/40" {"Submit"}
}
}
@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 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" {
input type="text" autocomplete="off" id="tagname" name="tagname" placeholder="e.g. fashion"
class="px-2 py-1 border border-neutral-200/25 bg-neutral-950/50 rounded";
button type="submit"
class="px-4 py-1 border border-neutral-200/25 bg-neutral-200/5 rounded cursor-pointer hover:border-neutral-200/40" {"Submit"}
}
}
} @else {
p class="text-red-400 text-center" {"Failed to load tags."}
}
} @else {
p class="text-center p-2" {"You must be logged in to view this page."}
p class="text-red-400 text-center" {"Failed to load tags."}
}
),
)
@@ -92,17 +117,41 @@ pub struct TagForm {
tagname: TagName,
}
pub async fn create(
State(state): State<MnemoState>,
headers: HeaderMap,
Form(form): Form<TagForm>,
) -> Result<Response, CompositeError> {
let u = User::authenticate(&headers)?.required()?;
let t = 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())
}

View File

@@ -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,49 +19,59 @@ 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))}
span class="text-2xl font-semibold font-lora" {"Users"}
}
p class="text-neutral-500 text-sm font-light" {
@if let Ok(v) = &us {
(v.len()) " users registered with Mnemosyne."
} @else {
"Could not fetch user count."
}
@if let Ok(true) = u.has_permission(Permission::ManuallyCreateUsers) {
" "
a href="/users/create" class="text-blue-500 hover:text-blue-400 hover:underline" {
"Create a new user"
}
}
}
div class="mx-auto max-w-4xl px-2 my-4" {
p class="flex items-center gap-2" {
span class="text-neutral-500" {(PreEscaped(icons::USERS))}
span class="text-2xl font-semibold font-lora" {"Users"}
}
div class="mx-auto max-w-4xl flex flex-wrap gap-4" {
@if let Ok(vec) = &us {
@for user in vec {
(user_miniprofile(user))
}
p class="text-neutral-500 text-sm font-light" {
@if let Ok(v) = &us {
(v.len()) " users registered with Mnemosyne."
} @else {
p class="text-center py-4 text-light text-red-500" {"Failed to load users."}
"Could not fetch user count."
}
@if let Ok(true) = can_create_users {
" "
a href="/users/create" class="text-blue-500 hover:text-blue-400 hover:underline" {
"Create a new user"
}
}
}
} @else {
p class="text-center p-2" {"You must be logged in to view this page."}
}
div class="mx-auto max-w-4xl flex flex-wrap gap-4" {
@if let Ok(vec) = &us {
@for user in vec {
(user_miniprofile(user))
}
} @else {
p class="text-center py-4 text-light text-red-500" {"Failed to load users."}
}
}
),
)

View File

@@ -1,6 +1,6 @@
use axum::{
Form,
extract::Request,
extract::{Request, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Redirect, Response},
};
@@ -8,53 +8,60 @@ 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"}
}
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) {
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"}
div class="flex w-64 items-center border border-neutral-200/25 rounded bg-neutral-950/50" {
span class="pl-2 text-neutral-500 select-none" {"@"}
input id="handle" name="handle" type="text" autocomplete="off"
class="w-fit pl-0.5 pr-1 py-1 outline-none";
}
label for="password" class="font-light text-neutral-500 mt-4" {"Password"} br;
input id="password" name="password" type="password" autocomplete="off"
class="px-2 w-64 py-1 border border-neutral-200/25 bg-neutral-950/50 rounded";
input type="submit" value="Create"
class="px-4 mt-4 w-64 py-1 border border-neutral-200/25 bg-neutral-200/5 rounded cursor-pointer hover:border-neutral-200/40";
}
@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"}
div class="flex w-64 items-center border border-neutral-200/25 rounded bg-neutral-950/50" {
span class="pl-2 text-neutral-500 select-none" {"@"}
input id="handle" name="handle" type="text" autocomplete="off"
class="w-fit pl-0.5 pr-1 py-1 outline-none";
}
label for="password" class="font-light text-neutral-500 mt-4" {"Password"} br;
input id="password" name="password" type="password" autocomplete="off"
class="px-2 w-64 py-1 border border-neutral-200/25 bg-neutral-950/50 rounded";
input type="submit" value="Create"
class="px-4 mt-4 w-64 py-1 border border-neutral-200/25 bg-neutral-200/5 rounded cursor-pointer hover:border-neutral-200/40";
}
} @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."}
p class="text-center p-2" {"You must have permission to view this page."}
}
),
)
@@ -67,21 +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())
}

View File

@@ -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"),
},
},
],
},
]
}

View File

@@ -1,6 +1,6 @@
use axum::{
Form,
extract::Request,
extract::{Request, State},
http::HeaderMap,
response::{IntoResponse, Redirect, Response},
};
@@ -8,64 +8,66 @@ 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-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."}
}
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::SETTINGS))}
span class="text-2xl font-semibold font-lora" {"Your User Settings"}
}
label for="handle" class="font-light text-neutral-500" {"Handle"}
form action="/user-settings/handle" method="post" class="flex gap-2" {
div class="flex items-center border border-neutral-200/25 rounded bg-neutral-950/50" {
span class="pl-2 text-neutral-500 select-none" {"@"}
input id="handle" name="handle" type="text" autocomplete="off" value={(u.handle)}
class="w-full bg-transparent pl-0.5 pr-1 py-1 outline-none";
}
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"
}
}
hr class="mt-6 mb-4 border-neutral-600";
p class="flex items-center gap-1" {
span class="text-neutral-500 scale-[.8]" {(PreEscaped(icons::USER_KEY))}
span class="text-lg font-semibold font-lora" {"Change Password"}
}
label for="password" class="font-light text-neutral-500" {"New password"}
form action="/user-settings/passwd" method="post" class="flex gap-2" {
input id="password" name="password" type="password" autocomplete="off" class="px-2 py-1 border border-neutral-200/25 bg-neutral-950/50 rounded";
button type="submit" class="px-4 py-1 border border-neutral-200/25 bg-neutral-200/5 rounded cursor-pointer hover:border-neutral-200/40" {
"Submit"
}
p class="text-neutral-500 text-sm font-light" {
"Hi, " (u.handle) "!" " " "This is your user settings page."
}
}
label for="handle" class="font-light text-neutral-500" {"Handle"}
form action="/user-settings/handle" method="post" class="flex gap-2" {
div class="flex items-center border border-neutral-200/25 rounded bg-neutral-950/50" {
span class="pl-2 text-neutral-500 select-none" {"@"}
input id="handle" name="handle" type="text" autocomplete="off" value={(u.handle)}
class="w-full bg-transparent pl-0.5 pr-1 py-1 outline-none";
}
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"
}
}
hr class="mt-6 mb-4 border-neutral-600";
p class="flex items-center gap-1" {
span class="text-neutral-500 scale-[.8]" {(PreEscaped(icons::USER_KEY))}
span class="text-lg font-semibold font-lora" {"Change Password"}
}
label for="password" class="font-light text-neutral-500" {"New password"}
form action="/user-settings/passwd" method="post" class="flex gap-2" {
input id="password" name="password" type="password" autocomplete="off" class="px-2 py-1 border border-neutral-200/25 bg-neutral-950/50 rounded";
button type="submit" class="px-4 py-1 border border-neutral-200/25 bg-neutral-200/5 rounded cursor-pointer hover:border-neutral-200/40" {
"Submit"
}
}
} @else {
p class="text-center p-2" {"You must be logged in to view this page."}
}
),
)
@@ -77,20 +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()?;
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(form.handle)?;
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())
}
@@ -99,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())
}

File diff suppressed because one or more lines are too long