Compare commits

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

100 commits

Author SHA1 Message Date
36d3a18b08 build: update dependencies
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/push/publish-docs Pipeline was successful
2025-06-25 14:33:05 +02:00
407460d2aa style: use const generic for token length instead of multiple functions
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/push/publish-docs Pipeline was successful
Simplifies codebase a bit and avoids having to add another function in future if we need another length of token
2025-06-25 13:25:39 +02:00
f752cddd73 fix: add missing match statements
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/push/publish-docs Pipeline was successful
2025-06-06 18:19:40 +02:00
8dca22de3a fix: make channel deletion work
Some checks failed
ci/woodpecker/push/publish-docs Pipeline is pending
ci/woodpecker/push/build-and-publish Pipeline failed
2025-06-06 18:16:25 +02:00
95c942eee4 feat: use permission system
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/push/publish-docs Pipeline was successful
2025-06-06 17:49:06 +02:00
0588541876 feat: move ownership to member column instead of table column 2025-06-06 17:20:02 +02:00
419f37b108 feat: move password reset tokens to valkey
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/push/publish-docs Pipeline was successful
Also just as useless to keep in DB
2025-06-03 11:03:52 +00:00
b223dff4ba feat: move email tokens to valkey
No need to have them in permanent DB storage when they are temporary
2025-06-03 11:01:33 +00:00
4cbe551061 fix: make custom id optional
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/push/publish-docs Pipeline was successful
2025-06-02 17:50:11 +02:00
c01570707d style: cargo clippy
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/push/publish-docs Pipeline was successful
2025-06-02 00:30:10 +02:00
7021c80f02 style: move structs to objects and split into several files for readability 2025-06-02 00:28:48 +02:00
08cb70ce18 fix: add patch request as a service in actix
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/push/publish-docs Pipeline was successful
whoops forgot to add /channels/{uuid} patch request into actix
2025-06-01 23:43:14 +02:00
c4fc23ec85 feat: add about to users
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/push/publish-docs Pipeline was successful
2025-06-01 22:20:29 +02:00
41defc4a25 feat: add patch request to channels! 2025-06-01 22:10:37 +02:00
15eb102784 build: try to make dev bearable 2025-06-01 22:10:23 +02:00
643f94b580 ci: add proper cross compiling!
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/push/publish-docs Pipeline was successful
2025-06-01 21:56:47 +02:00
ee8211a321 feat: add pronouns to users
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/push/publish-docs Pipeline was successful
2025-06-01 15:58:07 +02:00
2f7fac8db5 fix: dont use option in MpJson
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/push/publish-docs Pipeline was successful
2025-06-01 14:22:52 +02:00
57f52d96df feat: expire cache when updating user
Some checks failed
ci/woodpecker/push/publish-docs Pipeline is pending
ci/woodpecker/push/build-and-publish Pipeline failed
2025-06-01 14:09:38 +02:00
cade49d9c6 fix: return empty vector instead of 404 error
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/push/publish-docs Pipeline was successful
2025-06-01 01:47:30 +02:00
6bc2cdc3c7 revert: add domain to refresh_token_cookie
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/push/publish-docs Pipeline was successful
2025-05-31 23:07:09 +02:00
042aae66f2 fix: make /me/guilds return guilds instead of member objects
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/push/publish-docs Pipeline was successful
2025-05-31 17:52:40 +02:00
8163d0d9c0 style: clippy & fmt 2025-05-31 17:51:04 +02:00
6783bd22a7 feat: add backend_url config option
Some checks failed
ci/woodpecker/push/build-and-publish Pipeline failed
ci/woodpecker/push/publish-docs Pipeline was successful
Required for refresh_token cookie to work properly
2025-05-31 17:11:14 +02:00
4fce262551 docs: add documentation to logout endpoint
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/push/publish-docs Pipeline was successful
2025-05-31 14:52:57 +02:00
1e993026a0 fix: add missing /stats to docs 2025-05-31 14:52:42 +02:00
60f0219e85 feat: add logout endpoint
Some checks failed
ci/woodpecker/push/publish-docs Pipeline is pending
ci/woodpecker/push/build-and-publish Pipeline failed
2025-05-31 14:43:48 +02:00
38aab46534 style: rename refresh_token_cookie() to new_refresh_token_cookie() and fix error message when no refresh_token is found on refresh 2025-05-31 14:41:29 +02:00
d615f1392e style: cargo clippy && cargo fmt
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/push/publish-docs Pipeline was successful
2025-05-30 21:17:30 +02:00
c9a3e8c6c4 feat: add /guilds/{uuid}members
Also makes it return user object with the query
2025-05-30 21:12:07 +02:00
746285e0fb fix: make build number display! 2025-05-30 21:11:13 +02:00
aa37571b3b Merge pull request 'Path style changes' (#19) from wip/style-changes into main
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/push/publish-docs Pipeline was successful
Reviewed-on: #19

# BREAKING CHANGES

Allows removal or changing of the /api prefix
Moves /servers to /guilds
Moves /guilds/{uuid}/channels/{uuid} to /channels/{uuid} (to get channels or create one you still call the old endpoint)
2025-05-30 17:18:41 +00:00
55e343507e style: move /me/servers to /me/guilds 2025-05-30 08:37:45 +00:00
94c4428bb0 feat: add base_path to api
Lets you replace /api with whatever you want!
2025-05-29 20:41:50 +02:00
3c5f3fd654 style: rename url to frontend_url 2025-05-29 20:29:45 +02:00
556337aa4e docs: fix paths in guild comments 2025-05-29 20:16:29 +02:00
e4d9a1b5af style: move servers to guilds 2025-05-29 20:15:27 +02:00
1543a2f485 docs: change path in comments 2025-05-29 20:13:01 +02:00
66c3aef609 style: move channels to /channels 2025-05-29 20:11:50 +02:00
461295c14a feat: add instance name and use it in emails
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/push/publish-docs Pipeline was successful
2025-05-29 18:48:29 +02:00
8ddcbc4955 feat: add registration_enabled and email_verification_required fields to stats
Some checks failed
ci/woodpecker/push/publish-docs Pipeline is pending
ci/woodpecker/push/build-and-publish Pipeline failed
2025-05-29 18:36:07 +02:00
abfbaf8918 feat: add global email verification check 2025-05-29 18:35:13 +02:00
29dbb085a2 fix: dont require auth to check invite information 2025-05-29 18:31:26 +02:00
d102966198 fix: fetch messages properly
Some checks failed
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/push/publish-docs Pipeline failed
2025-05-29 16:11:13 +02:00
d0ecf1b375 build: add missing -y flags
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/push/publish-docs Pipeline was successful
this shouldnt have built before but i guess libssl3 is included by default?
2025-05-29 03:35:39 +02:00
21101fecd5 build: add missing ca-certificates to docker
Some checks failed
ci/woodpecker/push/build-and-publish Pipeline failed
ci/woodpecker/push/publish-docs Pipeline was successful
2025-05-29 03:31:02 +02:00
4d7aabc8ac feat: include user in message response
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/push/publish-docs Pipeline was successful
2025-05-29 02:39:05 +02:00
65918ae5f2 ci: make build system happy
Some checks failed
ci/woodpecker/push/build-and-publish Pipeline failed
ci/woodpecker/push/publish-docs Pipeline was successful
2025-05-29 02:07:36 +02:00
251e33c188 Merge pull request 'Add email support' (#18) from wip/email into main
Some checks failed
ci/woodpecker/push/build-and-publish Pipeline failed
ci/woodpecker/push/publish-docs Pipeline was successful
Reviewed-on: #18
2025-05-28 23:56:52 +00:00
cf2398ed66 fix: fix incorrect email templates 2025-05-28 23:36:18 +02:00
501141b584 feat: add password reset 2025-05-28 23:13:41 +02:00
695ecd96f1 Merge branch 'main' into wip/email 2025-05-28 19:56:57 +02:00
9728769b8c feat: add changing username, email and display_name to /me endpoint
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/push/publish-docs Pipeline was successful
2025-05-28 17:36:23 +02:00
82621d213f Merge branch 'main' into wip/email 2025-05-27 22:24:39 +02:00
1ff3fa69a7 ci: automatically create docs
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/push/publish-docs Pipeline was successful
2025-05-27 22:13:15 +02:00
83f031779f feat: add email verification system
Co-Authored-By: JustTemmie <git@beaver.mom>
2025-05-27 21:57:08 +02:00
862e2d6709 feat: add mail client
Untested
2025-05-27 13:59:06 +00:00
16ccf94631 docs: partially document codebase
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
Should make it easier for frontend to figure out what stuff actually does, more will be added as the project goes on
2025-05-27 11:52:17 +00:00
1aa38631b8 feat: implement is_above for roles and reuse same functions from channels!
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-05-27 11:16:33 +00:00
39d01bb0d0 feat: move me endpoint to /me and add /me/servers
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-05-27 07:46:10 +00:00
b8cf21903e feat: allow disabling of registration
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-05-26 23:41:20 +02:00
1cda34d16b fix: remove more unwraps
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
found more unwraps that needed to be changed to ?
2025-05-26 22:26:47 +02:00
d8541b2eea feat: add channel ordering 2025-05-26 22:26:16 +02:00
bcb82d0f46 fix: return message struct to websocket connection
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-05-26 21:32:43 +02:00
efa0cd555f fix: hack around websocket spec to make tokens work
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-05-26 19:41:32 +02:00
5d26f94cdd style: use ? operator instead of unwrap in websockets
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-05-26 19:17:36 +02:00
6640d03b70 fix: make container work properly
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
Tested-by: Radical <radical@radical.fun>
2025-05-25 19:20:02 +02:00
6c47d22ae6 fix: add bunny config to docker
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-05-25 18:40:13 +02:00
6fe1163969 build: update bunny-api-tokio dependency
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-05-24 03:09:31 +02:00
b5b68c71ba fix: return not found when CDN returns not found
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-05-24 01:29:20 +02:00
8605b81e7b style: cargo clippy && format
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-05-24 01:09:17 +02:00
860fa7a66e Merge pull request 'feat: Bunny CDN integration for images' (#17) from wip/images into main
Some checks failed
ci/woodpecker/push/build-and-publish Pipeline failed
Reviewed-on: #17
2025-05-23 23:06:21 +00:00
97072d54d1 feat: user avatars 2025-05-23 20:33:58 +02:00
d6364a0dc0 feat: add debug error printing
Got a random error message while coding (still have no idea what sent it), this will let you run the code with debug logging if you arent sure where errors are coming from
2025-05-23 20:33:42 +02:00
81f7527c79 feat: move image check to utils.rs 2025-05-23 20:32:43 +02:00
149b81973d Merge branch 'main' into wip/images 2025-05-23 13:45:17 +02:00
82ac501519 Merge pull request 'deadpool, diesel and errors!' (#16) from deadpool-diesel into main
Reviewed-on: #16
2025-05-23 11:07:20 +00:00
a670b32c86 feat: migrate to diesel and new error type in stats 2025-05-23 12:57:19 +02:00
49e08af3d9 feat: migrate to diesel and new error type in invites 2025-05-23 12:57:08 +02:00
dfe2ca9486 feat: migrate to diesel and new error type in users 2025-05-23 12:56:51 +02:00
6190d76285 feat: migrate to diesel and new error type in servers 2025-05-23 12:56:19 +02:00
bf51f623e4 feat: migrate to diesel and new error type in auth 2025-05-23 12:55:27 +02:00
49db25e454 feat: use new error type in structs, utils and config 2025-05-23 12:54:52 +02:00
3e698edf8c feat: use new error type in main 2025-05-23 12:54:10 +02:00
fee46e1433 feat: use thiserror for errors 2025-05-23 12:52:41 +02:00
73ceea63b6 feat: refactor structs.rs to diesel! 2025-05-22 16:31:38 +02:00
c1885210fb feat: include migrations in binary
Lets us change the schema and not worry about instance admins having to manually update their DB!
2025-05-22 16:29:57 +02:00
2e1382c1d4 feat: make channel description nullable 2025-05-22 16:28:58 +02:00
a6d35b0ba2 feat: use diesel-cli instead of hand writing tables
after reading the documentation, crazy right? I figured out i was making my life hard, this makes my life easy again
2025-05-21 21:49:01 +02:00
f1d5b4316e feat: add tables.rs 2025-05-21 20:49:20 +02:00
da804cd436 feat: use diesel on Channel and ChannelPermission structs 2025-05-21 20:49:13 +02:00
746949f0e5 feat: use url format 2025-05-21 20:48:43 +02:00
b9c7bda2b1 feat: use diesel in main fn and data struct 2025-05-21 20:48:09 +02:00
27fbb6508e build: switch sqlx to diesel 2025-05-21 20:47:45 +02:00
f655ced060 Merge branch 'main' into wip/images 2025-05-20 22:53:13 +02:00
85f6db499f fix: use patch request for updating user 2025-05-20 22:20:45 +02:00
4124b08bb2 style: change function name 2025-05-20 22:20:32 +02:00
b66c8f0613 feat: implement proper user and me structs 2025-05-20 18:04:44 +02:00
cee1b41e89 feat: implement server icons! 2025-05-20 14:54:47 +02:00
cf333b4eba feat: add bunny-api-tokio 2025-05-20 14:54:34 +02:00
112 changed files with 4382 additions and 2507 deletions

View file

@ -3,6 +3,21 @@ when:
branch: main
steps:
- name: build-x86_64
image: rust:bookworm
commands:
- cargo build --release
- name: build-arm64
image: rust:bookworm
commands:
- dpkg --add-architecture arm64
- apt-get update -y && apt-get install -y crossbuild-essential-arm64 libssl-dev:arm64
- rustup target add aarch64-unknown-linux-gnu
- cargo build --target aarch64-unknown-linux-gnu --release
environment:
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
PKG_CONFIG_ALLOW_CROSS: 1
PKG_CONFIG_PATH: /usr/aarch64-linux-gnu/lib/pkgconfig
- name: container-build-and-publish
image: docker
commands:

View file

@ -0,0 +1,19 @@
when:
- event: push
branch: main
steps:
- name: build-docs
image: rust:bookworm
commands:
- cargo doc --release --no-deps
- name: publish-docs
image: debian:12
commands:
- apt update -y && apt install -y rsync openssh-client
- printf "Host *\n StrictHostKeyChecking no" >> /etc/ssh/ssh_config
- ssh-agent bash -c "ssh-add <(echo '$KEY' | base64 -d) && rsync --archive --verbose --compress --hard-links --delete-during --partial --progress ./target/doc/ root@gorb.app:/var/www/docs.gorb.app/api && ssh root@gorb.app systemctl reload caddy.service"
environment:
KEY:
from_secret: ssh_key

View file

@ -8,6 +8,12 @@ strip = true
lto = true
codegen-units = 1
# Speed up compilation to make dev bearable
[profile.dev]
debug = 0
strip = "debuginfo"
codegen-units = 512
[dependencies]
actix-cors = "0.7.1"
actix-web = "4.11"
@ -21,16 +27,25 @@ regex = "1.11"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
simple_logger = "5.0.0"
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-native-tls", "postgres"] }
redis = { version = "0.31.0", features= ["tokio-comp"] }
tokio-tungstenite = { version = "0.26", features = ["native-tls", "url"] }
redis = { version = "0.32", features= ["tokio-comp"] }
tokio-tungstenite = { version = "0.27", features = ["native-tls", "url"] }
toml = "0.8"
url = { version = "2.5", features = ["serde"] }
uuid = { version = "1.16", features = ["serde", "v7"] }
uuid = { version = "1.17", features = ["serde", "v7"] }
random-string = "1.1"
actix-ws = "0.3.0"
futures-util = "0.3.31"
bunny-api-tokio = { version = "0.4", features = ["edge_storage"], default-features = false }
bindet = "0.3.2"
deadpool = "0.12"
diesel = { version = "2.2", features = ["uuid", "chrono"], default-features = false }
diesel-async = { version = "0.5", features = ["deadpool", "postgres", "async-connection-wrapper"] }
diesel_migrations = { version = "2.2.0", features = ["postgres"] }
thiserror = "2.0.12"
actix-multipart = "0.7.2"
lettre = { version = "0.11", features = ["tokio1", "tokio1-native-tls"] }
chrono = { version = "0.4.41", features = ["serde"] }
[dependencies.tokio]
version = "1.44"
version = "1.45"
features = ["full"]

View file

@ -1,16 +1,17 @@
FROM rust:bookworm AS builder
FROM --platform=linux/amd64 debian:12-slim AS prep
WORKDIR /src
COPY . .
RUN cargo build --release
COPY target/release/backend backend-amd64
COPY target/aarch64-unknown-linux-gnu/release/backend backend-arm64
FROM debian:12-slim
RUN apt update && apt install libssl3 && rm -rf /var/lib/apt/lists/* /var/cache/apt/* /tmp/*
ARG TARGETARCH
COPY --from=builder /src/target/release/backend /usr/bin/gorb-backend
RUN apt update -y && apt install libssl3 ca-certificates -y && rm -rf /var/lib/apt/lists/* /var/cache/apt/* /tmp/*
COPY --from=prep /src/backend-${TARGETARCH} /usr/bin/gorb-backend
COPY entrypoint.sh /usr/bin/entrypoint.sh
@ -18,12 +19,23 @@ RUN useradd --create-home --home-dir /gorb gorb
USER gorb
ENV DATABASE_USERNAME="gorb" \
DATABASE_PASSWORD="gorb" \
DATABASE="gorb" \
DATABASE_HOST="database" \
DATABASE_PORT="5432" \
CACHE_DB_HOST="valkey" \
CACHE_DB_PORT="6379"
ENV WEB_FRONTEND_URL=https://gorb.app/web/ \
WEB_BASE_PATH=/api \
DATABASE_USERNAME=gorb \
DATABASE_PASSWORD=gorb \
DATABASE=gorb \
DATABASE_HOST=database \
DATABASE_PORT=5432 \
CACHE_DB_HOST=valkey \
CACHE_DB_PORT=6379 \
BUNNY_API_KEY=your_storage_zone_password_here \
BUNNY_ENDPOINT=Frankfurt \
BUNNY_ZONE=gorb \
BUNNY_CDN_URL=https://cdn.gorb.app \
MAIL_ADDRESS=noreply@gorb.app \
MAIL_TLS=tls \
SMTP_SERVER=mail.gorb.app \
SMTP_USERNAME=your_smtp_username \
SMTP_PASSWORD=your_smtp_password
ENTRYPOINT ["/usr/bin/entrypoint.sh"]

16
build.rs Normal file
View file

@ -0,0 +1,16 @@
use std::process::Command;
fn main() {
println!("cargo:rerun-if-changed=migrations");
let git_short_hash = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string()) // Trim newline
.unwrap_or_else(|| "UNKNOWN".to_string());
// Tell Cargo to set `GIT_SHORT_HASH` for the main compilation
println!("cargo:rustc-env=GIT_SHORT_HASH={}", git_short_hash);
}

View file

@ -18,11 +18,21 @@ services:
- gorb-backend:/gorb
environment:
#- RUST_LOG=debug
- WEB_FRONTEND_URL=https://gorb.app/web/
- DATABASE_USERNAME=gorb
- DATABASE_PASSWORD=gorb
- DATABASE=gorb
- DATABASE_HOST=database
- DATABASE_PORT=5432
- BUNNY_API_KEY=your_storage_zone_password_here
- BUNNY_ENDPOINT=Frankfurt
- BUNNY_ZONE=gorb
- BUNNY_CDN_URL=https://cdn.gorb.app
- MAIL_ADDRESS=Gorb <noreply@gorb.app>
- MAIL_TLS=tls
- SMTP_SERVER=mail.gorb.app
- SMTP_USERNAME=your_smtp_username
- SMTP_PASSWORD=your_smtp_password
database:
image: postgres:16
restart: always

View file

@ -16,11 +16,21 @@ services:
- gorb-backend:/gorb
environment:
#- RUST_LOG=debug
- WEB_FRONTEND_URL=https://gorb.app/web/
- DATABASE_USERNAME=gorb
- DATABASE_PASSWORD=gorb
- DATABASE=gorb
- DATABASE_HOST=database
- DATABASE_PORT=5432
- BUNNY_API_KEY=your_storage_zone_password_here
- BUNNY_ENDPOINT=Frankfurt
- BUNNY_ZONE=gorb
- BUNNY_CDN_URL=https://cdn.gorb.app
- MAIL_ADDRESS=Gorb <noreply@gorb.app>
- MAIL_TLS=tls
- SMTP_SERVER=mail.gorb.app
- SMTP_USERNAME=your_smtp_username
- SMTP_PASSWORD=your_smtp_password
database:
image: postgres:16
restart: always

9
diesel.toml Normal file
View file

@ -0,0 +1,9 @@
# For documentation on how to configure this file,
# see https://diesel.rs/guides/configuring-diesel-cli
[print_schema]
file = "src/schema.rs"
custom_type_derives = ["diesel::query_builder::QueryId", "Clone"]
[migrations_directory]
dir = "migrations"

View file

@ -10,6 +10,10 @@ fi
if [ ! -f "/gorb/config/config.toml" ]; then
cat > /gorb/config/config.toml <<EOF
[web]
frontend_url = "${WEB_FRONTEND_URL}"
base_path = "${WEB_BASE_PATH}"
[database]
username = "${DATABASE_USERNAME}"
password = "${DATABASE_PASSWORD}"
@ -20,6 +24,22 @@ port = ${DATABASE_PORT}
[cache_database]
host = "${CACHE_DB_HOST}"
port = ${CACHE_DB_PORT}
[bunny]
api_key = "${BUNNY_API_KEY}"
endpoint = "${BUNNY_ENDPOINT}"
storage_zone = "${BUNNY_ZONE}"
cdn_url = "${BUNNY_CDN_URL}"
[mail]
address = "${MAIL_ADDRESS}"
tls = "${MAIL_TLS}"
[mail.smtp]
server = "${SMTP_SERVER}"
username = "${SMTP_USERNAME}"
password = "${SMTP_PASSWORD}"
EOF
fi
@ -42,4 +62,7 @@ rotate_log() {
rotate_log "/gorb/logs/backend.log"
# Give the DB time to start up before connecting
sleep 5
/usr/bin/gorb-backend --config /gorb/config/config.toml 2>&1 | tee /gorb/logs/backend.log

View file

@ -0,0 +1,6 @@
-- This file was automatically created by Diesel to setup helper functions
-- and other internal bookkeeping. This file is safe to edit, any future
-- changes will be added to existing projects as new migrations.
DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass);
DROP FUNCTION IF EXISTS diesel_set_updated_at();

View file

@ -0,0 +1,36 @@
-- This file was automatically created by Diesel to setup helper functions
-- and other internal bookkeeping. This file is safe to edit, any future
-- changes will be added to existing projects as new migrations.
-- Sets up a trigger for the given table to automatically set a column called
-- `updated_at` whenever the row is modified (unless `updated_at` was included
-- in the modified columns)
--
-- # Example
--
-- ```sql
-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW());
--
-- SELECT diesel_manage_updated_at('users');
-- ```
CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$
BEGIN
EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s
FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl);
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$
BEGIN
IF (
NEW IS DISTINCT FROM OLD AND
NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at
) THEN
NEW.updated_at := current_timestamp;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

View file

@ -0,0 +1,4 @@
-- This file should undo anything in `up.sql`
DROP INDEX idx_unique_username_active;
DROP INDEX idx_unique_email_active;
DROP TABLE users;

View file

@ -0,0 +1,20 @@
-- Your SQL goes here
CREATE TABLE users (
uuid uuid PRIMARY KEY NOT NULL,
username varchar(32) NOT NULL,
display_name varchar(64) DEFAULT NULL,
password varchar(512) NOT NULL,
email varchar(100) NOT NULL,
email_verified boolean NOT NULL DEFAULT FALSE,
is_deleted boolean NOT NULL DEFAULT FALSE,
deleted_at int8 DEFAULT NULL,
CONSTRAINT unique_username_active UNIQUE NULLS NOT DISTINCT (username, is_deleted),
CONSTRAINT unique_email_active UNIQUE NULLS NOT DISTINCT (email, is_deleted)
);
CREATE UNIQUE INDEX idx_unique_username_active
ON users(username)
WHERE is_deleted = FALSE;
CREATE UNIQUE INDEX idx_unique_email_active
ON users(email)
WHERE is_deleted = FALSE;

View file

@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
DROP TABLE instance_permissions;

View file

@ -0,0 +1,5 @@
-- Your SQL goes here
CREATE TABLE instance_permissions (
uuid uuid PRIMARY KEY NOT NULL REFERENCES users(uuid),
administrator boolean NOT NULL DEFAULT FALSE
);

View file

@ -0,0 +1,3 @@
-- This file should undo anything in `up.sql`
DROP TABLE access_tokens;
DROP TABLE refresh_tokens;

View file

@ -0,0 +1,13 @@
-- Your SQL goes here
CREATE TABLE refresh_tokens (
token varchar(64) PRIMARY KEY UNIQUE NOT NULL,
uuid uuid NOT NULL REFERENCES users(uuid),
created_at int8 NOT NULL,
device_name varchar(16) NOT NULL
);
CREATE TABLE access_tokens (
token varchar(32) PRIMARY KEY UNIQUE NOT NULL,
refresh_token varchar(64) UNIQUE NOT NULL REFERENCES refresh_tokens(token) ON UPDATE CASCADE ON DELETE CASCADE,
uuid uuid NOT NULL REFERENCES users(uuid),
created_at int8 NOT NULL
);

View file

@ -0,0 +1,3 @@
-- This file should undo anything in `up.sql`
DROP TABLE guild_members;
DROP TABLE guilds;

View file

@ -0,0 +1,13 @@
-- Your SQL goes here
CREATE TABLE guilds (
uuid uuid PRIMARY KEY NOT NULL,
owner_uuid uuid NOT NULL REFERENCES users(uuid),
name VARCHAR(100) NOT NULL,
description VARCHAR(300)
);
CREATE TABLE guild_members (
uuid uuid PRIMARY KEY NOT NULL,
guild_uuid uuid NOT NULL REFERENCES guilds(uuid) ON DELETE CASCADE,
user_uuid uuid NOT NULL REFERENCES users(uuid),
nickname VARCHAR(100) DEFAULT NULL
);

View file

@ -0,0 +1,3 @@
-- This file should undo anything in `up.sql`
DROP TABLE role_members;
DROP TABLE roles;

View file

@ -0,0 +1,15 @@
-- Your SQL goes here
CREATE TABLE roles (
uuid uuid UNIQUE NOT NULL,
guild_uuid uuid NOT NULL REFERENCES guilds(uuid) ON DELETE CASCADE,
name VARCHAR(50) NOT NULL,
color int NOT NULL DEFAULT 16777215,
position int NOT NULL,
permissions int8 NOT NULL DEFAULT 0,
PRIMARY KEY (uuid, guild_uuid)
);
CREATE TABLE role_members (
role_uuid uuid NOT NULL REFERENCES roles(uuid) ON DELETE CASCADE,
member_uuid uuid NOT NULL REFERENCES guild_members(uuid) ON DELETE CASCADE,
PRIMARY KEY (role_uuid, member_uuid)
);

View file

@ -0,0 +1,3 @@
-- This file should undo anything in `up.sql`
DROP TABLE channel_permissions;
DROP TABLE channels;

View file

@ -0,0 +1,13 @@
-- Your SQL goes here
CREATE TABLE channels (
uuid uuid PRIMARY KEY NOT NULL,
guild_uuid uuid NOT NULL REFERENCES guilds(uuid) ON DELETE CASCADE,
name varchar(32) NOT NULL,
description varchar(500) NOT NULL
);
CREATE TABLE channel_permissions (
channel_uuid uuid NOT NULL REFERENCES channels(uuid) ON DELETE CASCADE,
role_uuid uuid NOT NULL REFERENCES roles(uuid) ON DELETE CASCADE,
permissions int8 NOT NULL DEFAULT 0,
PRIMARY KEY (channel_uuid, role_uuid)
);

View file

@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
DROP TABLE messages;

View file

@ -0,0 +1,7 @@
-- Your SQL goes here
CREATE TABLE messages (
uuid uuid PRIMARY KEY NOT NULL,
channel_uuid uuid NOT NULL REFERENCES channels(uuid) ON DELETE CASCADE,
user_uuid uuid NOT NULL REFERENCES users(uuid),
message varchar(4000) NOT NULL
);

View file

@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
DROP TABLE invites;

View file

@ -0,0 +1,6 @@
-- Your SQL goes here
CREATE TABLE invites (
id varchar(32) PRIMARY KEY NOT NULL,
guild_uuid uuid NOT NULL REFERENCES guilds(uuid) ON DELETE CASCADE,
user_uuid uuid NOT NULL REFERENCES users(uuid)
);

View file

@ -0,0 +1,4 @@
-- This file should undo anything in `up.sql`
UPDATE channels SET description = '' WHERE description IS NULL;
ALTER TABLE ONLY channels ALTER COLUMN description SET NOT NULL;
ALTER TABLE ONLY channels ALTER COLUMN description DROP DEFAULT;

View file

@ -0,0 +1,3 @@
-- Your SQL goes here
ALTER TABLE ONLY channels ALTER COLUMN description DROP NOT NULL;
ALTER TABLE ONLY channels ALTER COLUMN description SET DEFAULT NULL;

View file

@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
ALTER TABLE guilds DROP COLUMN icon;

View file

@ -0,0 +1,2 @@
-- Your SQL goes here
ALTER TABLE guilds ADD COLUMN icon VARCHAR(100) DEFAULT NULL;

View file

@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
ALTER TABLE users DROP COLUMN avatar;

View file

@ -0,0 +1,2 @@
-- Your SQL goes here
ALTER TABLE users ADD COLUMN avatar varchar(100) DEFAULT NULL;

View file

@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
ALTER TABLE channels DROP COLUMN is_above;

View file

@ -0,0 +1,2 @@
-- Your SQL goes here
ALTER TABLE channels ADD COLUMN is_above UUID UNIQUE REFERENCES channels(uuid) DEFAULT NULL;

View file

@ -0,0 +1,3 @@
-- This file should undo anything in `up.sql`
ALTER TABLE roles ADD COLUMN position int NOT NULL DEFAULT 0;
ALTER TABLE roles DROP COLUMN is_above;

View file

@ -0,0 +1,3 @@
-- Your SQL goes here
ALTER TABLE roles DROP COLUMN position;
ALTER TABLE roles ADD COLUMN is_above UUID UNIQUE REFERENCES roles(uuid) DEFAULT NULL;

View file

@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
DROP TABLE email_tokens;

View file

@ -0,0 +1,7 @@
-- Your SQL goes here
CREATE TABLE email_tokens (
token VARCHAR(64) NOT NULL,
user_uuid uuid UNIQUE NOT NULL REFERENCES users(uuid),
created_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (token, user_uuid)
);

View file

@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
DROP TABLE password_reset_tokens;

View file

@ -0,0 +1,7 @@
-- Your SQL goes here
CREATE TABLE password_reset_tokens (
token VARCHAR(64) NOT NULL,
user_uuid uuid UNIQUE NOT NULL REFERENCES users(uuid),
created_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (token, user_uuid)
);

View file

@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
ALTER TABLE users DROP COLUMN pronouns;

View file

@ -0,0 +1,2 @@
-- Your SQL goes here
ALTER TABLE users ADD COLUMN pronouns VARCHAR(32) DEFAULT NULL;

View file

@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
ALTER TABLE users DROP COLUMN about;

View file

@ -0,0 +1,2 @@
-- Your SQL goes here
ALTER TABLE users ADD COLUMN about VARCHAR(200) DEFAULT NULL;

View file

@ -0,0 +1,7 @@
-- This file should undo anything in `up.sql`
CREATE TABLE email_tokens (
token VARCHAR(64) NOT NULL,
user_uuid uuid UNIQUE NOT NULL REFERENCES users(uuid),
created_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (token, user_uuid)
);

View file

@ -0,0 +1,2 @@
-- Your SQL goes here
DROP TABLE email_tokens;

View file

@ -0,0 +1,7 @@
-- This file should undo anything in `up.sql`
CREATE TABLE password_reset_tokens (
token VARCHAR(64) NOT NULL,
user_uuid uuid UNIQUE NOT NULL REFERENCES users(uuid),
created_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (token, user_uuid)
);

View file

@ -0,0 +1,2 @@
-- Your SQL goes here
DROP TABLE password_reset_tokens;

View file

@ -0,0 +1,14 @@
-- This file should undo anything in `up.sql`
ALTER TABLE guilds
ADD COLUMN owner_uuid UUID REFERENCES users(uuid);
UPDATE guilds g
SET owner_uuid = gm.user_uuid
FROM guild_members gm
WHERE gm.guild_uuid = g.uuid AND gm.is_owner = TRUE;
ALTER TABLE guilds
ALTER COLUMN owner_uuid SET NOT NULL;
ALTER TABLE guild_members
DROP COLUMN is_owner;

View file

@ -0,0 +1,14 @@
-- Your SQL goes here
ALTER TABLE guild_members
ADD COLUMN is_owner BOOLEAN NOT NULL DEFAULT false;
UPDATE guild_members gm
SET is_owner = true
FROM guilds g
WHERE gm.guild_uuid = g.uuid AND gm.user_uuid = g.owner_uuid;
CREATE UNIQUE INDEX one_owner_per_guild ON guild_members (guild_uuid)
WHERE is_owner;
ALTER TABLE guilds
DROP COLUMN owner_uuid;

View file

@ -3,7 +3,7 @@
podman-compose --file compose.dev.yml up --build
echo "SHUTTING DOWN CONTAINERS"
podman container stop backend_backend_1 backend_database_1
podman container stop backend_backend_1 backend_database_1 backend_valkey_1
echo "DELETING CONTAINERS"
podman container rm backend_backend_1 backend_database_1
podman container rm backend_backend_1 backend_database_1 backend_valkey_1

View file

@ -1,9 +1,13 @@
//! `/api` Contains the entire API
use actix_web::Scope;
use actix_web::web;
mod v1;
mod versions;
pub fn web() -> Scope {
web::scope("/api").service(v1::web()).service(versions::res)
pub fn web(path: &str) -> Scope {
web::scope(path.trim_end_matches('/'))
.service(v1::web())
.service(versions::get)
}

View file

@ -1,14 +1,19 @@
use std::time::{SystemTime, UNIX_EPOCH};
use actix_web::{Error, HttpResponse, post, web};
use actix_web::{HttpResponse, post, web};
use argon2::{PasswordHash, PasswordVerifier};
use log::error;
use diesel::{ExpressionMethods, QueryDsl, dsl::insert_into};
use diesel_async::RunQueryDsl;
use serde::Deserialize;
use crate::{
Data,
api::v1::auth::{EMAIL_REGEX, PASSWORD_REGEX, USERNAME_REGEX},
utils::{generate_access_token, generate_refresh_token, refresh_token_cookie},
error::Error,
schema::*,
utils::{
PASSWORD_REGEX, generate_token, new_refresh_token_cookie,
user_uuid_from_identifier,
},
};
use super::Response;
@ -29,146 +34,61 @@ pub async fn response(
return Ok(HttpResponse::Forbidden().json(r#"{ "password_hashed": false }"#));
}
if EMAIL_REGEX.is_match(&login_information.username) {
let row =
sqlx::query_as("SELECT CAST(uuid as VARCHAR), password FROM users WHERE email = $1")
.bind(&login_information.username)
.fetch_one(&data.pool)
.await;
use users::dsl;
if let Err(error) = row {
if error.to_string()
== "no rows returned by a query that expected to return at least one row"
{
return Ok(HttpResponse::Unauthorized().finish());
}
let mut conn = data.pool.get().await?;
error!("{}", error);
return Ok(HttpResponse::InternalServerError().json(
r#"{ "error": "Unhandled exception occured, contact the server administrator" }"#,
));
}
let uuid = user_uuid_from_identifier(&mut conn, &login_information.username).await?;
let (uuid, password): (String, String) = row.unwrap();
let database_password: String = dsl::users
.filter(dsl::uuid.eq(uuid))
.select(dsl::password)
.get_result(&mut conn)
.await?;
return Ok(login(
data.clone(),
uuid,
login_information.password.clone(),
password,
login_information.device_name.clone(),
)
.await);
} else if USERNAME_REGEX.is_match(&login_information.username) {
let row =
sqlx::query_as("SELECT CAST(uuid as VARCHAR), password FROM users WHERE username = $1")
.bind(&login_information.username)
.fetch_one(&data.pool)
.await;
if let Err(error) = row {
if error.to_string()
== "no rows returned by a query that expected to return at least one row"
{
return Ok(HttpResponse::Unauthorized().finish());
}
error!("{}", error);
return Ok(HttpResponse::InternalServerError().json(
r#"{ "error": "Unhandled exception occured, contact the server administrator" }"#,
));
}
let (uuid, password): (String, String) = row.unwrap();
return Ok(login(
data.clone(),
uuid,
login_information.password.clone(),
password,
login_information.device_name.clone(),
)
.await);
}
Ok(HttpResponse::Unauthorized().finish())
}
async fn login(
data: actix_web::web::Data<Data>,
uuid: String,
request_password: String,
database_password: String,
device_name: String,
) -> HttpResponse {
let parsed_hash_raw = PasswordHash::new(&database_password);
if let Err(error) = parsed_hash_raw {
error!("{}", error);
return HttpResponse::InternalServerError().finish();
}
let parsed_hash = parsed_hash_raw.unwrap();
let parsed_hash = PasswordHash::new(&database_password)
.map_err(|e| Error::PasswordHashError(e.to_string()))?;
if data
.argon2
.verify_password(request_password.as_bytes(), &parsed_hash)
.verify_password(login_information.password.as_bytes(), &parsed_hash)
.is_err()
{
return HttpResponse::Unauthorized().finish();
return Err(Error::Unauthorized(
"Wrong username or password".to_string(),
));
}
let refresh_token_raw = generate_refresh_token();
let access_token_raw = generate_access_token();
let refresh_token = generate_token::<32>()?;
let access_token = generate_token::<16>()?;
if let Err(error) = refresh_token_raw {
error!("{}", error);
return HttpResponse::InternalServerError().finish();
}
let current_time = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as i64;
let refresh_token = refresh_token_raw.unwrap();
use refresh_tokens::dsl as rdsl;
if let Err(error) = access_token_raw {
error!("{}", error);
return HttpResponse::InternalServerError().finish();
}
let access_token = access_token_raw.unwrap();
let current_time = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
if let Err(error) = sqlx::query(&format!(
"INSERT INTO refresh_tokens (token, uuid, created_at, device_name) VALUES ($1, '{}', $2, $3 )",
uuid
insert_into(refresh_tokens::table)
.values((
rdsl::token.eq(&refresh_token),
rdsl::uuid.eq(uuid),
rdsl::created_at.eq(current_time),
rdsl::device_name.eq(&login_information.device_name),
))
.bind(&refresh_token)
.bind(current_time)
.bind(device_name)
.execute(&data.pool)
.await
{
error!("{}", error);
return HttpResponse::InternalServerError().finish();
}
.execute(&mut conn)
.await?;
if let Err(error) = sqlx::query(&format!(
"INSERT INTO access_tokens (token, refresh_token, uuid, created_at) VALUES ($1, $2, '{}', $3 )",
uuid
use access_tokens::dsl as adsl;
insert_into(access_tokens::table)
.values((
adsl::token.eq(&access_token),
adsl::refresh_token.eq(&refresh_token),
adsl::uuid.eq(uuid),
adsl::created_at.eq(current_time),
))
.bind(&access_token)
.bind(&refresh_token)
.bind(current_time)
.execute(&data.pool)
.await
{
error!("{}", error);
return HttpResponse::InternalServerError().finish()
}
.execute(&mut conn)
.await?;
HttpResponse::Ok()
.cookie(refresh_token_cookie(refresh_token))
.json(Response { access_token })
Ok(HttpResponse::Ok()
.cookie(new_refresh_token_cookie(&data.config, refresh_token))
.json(Response { access_token }))
}

38
src/api/v1/auth/logout.rs Normal file
View file

@ -0,0 +1,38 @@
use actix_web::{HttpRequest, HttpResponse, post, web};
use diesel::{ExpressionMethods, delete};
use diesel_async::RunQueryDsl;
use crate::{
Data,
error::Error,
schema::refresh_tokens::{self, dsl},
};
/// `GET /api/v1/logout`
///
/// requires auth: kinda, needs refresh token set but no access token is technically required
///
/// ### Responses
/// 200 Logged out
/// 404 Refresh token is invalid
/// 401 Unauthorized (no refresh token found)
///
#[post("/logout")]
pub async fn res(req: HttpRequest, data: web::Data<Data>) -> Result<HttpResponse, Error> {
let mut refresh_token_cookie = req.cookie("refresh_token").ok_or(Error::Unauthorized(
"request has no refresh token".to_string(),
))?;
let refresh_token = String::from(refresh_token_cookie.value());
let mut conn = data.pool.get().await?;
delete(refresh_tokens::table)
.filter(dsl::token.eq(refresh_token))
.execute(&mut conn)
.await?;
refresh_token_cookie.make_removal();
Ok(HttpResponse::Ok().cookie(refresh_token_cookie).finish())
}

View file

@ -1,79 +1,60 @@
use std::{
str::FromStr,
sync::LazyLock,
time::{SystemTime, UNIX_EPOCH},
};
use std::time::{SystemTime, UNIX_EPOCH};
use actix_web::{HttpResponse, Scope, web};
use log::error;
use regex::Regex;
use actix_web::{Scope, web};
use diesel::{ExpressionMethods, QueryDsl};
use diesel_async::RunQueryDsl;
use serde::Serialize;
use sqlx::Postgres;
use uuid::Uuid;
use crate::{Conn, error::Error, schema::access_tokens::dsl};
mod login;
mod logout;
mod refresh;
mod register;
mod reset_password;
mod revoke;
mod verify_email;
#[derive(Serialize)]
struct Response {
access_token: String,
}
static EMAIL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"[-A-Za-z0-9!#$%&'*+/=?^_`{|}~]+(?:\.[-A-Za-z0-9!#$%&'*+/=?^_`{|}~]+)*@(?:[A-Za-z0-9](?:[-A-Za-z0-9]*[A-Za-z0-9])?\.)+[A-Za-z0-9](?:[-A-Za-z0-9]*[A-Za-z0-9])?").unwrap()
});
static USERNAME_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[a-z0-9_.-]+$").unwrap());
// Password is expected to be hashed using SHA3-384
static PASSWORD_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[0-9a-f]{96}").unwrap());
pub fn web() -> Scope {
web::scope("/auth")
.service(register::res)
.service(login::response)
.service(logout::res)
.service(refresh::res)
.service(revoke::res)
.service(verify_email::get)
.service(verify_email::post)
.service(reset_password::get)
.service(reset_password::post)
}
pub async fn check_access_token(
access_token: &str,
pool: &sqlx::Pool<Postgres>,
) -> Result<Uuid, HttpResponse> {
let row = sqlx::query_as(
"SELECT CAST(uuid as VARCHAR), created_at FROM access_tokens WHERE token = $1",
)
.bind(access_token)
.fetch_one(pool)
.await;
if let Err(error) = row {
if error.to_string()
== "no rows returned by a query that expected to return at least one row"
{
return Err(HttpResponse::Unauthorized().finish());
pub async fn check_access_token(access_token: &str, conn: &mut Conn) -> Result<Uuid, Error> {
let (uuid, created_at): (Uuid, i64) = dsl::access_tokens
.filter(dsl::token.eq(access_token))
.select((dsl::uuid, dsl::created_at))
.get_result(conn)
.await
.map_err(|error| {
if error == diesel::result::Error::NotFound {
Error::Unauthorized("Invalid access token".to_string())
} else {
Error::from(error)
}
})?;
error!("{}", error);
return Err(HttpResponse::InternalServerError().json(
r#"{ "error": "Unhandled exception occured, contact the server administrator" }"#,
));
}
let (uuid, created_at): (String, i64) = row.unwrap();
let current_time = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let current_time = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as i64;
let lifetime = current_time - created_at;
if lifetime > 3600 {
return Err(HttpResponse::Unauthorized().finish());
return Err(Error::Unauthorized("Invalid access token".to_string()));
}
Ok(Uuid::from_str(&uuid).unwrap())
Ok(uuid)
}

View file

@ -1,49 +1,50 @@
use actix_web::{Error, HttpRequest, HttpResponse, post, web};
use actix_web::{HttpRequest, HttpResponse, post, web};
use diesel::{ExpressionMethods, QueryDsl, delete, update};
use diesel_async::RunQueryDsl;
use log::error;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::{
Data,
utils::{generate_access_token, generate_refresh_token, refresh_token_cookie},
error::Error,
schema::{
access_tokens::{self, dsl},
refresh_tokens::{self, dsl as rdsl},
},
utils::{generate_token, new_refresh_token_cookie},
};
use super::Response;
#[post("/refresh")]
pub async fn res(req: HttpRequest, data: web::Data<Data>) -> Result<HttpResponse, Error> {
let recv_refresh_token_cookie = req.cookie("refresh_token");
let mut refresh_token_cookie = req.cookie("refresh_token").ok_or(Error::Unauthorized(
"request has no refresh token".to_string(),
))?;
if recv_refresh_token_cookie.is_none() {
return Ok(HttpResponse::Unauthorized().finish());
}
let mut refresh_token = String::from(refresh_token_cookie.value());
let mut refresh_token = String::from(recv_refresh_token_cookie.unwrap().value());
let current_time = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as i64;
let current_time = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let mut conn = data.pool.get().await?;
if let Ok(row) = sqlx::query_scalar("SELECT created_at FROM refresh_tokens WHERE token = $1")
.bind(&refresh_token)
.fetch_one(&data.pool)
if let Ok(created_at) = rdsl::refresh_tokens
.filter(rdsl::token.eq(&refresh_token))
.select(rdsl::created_at)
.get_result::<i64>(&mut conn)
.await
{
let created_at: i64 = row;
let lifetime = current_time - created_at;
if lifetime > 2592000 {
if let Err(error) = sqlx::query("DELETE FROM refresh_tokens WHERE token = $1")
.bind(&refresh_token)
.execute(&data.pool)
if let Err(error) = delete(refresh_tokens::table)
.filter(rdsl::token.eq(&refresh_token))
.execute(&mut conn)
.await
{
error!("{}", error);
}
let mut refresh_token_cookie = refresh_token_cookie(refresh_token);
refresh_token_cookie.make_removal();
return Ok(HttpResponse::Unauthorized()
@ -51,28 +52,18 @@ pub async fn res(req: HttpRequest, data: web::Data<Data>) -> Result<HttpResponse
.finish());
}
let current_time = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let current_time = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as i64;
if lifetime > 1987200 {
let new_refresh_token = generate_refresh_token();
let new_refresh_token = generate_token::<32>()?;
if new_refresh_token.is_err() {
error!("{}", new_refresh_token.unwrap_err());
return Ok(HttpResponse::InternalServerError().finish());
}
let new_refresh_token = new_refresh_token.unwrap();
match sqlx::query(
"UPDATE refresh_tokens SET token = $1, created_at = $2 WHERE token = $3",
)
.bind(&new_refresh_token)
.bind(current_time)
.bind(&refresh_token)
.execute(&data.pool)
match update(refresh_tokens::table)
.filter(rdsl::token.eq(&refresh_token))
.set((
rdsl::token.eq(&new_refresh_token),
rdsl::created_at.eq(current_time),
))
.execute(&mut conn)
.await
{
Ok(_) => {
@ -84,35 +75,22 @@ pub async fn res(req: HttpRequest, data: web::Data<Data>) -> Result<HttpResponse
}
}
let access_token = generate_access_token();
let access_token = generate_token::<16>()?;
if access_token.is_err() {
error!("{}", access_token.unwrap_err());
return Ok(HttpResponse::InternalServerError().finish());
}
let access_token = access_token.unwrap();
if let Err(error) = sqlx::query(
"UPDATE access_tokens SET token = $1, created_at = $2 WHERE refresh_token = $3",
)
.bind(&access_token)
.bind(current_time)
.bind(&refresh_token)
.execute(&data.pool)
.await
{
error!("{}", error);
return Ok(HttpResponse::InternalServerError().finish());
}
update(access_tokens::table)
.filter(dsl::refresh_token.eq(&refresh_token))
.set((
dsl::token.eq(&access_token),
dsl::created_at.eq(current_time),
))
.execute(&mut conn)
.await?;
return Ok(HttpResponse::Ok()
.cookie(refresh_token_cookie(refresh_token))
.cookie(new_refresh_token_cookie(&data.config, refresh_token))
.json(Response { access_token }));
}
let mut refresh_token_cookie = refresh_token_cookie(refresh_token);
refresh_token_cookie.make_removal();
Ok(HttpResponse::Unauthorized()

View file

@ -1,19 +1,28 @@
use std::time::{SystemTime, UNIX_EPOCH};
use actix_web::{Error, HttpResponse, post, web};
use actix_web::{HttpResponse, post, web};
use argon2::{
PasswordHasher,
password_hash::{SaltString, rand_core::OsRng},
};
use log::error;
use diesel::{ExpressionMethods, dsl::insert_into};
use diesel_async::RunQueryDsl;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::Response;
use crate::{
Data,
api::v1::auth::{EMAIL_REGEX, PASSWORD_REGEX, USERNAME_REGEX},
utils::{generate_access_token, generate_refresh_token, refresh_token_cookie},
error::Error,
schema::{
access_tokens::{self, dsl as adsl},
refresh_tokens::{self, dsl as rdsl},
users::{self, dsl as udsl},
},
utils::{
EMAIL_REGEX, PASSWORD_REGEX, USERNAME_REGEX, generate_token,
new_refresh_token_cookie,
},
};
#[derive(Deserialize)]
@ -60,6 +69,12 @@ pub async fn res(
account_information: web::Json<AccountInformation>,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
if !data.config.instance.registration {
return Err(Error::Forbidden(
"registration is disabled on this instance".to_string(),
));
}
let uuid = Uuid::now_v7();
if !EMAIL_REGEX.is_match(&account_information.email) {
@ -92,91 +107,47 @@ pub async fn res(
.argon2
.hash_password(account_information.password.as_bytes(), &salt)
{
let mut conn = data.pool.get().await?;
// TODO: Check security of this implementation
return Ok(
match sqlx::query(&format!(
"INSERT INTO users (uuid, username, password, email) VALUES ( '{}', $1, $2, $3 )",
uuid
insert_into(users::table)
.values((
udsl::uuid.eq(uuid),
udsl::username.eq(&account_information.identifier),
udsl::password.eq(hashed_password.to_string()),
udsl::email.eq(&account_information.email),
))
.bind(&account_information.identifier)
.bind(hashed_password.to_string())
.bind(&account_information.email)
.execute(&data.pool)
.await
{
Ok(_out) => {
let refresh_token = generate_refresh_token();
let access_token = generate_access_token();
.execute(&mut conn)
.await?;
if refresh_token.is_err() {
error!("{}", refresh_token.unwrap_err());
return Ok(HttpResponse::InternalServerError().finish());
}
let refresh_token = generate_token::<32>()?;
let access_token = generate_token::<16>()?;
let refresh_token = refresh_token.unwrap();
let current_time = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as i64;
if access_token.is_err() {
error!("{}", access_token.unwrap_err());
return Ok(HttpResponse::InternalServerError().finish());
}
insert_into(refresh_tokens::table)
.values((
rdsl::token.eq(&refresh_token),
rdsl::uuid.eq(uuid),
rdsl::created_at.eq(current_time),
rdsl::device_name.eq(&account_information.device_name),
))
.execute(&mut conn)
.await?;
let access_token = access_token.unwrap();
insert_into(access_tokens::table)
.values((
adsl::token.eq(&access_token),
adsl::refresh_token.eq(&refresh_token),
adsl::uuid.eq(uuid),
adsl::created_at.eq(current_time),
))
.execute(&mut conn)
.await?;
let current_time = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
if let Err(error) = sqlx::query(&format!("INSERT INTO refresh_tokens (token, uuid, created_at, device_name) VALUES ($1, '{}', $2, $3 )", uuid))
.bind(&refresh_token)
.bind(current_time)
.bind(&account_information.device_name)
.execute(&data.pool)
.await {
error!("{}", error);
return Ok(HttpResponse::InternalServerError().finish())
}
if let Err(error) = sqlx::query(&format!("INSERT INTO access_tokens (token, refresh_token, uuid, created_at) VALUES ($1, $2, '{}', $3 )", uuid))
.bind(&access_token)
.bind(&refresh_token)
.bind(current_time)
.execute(&data.pool)
.await {
error!("{}", error);
return Ok(HttpResponse::InternalServerError().finish())
}
HttpResponse::Ok()
.cookie(refresh_token_cookie(refresh_token))
.json(Response { access_token })
}
Err(error) => {
let err_msg = error.as_database_error().unwrap().message();
match err_msg {
err_msg
if err_msg.contains("unique") && err_msg.contains("username_key") =>
{
HttpResponse::Forbidden().json(ResponseError {
gorb_id_available: false,
..Default::default()
})
}
err_msg if err_msg.contains("unique") && err_msg.contains("email_key") => {
HttpResponse::Forbidden().json(ResponseError {
email_available: false,
..Default::default()
})
}
_ => {
error!("{}", err_msg);
HttpResponse::InternalServerError().finish()
}
}
}
},
);
return Ok(HttpResponse::Ok()
.cookie(new_refresh_token_cookie(&data.config, refresh_token))
.json(Response { access_token }));
}
Ok(HttpResponse::InternalServerError().finish())

View file

@ -0,0 +1,83 @@
//! `/api/v1/auth/reset-password` Endpoints for resetting user password
use actix_web::{HttpResponse, get, post, web};
use chrono::{Duration, Utc};
use serde::Deserialize;
use crate::{Data, error::Error, objects::PasswordResetToken};
#[derive(Deserialize)]
struct Query {
identifier: String,
}
/// `GET /api/v1/auth/reset-password` Sends password reset email to user
///
/// requires auth? no
///
/// ### Query Parameters
/// identifier: Email or username
///
/// ### Responses
/// 200 Email sent
/// 429 Too Many Requests
/// 404 Not found
/// 400 Bad request
///
#[get("/reset-password")]
pub async fn get(query: web::Query<Query>, data: web::Data<Data>) -> Result<HttpResponse, Error> {
if let Ok(password_reset_token) =
PasswordResetToken::get_with_identifier(&data, query.identifier.clone()).await
{
if Utc::now().signed_duration_since(password_reset_token.created_at) > Duration::hours(1) {
password_reset_token.delete(&data).await?;
} else {
return Err(Error::TooManyRequests(
"Please allow 1 hour before sending a new email".to_string(),
));
}
}
PasswordResetToken::new(&data, query.identifier.clone()).await?;
Ok(HttpResponse::Ok().finish())
}
#[derive(Deserialize)]
struct ResetPassword {
password: String,
token: String,
}
/// `POST /api/v1/auth/reset-password` Resets user password
///
/// requires auth? no
///
/// ### Request Example:
/// ```
/// json!({
/// "password": "1608c17a27f6ae3891c23d680c73ae91528f20a54dcf4973e2c3126b9734f48b7253047f2395b51bb8a44a6daa188003",
/// "token": "a3f7e29c1b8d0456e2c9f83b7a1d6e4f5028c3b9a7e1f2d5c6b8a0d3e7f4a2b"
/// });
/// ```
///
/// ### Responses
/// 200 Success
/// 410 Token Expired
/// 404 Not Found
/// 400 Bad Request
///
#[post("/reset-password")]
pub async fn post(
reset_password: web::Json<ResetPassword>,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
let password_reset_token =
PasswordResetToken::get(&data, reset_password.token.clone()).await?;
password_reset_token
.set_password(&data, reset_password.password.clone())
.await?;
Ok(HttpResponse::Ok().finish())
}

View file

@ -1,10 +1,17 @@
use actix_web::{Error, HttpRequest, HttpResponse, post, web};
use actix_web::{HttpRequest, HttpResponse, post, web};
use argon2::{PasswordHash, PasswordVerifier};
use futures::future;
use log::error;
use serde::{Deserialize, Serialize};
use diesel::{ExpressionMethods, QueryDsl, delete};
use diesel_async::RunQueryDsl;
use serde::Deserialize;
use crate::{Data, api::v1::auth::check_access_token, utils::get_auth_header};
use crate::{
Data,
api::v1::auth::check_access_token,
error::Error,
schema::refresh_tokens::{self, dsl as rdsl},
schema::users::dsl as udsl,
utils::get_auth_header,
};
#[derive(Deserialize)]
struct RevokeRequest {
@ -12,17 +19,6 @@ struct RevokeRequest {
device_name: String,
}
#[derive(Serialize)]
struct Response {
deleted: bool,
}
impl Response {
fn new(deleted: bool) -> Self {
Self { deleted }
}
}
// TODO: Should maybe be a delete request?
#[post("/revoke")]
pub async fn res(
@ -32,85 +28,36 @@ pub async fn res(
) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers);
let auth_header = get_auth_header(headers)?;
if let Err(error) = auth_header {
return Ok(error);
}
let mut conn = data.pool.get().await?;
let authorized = check_access_token(auth_header.unwrap(), &data.pool).await;
let uuid = check_access_token(auth_header, &mut conn).await?;
if let Err(error) = authorized {
return Ok(error);
}
let database_password: String = udsl::users
.filter(udsl::uuid.eq(uuid))
.select(udsl::password)
.get_result(&mut conn)
.await?;
let uuid = authorized.unwrap();
let database_password_raw = sqlx::query_scalar(&format!(
"SELECT password FROM users WHERE uuid = '{}'",
uuid
))
.fetch_one(&data.pool)
.await;
if let Err(error) = database_password_raw {
error!("{}", error);
return Ok(HttpResponse::InternalServerError().json(Response::new(false)));
}
let database_password: String = database_password_raw.unwrap();
let hashed_password_raw = PasswordHash::new(&database_password);
if let Err(error) = hashed_password_raw {
error!("{}", error);
return Ok(HttpResponse::InternalServerError().json(Response::new(false)));
}
let hashed_password = hashed_password_raw.unwrap();
let hashed_password = PasswordHash::new(&database_password)
.map_err(|e| Error::PasswordHashError(e.to_string()))?;
if data
.argon2
.verify_password(revoke_request.password.as_bytes(), &hashed_password)
.is_err()
{
return Ok(HttpResponse::Unauthorized().finish());
return Err(Error::Unauthorized(
"Wrong username or password".to_string(),
));
}
let tokens_raw = sqlx::query_scalar(&format!(
"SELECT token FROM refresh_tokens WHERE uuid = '{}' AND device_name = $1",
uuid
))
.bind(&revoke_request.device_name)
.fetch_all(&data.pool)
.await;
delete(refresh_tokens::table)
.filter(rdsl::uuid.eq(uuid))
.filter(rdsl::device_name.eq(&revoke_request.device_name))
.execute(&mut conn)
.await?;
if tokens_raw.is_err() {
error!("{:?}", tokens_raw);
return Ok(HttpResponse::InternalServerError().json(Response::new(false)));
}
let tokens: Vec<String> = tokens_raw.unwrap();
let mut refresh_tokens_delete = vec![];
for token in tokens {
refresh_tokens_delete.push(
sqlx::query("DELETE FROM refresh_tokens WHERE token = $1")
.bind(token.clone())
.execute(&data.pool),
);
}
let results = future::join_all(refresh_tokens_delete).await;
let errors: Vec<&Result<sqlx::postgres::PgQueryResult, sqlx::Error>> =
results.iter().filter(|r| r.is_err()).collect();
if !errors.is_empty() {
error!("{:?}", errors);
return Ok(HttpResponse::InternalServerError().finish());
}
Ok(HttpResponse::Ok().json(Response::new(true)))
Ok(HttpResponse::Ok().finish())
}

View file

@ -0,0 +1,101 @@
//! `/api/v1/auth/verify-email` Endpoints for verifying user emails
use actix_web::{HttpRequest, HttpResponse, get, post, web};
use chrono::{Duration, Utc};
use serde::Deserialize;
use crate::{
Data,
api::v1::auth::check_access_token,
error::Error,
objects::{EmailToken, Me},
utils::get_auth_header,
};
#[derive(Deserialize)]
struct Query {
token: String,
}
/// `GET /api/v1/auth/verify-email` Verifies user email address
///
/// requires auth? yes
///
/// ### Query Parameters
/// token
///
/// ### Responses
/// 200 Success
/// 410 Token Expired
/// 404 Not Found
/// 401 Unauthorized
///
#[get("/verify-email")]
pub async fn get(
req: HttpRequest,
query: web::Query<Query>,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers)?;
let mut conn = data.pool.get().await?;
let uuid = check_access_token(auth_header, &mut conn).await?;
let me = Me::get(&mut conn, uuid).await?;
let email_token = EmailToken::get(&data, me.uuid).await?;
if query.token != email_token.token {
return Ok(HttpResponse::Unauthorized().finish());
}
me.verify_email(&mut conn).await?;
email_token.delete(&data).await?;
Ok(HttpResponse::Ok().finish())
}
/// `POST /api/v1/auth/verify-email` Sends user verification email
///
/// requires auth? yes
///
/// ### Responses
/// 200 Email sent
/// 204 Already verified
/// 429 Too Many Requests
/// 401 Unauthorized
///
#[post("/verify-email")]
pub async fn post(req: HttpRequest, data: web::Data<Data>) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers)?;
let mut conn = data.pool.get().await?;
let uuid = check_access_token(auth_header, &mut conn).await?;
let me = Me::get(&mut conn, uuid).await?;
if me.email_verified {
return Ok(HttpResponse::NoContent().finish());
}
if let Ok(email_token) = EmailToken::get(&data, me.uuid).await {
if Utc::now().signed_duration_since(email_token.created_at) > Duration::hours(1) {
email_token.delete(&data).await?;
} else {
return Err(Error::TooManyRequests(
"Please allow 1 hour before sending a new email".to_string(),
));
}
}
EmailToken::new(&data, me).await?;
Ok(HttpResponse::Ok().finish())
}

View file

@ -0,0 +1,12 @@
use actix_web::{Scope, web};
mod uuid;
pub fn web() -> Scope {
web::scope("/channels")
.service(uuid::get)
.service(uuid::delete)
.service(uuid::patch)
.service(uuid::messages::get)
.service(uuid::socket::ws)
}

View file

@ -0,0 +1,78 @@
//! `/api/v1/channels/{uuid}/messages` Endpoints related to channel messages
use crate::{
Data,
api::v1::auth::check_access_token,
error::Error,
objects::{Channel, Member},
utils::{get_auth_header, global_checks},
};
use ::uuid::Uuid;
use actix_web::{HttpRequest, HttpResponse, get, web};
use serde::Deserialize;
#[derive(Deserialize)]
struct MessageRequest {
amount: i64,
offset: i64,
}
/// `GET /api/v1/channels/{uuid}/messages` Returns user with the given UUID
///
/// requires auth: yes
///
/// requires relation: yes
///
/// ### Request Example
/// ```
/// json!({
/// "amount": 100,
/// "offset": 0
/// })
/// ```
///
/// ### Response Example
/// ```
/// json!({
/// "uuid": "01971976-8618-74c0-b040-7ffbc44823f6",
/// "channel_uuid": "0196fcb1-e886-7de3-b685-0ee46def9a7b",
/// "user_uuid": "0196fc96-a822-76b0-b9bf-a9de232f54b7",
/// "message": "test",
/// "user": {
/// "uuid": "0196fc96-a822-76b0-b9bf-a9de232f54b7",
/// "username": "1234",
/// "display_name": null,
/// "avatar": "https://cdn.gorb.app/avatar/0196fc96-a822-76b0-b9bf-a9de232f54b7/avatar.jpg"
/// }
/// });
/// ```
///
#[get("/{uuid}/messages")]
pub async fn get(
req: HttpRequest,
path: web::Path<(Uuid,)>,
message_request: web::Query<MessageRequest>,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers)?;
let channel_uuid = path.into_inner().0;
let mut conn = data.pool.get().await?;
let uuid = check_access_token(auth_header, &mut conn).await?;
global_checks(&data, uuid).await?;
let channel = Channel::fetch_one(&data, channel_uuid).await?;
Member::check_membership(&mut conn, uuid, channel.guild_uuid).await?;
let messages = channel
.fetch_messages(&data, message_request.amount, message_request.offset)
.await?;
Ok(HttpResponse::Ok().json(messages))
}

View file

@ -0,0 +1,147 @@
//! `/api/v1/channels/{uuid}` Channel specific endpoints
pub mod messages;
pub mod socket;
use crate::{
api::v1::auth::check_access_token, error::Error, objects::{Channel, Member, Permissions}, utils::{get_auth_header, global_checks}, Data
};
use actix_web::{HttpRequest, HttpResponse, delete, get, patch, web};
use serde::Deserialize;
use uuid::Uuid;
#[get("/{uuid}")]
pub async fn get(
req: HttpRequest,
path: web::Path<(Uuid,)>,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers)?;
let channel_uuid = path.into_inner().0;
let mut conn = data.pool.get().await?;
let uuid = check_access_token(auth_header, &mut conn).await?;
global_checks(&data, uuid).await?;
let channel = Channel::fetch_one(&data, channel_uuid).await?;
Member::check_membership(&mut conn, uuid, channel.guild_uuid).await?;
Ok(HttpResponse::Ok().json(channel))
}
#[delete("/{uuid}")]
pub async fn delete(
req: HttpRequest,
path: web::Path<(Uuid,)>,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers)?;
let channel_uuid = path.into_inner().0;
let mut conn = data.pool.get().await?;
let uuid = check_access_token(auth_header, &mut conn).await?;
global_checks(&data, uuid).await?;
let channel = Channel::fetch_one(&data, channel_uuid).await?;
let member = Member::check_membership(&mut conn, uuid, channel.guild_uuid).await?;
member.check_permission(&data, Permissions::DeleteChannel).await?;
channel.delete(&data).await?;
Ok(HttpResponse::Ok().finish())
}
#[derive(Deserialize)]
struct NewInfo {
name: Option<String>,
description: Option<String>,
is_above: Option<String>,
}
/// `PATCH /api/v1/channels/{uuid}` Returns user with the given UUID
///
/// requires auth: yes
///
/// requires relation: yes
///
/// ### Request Example
/// All fields are optional and can be nulled/dropped if only changing 1 value
/// ```
/// json!({
/// "name": "gaming-chat",
/// "description": "Gaming related topics.",
/// "is_above": "398f6d7b-752c-4348-9771-fe6024adbfb1"
/// });
/// ```
///
/// ### Response Example
/// ```
/// json!({
/// uuid: "cdcac171-5add-4f88-9559-3a247c8bba2c",
/// guild_uuid: "383d2afa-082f-4dd3-9050-ca6ed91487b6",
/// name: "gaming-chat",
/// description: "Gaming related topics.",
/// is_above: "398f6d7b-752c-4348-9771-fe6024adbfb1",
/// permissions: {
/// role_uuid: "79cc0806-0f37-4a06-a468-6639c4311a2d",
/// permissions: 0
/// }
/// });
/// ```
/// NOTE: UUIDs in this response are made using `uuidgen`, UUIDs made by the actual backend will be UUIDv7 and have extractable timestamps
#[patch("/{uuid}")]
pub async fn patch(
req: HttpRequest,
path: web::Path<(Uuid,)>,
new_info: web::Json<NewInfo>,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers)?;
let channel_uuid = path.into_inner().0;
let mut conn = data.pool.get().await?;
let uuid = check_access_token(auth_header, &mut conn).await?;
global_checks(&data, uuid).await?;
let mut channel = Channel::fetch_one(&data, channel_uuid).await?;
let member = Member::check_membership(&mut conn, uuid, channel.guild_uuid).await?;
member.check_permission(&data, Permissions::ManageChannel).await?;
if let Some(new_name) = &new_info.name {
channel.set_name(&data, new_name.to_string()).await?;
}
if let Some(new_description) = &new_info.description {
channel
.set_description(&data, new_description.to_string())
.await?;
}
if let Some(new_is_above) = &new_info.is_above {
channel
.set_description(&data, new_is_above.to_string())
.await?;
}
Ok(HttpResponse::Ok().json(channel))
}

View file

@ -0,0 +1,111 @@
use actix_web::{
Error, HttpRequest, HttpResponse, get,
http::header::{HeaderValue, SEC_WEBSOCKET_PROTOCOL},
rt, web,
};
use actix_ws::AggregatedMessage;
use futures_util::StreamExt as _;
use uuid::Uuid;
use crate::{
Data,
api::v1::auth::check_access_token,
objects::{Channel, Member},
utils::{get_ws_protocol_header, global_checks},
};
#[get("/{uuid}/socket")]
pub async fn ws(
req: HttpRequest,
path: web::Path<(Uuid,)>,
stream: web::Payload,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
// Get all headers
let headers = req.headers();
// Retrieve auth header
let auth_header = get_ws_protocol_header(headers)?;
// Get uuid from path
let channel_uuid = path.into_inner().0;
let mut conn = data.pool.get().await.map_err(crate::error::Error::from)?;
// Authorize client using auth header
let uuid = check_access_token(auth_header, &mut conn).await?;
global_checks(&data, uuid).await?;
let channel = Channel::fetch_one(&data, channel_uuid).await?;
Member::check_membership(&mut conn, uuid, channel.guild_uuid).await?;
let (mut res, mut session_1, stream) = actix_ws::handle(&req, stream)?;
let mut stream = stream
.aggregate_continuations()
// aggregate continuation frames up to 1MiB
.max_continuation_size(2_usize.pow(20));
let mut pubsub = data
.cache_pool
.get_async_pubsub()
.await
.map_err(crate::error::Error::from)?;
let mut session_2 = session_1.clone();
rt::spawn(async move {
pubsub.subscribe(channel_uuid.to_string()).await?;
while let Some(msg) = pubsub.on_message().next().await {
let payload: String = msg.get_payload()?;
session_1.text(payload).await?;
}
Ok::<(), crate::error::Error>(())
});
// start task but don't wait for it
rt::spawn(async move {
// receive messages from websocket
while let Some(msg) = stream.next().await {
match msg {
Ok(AggregatedMessage::Text(text)) => {
let mut conn = data.cache_pool.get_multiplexed_tokio_connection().await?;
let message = channel.new_message(&data, uuid, text.to_string()).await?;
redis::cmd("PUBLISH")
.arg(&[channel_uuid.to_string(), serde_json::to_string(&message)?])
.exec_async(&mut conn)
.await?;
}
Ok(AggregatedMessage::Binary(bin)) => {
// echo binary message
session_2.binary(bin).await?;
}
Ok(AggregatedMessage::Ping(msg)) => {
// respond to PING frame with PONG frame
session_2.pong(&msg).await?;
}
_ => {}
}
}
Ok::<(), crate::error::Error>(())
});
let headers = res.headers_mut();
headers.append(
SEC_WEBSOCKET_PROTOCOL,
HeaderValue::from_str("Authorization")?,
);
// respond immediately with response connected to WS session
Ok(res)
}

139
src/api/v1/guilds/mod.rs Normal file
View file

@ -0,0 +1,139 @@
//! `/api/v1/guilds` Guild related endpoints
use actix_web::{HttpRequest, HttpResponse, Scope, get, post, web};
use serde::Deserialize;
mod uuid;
use crate::{
Data,
api::v1::auth::check_access_token,
error::Error,
objects::{Guild, StartAmountQuery},
utils::{get_auth_header, global_checks},
};
#[derive(Deserialize)]
struct GuildInfo {
name: String,
}
pub fn web() -> Scope {
web::scope("/guilds")
.service(post)
.service(get)
.service(uuid::web())
}
/// `POST /api/v1/guilds` Creates a new guild
///
/// requires auth: yes
///
/// ### Request Example
/// ```
/// json!({
/// "name": "My new server!"
/// });
/// ```
///
/// ### Response Example
/// ```
/// json!({
/// "uuid": "383d2afa-082f-4dd3-9050-ca6ed91487b6",
/// "name": "My new server!",
/// "description": null,
/// "icon": null,
/// "owner_uuid": "155d2291-fb23-46bd-a656-ae7c5d8218e6",
/// "roles": [],
/// "member_count": 1
/// });
/// ```
/// NOTE: UUIDs in this response are made using `uuidgen`, UUIDs made by the actual backend will be UUIDv7 and have extractable timestamps
#[post("")]
pub async fn post(
req: HttpRequest,
guild_info: web::Json<GuildInfo>,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers)?;
let mut conn = data.pool.get().await?;
let uuid = check_access_token(auth_header, &mut conn).await?;
let guild = Guild::new(&mut conn, guild_info.name.clone(), uuid).await?;
Ok(HttpResponse::Ok().json(guild))
}
/// `GET /api/v1/servers` Fetches all guilds
///
/// requires auth: yes
///
/// requires admin: yes
///
/// ### Response Example
/// ```
/// json!([
/// {
/// "uuid": "383d2afa-082f-4dd3-9050-ca6ed91487b6",
/// "name": "My new server!",
/// "description": null,
/// "icon": null,
/// "owner_uuid": "155d2291-fb23-46bd-a656-ae7c5d8218e6",
/// "roles": [],
/// "member_count": 1
/// },
/// {
/// "uuid": "5ba61ec7-5f97-43e1-89a5-d4693c155612",
/// "name": "My first server!",
/// "description": "This is a cool and nullable description!",
/// "icon": "https://nullable-url/path/to/icon.png",
/// "owner_uuid": "155d2291-fb23-46bd-a656-ae7c5d8218e6",
/// "roles": [
/// {
/// "uuid": "be0e4da4-cf73-4f45-98f8-bb1c73d1ab8b",
/// "guild_uuid": "5ba61ec7-5f97-43e1-89a5-d4693c155612",
/// "name": "Cool people",
/// "color": 15650773,
/// "is_above": c7432f1c-f4ad-4ad3-8216-51388b6abb5b,
/// "permissions": 0
/// }
/// {
/// "uuid": "c7432f1c-f4ad-4ad3-8216-51388b6abb5b",
/// "guild_uuid": "5ba61ec7-5f97-43e1-89a5-d4693c155612",
/// "name": "Equally cool people",
/// "color": 16777215,
/// "is_above": null,
/// "permissions": 0
/// }
/// ],
/// "member_count": 20
/// }
/// ]);
/// ```
/// NOTE: UUIDs in this response are made using `uuidgen`, UUIDs made by the actual backend will be UUIDv7 and have extractable timestamps
#[get("")]
pub async fn get(
req: HttpRequest,
request_query: web::Query<StartAmountQuery>,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers)?;
let start = request_query.start.unwrap_or(0);
let amount = request_query.amount.unwrap_or(10);
let uuid = check_access_token(auth_header, &mut data.pool.get().await?).await?;
global_checks(&data, uuid).await?;
let guilds = Guild::fetch_amount(&data.pool, start, amount).await?;
Ok(HttpResponse::Ok().json(guilds))
}

View file

@ -0,0 +1,86 @@
use crate::{
api::v1::auth::check_access_token, error::Error, objects::{Channel, Member, Permissions}, utils::{get_auth_header, global_checks, order_by_is_above}, Data
};
use ::uuid::Uuid;
use actix_web::{HttpRequest, HttpResponse, get, post, web};
use serde::Deserialize;
#[derive(Deserialize)]
struct ChannelInfo {
name: String,
description: Option<String>,
}
#[get("{uuid}/channels")]
pub async fn get(
req: HttpRequest,
path: web::Path<(Uuid,)>,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers)?;
let guild_uuid = path.into_inner().0;
let mut conn = data.pool.get().await?;
let uuid = check_access_token(auth_header, &mut conn).await?;
global_checks(&data, uuid).await?;
Member::check_membership(&mut conn, uuid, guild_uuid).await?;
if let Ok(cache_hit) = data.get_cache_key(format!("{}_channels", guild_uuid)).await {
return Ok(HttpResponse::Ok()
.content_type("application/json")
.body(cache_hit));
}
let channels = Channel::fetch_all(&data.pool, guild_uuid).await?;
let channels_ordered = order_by_is_above(channels).await?;
data.set_cache_key(
format!("{}_channels", guild_uuid),
channels_ordered.clone(),
1800,
)
.await?;
Ok(HttpResponse::Ok().json(channels_ordered))
}
#[post("{uuid}/channels")]
pub async fn create(
req: HttpRequest,
channel_info: web::Json<ChannelInfo>,
path: web::Path<(Uuid,)>,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers)?;
let guild_uuid = path.into_inner().0;
let mut conn = data.pool.get().await?;
let uuid = check_access_token(auth_header, &mut conn).await?;
global_checks(&data, uuid).await?;
let member = Member::check_membership(&mut conn, uuid, guild_uuid).await?;
member.check_permission(&data, Permissions::CreateChannel).await?;
let channel = Channel::new(
data.clone(),
guild_uuid,
channel_info.name.clone(),
channel_info.description.clone(),
)
.await?;
Ok(HttpResponse::Ok().json(channel))
}

View file

@ -0,0 +1,56 @@
//! `/api/v1/guilds/{uuid}/icon` icon related endpoints, will probably be replaced by a multipart post to above endpoint
use actix_web::{HttpRequest, HttpResponse, put, web};
use futures_util::StreamExt as _;
use uuid::Uuid;
use crate::{
api::v1::auth::check_access_token, error::Error, objects::{Guild, Member, Permissions}, utils::{get_auth_header, global_checks}, Data
};
/// `PUT /api/v1/guilds/{uuid}/icon` Icon upload
///
/// requires auth: no
///
/// put request expects a file and nothing else
#[put("{uuid}/icon")]
pub async fn upload(
req: HttpRequest,
path: web::Path<(Uuid,)>,
mut payload: web::Payload,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers)?;
let guild_uuid = path.into_inner().0;
let mut conn = data.pool.get().await?;
let uuid = check_access_token(auth_header, &mut conn).await?;
global_checks(&data, uuid).await?;
let member = Member::check_membership(&mut conn, uuid, guild_uuid).await?;
member.check_permission(&data, Permissions::ManageServer).await?;
let mut guild = Guild::fetch_one(&mut conn, guild_uuid).await?;
let mut bytes = web::BytesMut::new();
while let Some(item) = payload.next().await {
bytes.extend_from_slice(&item?);
}
guild
.set_icon(
&data.bunny_storage,
&mut conn,
data.config.bunny.cdn_url.clone(),
bytes,
)
.await?;
Ok(HttpResponse::Ok().finish())
}

View file

View file

@ -0,0 +1,69 @@
use actix_web::{HttpRequest, HttpResponse, get, post, web};
use serde::Deserialize;
use uuid::Uuid;
use crate::{
api::v1::auth::check_access_token, error::Error, objects::{Guild, Member, Permissions}, utils::{get_auth_header, global_checks}, Data
};
#[derive(Deserialize)]
struct InviteRequest {
custom_id: Option<String>,
}
#[get("{uuid}/invites")]
pub async fn get(
req: HttpRequest,
path: web::Path<(Uuid,)>,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers)?;
let guild_uuid = path.into_inner().0;
let mut conn = data.pool.get().await?;
let uuid = check_access_token(auth_header, &mut conn).await?;
global_checks(&data, uuid).await?;
Member::check_membership(&mut conn, uuid, guild_uuid).await?;
let guild = Guild::fetch_one(&mut conn, guild_uuid).await?;
let invites = guild.get_invites(&mut conn).await?;
Ok(HttpResponse::Ok().json(invites))
}
#[post("{uuid}/invites")]
pub async fn create(
req: HttpRequest,
path: web::Path<(Uuid,)>,
invite_request: web::Json<InviteRequest>,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers)?;
let guild_uuid = path.into_inner().0;
let mut conn = data.pool.get().await?;
let uuid = check_access_token(auth_header, &mut conn).await?;
global_checks(&data, uuid).await?;
let member = Member::check_membership(&mut conn, uuid, guild_uuid).await?;
member.check_permission(&data, Permissions::CreateInvite).await?;
let guild = Guild::fetch_one(&mut conn, guild_uuid).await?;
let invite = guild.create_invite(&mut conn, uuid, invite_request.custom_id.clone()).await?;
Ok(HttpResponse::Ok().json(invite))
}

View file

@ -0,0 +1,34 @@
use crate::{
Data,
api::v1::auth::check_access_token,
error::Error,
objects::Member,
utils::{get_auth_header, global_checks},
};
use ::uuid::Uuid;
use actix_web::{HttpRequest, HttpResponse, get, web};
#[get("{uuid}/members")]
pub async fn get(
req: HttpRequest,
path: web::Path<(Uuid,)>,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers)?;
let guild_uuid = path.into_inner().0;
let mut conn = data.pool.get().await?;
let uuid = check_access_token(auth_header, &mut conn).await?;
global_checks(&data, uuid).await?;
Member::check_membership(&mut conn, uuid, guild_uuid).await?;
let members = Member::fetch_all(&data, guild_uuid).await?;
Ok(HttpResponse::Ok().json(members))
}

View file

@ -0,0 +1,96 @@
//! `/api/v1/guilds/{uuid}` Specific server endpoints
use actix_web::{HttpRequest, HttpResponse, Scope, get, web};
use uuid::Uuid;
mod channels;
mod icon;
mod invites;
mod members;
mod roles;
use crate::{
Data,
api::v1::auth::check_access_token,
error::Error,
objects::{Guild, Member},
utils::{get_auth_header, global_checks},
};
pub fn web() -> Scope {
web::scope("")
// Servers
.service(get)
// Channels
.service(channels::get)
.service(channels::create)
// Roles
.service(roles::get)
.service(roles::create)
.service(roles::uuid::get)
// Invites
.service(invites::get)
.service(invites::create)
// Icon
.service(icon::upload)
// Members
.service(members::get)
}
/// `GET /api/v1/guilds/{uuid}` DESCRIPTION
///
/// requires auth: yes
///
/// ### Response Example
/// ```
/// json!({
/// "uuid": "5ba61ec7-5f97-43e1-89a5-d4693c155612",
/// "name": "My first server!",
/// "description": "This is a cool and nullable description!",
/// "icon": "https://nullable-url/path/to/icon.png",
/// "owner_uuid": "155d2291-fb23-46bd-a656-ae7c5d8218e6",
/// "roles": [
/// {
/// "uuid": "be0e4da4-cf73-4f45-98f8-bb1c73d1ab8b",
/// "guild_uuid": "5ba61ec7-5f97-43e1-89a5-d4693c155612",
/// "name": "Cool people",
/// "color": 15650773,
/// "is_above": c7432f1c-f4ad-4ad3-8216-51388b6abb5b,
/// "permissions": 0
/// }
/// {
/// "uuid": "c7432f1c-f4ad-4ad3-8216-51388b6abb5b",
/// "guild_uuid": "5ba61ec7-5f97-43e1-89a5-d4693c155612",
/// "name": "Equally cool people",
/// "color": 16777215,
/// "is_above": null,
/// "permissions": 0
/// }
/// ],
/// "member_count": 20
/// });
/// ```
#[get("/{uuid}")]
pub async fn get(
req: HttpRequest,
path: web::Path<(Uuid,)>,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers)?;
let guild_uuid = path.into_inner().0;
let mut conn = data.pool.get().await?;
let uuid = check_access_token(auth_header, &mut conn).await?;
global_checks(&data, uuid).await?;
Member::check_membership(&mut conn, uuid, guild_uuid).await?;
let guild = Guild::fetch_one(&mut conn, guild_uuid).await?;
Ok(HttpResponse::Ok().json(guild))
}

View file

@ -0,0 +1,76 @@
use ::uuid::Uuid;
use actix_web::{HttpRequest, HttpResponse, get, post, web};
use serde::Deserialize;
use crate::{
api::v1::auth::check_access_token, error::Error, objects::{Member, Permissions, Role}, utils::{get_auth_header, global_checks, order_by_is_above}, Data
};
pub mod uuid;
#[derive(Deserialize)]
struct RoleInfo {
name: String,
}
#[get("{uuid}/roles")]
pub async fn get(
req: HttpRequest,
path: web::Path<(Uuid,)>,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers)?;
let guild_uuid = path.into_inner().0;
let mut conn = data.pool.get().await?;
let uuid = check_access_token(auth_header, &mut conn).await?;
Member::check_membership(&mut conn, uuid, guild_uuid).await?;
if let Ok(cache_hit) = data.get_cache_key(format!("{}_roles", guild_uuid)).await {
return Ok(HttpResponse::Ok()
.content_type("application/json")
.body(cache_hit));
}
let roles = Role::fetch_all(&mut conn, guild_uuid).await?;
let roles_ordered = order_by_is_above(roles).await?;
data.set_cache_key(format!("{}_roles", guild_uuid), roles_ordered.clone(), 1800)
.await?;
Ok(HttpResponse::Ok().json(roles_ordered))
}
#[post("{uuid}/roles")]
pub async fn create(
req: HttpRequest,
role_info: web::Json<RoleInfo>,
path: web::Path<(Uuid,)>,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers)?;
let guild_uuid = path.into_inner().0;
let mut conn = data.pool.get().await?;
let uuid = check_access_token(auth_header, &mut conn).await?;
global_checks(&data, uuid).await?;
let member = Member::check_membership(&mut conn, uuid, guild_uuid).await?;
member.check_permission(&data, Permissions::CreateRole).await?;
let role = Role::new(&mut conn, guild_uuid, role_info.name.clone()).await?;
Ok(HttpResponse::Ok().json(role))
}

View file

@ -0,0 +1,43 @@
use crate::{
Data,
api::v1::auth::check_access_token,
error::Error,
objects::{Member, Role},
utils::{get_auth_header, global_checks},
};
use ::uuid::Uuid;
use actix_web::{HttpRequest, HttpResponse, get, web};
#[get("{uuid}/roles/{role_uuid}")]
pub async fn get(
req: HttpRequest,
path: web::Path<(Uuid, Uuid)>,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers)?;
let (guild_uuid, role_uuid) = path.into_inner();
let mut conn = data.pool.get().await?;
let uuid = check_access_token(auth_header, &mut conn).await?;
global_checks(&data, uuid).await?;
Member::check_membership(&mut conn, uuid, guild_uuid).await?;
if let Ok(cache_hit) = data.get_cache_key(format!("{}", role_uuid)).await {
return Ok(HttpResponse::Ok()
.content_type("application/json")
.body(cache_hit));
}
let role = Role::fetch_one(&mut conn, role_uuid).await?;
data.set_cache_key(format!("{}", role_uuid), role.clone(), 60)
.await?;
Ok(HttpResponse::Ok().json(role))
}

View file

@ -1,43 +1,22 @@
use actix_web::{Error, HttpRequest, HttpResponse, get, post, web};
use actix_web::{HttpRequest, HttpResponse, get, post, web};
use crate::{
Data,
api::v1::auth::check_access_token,
structs::{Guild, Invite, Member},
utils::get_auth_header,
error::Error,
objects::{Guild, Invite, Member},
utils::{get_auth_header, global_checks},
};
#[get("{id}")]
pub async fn get(
req: HttpRequest,
path: web::Path<(String,)>,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers);
if let Err(error) = auth_header {
return Ok(error);
}
pub async fn get(path: web::Path<(String,)>, data: web::Data<Data>) -> Result<HttpResponse, Error> {
let mut conn = data.pool.get().await?;
let invite_id = path.into_inner().0;
let result = Invite::fetch_one(&data.pool, invite_id).await;
let invite = Invite::fetch_one(&mut conn, invite_id).await?;
if let Err(error) = result {
return Ok(error);
}
let invite = result.unwrap();
let guild_result = Guild::fetch_one(&data.pool, invite.guild_uuid).await;
if let Err(error) = guild_result {
return Ok(error);
}
let guild = guild_result.unwrap();
let guild = Guild::fetch_one(&mut conn, invite.guild_uuid).await?;
Ok(HttpResponse::Ok().json(guild))
}
@ -50,43 +29,21 @@ pub async fn join(
) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers);
if let Err(error) = auth_header {
return Ok(error);
}
let auth_header = get_auth_header(headers)?;
let invite_id = path.into_inner().0;
let authorized = check_access_token(auth_header.unwrap(), &data.pool).await;
let mut conn = data.pool.get().await?;
if let Err(error) = authorized {
return Ok(error);
}
let uuid = check_access_token(auth_header, &mut conn).await?;
let uuid = authorized.unwrap();
global_checks(&data, uuid).await?;
let result = Invite::fetch_one(&data.pool, invite_id).await;
let invite = Invite::fetch_one(&mut conn, invite_id).await?;
if let Err(error) = result {
return Ok(error);
}
let guild = Guild::fetch_one(&mut conn, invite.guild_uuid).await?;
let invite = result.unwrap();
let guild_result = Guild::fetch_one(&data.pool, invite.guild_uuid).await;
if let Err(error) = guild_result {
return Ok(error);
}
let guild = guild_result.unwrap();
let member = Member::new(&data.pool, uuid, guild.uuid).await;
if let Err(error) = member {
return Ok(error);
}
Member::new(&data, uuid, guild.uuid).await?;
Ok(HttpResponse::Ok().json(guild))
}

View file

View file

75
src/api/v1/me/guilds.rs Normal file
View file

@ -0,0 +1,75 @@
//! `/api/v1/me/guilds` Contains endpoint related to guild memberships
use actix_web::{HttpRequest, HttpResponse, get, web};
use crate::{
Data,
api::v1::auth::check_access_token,
error::Error,
objects::Me,
utils::{get_auth_header, global_checks},
};
/// `GET /api/v1/me/guilds` Returns all guild memberships in a list
///
/// requires auth: yes
///
/// ### Example Response
/// ```
/// json!([
/// {
/// "uuid": "383d2afa-082f-4dd3-9050-ca6ed91487b6",
/// "name": "My new server!",
/// "description": null,
/// "icon": null,
/// "owner_uuid": "155d2291-fb23-46bd-a656-ae7c5d8218e6",
/// "roles": [],
/// "member_count": 1
/// },
/// {
/// "uuid": "5ba61ec7-5f97-43e1-89a5-d4693c155612",
/// "name": "My first server!",
/// "description": "This is a cool and nullable description!",
/// "icon": "https://nullable-url/path/to/icon.png",
/// "owner_uuid": "155d2291-fb23-46bd-a656-ae7c5d8218e6",
/// "roles": [
/// {
/// "uuid": "be0e4da4-cf73-4f45-98f8-bb1c73d1ab8b",
/// "guild_uuid": "5ba61ec7-5f97-43e1-89a5-d4693c155612",
/// "name": "Cool people",
/// "color": 15650773,
/// "is_above": c7432f1c-f4ad-4ad3-8216-51388b6abb5b,
/// "permissions": 0
/// }
/// {
/// "uuid": "c7432f1c-f4ad-4ad3-8216-51388b6abb5b",
/// "guild_uuid": "5ba61ec7-5f97-43e1-89a5-d4693c155612",
/// "name": "Equally cool people",
/// "color": 16777215,
/// "is_above": null,
/// "permissions": 0
/// }
/// ],
/// "member_count": 20
/// }
/// ]);
/// ```
/// NOTE: UUIDs in this response are made using `uuidgen`, UUIDs made by the actual backend will be UUIDv7 and have extractable timestamps
#[get("/guilds")]
pub async fn get(req: HttpRequest, data: web::Data<Data>) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers)?;
let mut conn = data.pool.get().await?;
let uuid = check_access_token(auth_header, &mut conn).await?;
global_checks(&data, uuid).await?;
let me = Me::get(&mut conn, uuid).await?;
let memberships = me.fetch_memberships(&mut conn).await?;
Ok(HttpResponse::Ok().json(memberships))
}

104
src/api/v1/me/mod.rs Normal file
View file

@ -0,0 +1,104 @@
use actix_multipart::form::{MultipartForm, json::Json as MpJson, tempfile::TempFile};
use actix_web::{HttpRequest, HttpResponse, Scope, get, patch, web};
use serde::Deserialize;
use crate::{
Data,
api::v1::auth::check_access_token,
error::Error,
objects::Me,
utils::{get_auth_header, global_checks},
};
mod guilds;
pub fn web() -> Scope {
web::scope("/me")
.service(get)
.service(update)
.service(guilds::get)
}
#[get("")]
pub async fn get(req: HttpRequest, data: web::Data<Data>) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers)?;
let mut conn = data.pool.get().await?;
let uuid = check_access_token(auth_header, &mut conn).await?;
let me = Me::get(&mut conn, uuid).await?;
Ok(HttpResponse::Ok().json(me))
}
#[derive(Debug, Deserialize, Clone)]
struct NewInfo {
username: Option<String>,
display_name: Option<String>,
//password: Option<String>, will probably be handled through a reset password link
email: Option<String>,
pronouns: Option<String>,
about: Option<String>,
}
#[derive(Debug, MultipartForm)]
struct UploadForm {
#[multipart(limit = "100MB")]
avatar: Option<TempFile>,
json: MpJson<NewInfo>,
}
#[patch("")]
pub async fn update(
req: HttpRequest,
MultipartForm(form): MultipartForm<UploadForm>,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers)?;
let mut conn = data.pool.get().await?;
let uuid = check_access_token(auth_header, &mut conn).await?;
if form.avatar.is_some() || form.json.username.is_some() || form.json.display_name.is_some() {
global_checks(&data, uuid).await?;
}
let mut me = Me::get(&mut conn, uuid).await?;
if let Some(avatar) = form.avatar {
let bytes = tokio::fs::read(avatar.file).await?;
let byte_slice: &[u8] = &bytes;
me.set_avatar(&data, data.config.bunny.cdn_url.clone(), byte_slice.into())
.await?;
}
if let Some(username) = &form.json.username {
me.set_username(&data, username.clone()).await?;
}
if let Some(display_name) = &form.json.display_name {
me.set_display_name(&data, display_name.clone()).await?;
}
if let Some(email) = &form.json.email {
me.set_email(&data, email.clone()).await?;
}
if let Some(pronouns) = &form.json.pronouns {
me.set_pronouns(&data, pronouns.clone()).await?;
}
if let Some(about) = &form.json.about {
me.set_about(&data, about.clone()).await?;
}
Ok(HttpResponse::Ok().finish())
}

View file

@ -1,8 +1,12 @@
//! `/api/v1` Contains version 1 of the api
use actix_web::{Scope, web};
mod auth;
mod channels;
mod guilds;
mod invites;
mod servers;
mod me;
mod stats;
mod users;
@ -11,6 +15,8 @@ pub fn web() -> Scope {
.service(stats::res)
.service(auth::web())
.service(users::web())
.service(servers::web())
.service(channels::web())
.service(guilds::web())
.service(invites::web())
.service(me::web())
}

View file

@ -1,90 +0,0 @@
use actix_web::{get, post, web, Error, HttpRequest, HttpResponse, Scope};
use serde::Deserialize;
mod uuid;
use crate::{api::v1::auth::check_access_token, structs::{Guild, StartAmountQuery}, utils::get_auth_header, Data};
#[derive(Deserialize)]
struct GuildInfo {
name: String,
description: Option<String>,
}
pub fn web() -> Scope {
web::scope("/servers")
.service(create)
.service(get)
.service(uuid::web())
}
#[post("")]
pub async fn create(
req: HttpRequest,
guild_info: web::Json<GuildInfo>,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers);
if let Err(error) = auth_header {
return Ok(error);
}
let authorized = check_access_token(auth_header.unwrap(), &data.pool).await;
if let Err(error) = authorized {
return Ok(error);
}
let uuid = authorized.unwrap();
let guild = Guild::new(
&data.pool,
guild_info.name.clone(),
guild_info.description.clone(),
uuid,
)
.await;
if let Err(error) = guild {
return Ok(error);
}
Ok(HttpResponse::Ok().json(guild.unwrap()))
}
#[get("")]
pub async fn get(
req: HttpRequest,
request_query: web::Query<StartAmountQuery>,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers);
let start = request_query.start.unwrap_or(0);
let amount = request_query.amount.unwrap_or(10);
if let Err(error) = auth_header {
return Ok(error);
}
let authorized = check_access_token(auth_header.unwrap(), &data.pool).await;
if let Err(error) = authorized {
return Ok(error);
}
let guilds = Guild::fetch_amount(&data.pool, start, amount).await;
if let Err(error) = guilds {
return Ok(error);
}
Ok(HttpResponse::Ok().json(guilds.unwrap()))
}

View file

@ -1,124 +0,0 @@
use crate::{
Data,
api::v1::auth::check_access_token,
structs::{Channel, Member},
utils::get_auth_header,
};
use ::uuid::Uuid;
use actix_web::{Error, HttpRequest, HttpResponse, get, post, web};
use log::error;
use serde::Deserialize;
pub mod uuid;
#[derive(Deserialize)]
struct ChannelInfo {
name: String,
description: Option<String>,
}
#[get("{uuid}/channels")]
pub async fn get(
req: HttpRequest,
path: web::Path<(Uuid,)>,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers);
if let Err(error) = auth_header {
return Ok(error);
}
let guild_uuid = path.into_inner().0;
let authorized = check_access_token(auth_header.unwrap(), &data.pool).await;
if let Err(error) = authorized {
return Ok(error);
}
let uuid = authorized.unwrap();
let member = Member::fetch_one(&data.pool, uuid, guild_uuid).await;
if let Err(error) = member {
return Ok(error);
}
let cache_result = data.get_cache_key(format!("{}_channels", guild_uuid)).await;
if let Ok(cache_hit) = cache_result {
return Ok(HttpResponse::Ok()
.content_type("application/json")
.body(cache_hit));
}
let channels_result = Channel::fetch_all(&data.pool, guild_uuid).await;
if let Err(error) = channels_result {
return Ok(error);
}
let channels = channels_result.unwrap();
let cache_result = data
.set_cache_key(format!("{}_channels", guild_uuid), channels.clone(), 1800)
.await;
if let Err(error) = cache_result {
error!("{}", error);
return Ok(HttpResponse::InternalServerError().finish());
}
Ok(HttpResponse::Ok().json(channels))
}
#[post("{uuid}/channels")]
pub async fn create(
req: HttpRequest,
channel_info: web::Json<ChannelInfo>,
path: web::Path<(Uuid,)>,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers);
if let Err(error) = auth_header {
return Ok(error);
}
let guild_uuid = path.into_inner().0;
let authorized = check_access_token(auth_header.unwrap(), &data.pool).await;
if let Err(error) = authorized {
return Ok(error);
}
let uuid = authorized.unwrap();
let member = Member::fetch_one(&data.pool, uuid, guild_uuid).await;
if let Err(error) = member {
return Ok(error);
}
// FIXME: Logic to check permissions, should probably be done in utils.rs
let channel = Channel::new(
data.clone(),
guild_uuid,
channel_info.name.clone(),
channel_info.description.clone(),
)
.await;
if let Err(error) = channel {
return Ok(error);
}
Ok(HttpResponse::Ok().json(channel.unwrap()))
}

View file

@ -1,83 +0,0 @@
use crate::{
Data,
api::v1::auth::check_access_token,
structs::{Channel, Member},
utils::get_auth_header,
};
use ::uuid::Uuid;
use actix_web::{Error, HttpRequest, HttpResponse, get, web};
use log::error;
use serde::Deserialize;
#[derive(Deserialize)]
struct MessageRequest {
amount: i64,
offset: i64,
}
#[get("{uuid}/channels/{channel_uuid}/messages")]
pub async fn get(
req: HttpRequest,
path: web::Path<(Uuid, Uuid)>,
message_request: web::Query<MessageRequest>,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers);
if let Err(error) = auth_header {
return Ok(error);
}
let (guild_uuid, channel_uuid) = path.into_inner();
let authorized = check_access_token(auth_header.unwrap(), &data.pool).await;
if let Err(error) = authorized {
return Ok(error);
}
let uuid = authorized.unwrap();
let member = Member::fetch_one(&data.pool, uuid, guild_uuid).await;
if let Err(error) = member {
return Ok(error);
}
let cache_result = data.get_cache_key(format!("{}", channel_uuid)).await;
let channel: Channel;
if let Ok(cache_hit) = cache_result {
channel = serde_json::from_str(&cache_hit).unwrap()
} else {
let channel_result = Channel::fetch_one(&data.pool, guild_uuid, channel_uuid).await;
if let Err(error) = channel_result {
return Ok(error);
}
channel = channel_result.unwrap();
let cache_result = data
.set_cache_key(format!("{}", channel_uuid), channel.clone(), 60)
.await;
if let Err(error) = cache_result {
error!("{}", error);
return Ok(HttpResponse::InternalServerError().finish());
}
}
let messages = channel
.fetch_messages(&data.pool, message_request.amount, message_request.offset)
.await;
if let Err(error) = messages {
return Ok(error);
}
Ok(HttpResponse::Ok().json(messages.unwrap()))
}

View file

@ -1,131 +0,0 @@
pub mod messages;
pub mod socket;
use crate::{
Data,
api::v1::auth::check_access_token,
structs::{Channel, Member},
utils::get_auth_header,
};
use ::uuid::Uuid;
use actix_web::{Error, HttpRequest, HttpResponse, delete, get, web};
use log::error;
#[get("{uuid}/channels/{channel_uuid}")]
pub async fn get(
req: HttpRequest,
path: web::Path<(Uuid, Uuid)>,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers);
if let Err(error) = auth_header {
return Ok(error);
}
let (guild_uuid, channel_uuid) = path.into_inner();
let authorized = check_access_token(auth_header.unwrap(), &data.pool).await;
if let Err(error) = authorized {
return Ok(error);
}
let uuid = authorized.unwrap();
let member = Member::fetch_one(&data.pool, uuid, guild_uuid).await;
if let Err(error) = member {
return Ok(error);
}
let cache_result = data.get_cache_key(format!("{}", channel_uuid)).await;
if let Ok(cache_hit) = cache_result {
return Ok(HttpResponse::Ok()
.content_type("application/json")
.body(cache_hit));
}
let channel_result = Channel::fetch_one(&data.pool, guild_uuid, channel_uuid).await;
if let Err(error) = channel_result {
return Ok(error);
}
let channel = channel_result.unwrap();
let cache_result = data
.set_cache_key(format!("{}", channel_uuid), channel.clone(), 60)
.await;
if let Err(error) = cache_result {
error!("{}", error);
return Ok(HttpResponse::InternalServerError().finish());
}
Ok(HttpResponse::Ok().json(channel))
}
#[delete("{uuid}/channels/{channel_uuid}")]
pub async fn delete(
req: HttpRequest,
path: web::Path<(Uuid, Uuid)>,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers);
if let Err(error) = auth_header {
return Ok(error);
}
let (guild_uuid, channel_uuid) = path.into_inner();
let authorized = check_access_token(auth_header.unwrap(), &data.pool).await;
if let Err(error) = authorized {
return Ok(error);
}
let uuid = authorized.unwrap();
let member = Member::fetch_one(&data.pool, uuid, guild_uuid).await;
if let Err(error) = member {
return Ok(error);
}
let cache_result = data.get_cache_key(format!("{}", channel_uuid)).await;
let channel: Channel;
if let Ok(cache_hit) = cache_result {
channel = serde_json::from_str(&cache_hit).unwrap();
let result = data.del_cache_key(format!("{}", channel_uuid)).await;
if let Err(error) = result {
error!("{}", error)
}
} else {
let channel_result = Channel::fetch_one(&data.pool, guild_uuid, channel_uuid).await;
if let Err(error) = channel_result {
return Ok(error);
}
channel = channel_result.unwrap();
}
let delete_result = channel.delete(&data.pool).await;
if let Err(error) = delete_result {
return Ok(error);
}
Ok(HttpResponse::Ok().finish())
}

View file

@ -1,143 +0,0 @@
use actix_web::{Error, HttpRequest, HttpResponse, get, rt, web};
use actix_ws::AggregatedMessage;
use futures_util::StreamExt as _;
use log::error;
use uuid::Uuid;
use crate::{
Data,
api::v1::auth::check_access_token,
structs::{Channel, Member},
utils::get_auth_header,
};
#[get("{uuid}/channels/{channel_uuid}/socket")]
pub async fn echo(
req: HttpRequest,
path: web::Path<(Uuid, Uuid)>,
stream: web::Payload,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
// Get all headers
let headers = req.headers();
// Retrieve auth header
let auth_header = get_auth_header(headers);
if let Err(error) = auth_header {
return Ok(error);
}
// Get uuids from path
let (guild_uuid, channel_uuid) = path.into_inner();
// Authorize client using auth header
let authorized = check_access_token(auth_header.unwrap(), &data.pool).await;
if let Err(error) = authorized {
return Ok(error);
}
// Unwrap user uuid from authorization
let uuid = authorized.unwrap();
// Get server member from psql
let member = Member::fetch_one(&data.pool, uuid, guild_uuid).await;
if let Err(error) = member {
return Ok(error);
}
// Get cache for channel
let cache_result = data.get_cache_key(format!("{}", channel_uuid)).await;
let channel: Channel;
// Return channel cache or result from psql as `channel` variable
if let Ok(cache_hit) = cache_result {
channel = serde_json::from_str(&cache_hit).unwrap()
} else {
let channel_result = Channel::fetch_one(&data.pool, guild_uuid, channel_uuid).await;
if let Err(error) = channel_result {
return Ok(error);
}
channel = channel_result.unwrap();
let cache_result = data
.set_cache_key(format!("{}", channel_uuid), channel.clone(), 60)
.await;
if let Err(error) = cache_result {
error!("{}", error);
return Ok(HttpResponse::InternalServerError().finish());
}
}
let (res, mut session_1, stream) = actix_ws::handle(&req, stream)?;
let mut stream = stream
.aggregate_continuations()
// aggregate continuation frames up to 1MiB
.max_continuation_size(2_usize.pow(20));
let pubsub_result = data.cache_pool.get_async_pubsub().await;
if let Err(error) = pubsub_result {
error!("{}", error);
return Ok(HttpResponse::InternalServerError().finish());
}
let mut session_2 = session_1.clone();
rt::spawn(async move {
let mut pubsub = pubsub_result.unwrap();
pubsub.subscribe(channel_uuid.to_string()).await.unwrap();
while let Some(msg) = pubsub.on_message().next().await {
let payload: String = msg.get_payload().unwrap();
session_1.text(payload).await.unwrap();
}
});
// start task but don't wait for it
rt::spawn(async move {
let mut conn = data
.cache_pool
.get_multiplexed_tokio_connection()
.await
.unwrap();
// receive messages from websocket
while let Some(msg) = stream.next().await {
match msg {
Ok(AggregatedMessage::Text(text)) => {
// echo text message
redis::cmd("PUBLISH")
.arg(&[channel_uuid.to_string(), text.to_string()])
.exec_async(&mut conn)
.await
.unwrap();
channel
.new_message(&data.pool, uuid, text.to_string())
.await
.unwrap();
}
Ok(AggregatedMessage::Binary(bin)) => {
// echo binary message
session_2.binary(bin).await.unwrap();
}
Ok(AggregatedMessage::Ping(msg)) => {
// respond to PING frame with PONG frame
session_2.pong(&msg).await.unwrap();
}
_ => {}
}
}
});
// respond immediately with response connected to WS session
Ok(res)
}

View file

@ -1,114 +0,0 @@
use actix_web::{Error, HttpRequest, HttpResponse, get, post, web};
use serde::Deserialize;
use uuid::Uuid;
use crate::{
Data,
api::v1::auth::check_access_token,
structs::{Guild, Member},
utils::get_auth_header,
};
#[derive(Deserialize)]
struct InviteRequest {
custom_id: String,
}
#[get("{uuid}/invites")]
pub async fn get(
req: HttpRequest,
path: web::Path<(Uuid,)>,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers);
if let Err(error) = auth_header {
return Ok(error);
}
let guild_uuid = path.into_inner().0;
let authorized = check_access_token(auth_header.unwrap(), &data.pool).await;
if let Err(error) = authorized {
return Ok(error);
}
let uuid = authorized.unwrap();
let member = Member::fetch_one(&data.pool, uuid, guild_uuid).await;
if let Err(error) = member {
return Ok(error);
}
let guild_result = Guild::fetch_one(&data.pool, guild_uuid).await;
if let Err(error) = guild_result {
return Ok(error);
}
let guild = guild_result.unwrap();
let invites = guild.get_invites(&data.pool).await;
if let Err(error) = invites {
return Ok(error);
}
Ok(HttpResponse::Ok().json(invites.unwrap()))
}
#[post("{uuid}/invites")]
pub async fn create(
req: HttpRequest,
path: web::Path<(Uuid,)>,
invite_request: web::Json<Option<InviteRequest>>,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers);
if let Err(error) = auth_header {
return Ok(error);
}
let guild_uuid = path.into_inner().0;
let authorized = check_access_token(auth_header.unwrap(), &data.pool).await;
if let Err(error) = authorized {
return Ok(error);
}
let uuid = authorized.unwrap();
let member_result = Member::fetch_one(&data.pool, uuid, guild_uuid).await;
if let Err(error) = member_result {
return Ok(error);
}
let member = member_result.unwrap();
let guild_result = Guild::fetch_one(&data.pool, guild_uuid).await;
if let Err(error) = guild_result {
return Ok(error);
}
let guild = guild_result.unwrap();
let custom_id = invite_request.as_ref().map(|ir| ir.custom_id.clone());
let invite = guild.create_invite(&data.pool, &member, custom_id).await;
if let Err(error) = invite {
return Ok(error);
}
Ok(HttpResponse::Ok().json(invite.unwrap()))
}

View file

@ -1,72 +0,0 @@
use actix_web::{Error, HttpRequest, HttpResponse, Scope, get, web};
use uuid::Uuid;
mod channels;
mod invites;
mod roles;
use crate::{
Data,
api::v1::auth::check_access_token,
structs::{Guild, Member},
utils::get_auth_header,
};
pub fn web() -> Scope {
web::scope("")
// Servers
.service(res)
// Channels
.service(channels::get)
.service(channels::create)
.service(channels::uuid::get)
.service(channels::uuid::delete)
.service(channels::uuid::messages::get)
.service(channels::uuid::socket::echo)
// Roles
.service(roles::get)
.service(roles::create)
.service(roles::uuid::get)
// Invites
.service(invites::get)
.service(invites::create)
}
#[get("/{uuid}")]
pub async fn res(
req: HttpRequest,
path: web::Path<(Uuid,)>,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers);
if let Err(error) = auth_header {
return Ok(error);
}
let guild_uuid = path.into_inner().0;
let authorized = check_access_token(auth_header.unwrap(), &data.pool).await;
if let Err(error) = authorized {
return Ok(error);
}
let uuid = authorized.unwrap();
let member = Member::fetch_one(&data.pool, uuid, guild_uuid).await;
if let Err(error) = member {
return Ok(error);
}
let guild = Guild::fetch_one(&data.pool, guild_uuid).await;
if let Err(error) = guild {
return Ok(error);
}
Ok(HttpResponse::Ok().json(guild.unwrap()))
}

View file

@ -1,117 +0,0 @@
use crate::{
Data,
api::v1::auth::check_access_token,
structs::{Member, Role},
utils::get_auth_header,
};
use ::uuid::Uuid;
use actix_web::{Error, HttpRequest, HttpResponse, get, post, web};
use log::error;
use serde::Deserialize;
pub mod uuid;
#[derive(Deserialize)]
struct RoleInfo {
name: String,
}
#[get("{uuid}/roles")]
pub async fn get(
req: HttpRequest,
path: web::Path<(Uuid,)>,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers);
if let Err(error) = auth_header {
return Ok(error);
}
let guild_uuid = path.into_inner().0;
let authorized = check_access_token(auth_header.unwrap(), &data.pool).await;
if let Err(error) = authorized {
return Ok(error);
}
let uuid = authorized.unwrap();
let member = Member::fetch_one(&data.pool, uuid, guild_uuid).await;
if let Err(error) = member {
return Ok(error);
}
let cache_result = data.get_cache_key(format!("{}_roles", guild_uuid)).await;
if let Ok(cache_hit) = cache_result {
return Ok(HttpResponse::Ok()
.content_type("application/json")
.body(cache_hit));
}
let roles_result = Role::fetch_all(&data.pool, guild_uuid).await;
if let Err(error) = roles_result {
return Ok(error);
}
let roles = roles_result.unwrap();
let cache_result = data
.set_cache_key(format!("{}_roles", guild_uuid), roles.clone(), 1800)
.await;
if let Err(error) = cache_result {
error!("{}", error);
return Ok(HttpResponse::InternalServerError().finish());
}
Ok(HttpResponse::Ok().json(roles))
}
#[post("{uuid}/roles")]
pub async fn create(
req: HttpRequest,
role_info: web::Json<RoleInfo>,
path: web::Path<(Uuid,)>,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers);
if let Err(error) = auth_header {
return Ok(error);
}
let guild_uuid = path.into_inner().0;
let authorized = check_access_token(auth_header.unwrap(), &data.pool).await;
if let Err(error) = authorized {
return Ok(error);
}
let uuid = authorized.unwrap();
let member = Member::fetch_one(&data.pool, uuid, guild_uuid).await;
if let Err(error) = member {
return Ok(error);
}
// FIXME: Logic to check permissions, should probably be done in utils.rs
let role = Role::new(&data.pool, guild_uuid, role_info.name.clone()).await;
if let Err(error) = role {
return Ok(error);
}
Ok(HttpResponse::Ok().json(role.unwrap()))
}

View file

@ -1,67 +0,0 @@
use crate::{
Data,
api::v1::auth::check_access_token,
structs::{Member, Role},
utils::get_auth_header,
};
use ::uuid::Uuid;
use actix_web::{Error, HttpRequest, HttpResponse, get, web};
use log::error;
#[get("{uuid}/roles/{role_uuid}")]
pub async fn get(
req: HttpRequest,
path: web::Path<(Uuid, Uuid)>,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers);
if let Err(error) = auth_header {
return Ok(error);
}
let (guild_uuid, role_uuid) = path.into_inner();
let authorized = check_access_token(auth_header.unwrap(), &data.pool).await;
if let Err(error) = authorized {
return Ok(error);
}
let uuid = authorized.unwrap();
let member = Member::fetch_one(&data.pool, uuid, guild_uuid).await;
if let Err(error) = member {
return Ok(error);
}
let cache_result = data.get_cache_key(format!("{}", role_uuid)).await;
if let Ok(cache_hit) = cache_result {
return Ok(HttpResponse::Ok()
.content_type("application/json")
.body(cache_hit));
}
let role_result = Role::fetch_one(&data.pool, guild_uuid, role_uuid).await;
if let Err(error) = role_result {
return Ok(error);
}
let role = role_result.unwrap();
let cache_result = data
.set_cache_key(format!("{}", role_uuid), role.clone(), 60)
.await;
if let Err(error) = cache_result {
error!("{}", error);
return Ok(HttpResponse::InternalServerError().finish());
}
Ok(HttpResponse::Ok().json(role))
}

View file

@ -1,31 +1,51 @@
//! `/api/v1/stats` Returns stats about the server
use std::time::SystemTime;
use actix_web::{HttpResponse, Responder, get, web};
use actix_web::{HttpResponse, get, web};
use diesel::QueryDsl;
use diesel_async::RunQueryDsl;
use serde::Serialize;
use crate::Data;
use crate::error::Error;
use crate::schema::users::dsl::{users, uuid};
const VERSION: Option<&'static str> = option_env!("CARGO_PKG_VERSION");
const GIT_SHORT_HASH: &str = env!("GIT_SHORT_HASH");
#[derive(Serialize)]
struct Response {
accounts: usize,
accounts: i64,
uptime: u64,
version: String,
registration_enabled: bool,
email_verification_required: bool,
build_number: String,
}
/// `GET /api/v1/stats` Returns stats about the server
///
/// requires auth: no
///
/// ### Response Example
/// ```
/// json!({
/// "accounts": 3,
/// "uptime": 50000,
/// "version": "0.1.0",
/// "registration_enabled": true,
/// "email_verification_required": true,
/// "build_number": "39d01bb"
/// });
/// ```
#[get("/stats")]
pub async fn res(data: web::Data<Data>) -> impl Responder {
let accounts;
if let Ok(users) = sqlx::query("SELECT uuid FROM users")
.fetch_all(&data.pool)
.await
{
accounts = users.len();
} else {
return HttpResponse::InternalServerError().finish();
}
pub async fn res(data: web::Data<Data>) -> Result<HttpResponse, Error> {
let accounts: i64 = users
.select(uuid)
.count()
.get_result(&mut data.pool.get().await?)
.await?;
let response = Response {
// TODO: Get number of accounts from db
@ -35,9 +55,11 @@ pub async fn res(data: web::Data<Data>) -> impl Responder {
.expect("Seriously why dont you have time??")
.as_secs(),
version: String::from(VERSION.unwrap_or("UNKNOWN")),
registration_enabled: data.config.instance.registration,
email_verification_required: data.config.instance.require_email_verification,
// TODO: Get build number from git hash or remove this from the spec
build_number: String::from("how do i implement this?"),
build_number: String::from(GIT_SHORT_HASH),
};
HttpResponse::Ok().json(response)
Ok(HttpResponse::Ok().json(response))
}

View file

@ -1,51 +0,0 @@
use actix_web::{Error, HttpRequest, HttpResponse, get, web};
use log::error;
use serde::Serialize;
use crate::{Data, api::v1::auth::check_access_token, utils::get_auth_header};
#[derive(Serialize)]
struct Response {
uuid: String,
username: String,
display_name: String,
}
#[get("/me")]
pub async fn res(req: HttpRequest, data: web::Data<Data>) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers);
if let Err(error) = auth_header {
return Ok(error);
}
let authorized = check_access_token(auth_header.unwrap(), &data.pool).await;
if let Err(error) = authorized {
return Ok(error);
}
let uuid = authorized.unwrap();
let row = sqlx::query_as(&format!(
"SELECT username, display_name FROM users WHERE uuid = '{}'",
uuid
))
.fetch_one(&data.pool)
.await;
if let Err(error) = row {
error!("{}", error);
return Ok(HttpResponse::InternalServerError().finish());
}
let (username, display_name): (String, Option<String>) = row.unwrap();
Ok(HttpResponse::Ok().json(Response {
uuid: uuid.to_string(),
username,
display_name: display_name.unwrap_or_default(),
}))
}

View file

@ -1,36 +1,60 @@
use crate::{api::v1::auth::check_access_token, structs::StartAmountQuery, utils::get_auth_header, Data};
use actix_web::{Error, HttpRequest, HttpResponse, Scope, get, web};
use log::error;
use serde::Serialize;
use sqlx::prelude::FromRow;
//! `/api/v1/users` Contains endpoints related to all users
use actix_web::{HttpRequest, HttpResponse, Scope, get, web};
use crate::{
Data,
api::v1::auth::check_access_token,
error::Error,
objects::{StartAmountQuery, User},
utils::{get_auth_header, global_checks},
};
mod me;
mod uuid;
#[derive(Serialize, FromRow)]
struct Response {
uuid: String,
username: String,
display_name: Option<String>,
email: String,
}
pub fn web() -> Scope {
web::scope("/users")
.service(res)
.service(me::res)
.service(uuid::res)
web::scope("/users").service(get).service(uuid::get)
}
/// `GET /api/v1/users` Returns all users on this instance
///
/// requires auth: yes
///
/// requires admin: yes
///
/// ### Response Example
/// ```
/// json!([
/// {
/// "uuid": "155d2291-fb23-46bd-a656-ae7c5d8218e6",
/// "username": "user1",
/// "display_name": "Nullable Name",
/// "avatar": "https://nullable-url.com/path/to/image.png"
/// },
/// {
/// "uuid": "d48a3317-7b4d-443f-a250-ea9ab2bb8661",
/// "username": "user2",
/// "display_name": "John User 2",
/// "avatar": "https://also-supports-jpg.com/path/to/image.jpg"
/// },
/// {
/// "uuid": "12c4b3f8-a25b-4b9b-8136-b275c855ed4a",
/// "username": "user3",
/// "display_name": null,
/// "avatar": null
/// }
/// ]);
/// ```
/// NOTE: UUIDs in this response are made using `uuidgen`, UUIDs made by the actual backend will be UUIDv7 and have extractable timestamps
#[get("")]
pub async fn res(
pub async fn get(
req: HttpRequest,
request_query: web::Query<StartAmountQuery>,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers);
let auth_header = get_auth_header(headers)?;
let start = request_query.start.unwrap_or(0);
@ -40,24 +64,13 @@ pub async fn res(
return Ok(HttpResponse::BadRequest().finish());
}
let authorized = check_access_token(auth_header.unwrap(), &data.pool).await;
let mut conn = data.pool.get().await?;
if let Err(error) = authorized {
return Ok(error);
}
let uuid = check_access_token(auth_header, &mut conn).await?;
let row = sqlx::query_as("SELECT CAST(uuid AS VARCHAR), username, display_name, email FROM users ORDER BY username LIMIT $1 OFFSET $2")
.bind(amount)
.bind(start)
.fetch_all(&data.pool)
.await;
global_checks(&data, uuid).await?;
if let Err(error) = row {
error!("{}", error);
return Ok(HttpResponse::InternalServerError().finish());
}
let users = User::fetch_amount(&mut conn, start, amount).await?;
let accounts: Vec<Response> = row.unwrap();
Ok(HttpResponse::Ok().json(accounts))
Ok(HttpResponse::Ok().json(users))
}

View file

@ -1,75 +1,51 @@
use actix_web::{Error, HttpRequest, HttpResponse, get, web};
use log::error;
use serde::Serialize;
//! `/api/v1/users/{uuid}` Specific user endpoints
use actix_web::{HttpRequest, HttpResponse, get, web};
use uuid::Uuid;
use crate::{Data, api::v1::auth::check_access_token, utils::get_auth_header};
#[derive(Serialize, Clone)]
struct Response {
uuid: String,
username: String,
display_name: String,
}
use crate::{
Data,
api::v1::auth::check_access_token,
error::Error,
objects::User,
utils::{get_auth_header, global_checks},
};
/// `GET /api/v1/users/{uuid}` Returns user with the given UUID
///
/// requires auth: yes
///
/// requires relation: yes
///
/// ### Response Example
/// ```
/// json!({
/// "uuid": "155d2291-fb23-46bd-a656-ae7c5d8218e6",
/// "username": "user1",
/// "display_name": "Nullable Name",
/// "avatar": "https://nullable-url.com/path/to/image.png"
/// });
/// ```
/// NOTE: UUIDs in this response are made using `uuidgen`, UUIDs made by the actual backend will be UUIDv7 and have extractable timestamps
#[get("/{uuid}")]
pub async fn res(
pub async fn get(
req: HttpRequest,
path: web::Path<(Uuid,)>,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
let headers = req.headers();
let uuid = path.into_inner().0;
let user_uuid = path.into_inner().0;
let auth_header = get_auth_header(headers);
let auth_header = get_auth_header(headers)?;
if let Err(error) = auth_header {
return Ok(error);
}
let mut conn = data.pool.get().await?;
let authorized = check_access_token(auth_header.unwrap(), &data.pool).await;
let uuid = check_access_token(auth_header, &mut conn).await?;
if let Err(error) = authorized {
return Ok(error);
}
global_checks(&data, uuid).await?;
let cache_result = data.get_cache_key(uuid.to_string()).await;
if let Ok(cache_hit) = cache_result {
return Ok(HttpResponse::Ok()
.content_type("application/json")
.body(cache_hit));
}
let row = sqlx::query_as(&format!(
"SELECT username, display_name FROM users WHERE uuid = '{}'",
uuid
))
.fetch_one(&data.pool)
.await;
if let Err(error) = row {
error!("{}", error);
return Ok(HttpResponse::InternalServerError().finish());
}
let (username, display_name): (String, Option<String>) = row.unwrap();
let user = Response {
uuid: uuid.to_string(),
username,
display_name: display_name.unwrap_or_default(),
};
let cache_result = data
.set_cache_key(uuid.to_string(), user.clone(), 1800)
.await;
if let Err(error) = cache_result {
error!("{}", error);
return Ok(HttpResponse::InternalServerError().finish());
}
let user = User::fetch_one(&data, user_uuid).await?;
Ok(HttpResponse::Ok().json(user))
}

View file

@ -1,3 +1,4 @@
//! `/api/v1/versions` Returns info about api versions
use actix_web::{HttpResponse, Responder, get};
use serde::Serialize;
@ -10,8 +11,21 @@ struct Response {
#[derive(Serialize)]
struct UnstableFeatures;
/// `GET /api/versions` Returns info about api versions.
///
/// requires auth: no
///
/// ### Response Example
/// ```
/// json!({
/// "unstable_features": {},
/// "versions": [
/// "1"
/// ]
/// });
/// ```
#[get("/versions")]
pub async fn res() -> impl Responder {
pub async fn get() -> impl Responder {
let response = Response {
unstable_features: UnstableFeatures,
// TODO: Find a way to dynamically update this possibly?

View file

@ -1,14 +1,19 @@
use crate::Error;
use crate::error::Error;
use bunny_api_tokio::edge_storage::Endpoint;
use lettre::transport::smtp::authentication::Credentials;
use log::debug;
use serde::Deserialize;
use sqlx::postgres::PgConnectOptions;
use tokio::fs::read_to_string;
use url::Url;
#[derive(Debug, Deserialize)]
pub struct ConfigBuilder {
database: Database,
cache_database: CacheDatabase,
web: Option<WebBuilder>,
web: WebBuilder,
instance: Option<InstanceBuilder>,
bunny: BunnyBuilder,
mail: Mail,
}
#[derive(Debug, Deserialize, Clone)]
@ -31,11 +36,42 @@ pub struct CacheDatabase {
#[derive(Debug, Deserialize)]
struct WebBuilder {
url: Option<String>,
ip: Option<String>,
port: Option<u16>,
frontend_url: Url,
backend_url: Option<Url>,
_ssl: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct InstanceBuilder {
name: Option<String>,
registration: Option<bool>,
require_email_verification: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct BunnyBuilder {
api_key: String,
endpoint: String,
storage_zone: String,
cdn_url: Url,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Mail {
pub smtp: Smtp,
pub address: String,
pub tls: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Smtp {
pub server: String,
username: String,
password: String,
}
impl ConfigBuilder {
pub async fn load(path: String) -> Result<Self, Error> {
debug!("loading config from: {}", path);
@ -47,22 +83,57 @@ impl ConfigBuilder {
}
pub fn build(self) -> Config {
let web = if let Some(web) = self.web {
Web {
url: web.url.unwrap_or(String::from("0.0.0.0")),
port: web.port.unwrap_or(8080),
}
} else {
Web {
url: String::from("0.0.0.0"),
port: 8080,
}
let web = Web {
ip: self.web.ip.unwrap_or(String::from("0.0.0.0")),
port: self.web.port.unwrap_or(8080),
frontend_url: self.web.frontend_url.clone(),
backend_url: self
.web
.backend_url
.or_else(|| self.web.frontend_url.join("/api").ok())
.unwrap(),
};
let endpoint = match &*self.bunny.endpoint {
"Frankfurt" => Endpoint::Frankfurt,
"London" => Endpoint::London,
"New York" => Endpoint::NewYork,
"Los Angeles" => Endpoint::LosAngeles,
"Singapore" => Endpoint::Singapore,
"Stockholm" => Endpoint::Stockholm,
"Sao Paulo" => Endpoint::SaoPaulo,
"Johannesburg" => Endpoint::Johannesburg,
"Sydney" => Endpoint::Sydney,
url => Endpoint::Custom(url.to_string()),
};
let bunny = Bunny {
api_key: self.bunny.api_key,
endpoint,
storage_zone: self.bunny.storage_zone,
cdn_url: self.bunny.cdn_url,
};
let instance = match self.instance {
Some(instance) => Instance {
name: instance.name.unwrap_or("Gorb".to_string()),
registration: instance.registration.unwrap_or(true),
require_email_verification: instance.require_email_verification.unwrap_or(false),
},
None => Instance {
name: "Gorb".to_string(),
registration: true,
require_email_verification: false,
},
};
Config {
database: self.database,
cache_database: self.cache_database,
web,
instance,
bunny,
mail: self.mail,
}
}
}
@ -72,22 +143,53 @@ pub struct Config {
pub database: Database,
pub cache_database: CacheDatabase,
pub web: Web,
pub instance: Instance,
pub bunny: Bunny,
pub mail: Mail,
}
#[derive(Debug, Clone)]
pub struct Web {
pub url: String,
pub ip: String,
pub port: u16,
pub frontend_url: Url,
pub backend_url: Url,
}
#[derive(Debug, Clone)]
pub struct Instance {
pub name: String,
pub registration: bool,
pub require_email_verification: bool,
}
#[derive(Debug, Clone)]
pub struct Bunny {
pub api_key: String,
pub endpoint: Endpoint,
pub storage_zone: String,
pub cdn_url: Url,
}
impl Database {
pub fn connect_options(&self) -> PgConnectOptions {
PgConnectOptions::new()
.database(&self.database)
.host(&self.host)
.username(&self.username)
.password(&self.password)
.port(self.port)
pub fn url(&self) -> String {
let mut url = String::from("postgres://");
url += &self.username;
url += ":";
url += &self.password;
url += "@";
url += &self.host;
url += ":";
url += &self.port.to_string();
url += "/";
url += &self.database;
url
}
}
@ -120,3 +222,9 @@ impl CacheDatabase {
url
}
}
impl Smtp {
pub fn credentials(&self) -> Credentials {
Credentials::new(self.username.clone(), self.password.clone())
}
}

112
src/error.rs Normal file
View file

@ -0,0 +1,112 @@
use std::{io, time::SystemTimeError};
use actix_web::{
HttpResponse,
error::{PayloadError, ResponseError},
http::{
StatusCode,
header::{ContentType, ToStrError},
},
};
use bunny_api_tokio::error::Error as BunnyError;
use deadpool::managed::{BuildError, PoolError};
use diesel::{ConnectionError, result::Error as DieselError};
use diesel_async::pooled_connection::PoolError as DieselPoolError;
use lettre::{
address::AddressError, error::Error as EmailError, transport::smtp::Error as SmtpError,
};
use log::{debug, error};
use redis::RedisError;
use serde::Serialize;
use serde_json::Error as JsonError;
use thiserror::Error;
use tokio::task::JoinError;
use toml::de::Error as TomlError;
#[derive(Debug, Error)]
pub enum Error {
#[error(transparent)]
SqlError(#[from] DieselError),
#[error(transparent)]
PoolError(#[from] PoolError<DieselPoolError>),
#[error(transparent)]
BuildError(#[from] BuildError),
#[error(transparent)]
RedisError(#[from] RedisError),
#[error(transparent)]
ConnectionError(#[from] ConnectionError),
#[error(transparent)]
JoinError(#[from] JoinError),
#[error(transparent)]
IoError(#[from] io::Error),
#[error(transparent)]
TomlError(#[from] TomlError),
#[error(transparent)]
JsonError(#[from] JsonError),
#[error(transparent)]
SystemTimeError(#[from] SystemTimeError),
#[error(transparent)]
ToStrError(#[from] ToStrError),
#[error(transparent)]
RandomError(#[from] getrandom::Error),
#[error(transparent)]
BunnyError(#[from] BunnyError),
#[error(transparent)]
UrlParseError(#[from] url::ParseError),
#[error(transparent)]
PayloadError(#[from] PayloadError),
#[error(transparent)]
WsClosed(#[from] actix_ws::Closed),
#[error(transparent)]
EmailError(#[from] EmailError),
#[error(transparent)]
SmtpError(#[from] SmtpError),
#[error(transparent)]
SmtpAddressError(#[from] AddressError),
#[error("{0}")]
PasswordHashError(String),
#[error("{0}")]
BadRequest(String),
#[error("{0}")]
Unauthorized(String),
#[error("{0}")]
Forbidden(String),
#[error("{0}")]
TooManyRequests(String),
#[error("{0}")]
InternalServerError(String),
}
impl ResponseError for Error {
fn error_response(&self) -> HttpResponse {
debug!("{:?}", self);
error!("{}: {}", self.status_code(), self);
HttpResponse::build(self.status_code())
.insert_header(ContentType::json())
.json(WebError::new(self.to_string()))
}
fn status_code(&self) -> StatusCode {
match *self {
Error::SqlError(DieselError::NotFound) => StatusCode::NOT_FOUND,
Error::BunnyError(BunnyError::NotFound(_)) => StatusCode::NOT_FOUND,
Error::BadRequest(_) => StatusCode::BAD_REQUEST,
Error::Unauthorized(_) => StatusCode::UNAUTHORIZED,
Error::Forbidden(_) => StatusCode::FORBIDDEN,
Error::TooManyRequests(_) => StatusCode::TOO_MANY_REQUESTS,
_ => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
#[derive(Serialize)]
struct WebError {
message: String,
}
impl WebError {
fn new(message: String) -> Self {
Self { message }
}
}

View file

@ -2,18 +2,27 @@ use actix_cors::Cors;
use actix_web::{App, HttpServer, web};
use argon2::Argon2;
use clap::Parser;
use diesel_async::pooled_connection::AsyncDieselConnectionManager;
use diesel_async::pooled_connection::deadpool::Pool;
use error::Error;
use objects::MailClient;
use simple_logger::SimpleLogger;
use sqlx::{PgPool, Pool, Postgres};
use std::time::SystemTime;
mod config;
use config::{Config, ConfigBuilder};
use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations};
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!();
type Conn =
deadpool::managed::Object<AsyncDieselConnectionManager<diesel_async::AsyncPgConnection>>;
mod api;
pub mod structs;
pub mod error;
pub mod objects;
pub mod schema;
pub mod utils;
type Error = Box<dyn std::error::Error>;
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Args {
@ -23,11 +32,16 @@ struct Args {
#[derive(Clone)]
pub struct Data {
pub pool: Pool<Postgres>,
pub pool: deadpool::managed::Pool<
AsyncDieselConnectionManager<diesel_async::AsyncPgConnection>,
Conn,
>,
pub cache_pool: redis::Client,
pub _config: Config,
pub config: Config,
pub argon2: Argon2<'static>,
pub start_time: SystemTime,
pub bunny_storage: bunny_api_tokio::EdgeStorageClient,
pub mail_client: MailClient,
}
#[tokio::main]
@ -44,105 +58,40 @@ async fn main() -> Result<(), Error> {
let web = config.web.clone();
let pool = PgPool::connect_with(config.database.connect_options()).await?;
// create a new connection pool with the default config
let pool_config =
AsyncDieselConnectionManager::<diesel_async::AsyncPgConnection>::new(config.database.url());
let pool = Pool::builder(pool_config).build()?;
let cache_pool = redis::Client::open(config.cache_database.url())?;
/*
TODO: Figure out if a table should be used here and if not then what.
Also figure out if these should be different types from what they currently are and if we should add more "constraints"
let bunny = config.bunny.clone();
TODO: References to time should be removed in favor of using the timestamp built in to UUIDv7 (apart from deleted_at in users)
*/
sqlx::raw_sql(
r#"
CREATE TABLE IF NOT EXISTS users (
uuid uuid PRIMARY KEY NOT NULL,
username varchar(32) NOT NULL,
display_name varchar(64) DEFAULT NULL,
password varchar(512) NOT NULL,
email varchar(100) NOT NULL,
email_verified boolean NOT NULL DEFAULT FALSE,
is_deleted boolean NOT NULL DEFAULT FALSE,
deleted_at int8 DEFAULT NULL,
CONSTRAINT unique_username_active UNIQUE NULLS NOT DISTINCT (username, is_deleted),
CONSTRAINT unique_email_active UNIQUE NULLS NOT DISTINCT (email, is_deleted)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_unique_username_active
ON users(username)
WHERE is_deleted = FALSE;
CREATE UNIQUE INDEX IF NOT EXISTS idx_unique_email_active
ON users(email)
WHERE is_deleted = FALSE;
CREATE TABLE IF NOT EXISTS instance_permissions (
uuid uuid NOT NULL REFERENCES users(uuid),
administrator boolean NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS refresh_tokens (
token varchar(64) PRIMARY KEY UNIQUE NOT NULL,
uuid uuid NOT NULL REFERENCES users(uuid),
created_at int8 NOT NULL,
device_name varchar(16) NOT NULL
);
CREATE TABLE IF NOT EXISTS access_tokens (
token varchar(32) PRIMARY KEY UNIQUE NOT NULL,
refresh_token varchar(64) UNIQUE NOT NULL REFERENCES refresh_tokens(token) ON UPDATE CASCADE ON DELETE CASCADE,
uuid uuid NOT NULL REFERENCES users(uuid),
created_at int8 NOT NULL
);
CREATE TABLE IF NOT EXISTS guilds (
uuid uuid PRIMARY KEY NOT NULL,
owner_uuid uuid NOT NULL REFERENCES users(uuid),
name VARCHAR(100) NOT NULL,
description VARCHAR(300)
);
CREATE TABLE IF NOT EXISTS guild_members (
uuid uuid PRIMARY KEY NOT NULL,
guild_uuid uuid NOT NULL REFERENCES guilds(uuid) ON DELETE CASCADE,
user_uuid uuid NOT NULL REFERENCES users(uuid),
nickname VARCHAR(100) DEFAULT NULL
);
CREATE TABLE IF NOT EXISTS roles (
uuid uuid UNIQUE NOT NULL,
guild_uuid uuid NOT NULL REFERENCES guilds(uuid) ON DELETE CASCADE,
name VARCHAR(50) NOT NULL,
color int NOT NULL DEFAULT 16777215,
position int NOT NULL,
permissions int8 NOT NULL DEFAULT 0,
PRIMARY KEY (uuid, guild_uuid)
);
CREATE TABLE IF NOT EXISTS role_members (
role_uuid uuid NOT NULL REFERENCES roles(uuid) ON DELETE CASCADE,
member_uuid uuid NOT NULL REFERENCES guild_members(uuid) ON DELETE CASCADE,
PRIMARY KEY (role_uuid, member_uuid)
);
CREATE TABLE IF NOT EXISTS channels (
uuid uuid PRIMARY KEY NOT NULL,
guild_uuid uuid NOT NULL REFERENCES guilds(uuid) ON DELETE CASCADE,
name varchar(32) NOT NULL,
description varchar(500) NOT NULL
);
CREATE TABLE IF NOT EXISTS channel_permissions (
channel_uuid uuid NOT NULL REFERENCES channels(uuid) ON DELETE CASCADE,
role_uuid uuid NOT NULL REFERENCES roles(uuid) ON DELETE CASCADE,
permissions int8 NOT NULL DEFAULT 0,
PRIMARY KEY (channel_uuid, role_uuid)
);
CREATE TABLE IF NOT EXISTS messages (
uuid uuid PRIMARY KEY NOT NULL,
channel_uuid uuid NOT NULL REFERENCES channels(uuid) ON DELETE CASCADE,
user_uuid uuid NOT NULL REFERENCES users(uuid),
message varchar(4000) NOT NULL
);
CREATE TABLE IF NOT EXISTS invites (
id varchar(32) PRIMARY KEY NOT NULL,
guild_uuid uuid NOT NULL REFERENCES guilds(uuid) ON DELETE CASCADE,
user_uuid uuid NOT NULL REFERENCES users(uuid)
);
"#,
)
.execute(&pool)
.await?;
let bunny_storage = bunny_api_tokio::EdgeStorageClient::new(bunny.api_key, bunny.endpoint, bunny.storage_zone).await?;
let mail = config.mail.clone();
let mail_client = MailClient::new(
mail.smtp.credentials(),
mail.smtp.server,
mail.address,
mail.tls,
)?;
let database_url = config.database.url();
tokio::task::spawn_blocking(move || {
use diesel::prelude::Connection;
use diesel_async::async_connection_wrapper::AsyncConnectionWrapper;
let mut conn =
AsyncConnectionWrapper::<diesel_async::AsyncPgConnection>::establish(&database_url)?;
conn.run_pending_migrations(MIGRATIONS)?;
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(())
})
.await?
.unwrap();
/*
**Stored for later possible use**
@ -164,10 +113,12 @@ async fn main() -> Result<(), Error> {
let data = Data {
pool,
cache_pool,
_config: config,
config,
// TODO: Possibly implement "pepper" into this (thinking it could generate one if it doesnt exist and store it on disk)
argon2: Argon2::default(),
start_time: SystemTime::now(),
bunny_storage,
mail_client,
};
HttpServer::new(move || {
@ -199,9 +150,9 @@ async fn main() -> Result<(), Error> {
App::new()
.app_data(web::Data::new(data.clone()))
.wrap(cors)
.service(api::web())
.service(api::web(data.config.web.backend_url.path()))
})
.bind((web.url, web.port))?
.bind((web.ip, web.port))?
.run()
.await?;

384
src/objects/channel.rs Normal file
View file

@ -0,0 +1,384 @@
use diesel::{
ExpressionMethods, Insertable, QueryDsl, Queryable, Selectable, SelectableHelper, delete,
insert_into, update,
};
use diesel_async::{RunQueryDsl, pooled_connection::AsyncDieselConnectionManager};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{
Conn, Data,
error::Error,
schema::{channel_permissions, channels, messages},
utils::{CHANNEL_REGEX, order_by_is_above},
};
use super::{HasIsAbove, HasUuid, Message, load_or_empty, message::MessageBuilder};
#[derive(Queryable, Selectable, Insertable, Clone, Debug)]
#[diesel(table_name = channels)]
#[diesel(check_for_backend(diesel::pg::Pg))]
struct ChannelBuilder {
uuid: Uuid,
guild_uuid: Uuid,
name: String,
description: Option<String>,
is_above: Option<Uuid>,
}
impl ChannelBuilder {
async fn build(self, conn: &mut Conn) -> Result<Channel, Error> {
use self::channel_permissions::dsl::*;
let channel_permission: Vec<ChannelPermission> = load_or_empty(
channel_permissions
.filter(channel_uuid.eq(self.uuid))
.select(ChannelPermission::as_select())
.load(conn)
.await,
)?;
Ok(Channel {
uuid: self.uuid,
guild_uuid: self.guild_uuid,
name: self.name,
description: self.description,
is_above: self.is_above,
permissions: channel_permission,
})
}
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Channel {
pub uuid: Uuid,
pub guild_uuid: Uuid,
name: String,
description: Option<String>,
pub is_above: Option<Uuid>,
pub permissions: Vec<ChannelPermission>,
}
#[derive(Serialize, Deserialize, Clone, Queryable, Selectable, Debug)]
#[diesel(table_name = channel_permissions)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct ChannelPermission {
pub role_uuid: Uuid,
pub permissions: i64,
}
impl HasUuid for Channel {
fn uuid(&self) -> &Uuid {
self.uuid.as_ref()
}
}
impl HasIsAbove for Channel {
fn is_above(&self) -> Option<&Uuid> {
self.is_above.as_ref()
}
}
impl Channel {
pub async fn fetch_all(
pool: &deadpool::managed::Pool<
AsyncDieselConnectionManager<diesel_async::AsyncPgConnection>,
Conn,
>,
guild_uuid: Uuid,
) -> Result<Vec<Self>, Error> {
let mut conn = pool.get().await?;
use channels::dsl;
let channel_builders: Vec<ChannelBuilder> = load_or_empty(
dsl::channels
.filter(dsl::guild_uuid.eq(guild_uuid))
.select(ChannelBuilder::as_select())
.load(&mut conn)
.await,
)?;
let channel_futures = channel_builders.iter().map(async move |c| {
let mut conn = pool.get().await?;
c.clone().build(&mut conn).await
});
futures::future::try_join_all(channel_futures).await
}
pub async fn fetch_one(data: &Data, channel_uuid: Uuid) -> Result<Self, Error> {
if let Ok(cache_hit) = data.get_cache_key(channel_uuid.to_string()).await {
return Ok(serde_json::from_str(&cache_hit)?);
}
let mut conn = data.pool.get().await?;
use channels::dsl;
let channel_builder: ChannelBuilder = dsl::channels
.filter(dsl::uuid.eq(channel_uuid))
.select(ChannelBuilder::as_select())
.get_result(&mut conn)
.await?;
let channel = channel_builder.build(&mut conn).await?;
data.set_cache_key(channel_uuid.to_string(), channel.clone(), 60)
.await?;
Ok(channel)
}
pub async fn new(
data: actix_web::web::Data<Data>,
guild_uuid: Uuid,
name: String,
description: Option<String>,
) -> Result<Self, Error> {
if !CHANNEL_REGEX.is_match(&name) {
return Err(Error::BadRequest("Channel name is invalid".to_string()));
}
let mut conn = data.pool.get().await?;
let channel_uuid = Uuid::now_v7();
let channels = Self::fetch_all(&data.pool, guild_uuid).await?;
let channels_ordered = order_by_is_above(channels).await?;
let last_channel = channels_ordered.last();
let new_channel = ChannelBuilder {
uuid: channel_uuid,
guild_uuid,
name: name.clone(),
description: description.clone(),
is_above: None,
};
insert_into(channels::table)
.values(new_channel.clone())
.execute(&mut conn)
.await?;
if let Some(old_last_channel) = last_channel {
use channels::dsl;
update(channels::table)
.filter(dsl::uuid.eq(old_last_channel.uuid))
.set(dsl::is_above.eq(new_channel.uuid))
.execute(&mut conn)
.await?;
}
// returns different object because there's no reason to build the channelbuilder (wastes 1 database request)
let channel = Self {
uuid: channel_uuid,
guild_uuid,
name,
description,
is_above: None,
permissions: vec![],
};
data.set_cache_key(channel_uuid.to_string(), channel.clone(), 1800)
.await?;
if data
.get_cache_key(format!("{}_channels", guild_uuid))
.await
.is_ok()
{
data.del_cache_key(format!("{}_channels", guild_uuid))
.await?;
}
Ok(channel)
}
pub async fn delete(self, data: &Data) -> Result<(), Error> {
let mut conn = data.pool.get().await?;
use channels::dsl;
match update(channels::table)
.filter(dsl::is_above.eq(self.uuid))
.set(dsl::is_above.eq(None::<Uuid>))
.execute(&mut conn)
.await
{
Ok(r) => Ok(r),
Err(diesel::result::Error::NotFound) => Ok(0),
Err(e) => Err(e),
}?;
delete(channels::table)
.filter(dsl::uuid.eq(self.uuid))
.execute(&mut conn)
.await?;
match update(channels::table)
.filter(dsl::is_above.eq(self.uuid))
.set(dsl::is_above.eq(self.is_above))
.execute(&mut conn)
.await
{
Ok(r) => Ok(r),
Err(diesel::result::Error::NotFound) => Ok(0),
Err(e) => Err(e),
}?;
if data.get_cache_key(self.uuid.to_string()).await.is_ok() {
data.del_cache_key(self.uuid.to_string()).await?;
}
if data
.get_cache_key(format!("{}_channels", self.guild_uuid))
.await
.is_ok()
{
data.del_cache_key(format!("{}_channels", self.guild_uuid))
.await?;
}
Ok(())
}
pub async fn fetch_messages(
&self,
data: &Data,
amount: i64,
offset: i64,
) -> Result<Vec<Message>, Error> {
let mut conn = data.pool.get().await?;
use messages::dsl;
let messages: Vec<MessageBuilder> = load_or_empty(
dsl::messages
.filter(dsl::channel_uuid.eq(self.uuid))
.select(MessageBuilder::as_select())
.order(dsl::uuid.desc())
.limit(amount)
.offset(offset)
.load(&mut conn)
.await,
)?;
let message_futures = messages.iter().map(async move |b| b.build(data).await);
futures::future::try_join_all(message_futures).await
}
pub async fn new_message(
&self,
data: &Data,
user_uuid: Uuid,
message: String,
) -> Result<Message, Error> {
let message_uuid = Uuid::now_v7();
let message = MessageBuilder {
uuid: message_uuid,
channel_uuid: self.uuid,
user_uuid,
message,
};
let mut conn = data.pool.get().await?;
insert_into(messages::table)
.values(message.clone())
.execute(&mut conn)
.await?;
message.build(data).await
}
pub async fn set_name(&mut self, data: &Data, new_name: String) -> Result<(), Error> {
if !CHANNEL_REGEX.is_match(&new_name) {
return Err(Error::BadRequest("Channel name is invalid".to_string()));
}
let mut conn = data.pool.get().await?;
use channels::dsl;
update(channels::table)
.filter(dsl::uuid.eq(self.uuid))
.set(dsl::name.eq(&new_name))
.execute(&mut conn)
.await?;
self.name = new_name;
Ok(())
}
pub async fn set_description(
&mut self,
data: &Data,
new_description: String,
) -> Result<(), Error> {
let mut conn = data.pool.get().await?;
use channels::dsl;
update(channels::table)
.filter(dsl::uuid.eq(self.uuid))
.set(dsl::description.eq(&new_description))
.execute(&mut conn)
.await?;
self.description = Some(new_description);
Ok(())
}
pub async fn move_channel(&mut self, data: &Data, new_is_above: Uuid) -> Result<(), Error> {
let mut conn = data.pool.get().await?;
use channels::dsl;
let old_above_uuid: Option<Uuid> = match dsl::channels
.filter(dsl::is_above.eq(self.uuid))
.select(dsl::uuid)
.get_result(&mut conn)
.await
{
Ok(r) => Ok(Some(r)),
Err(diesel::result::Error::NotFound) => Ok(None),
Err(e) => Err(e),
}?;
if let Some(uuid) = old_above_uuid {
update(channels::table)
.filter(dsl::uuid.eq(uuid))
.set(dsl::is_above.eq(None::<Uuid>))
.execute(&mut conn)
.await?;
}
match update(channels::table)
.filter(dsl::is_above.eq(new_is_above))
.set(dsl::is_above.eq(self.uuid))
.execute(&mut conn)
.await
{
Ok(r) => Ok(r),
Err(diesel::result::Error::NotFound) => Ok(0),
Err(e) => Err(e),
}?;
update(channels::table)
.filter(dsl::uuid.eq(self.uuid))
.set(dsl::is_above.eq(new_is_above))
.execute(&mut conn)
.await?;
if let Some(uuid) = old_above_uuid {
update(channels::table)
.filter(dsl::uuid.eq(uuid))
.set(dsl::is_above.eq(self.is_above))
.execute(&mut conn)
.await?;
}
self.is_above = Some(new_is_above);
Ok(())
}
}

View file

@ -0,0 +1,61 @@
use chrono::Utc;
use lettre::message::MultiPart;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{Data, error::Error, utils::generate_token};
use super::Me;
#[derive(Serialize, Deserialize)]
pub struct EmailToken {
user_uuid: Uuid,
pub token: String,
pub created_at: chrono::DateTime<Utc>,
}
impl EmailToken {
pub async fn get(data: &Data, user_uuid: Uuid) -> Result<EmailToken, Error> {
let email_token = serde_json::from_str(&data.get_cache_key(format!("{}_email_verify", user_uuid)).await?)?;
Ok(email_token)
}
#[allow(clippy::new_ret_no_self)]
pub async fn new(data: &Data, me: Me) -> Result<(), Error> {
let token = generate_token::<32>()?;
let email_token = EmailToken {
user_uuid: me.uuid,
token: token.clone(),
// TODO: Check if this can be replaced with something built into valkey
created_at: Utc::now()
};
data.set_cache_key(format!("{}_email_verify", me.uuid), email_token, 86400).await?;
let mut verify_endpoint = data.config.web.frontend_url.join("verify-email")?;
verify_endpoint.set_query(Some(&format!("token={}", token)));
let email = data
.mail_client
.message_builder()
.to(me.email.parse()?)
.subject(format!("{} E-mail Verification", data.config.instance.name))
.multipart(MultiPart::alternative_plain_html(
format!("Verify your {} account\n\nHello, {}!\nThanks for creating a new account on Gorb.\nThe final step to create your account is to verify your email address by visiting the page, within 24 hours.\n\n{}\n\nIf you didn't ask to verify this address, you can safely ignore this email\n\nThanks, The gorb team.", data.config.instance.name, me.username, verify_endpoint),
format!(r#"<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><style>:root{{--header-text-colour: #ffffff;--footer-text-colour: #7f7f7f;--button-text-colour: #170e08;--text-colour: #170e08;--background-colour: #fbf6f2;--primary-colour: #df5f0b;--secondary-colour: #e8ac84;--accent-colour: #e68b4e;}}@media (prefers-color-scheme: dark){{:root{{--header-text-colour: #ffffff;--footer-text-colour: #585858;--button-text-colour: #ffffff;--text-colour: #f7eee8;--background-colour: #0c0704;--primary-colour: #f4741f;--secondary-colour: #7c4018;--accent-colour: #b35719;}}}}@media (max-width: 600px){{.container{{width: 100%;}}}}body{{font-family: Arial, sans-serif;align-content: center;text-align: center;margin: 0;padding: 0;background-color: var(--background-colour);color: var(--text-colour);width: 100%;max-width: 600px;margin: 0 auto;border-radius: 5px;}}.header{{background-color: var(--primary-colour);color: var(--header-text-colour);padding: 20px;}}.verify-button{{background-color: var(--accent-colour);color: var(--button-text-colour);padding: 12px 30px;margin: 16px;font-size: 20px;transition: background-color 0.3s;cursor: pointer;border: none;border-radius: 14px;text-decoration: none;display: inline-block;}}.verify-button:hover{{background-color: var(--secondary-colour);}}.content{{padding: 20px 30px;}}.footer{{padding: 10px;font-size: 12px;color: var(--footer-text-colour);}}</style></head><body><div class="container"><div class="header"><h1>Verify your {} Account</h1></div><div class="content"><h2>Hello, {}!</h2><p>Thanks for creating a new account on Gorb.</p><p>The final step to create your account is to verify your email address by clicking the button below, within 24 hours.</p><a href="{}" class="verify-button">VERIFY ACCOUNT</a><p>If you didn't ask to verify this address, you can safely ignore this email.</p><div class="footer"><p>Thanks<br>The gorb team.</p></div></div></div></body></html>"#, data.config.instance.name, me.username, verify_endpoint)
))?;
data.mail_client.send_mail(email).await?;
Ok(())
}
pub async fn delete(&self, data: &Data) -> Result<(), Error> {
data.del_cache_key(format!("{}_email_verify", self.user_uuid)).await?;
Ok(())
}
}

Some files were not shown because too many files have changed in this diff Show more