From 315a8b9954c77c8dff9f0b31a5674ccecb34ec96 Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 1 Jun 2025 19:38:19 +0200 Subject: [PATCH 01/38] ci: test ci --- .woodpecker/build-and-publish.yml | 13 +++++++++++-- .woodpecker/publish-docs.yml | 19 ------------------- Dockerfile | 11 ++++++----- 3 files changed, 17 insertions(+), 26 deletions(-) delete mode 100644 .woodpecker/publish-docs.yml diff --git a/.woodpecker/build-and-publish.yml b/.woodpecker/build-and-publish.yml index c0f367a..babf3b4 100644 --- a/.woodpecker/build-and-publish.yml +++ b/.woodpecker/build-and-publish.yml @@ -3,11 +3,20 @@ when: branch: main steps: + - name: rust-build + image: rust:bookworm + commands: + - cargo install cross + - cargo build --release + - cross build --target aarch64-unknown-linux-gnu --release + environment: + CROSS_CONTAINER_IN_CONTAINER: "true" + volumes: + - /var/run/podman/podman.sock:/var/run/docker.sock - name: container-build-and-publish image: docker commands: - - docker login --username radical --password $PASSWORD git.gorb.app - - docker buildx build --platform linux/amd64,linux/arm64 --rm --push -t git.gorb.app/gorb/backend:main . + - docker buildx build --platform linux/amd64,linux/arm64 --rm -t gorb/backend:main . environment: PASSWORD: from_secret: docker_password diff --git a/.woodpecker/publish-docs.yml b/.woodpecker/publish-docs.yml deleted file mode 100644 index e6ce482..0000000 --- a/.woodpecker/publish-docs.yml +++ /dev/null @@ -1,19 +0,0 @@ -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 diff --git a/Dockerfile b/Dockerfile index d7209ef..25795a9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 +ARG TARGETARCH + RUN apt update -y && apt install libssl3 ca-certificates -y && rm -rf /var/lib/apt/lists/* /var/cache/apt/* /tmp/* -COPY --from=builder /src/target/release/backend /usr/bin/gorb-backend +COPY --from=prep /src/backend-${TARGETARCH} /usr/bin/gorb-backend COPY entrypoint.sh /usr/bin/entrypoint.sh From d3ae5dc91ff0078248913ca136df39e2345619e6 Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 1 Jun 2025 19:38:59 +0200 Subject: [PATCH 02/38] ci: remove non-existent secret --- .woodpecker/build-and-publish.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.woodpecker/build-and-publish.yml b/.woodpecker/build-and-publish.yml index babf3b4..b1b0f07 100644 --- a/.woodpecker/build-and-publish.yml +++ b/.woodpecker/build-and-publish.yml @@ -17,8 +17,5 @@ steps: image: docker commands: - docker buildx build --platform linux/amd64,linux/arm64 --rm -t gorb/backend:main . - environment: - PASSWORD: - from_secret: docker_password volumes: - /var/run/podman/podman.sock:/var/run/docker.sock From 42f281b3e9dea57f3e392f1517461c52a9d44aaa Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 1 Jun 2025 19:42:47 +0200 Subject: [PATCH 03/38] ci: install podman container engine --- .woodpecker/build-and-publish.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.woodpecker/build-and-publish.yml b/.woodpecker/build-and-publish.yml index b1b0f07..4b5a054 100644 --- a/.woodpecker/build-and-publish.yml +++ b/.woodpecker/build-and-publish.yml @@ -6,13 +6,15 @@ steps: - name: rust-build image: rust:bookworm commands: + - apt update && apt install podman - cargo install cross - cargo build --release - cross build --target aarch64-unknown-linux-gnu --release environment: + CROSS_CONTAINER_ENGINE: "podman" CROSS_CONTAINER_IN_CONTAINER: "true" volumes: - - /var/run/podman/podman.sock:/var/run/docker.sock + - /var/run/podman/podman.sock:/var/run/podman/podman.sock - name: container-build-and-publish image: docker commands: From 47cb246a63ae7746ce4e82edd040b34207e69a00 Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 1 Jun 2025 19:43:19 +0200 Subject: [PATCH 04/38] ci: add missing -y --- .woodpecker/build-and-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.woodpecker/build-and-publish.yml b/.woodpecker/build-and-publish.yml index 4b5a054..d27d65a 100644 --- a/.woodpecker/build-and-publish.yml +++ b/.woodpecker/build-and-publish.yml @@ -6,7 +6,7 @@ steps: - name: rust-build image: rust:bookworm commands: - - apt update && apt install podman + - apt update -y && apt install podman -y - cargo install cross - cargo build --release - cross build --target aarch64-unknown-linux-gnu --release From 93ccbcb5f1aa433c11822ea35ae0ad746c0d34c5 Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 1 Jun 2025 19:46:51 +0200 Subject: [PATCH 05/38] ci: try removing quotes? --- .woodpecker/build-and-publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.woodpecker/build-and-publish.yml b/.woodpecker/build-and-publish.yml index d27d65a..c2225ee 100644 --- a/.woodpecker/build-and-publish.yml +++ b/.woodpecker/build-and-publish.yml @@ -11,8 +11,8 @@ steps: - cargo build --release - cross build --target aarch64-unknown-linux-gnu --release environment: - CROSS_CONTAINER_ENGINE: "podman" - CROSS_CONTAINER_IN_CONTAINER: "true" + CROSS_CONTAINER_ENGINE: podman + CROSS_CONTAINER_IN_CONTAINER: true volumes: - /var/run/podman/podman.sock:/var/run/podman/podman.sock - name: container-build-and-publish From 0b0befe5b9aa0e4678cf5e466e3ff5f4ea4a9daf Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 1 Jun 2025 20:00:11 +0200 Subject: [PATCH 06/38] ci: lets try this another way --- .woodpecker/build-and-publish.yml | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/.woodpecker/build-and-publish.yml b/.woodpecker/build-and-publish.yml index c2225ee..503f06a 100644 --- a/.woodpecker/build-and-publish.yml +++ b/.woodpecker/build-and-publish.yml @@ -6,15 +6,14 @@ steps: - name: rust-build image: rust:bookworm commands: - - apt update -y && apt install podman -y - - cargo install cross + - rustup target add aarch64-unknown-linux-gnu && \ + apt update && apt install -y \ + gcc-aarch64-linux-gnu \ + libc6-dev-arm64-cross - cargo build --release - - cross build --target aarch64-unknown-linux-gnu --release + - cargo build --target aarch64-unknown-linux-gnu --release environment: - CROSS_CONTAINER_ENGINE: podman - CROSS_CONTAINER_IN_CONTAINER: true - volumes: - - /var/run/podman/podman.sock:/var/run/podman/podman.sock + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc - name: container-build-and-publish image: docker commands: From db2f43f32693f16feb74fb6835c36fe0f556f760 Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 1 Jun 2025 20:03:59 +0200 Subject: [PATCH 07/38] ci: add libssl-dev for arm64 cross --- .woodpecker/build-and-publish.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.woodpecker/build-and-publish.yml b/.woodpecker/build-and-publish.yml index 503f06a..2fb474e 100644 --- a/.woodpecker/build-and-publish.yml +++ b/.woodpecker/build-and-publish.yml @@ -9,7 +9,9 @@ steps: - rustup target add aarch64-unknown-linux-gnu && \ apt update && apt install -y \ gcc-aarch64-linux-gnu \ - libc6-dev-arm64-cross + libc6-dev-arm64-cross \ + libssl-dev:arm64 \ + pkg-config-aarch64-linux-gnu - cargo build --release - cargo build --target aarch64-unknown-linux-gnu --release environment: From 554bdadb97632e1239eadc42c46001b157ee4d03 Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 1 Jun 2025 20:05:54 +0200 Subject: [PATCH 08/38] ci: fix apt command --- .woodpecker/build-and-publish.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.woodpecker/build-and-publish.yml b/.woodpecker/build-and-publish.yml index 2fb474e..278d3b7 100644 --- a/.woodpecker/build-and-publish.yml +++ b/.woodpecker/build-and-publish.yml @@ -6,12 +6,12 @@ steps: - name: rust-build image: rust:bookworm commands: - - rustup target add aarch64-unknown-linux-gnu && \ - apt update && apt install -y \ - gcc-aarch64-linux-gnu \ - libc6-dev-arm64-cross \ - libssl-dev:arm64 \ - pkg-config-aarch64-linux-gnu + - rustup target add aarch64-unknown-linux-gnu + - apt-get update -y && apt-get install -y \ + gcc-aarch64-linux-gnu \ + libc6-dev-arm64-cross \ + libssl-dev:arm64 \ + pkg-config-aarch64-linux-gnu - cargo build --release - cargo build --target aarch64-unknown-linux-gnu --release environment: From e915e38a1ef4b131cf680478681c8b171cb15863 Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 1 Jun 2025 20:07:29 +0200 Subject: [PATCH 09/38] ci: add missing architecture --- .woodpecker/build-and-publish.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.woodpecker/build-and-publish.yml b/.woodpecker/build-and-publish.yml index 278d3b7..c365a8d 100644 --- a/.woodpecker/build-and-publish.yml +++ b/.woodpecker/build-and-publish.yml @@ -7,6 +7,7 @@ steps: image: rust:bookworm commands: - rustup target add aarch64-unknown-linux-gnu + - sudo dpkg --add-architecture arm64 - apt-get update -y && apt-get install -y \ gcc-aarch64-linux-gnu \ libc6-dev-arm64-cross \ From 57a5733cbee0677a7b913a7c2e34b383b9a6b773 Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 1 Jun 2025 20:07:59 +0200 Subject: [PATCH 10/38] ci: remove sudo -_- --- .woodpecker/build-and-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.woodpecker/build-and-publish.yml b/.woodpecker/build-and-publish.yml index c365a8d..3d4142b 100644 --- a/.woodpecker/build-and-publish.yml +++ b/.woodpecker/build-and-publish.yml @@ -7,7 +7,7 @@ steps: image: rust:bookworm commands: - rustup target add aarch64-unknown-linux-gnu - - sudo dpkg --add-architecture arm64 + - dpkg --add-architecture arm64 - apt-get update -y && apt-get install -y \ gcc-aarch64-linux-gnu \ libc6-dev-arm64-cross \ From 1a7fdac0494705378663e5725c935e456e894902 Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 1 Jun 2025 20:10:58 +0200 Subject: [PATCH 11/38] ci: try using crossbuild package please please please work --- .woodpecker/build-and-publish.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.woodpecker/build-and-publish.yml b/.woodpecker/build-and-publish.yml index 3d4142b..6a77681 100644 --- a/.woodpecker/build-and-publish.yml +++ b/.woodpecker/build-and-publish.yml @@ -9,10 +9,7 @@ steps: - rustup target add aarch64-unknown-linux-gnu - dpkg --add-architecture arm64 - apt-get update -y && apt-get install -y \ - gcc-aarch64-linux-gnu \ - libc6-dev-arm64-cross \ - libssl-dev:arm64 \ - pkg-config-aarch64-linux-gnu + crossbuild-essential-arm64 - cargo build --release - cargo build --target aarch64-unknown-linux-gnu --release environment: From 770e72ff5a599cff5974d3ac98a7b7a7df7da47b Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 1 Jun 2025 21:11:46 +0200 Subject: [PATCH 12/38] Revert "ci: try using crossbuild package" This reverts commit 1a7fdac0494705378663e5725c935e456e894902. --- .woodpecker/build-and-publish.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.woodpecker/build-and-publish.yml b/.woodpecker/build-and-publish.yml index 6a77681..3d4142b 100644 --- a/.woodpecker/build-and-publish.yml +++ b/.woodpecker/build-and-publish.yml @@ -9,7 +9,10 @@ steps: - rustup target add aarch64-unknown-linux-gnu - dpkg --add-architecture arm64 - apt-get update -y && apt-get install -y \ - crossbuild-essential-arm64 + gcc-aarch64-linux-gnu \ + libc6-dev-arm64-cross \ + libssl-dev:arm64 \ + pkg-config-aarch64-linux-gnu - cargo build --release - cargo build --target aarch64-unknown-linux-gnu --release environment: From 3e49b349e84546ae0b9fe7bc1b44cb57e4f57fb8 Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 1 Jun 2025 21:14:13 +0200 Subject: [PATCH 13/38] ci: add a repo --- .woodpecker/build-and-publish.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.woodpecker/build-and-publish.yml b/.woodpecker/build-and-publish.yml index 3d4142b..4a26764 100644 --- a/.woodpecker/build-and-publish.yml +++ b/.woodpecker/build-and-publish.yml @@ -7,6 +7,7 @@ steps: image: rust:bookworm commands: - rustup target add aarch64-unknown-linux-gnu + - echo "deb [arch=arm64] http://ftp.ports.debian.org/debian-ports bookworm main" | sudo tee /etc/apt/sources.list.d/arm64-cross.list - dpkg --add-architecture arm64 - apt-get update -y && apt-get install -y \ gcc-aarch64-linux-gnu \ From f9195115f3a78e23098022ecc6570bd6b4a1cb53 Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 1 Jun 2025 21:14:50 +0200 Subject: [PATCH 14/38] ci: remove sudo again --- .woodpecker/build-and-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.woodpecker/build-and-publish.yml b/.woodpecker/build-and-publish.yml index 4a26764..ba53351 100644 --- a/.woodpecker/build-and-publish.yml +++ b/.woodpecker/build-and-publish.yml @@ -7,7 +7,7 @@ steps: image: rust:bookworm commands: - rustup target add aarch64-unknown-linux-gnu - - echo "deb [arch=arm64] http://ftp.ports.debian.org/debian-ports bookworm main" | sudo tee /etc/apt/sources.list.d/arm64-cross.list + - echo "deb [arch=arm64] http://ftp.ports.debian.org/debian-ports bookworm main" | tee /etc/apt/sources.list.d/arm64-cross.list - dpkg --add-architecture arm64 - apt-get update -y && apt-get install -y \ gcc-aarch64-linux-gnu \ From 0e2bef889ba8926bf7b6af8b8ed7cce58757ff59 Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 1 Jun 2025 21:17:47 +0200 Subject: [PATCH 15/38] ci: yuh --- .woodpecker/build-and-publish.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.woodpecker/build-and-publish.yml b/.woodpecker/build-and-publish.yml index ba53351..3f791e0 100644 --- a/.woodpecker/build-and-publish.yml +++ b/.woodpecker/build-and-publish.yml @@ -7,13 +7,8 @@ steps: image: rust:bookworm commands: - rustup target add aarch64-unknown-linux-gnu - - echo "deb [arch=arm64] http://ftp.ports.debian.org/debian-ports bookworm main" | tee /etc/apt/sources.list.d/arm64-cross.list - - dpkg --add-architecture arm64 - apt-get update -y && apt-get install -y \ - gcc-aarch64-linux-gnu \ - libc6-dev-arm64-cross \ - libssl-dev:arm64 \ - pkg-config-aarch64-linux-gnu + libssl-dev - cargo build --release - cargo build --target aarch64-unknown-linux-gnu --release environment: From 9116ce390904ea17091571443ab23a9fb070e1d5 Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 1 Jun 2025 21:19:23 +0200 Subject: [PATCH 16/38] ci: lets try this --- .woodpecker/build-and-publish.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.woodpecker/build-and-publish.yml b/.woodpecker/build-and-publish.yml index 3f791e0..78d76e5 100644 --- a/.woodpecker/build-and-publish.yml +++ b/.woodpecker/build-and-publish.yml @@ -7,8 +7,9 @@ steps: image: rust:bookworm commands: - rustup target add aarch64-unknown-linux-gnu + - dpkg --add-architecture arm64 - apt-get update -y && apt-get install -y \ - libssl-dev + libssl3:arm64 - cargo build --release - cargo build --target aarch64-unknown-linux-gnu --release environment: From dbf1504e7ca7770c402dd5796036deeeb14949ac Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 1 Jun 2025 21:23:59 +0200 Subject: [PATCH 17/38] ci: lets try again --- .woodpecker/build-and-publish.yml | 12 ++++-------- Dockerfile | 4 ++-- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/.woodpecker/build-and-publish.yml b/.woodpecker/build-and-publish.yml index 78d76e5..16577f4 100644 --- a/.woodpecker/build-and-publish.yml +++ b/.woodpecker/build-and-publish.yml @@ -4,16 +4,12 @@ when: steps: - name: rust-build - image: rust:bookworm + image: rust:alpine commands: - - rustup target add aarch64-unknown-linux-gnu - - dpkg --add-architecture arm64 - - apt-get update -y && apt-get install -y \ - libssl3:arm64 + - rustup target add aarch64-unknown-linux-musl + - apk add openssl-dev musl-dev - cargo build --release - - cargo build --target aarch64-unknown-linux-gnu --release - environment: - CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc + - cargo build --target aarch64-unknown-linux-musl --release - name: container-build-and-publish image: docker commands: diff --git a/Dockerfile b/Dockerfile index 25795a9..230deaa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,13 +3,13 @@ FROM --platform=linux/amd64 debian:12-slim AS prep WORKDIR /src COPY target/release/backend backend-amd64 -COPY target/aarch64-unknown-linux-gnu/release/backend backend-arm64 +COPY target/aarch64-unknown-linux-musl/release/backend backend-arm64 FROM debian:12-slim ARG TARGETARCH -RUN apt update -y && apt install libssl3 ca-certificates -y && rm -rf /var/lib/apt/lists/* /var/cache/apt/* /tmp/* +RUN apt update -y && apt install ca-certificates -y && rm -rf /var/lib/apt/lists/* /var/cache/apt/* /tmp/* COPY --from=prep /src/backend-${TARGETARCH} /usr/bin/gorb-backend From 3744307b8b2352ae0e41997f3ecb86feb87f73ec Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 1 Jun 2025 21:28:19 +0200 Subject: [PATCH 18/38] ci: okay then --- .woodpecker/build-and-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.woodpecker/build-and-publish.yml b/.woodpecker/build-and-publish.yml index 16577f4..6f86baf 100644 --- a/.woodpecker/build-and-publish.yml +++ b/.woodpecker/build-and-publish.yml @@ -7,7 +7,7 @@ steps: image: rust:alpine commands: - rustup target add aarch64-unknown-linux-musl - - apk add openssl-dev musl-dev + - apk add openssl-dev musl-dev openssl-libs-static - cargo build --release - cargo build --target aarch64-unknown-linux-musl --release - name: container-build-and-publish From c1c201d3c090a009528071b8343d0bed613f9bf1 Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 1 Jun 2025 21:36:44 +0200 Subject: [PATCH 19/38] ci: test 1 million and 2 --- .woodpecker/build-and-publish.yml | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/.woodpecker/build-and-publish.yml b/.woodpecker/build-and-publish.yml index 6f86baf..1e37b83 100644 --- a/.woodpecker/build-and-publish.yml +++ b/.woodpecker/build-and-publish.yml @@ -3,13 +3,24 @@ when: branch: main steps: - - name: rust-build + - name: build-x86_64 image: rust:alpine commands: - - rustup target add aarch64-unknown-linux-musl - apk add openssl-dev musl-dev openssl-libs-static - cargo build --release - - cargo build --target aarch64-unknown-linux-musl --release + - name: build-arm64 + image: rust:alpine + commands: + - echo "https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories + - apk update + - apk add gcc-aarch64-linux-musl g++-aarch64-linux-musl + - apk add openssl-dev openssl-libs-static --repository=https://dl-cdn.alpinelinux.org/alpine/edge/community --target=aarch64 --no-cache + - rustup target add aarch64-unknown-linux-musl + environment: + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER: aarch64-linux-musl-gcc + PKG_CONFIG_ALLOW_CROSS: 1 + PKG_CONFIG_PATH: /usr/aarch64-linux-musl/lib/pkgconfig + OPENSSL_DIR: /usr/aarch64-linux-musl - name: container-build-and-publish image: docker commands: From 193832b43a28de8118836a0d8f3961a913b44da4 Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 1 Jun 2025 21:38:15 +0200 Subject: [PATCH 20/38] ci: whoops add missing commands --- .woodpecker/build-and-publish.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.woodpecker/build-and-publish.yml b/.woodpecker/build-and-publish.yml index 1e37b83..a8187fc 100644 --- a/.woodpecker/build-and-publish.yml +++ b/.woodpecker/build-and-publish.yml @@ -11,11 +11,13 @@ steps: - name: build-arm64 image: rust:alpine commands: + - apk add musl-dev - echo "https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories - apk update - apk add gcc-aarch64-linux-musl g++-aarch64-linux-musl - apk add openssl-dev openssl-libs-static --repository=https://dl-cdn.alpinelinux.org/alpine/edge/community --target=aarch64 --no-cache - rustup target add aarch64-unknown-linux-musl + - cargo build --target aarch64-unknown-linux-musl --release environment: CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER: aarch64-linux-musl-gcc PKG_CONFIG_ALLOW_CROSS: 1 From 6910afd12a30b6900e35879cf37e53793e44a4e7 Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 1 Jun 2025 21:45:24 +0200 Subject: [PATCH 21/38] ci: back to debian we go --- .woodpecker/build-and-publish.yml | 21 +++++++-------------- Dockerfile | 4 ++-- 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/.woodpecker/build-and-publish.yml b/.woodpecker/build-and-publish.yml index a8187fc..3a04b04 100644 --- a/.woodpecker/build-and-publish.yml +++ b/.woodpecker/build-and-publish.yml @@ -4,25 +4,18 @@ when: steps: - name: build-x86_64 - image: rust:alpine + image: rust:bookworm commands: - - apk add openssl-dev musl-dev openssl-libs-static - cargo build --release - name: build-arm64 - image: rust:alpine + image: rust:bookworm commands: - - apk add musl-dev - - echo "https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories - - apk update - - apk add gcc-aarch64-linux-musl g++-aarch64-linux-musl - - apk add openssl-dev openssl-libs-static --repository=https://dl-cdn.alpinelinux.org/alpine/edge/community --target=aarch64 --no-cache - - rustup target add aarch64-unknown-linux-musl - - cargo build --target aarch64-unknown-linux-musl --release + - dpkg --add-architecture arm64 + - apt-get update -y && apt-get install -y gcc-aarch64-linux-gnu libc6-dev-arm64-cross libssl-dev:arm64 pkg-config-aarch64-linux-gnu + - rustup target add aarch64-unknown-linux-gnu + - cargo build --target aarch64-unknown-linux-gnu --release environment: - CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER: aarch64-linux-musl-gcc - PKG_CONFIG_ALLOW_CROSS: 1 - PKG_CONFIG_PATH: /usr/aarch64-linux-musl/lib/pkgconfig - OPENSSL_DIR: /usr/aarch64-linux-musl + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc - name: container-build-and-publish image: docker commands: diff --git a/Dockerfile b/Dockerfile index 230deaa..25795a9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,13 +3,13 @@ FROM --platform=linux/amd64 debian:12-slim AS prep WORKDIR /src COPY target/release/backend backend-amd64 -COPY target/aarch64-unknown-linux-musl/release/backend backend-arm64 +COPY target/aarch64-unknown-linux-gnu/release/backend backend-arm64 FROM debian:12-slim ARG TARGETARCH -RUN apt update -y && apt install ca-certificates -y && rm -rf /var/lib/apt/lists/* /var/cache/apt/* /tmp/* +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 From 73a3cd2aab9c3f1bb937536a3fe176577a1882da Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 1 Jun 2025 21:49:30 +0200 Subject: [PATCH 22/38] ci: maybe? --- .woodpecker/build-and-publish.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.woodpecker/build-and-publish.yml b/.woodpecker/build-and-publish.yml index 3a04b04..3643953 100644 --- a/.woodpecker/build-and-publish.yml +++ b/.woodpecker/build-and-publish.yml @@ -11,11 +11,13 @@ steps: image: rust:bookworm commands: - dpkg --add-architecture arm64 - - apt-get update -y && apt-get install -y gcc-aarch64-linux-gnu libc6-dev-arm64-cross libssl-dev:arm64 pkg-config-aarch64-linux-gnu + - 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: From 643f94b5805f616dcf79cb663f76236c99ef685a Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 1 Jun 2025 21:56:47 +0200 Subject: [PATCH 23/38] ci: add proper cross compiling! --- .woodpecker/build-and-publish.yml | 15 +++++++++++++++ Dockerfile | 11 ++++++----- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/.woodpecker/build-and-publish.yml b/.woodpecker/build-and-publish.yml index c0f367a..57f2761 100644 --- a/.woodpecker/build-and-publish.yml +++ b/.woodpecker/build-and-publish.yml @@ -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: diff --git a/Dockerfile b/Dockerfile index d7209ef..25795a9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 +ARG TARGETARCH + RUN apt update -y && apt install libssl3 ca-certificates -y && rm -rf /var/lib/apt/lists/* /var/cache/apt/* /tmp/* -COPY --from=builder /src/target/release/backend /usr/bin/gorb-backend +COPY --from=prep /src/backend-${TARGETARCH} /usr/bin/gorb-backend COPY entrypoint.sh /usr/bin/entrypoint.sh From 15eb1027845e1a322f9f6d08387389226d6a3c3c Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 1 Jun 2025 22:10:23 +0200 Subject: [PATCH 24/38] build: try to make dev bearable --- Cargo.toml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 492a284..30b5827 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" @@ -32,7 +38,7 @@ futures-util = "0.3.31" bunny-api-tokio = "0.3.0" bindet = "0.3.2" deadpool = "0.12" -diesel = { version = "2.2", features = ["uuid", "chrono"] } +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" From 41defc4a252bf577b96d4d125f7a4335dc9d4b17 Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 1 Jun 2025 22:10:37 +0200 Subject: [PATCH 25/38] feat: add patch request to channels! --- src/api/v1/channels/uuid/mod.rs | 82 +++++++++++++++++++++++++- src/structs.rs | 101 +++++++++++++++++++++++++++++--- src/utils.rs | 3 + 3 files changed, 178 insertions(+), 8 deletions(-) diff --git a/src/api/v1/channels/uuid/mod.rs b/src/api/v1/channels/uuid/mod.rs index f429159..1874dfc 100644 --- a/src/api/v1/channels/uuid/mod.rs +++ b/src/api/v1/channels/uuid/mod.rs @@ -1,3 +1,5 @@ +//! `/api/v1/channels/{uuid}` Channel specific endpoints + pub mod messages; pub mod socket; @@ -8,8 +10,9 @@ use crate::{ structs::{Channel, Member}, utils::{get_auth_header, global_checks}, }; -use actix_web::{HttpRequest, HttpResponse, delete, get, web}; +use actix_web::{delete, get, patch, web, HttpRequest, HttpResponse}; use uuid::Uuid; +use serde::Deserialize; #[get("/{uuid}")] pub async fn get( @@ -62,3 +65,80 @@ pub async fn delete( Ok(HttpResponse::Ok().finish()) } + +#[derive(Deserialize)] +struct NewInfo { + name: Option, + description: Option, + is_above: Option, +} + +/// `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, + data: web::Data, +) -> Result { + 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?; + + Member::check_membership(&mut conn, uuid, channel.guild_uuid).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)) +} + diff --git a/src/structs.rs b/src/structs.rs index 5e19dad..b955133 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -24,13 +24,9 @@ use url::Url; use uuid::Uuid; use crate::{ - Conn, Data, - error::Error, - schema::*, - utils::{ - EMAIL_REGEX, PASSWORD_REGEX, USERNAME_REGEX, generate_refresh_token, global_checks, - image_check, order_by_is_above, user_uuid_from_identifier, - }, + error::Error, schema::*, utils::{ + generate_refresh_token, global_checks, image_check, order_by_is_above, user_uuid_from_identifier, CHANNEL_REGEX, EMAIL_REGEX, PASSWORD_REGEX, USERNAME_REGEX + }, Conn, Data }; pub trait HasUuid { @@ -231,6 +227,10 @@ impl Channel { name: String, description: Option, ) -> Result { + 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(); @@ -353,6 +353,93 @@ impl Channel { 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 = match dsl::channels + .filter(dsl::is_above.eq(self.uuid)) + .select(dsl::uuid) + .get_result(&mut conn) + .await + { + Ok(r) => Ok(Some(r)), + Err(e) if e == 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::)) + .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(e) if e == 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(()) + } } #[derive(Clone, Copy)] diff --git a/src/utils.rs b/src/utils.rs index c9f3cb2..3bf7332 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -30,6 +30,9 @@ pub static EMAIL_REGEX: LazyLock = LazyLock::new(|| { pub static USERNAME_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"^[a-z0-9_.-]+$").unwrap()); +pub static CHANNEL_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r"^[a-z0-9_.-]+$").unwrap()); + // Password is expected to be hashed using SHA3-384 pub static PASSWORD_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"[0-9a-f]{96}").unwrap()); From c4fc23ec85f5da1422688279de9848b1e710abf8 Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 1 Jun 2025 22:20:29 +0200 Subject: [PATCH 26/38] feat: add about to users --- .../down.sql | 2 ++ .../up.sql | 2 ++ src/api/v1/me/mod.rs | 5 +++++ src/schema.rs | 2 ++ src/structs.rs | 21 +++++++++++++++++++ 5 files changed, 32 insertions(+) create mode 100644 migrations/2025-06-01-143713_add_about_to_users/down.sql create mode 100644 migrations/2025-06-01-143713_add_about_to_users/up.sql diff --git a/migrations/2025-06-01-143713_add_about_to_users/down.sql b/migrations/2025-06-01-143713_add_about_to_users/down.sql new file mode 100644 index 0000000..de48d07 --- /dev/null +++ b/migrations/2025-06-01-143713_add_about_to_users/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +ALTER TABLE users DROP COLUMN about; \ No newline at end of file diff --git a/migrations/2025-06-01-143713_add_about_to_users/up.sql b/migrations/2025-06-01-143713_add_about_to_users/up.sql new file mode 100644 index 0000000..54b5449 --- /dev/null +++ b/migrations/2025-06-01-143713_add_about_to_users/up.sql @@ -0,0 +1,2 @@ +-- Your SQL goes here +ALTER TABLE users ADD COLUMN about VARCHAR(200) DEFAULT NULL; diff --git a/src/api/v1/me/mod.rs b/src/api/v1/me/mod.rs index fc9e61b..ac35140 100644 --- a/src/api/v1/me/mod.rs +++ b/src/api/v1/me/mod.rs @@ -41,6 +41,7 @@ struct NewInfo { //password: Option, will probably be handled through a reset password link email: Option, pronouns: Option, + about: Option, } #[derive(Debug, MultipartForm)] @@ -102,5 +103,9 @@ pub async fn update( 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()) } diff --git a/src/schema.rs b/src/schema.rs index 3be885a..09ea7a3 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -146,6 +146,8 @@ diesel::table! { avatar -> Nullable, #[max_length = 32] pronouns -> Nullable, + #[max_length = 200] + about -> Nullable, } } diff --git a/src/structs.rs b/src/structs.rs index b955133..b68aaf5 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -953,6 +953,7 @@ pub struct User { display_name: Option, avatar: Option, pronouns: Option, + about: Option, } impl User { @@ -1004,6 +1005,7 @@ pub struct Me { display_name: Option, avatar: Option, pronouns: Option, + about: Option, email: String, pub email_verified: bool, } @@ -1198,6 +1200,25 @@ impl Me { Ok(()) } + + pub async fn set_about(&mut self, data: &Data, new_about: String) -> Result<(), Error> { + let mut conn = data.pool.get().await?; + + use users::dsl; + update(users::table) + .filter(dsl::uuid.eq(self.uuid)) + .set(( + dsl::about.eq(new_about.as_str()), + )) + .execute(&mut conn) + .await?; + + if data.get_cache_key(self.uuid.to_string()).await.is_ok() { + data.del_cache_key(self.uuid.to_string()).await? + } + + Ok(()) + } } #[derive(Deserialize)] From 08cb70ce18b27966b118b11a02bda654d2c79947 Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 1 Jun 2025 23:43:14 +0200 Subject: [PATCH 27/38] fix: add patch request as a service in actix whoops forgot to add /channels/{uuid} patch request into actix --- src/api/v1/channels/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/api/v1/channels/mod.rs b/src/api/v1/channels/mod.rs index 999bb23..e9558c9 100644 --- a/src/api/v1/channels/mod.rs +++ b/src/api/v1/channels/mod.rs @@ -6,6 +6,7 @@ pub fn web() -> Scope { web::scope("/channels") .service(uuid::get) .service(uuid::delete) + .service(uuid::patch) .service(uuid::messages::get) .service(uuid::socket::ws) } From 7021c80f0200931c247192953bb0f265688bf1d3 Mon Sep 17 00:00:00 2001 From: Radical Date: Mon, 2 Jun 2025 00:28:48 +0200 Subject: [PATCH 28/38] style: move structs to objects and split into several files for readability --- src/api/v1/auth/reset_password.rs | 2 +- src/api/v1/auth/verify_email.rs | 2 +- src/api/v1/channels/uuid/messages.rs | 2 +- src/api/v1/channels/uuid/mod.rs | 17 +- src/api/v1/channels/uuid/socket.rs | 2 +- src/api/v1/guilds/mod.rs | 2 +- src/api/v1/guilds/uuid/channels.rs | 2 +- src/api/v1/guilds/uuid/icon.rs | 2 +- src/api/v1/guilds/uuid/invites/mod.rs | 2 +- src/api/v1/guilds/uuid/members.rs | 2 +- src/api/v1/guilds/uuid/mod.rs | 2 +- src/api/v1/guilds/uuid/roles/mod.rs | 2 +- src/api/v1/guilds/uuid/roles/uuid.rs | 2 +- src/api/v1/invites/id.rs | 2 +- src/api/v1/me/guilds.rs | 2 +- src/api/v1/me/mod.rs | 15 +- src/api/v1/users/mod.rs | 2 +- src/api/v1/users/uuid.rs | 2 +- src/main.rs | 4 +- src/objects/channel.rs | 353 ++++++ src/objects/email_token.rs | 80 ++ src/objects/guild.rs | 226 ++++ src/objects/invite.rs | 30 + src/objects/me.rs | 232 ++++ src/objects/member.rs | 125 +++ src/objects/message.rs | 40 + src/objects/mod.rs | 156 +++ src/objects/password_reset_token.rs | 160 +++ src/objects/role.rs | 96 ++ src/objects/user.rs | 60 ++ src/structs.rs | 1437 ------------------------- src/utils.rs | 2 +- 32 files changed, 1591 insertions(+), 1474 deletions(-) create mode 100644 src/objects/channel.rs create mode 100644 src/objects/email_token.rs create mode 100644 src/objects/guild.rs create mode 100644 src/objects/invite.rs create mode 100644 src/objects/me.rs create mode 100644 src/objects/member.rs create mode 100644 src/objects/message.rs create mode 100644 src/objects/mod.rs create mode 100644 src/objects/password_reset_token.rs create mode 100644 src/objects/role.rs create mode 100644 src/objects/user.rs delete mode 100644 src/structs.rs diff --git a/src/api/v1/auth/reset_password.rs b/src/api/v1/auth/reset_password.rs index 8240fbd..4373a82 100644 --- a/src/api/v1/auth/reset_password.rs +++ b/src/api/v1/auth/reset_password.rs @@ -4,7 +4,7 @@ use actix_web::{HttpResponse, get, post, web}; use chrono::{Duration, Utc}; use serde::Deserialize; -use crate::{Data, error::Error, structs::PasswordResetToken}; +use crate::{Data, error::Error, objects::PasswordResetToken}; #[derive(Deserialize)] struct Query { diff --git a/src/api/v1/auth/verify_email.rs b/src/api/v1/auth/verify_email.rs index c5c9097..0f23649 100644 --- a/src/api/v1/auth/verify_email.rs +++ b/src/api/v1/auth/verify_email.rs @@ -8,7 +8,7 @@ use crate::{ Data, api::v1::auth::check_access_token, error::Error, - structs::{EmailToken, Me}, + objects::{EmailToken, Me}, utils::get_auth_header, }; diff --git a/src/api/v1/channels/uuid/messages.rs b/src/api/v1/channels/uuid/messages.rs index ddcc800..9fdea0b 100644 --- a/src/api/v1/channels/uuid/messages.rs +++ b/src/api/v1/channels/uuid/messages.rs @@ -4,7 +4,7 @@ use crate::{ Data, api::v1::auth::check_access_token, error::Error, - structs::{Channel, Member}, + objects::{Channel, Member}, utils::{get_auth_header, global_checks}, }; use ::uuid::Uuid; diff --git a/src/api/v1/channels/uuid/mod.rs b/src/api/v1/channels/uuid/mod.rs index 1874dfc..1cb20c7 100644 --- a/src/api/v1/channels/uuid/mod.rs +++ b/src/api/v1/channels/uuid/mod.rs @@ -7,12 +7,12 @@ use crate::{ Data, api::v1::auth::check_access_token, error::Error, - structs::{Channel, Member}, + objects::{Channel, Member}, utils::{get_auth_header, global_checks}, }; -use actix_web::{delete, get, patch, web, HttpRequest, HttpResponse}; -use uuid::Uuid; +use actix_web::{HttpRequest, HttpResponse, delete, get, patch, web}; use serde::Deserialize; +use uuid::Uuid; #[get("/{uuid}")] pub async fn get( @@ -88,7 +88,7 @@ struct NewInfo { /// "is_above": "398f6d7b-752c-4348-9771-fe6024adbfb1" /// }); /// ``` -/// +/// /// ### Response Example /// ``` /// json!({ @@ -132,13 +132,16 @@ pub async fn patch( } if let Some(new_description) = &new_info.description { - channel.set_description(&data, new_description.to_string()).await?; + 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?; + channel + .set_description(&data, new_is_above.to_string()) + .await?; } Ok(HttpResponse::Ok().json(channel)) } - diff --git a/src/api/v1/channels/uuid/socket.rs b/src/api/v1/channels/uuid/socket.rs index 556dca3..b346e8e 100644 --- a/src/api/v1/channels/uuid/socket.rs +++ b/src/api/v1/channels/uuid/socket.rs @@ -10,7 +10,7 @@ use uuid::Uuid; use crate::{ Data, api::v1::auth::check_access_token, - structs::{Channel, Member}, + objects::{Channel, Member}, utils::{get_ws_protocol_header, global_checks}, }; diff --git a/src/api/v1/guilds/mod.rs b/src/api/v1/guilds/mod.rs index b7a7a7c..ada5dc8 100644 --- a/src/api/v1/guilds/mod.rs +++ b/src/api/v1/guilds/mod.rs @@ -9,7 +9,7 @@ use crate::{ Data, api::v1::auth::check_access_token, error::Error, - structs::{Guild, StartAmountQuery}, + objects::{Guild, StartAmountQuery}, utils::{get_auth_header, global_checks}, }; diff --git a/src/api/v1/guilds/uuid/channels.rs b/src/api/v1/guilds/uuid/channels.rs index 0dd4566..083553a 100644 --- a/src/api/v1/guilds/uuid/channels.rs +++ b/src/api/v1/guilds/uuid/channels.rs @@ -2,7 +2,7 @@ use crate::{ Data, api::v1::auth::check_access_token, error::Error, - structs::{Channel, Member}, + objects::{Channel, Member}, utils::{get_auth_header, global_checks, order_by_is_above}, }; use ::uuid::Uuid; diff --git a/src/api/v1/guilds/uuid/icon.rs b/src/api/v1/guilds/uuid/icon.rs index f2e15b6..5025416 100644 --- a/src/api/v1/guilds/uuid/icon.rs +++ b/src/api/v1/guilds/uuid/icon.rs @@ -8,7 +8,7 @@ use crate::{ Data, api::v1::auth::check_access_token, error::Error, - structs::{Guild, Member}, + objects::{Guild, Member}, utils::{get_auth_header, global_checks}, }; diff --git a/src/api/v1/guilds/uuid/invites/mod.rs b/src/api/v1/guilds/uuid/invites/mod.rs index ea04529..f4f06bc 100644 --- a/src/api/v1/guilds/uuid/invites/mod.rs +++ b/src/api/v1/guilds/uuid/invites/mod.rs @@ -6,7 +6,7 @@ use crate::{ Data, api::v1::auth::check_access_token, error::Error, - structs::{Guild, Member}, + objects::{Guild, Member}, utils::{get_auth_header, global_checks}, }; diff --git a/src/api/v1/guilds/uuid/members.rs b/src/api/v1/guilds/uuid/members.rs index d7ed0a5..972d862 100644 --- a/src/api/v1/guilds/uuid/members.rs +++ b/src/api/v1/guilds/uuid/members.rs @@ -2,7 +2,7 @@ use crate::{ Data, api::v1::auth::check_access_token, error::Error, - structs::Member, + objects::Member, utils::{get_auth_header, global_checks}, }; use ::uuid::Uuid; diff --git a/src/api/v1/guilds/uuid/mod.rs b/src/api/v1/guilds/uuid/mod.rs index c24e957..4c88d7a 100644 --- a/src/api/v1/guilds/uuid/mod.rs +++ b/src/api/v1/guilds/uuid/mod.rs @@ -13,7 +13,7 @@ use crate::{ Data, api::v1::auth::check_access_token, error::Error, - structs::{Guild, Member}, + objects::{Guild, Member}, utils::{get_auth_header, global_checks}, }; diff --git a/src/api/v1/guilds/uuid/roles/mod.rs b/src/api/v1/guilds/uuid/roles/mod.rs index 8015384..717b30b 100644 --- a/src/api/v1/guilds/uuid/roles/mod.rs +++ b/src/api/v1/guilds/uuid/roles/mod.rs @@ -6,7 +6,7 @@ use crate::{ Data, api::v1::auth::check_access_token, error::Error, - structs::{Member, Role}, + objects::{Member, Role}, utils::{get_auth_header, global_checks, order_by_is_above}, }; diff --git a/src/api/v1/guilds/uuid/roles/uuid.rs b/src/api/v1/guilds/uuid/roles/uuid.rs index 0e7f306..f1a3206 100644 --- a/src/api/v1/guilds/uuid/roles/uuid.rs +++ b/src/api/v1/guilds/uuid/roles/uuid.rs @@ -2,7 +2,7 @@ use crate::{ Data, api::v1::auth::check_access_token, error::Error, - structs::{Member, Role}, + objects::{Member, Role}, utils::{get_auth_header, global_checks}, }; use ::uuid::Uuid; diff --git a/src/api/v1/invites/id.rs b/src/api/v1/invites/id.rs index 687b825..22e2868 100644 --- a/src/api/v1/invites/id.rs +++ b/src/api/v1/invites/id.rs @@ -4,7 +4,7 @@ use crate::{ Data, api::v1::auth::check_access_token, error::Error, - structs::{Guild, Invite, Member}, + objects::{Guild, Invite, Member}, utils::{get_auth_header, global_checks}, }; diff --git a/src/api/v1/me/guilds.rs b/src/api/v1/me/guilds.rs index 7fe02bd..71cfca4 100644 --- a/src/api/v1/me/guilds.rs +++ b/src/api/v1/me/guilds.rs @@ -6,7 +6,7 @@ use crate::{ Data, api::v1::auth::check_access_token, error::Error, - structs::Me, + objects::Me, utils::{get_auth_header, global_checks}, }; diff --git a/src/api/v1/me/mod.rs b/src/api/v1/me/mod.rs index ac35140..da5c929 100644 --- a/src/api/v1/me/mod.rs +++ b/src/api/v1/me/mod.rs @@ -6,7 +6,7 @@ use crate::{ Data, api::v1::auth::check_access_token, error::Error, - structs::Me, + objects::Me, utils::{get_auth_header, global_checks}, }; @@ -65,10 +65,7 @@ pub async fn update( 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() - { + if form.avatar.is_some() || form.json.username.is_some() || form.json.display_name.is_some() { global_checks(&data, uuid).await?; } @@ -79,12 +76,8 @@ pub async fn update( let byte_slice: &[u8] = &bytes; - me.set_avatar( - &data, - data.config.bunny.cdn_url.clone(), - byte_slice.into(), - ) - .await?; + me.set_avatar(&data, data.config.bunny.cdn_url.clone(), byte_slice.into()) + .await?; } if let Some(username) = &form.json.username { diff --git a/src/api/v1/users/mod.rs b/src/api/v1/users/mod.rs index fd3980d..334fd5f 100644 --- a/src/api/v1/users/mod.rs +++ b/src/api/v1/users/mod.rs @@ -6,7 +6,7 @@ use crate::{ Data, api::v1::auth::check_access_token, error::Error, - structs::{StartAmountQuery, User}, + objects::{StartAmountQuery, User}, utils::{get_auth_header, global_checks}, }; diff --git a/src/api/v1/users/uuid.rs b/src/api/v1/users/uuid.rs index 213afe5..9e602a0 100644 --- a/src/api/v1/users/uuid.rs +++ b/src/api/v1/users/uuid.rs @@ -7,7 +7,7 @@ use crate::{ Data, api::v1::auth::check_access_token, error::Error, - structs::User, + objects::User, utils::{get_auth_header, global_checks}, }; diff --git a/src/main.rs b/src/main.rs index d026f55..9a24d3d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,9 +5,9 @@ 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 std::time::SystemTime; -use structs::MailClient; mod config; use config::{Config, ConfigBuilder}; use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations}; @@ -19,8 +19,8 @@ type Conn = mod api; pub mod error; +pub mod objects; pub mod schema; -pub mod structs; pub mod utils; #[derive(Parser, Debug)] diff --git a/src/objects/channel.rs b/src/objects/channel.rs new file mode 100644 index 0000000..c9b5f1f --- /dev/null +++ b/src/objects/channel.rs @@ -0,0 +1,353 @@ +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, + is_above: Option, +} + +impl ChannelBuilder { + async fn build(self, conn: &mut Conn) -> Result { + use self::channel_permissions::dsl::*; + let channel_permission: Vec = 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, + pub is_above: Option, + pub permissions: Vec, +} + +#[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, + Conn, + >, + guild_uuid: Uuid, + ) -> Result, Error> { + let mut conn = pool.get().await?; + + use channels::dsl; + let channel_builders: Vec = 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 { + 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, + guild_uuid: Uuid, + name: String, + description: Option, + ) -> Result { + 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; + delete(channels::table) + .filter(dsl::uuid.eq(self.uuid)) + .execute(&mut conn) + .await?; + + if data.get_cache_key(self.uuid.to_string()).await.is_ok() { + data.del_cache_key(self.uuid.to_string()).await?; + } + + Ok(()) + } + + pub async fn fetch_messages( + &self, + data: &Data, + amount: i64, + offset: i64, + ) -> Result, Error> { + let mut conn = data.pool.get().await?; + + use messages::dsl; + let messages: Vec = 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 { + 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 = match dsl::channels + .filter(dsl::is_above.eq(self.uuid)) + .select(dsl::uuid) + .get_result(&mut conn) + .await + { + Ok(r) => Ok(Some(r)), + Err(e) if e == 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::)) + .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(e) if e == 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(()) + } +} diff --git a/src/objects/email_token.rs b/src/objects/email_token.rs new file mode 100644 index 0000000..e458cf7 --- /dev/null +++ b/src/objects/email_token.rs @@ -0,0 +1,80 @@ +use chrono::Utc; +use diesel::{ + ExpressionMethods, QueryDsl, Queryable, Selectable, SelectableHelper, delete, dsl::now, + insert_into, +}; +use diesel_async::RunQueryDsl; +use lettre::message::MultiPart; +use uuid::Uuid; + +use crate::{Conn, Data, error::Error, schema::email_tokens, utils::generate_refresh_token}; + +use super::Me; + +#[derive(Selectable, Queryable)] +#[diesel(table_name = email_tokens)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct EmailToken { + user_uuid: Uuid, + pub token: String, + pub created_at: chrono::DateTime, +} + +impl EmailToken { + pub async fn get(conn: &mut Conn, user_uuid: Uuid) -> Result { + use email_tokens::dsl; + let email_token = dsl::email_tokens + .filter(dsl::user_uuid.eq(user_uuid)) + .select(EmailToken::as_select()) + .get_result(conn) + .await?; + + Ok(email_token) + } + + #[allow(clippy::new_ret_no_self)] + pub async fn new(data: &Data, me: Me) -> Result<(), Error> { + let token = generate_refresh_token()?; + + let mut conn = data.pool.get().await?; + + use email_tokens::dsl; + insert_into(email_tokens::table) + .values(( + dsl::user_uuid.eq(me.uuid), + dsl::token.eq(&token), + dsl::created_at.eq(now), + )) + .execute(&mut conn) + .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#"

Verify your {} Account

Hello, {}!

Thanks for creating a new account on Gorb.

The final step to create your account is to verify your email address by clicking the button below, within 24 hours.

VERIFY ACCOUNT

If you didn't ask to verify this address, you can safely ignore this email.

"#, data.config.instance.name, me.username, verify_endpoint) + ))?; + + data.mail_client.send_mail(email).await?; + + Ok(()) + } + + pub async fn delete(&self, conn: &mut Conn) -> Result<(), Error> { + use email_tokens::dsl; + delete(email_tokens::table) + .filter(dsl::user_uuid.eq(self.user_uuid)) + .filter(dsl::token.eq(&self.token)) + .execute(conn) + .await?; + + Ok(()) + } +} diff --git a/src/objects/guild.rs b/src/objects/guild.rs new file mode 100644 index 0000000..f5e973d --- /dev/null +++ b/src/objects/guild.rs @@ -0,0 +1,226 @@ +use actix_web::web::BytesMut; +use diesel::{ + ExpressionMethods, Insertable, QueryDsl, Queryable, Selectable, SelectableHelper, insert_into, + update, +}; +use diesel_async::{RunQueryDsl, pooled_connection::AsyncDieselConnectionManager}; +use serde::Serialize; +use tokio::task; +use url::Url; +use uuid::Uuid; + +use crate::{ + Conn, + error::Error, + schema::{guild_members, guilds, invites}, + utils::image_check, +}; + +use super::{Invite, Member, Role, load_or_empty, member::MemberBuilder}; + +#[derive(Serialize, Queryable, Selectable, Insertable, Clone)] +#[diesel(table_name = guilds)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct GuildBuilder { + uuid: Uuid, + name: String, + description: Option, + icon: Option, + owner_uuid: Uuid, +} + +impl GuildBuilder { + pub async fn build(self, conn: &mut Conn) -> Result { + let member_count = Member::count(conn, self.uuid).await?; + + let roles = Role::fetch_all(conn, self.uuid).await?; + + Ok(Guild { + uuid: self.uuid, + name: self.name, + description: self.description, + icon: self.icon.and_then(|i| i.parse().ok()), + owner_uuid: self.owner_uuid, + roles, + member_count, + }) + } +} + +#[derive(Serialize)] +pub struct Guild { + pub uuid: Uuid, + name: String, + description: Option, + icon: Option, + owner_uuid: Uuid, + pub roles: Vec, + member_count: i64, +} + +impl Guild { + pub async fn fetch_one(conn: &mut Conn, guild_uuid: Uuid) -> Result { + use guilds::dsl; + let guild_builder: GuildBuilder = dsl::guilds + .filter(dsl::uuid.eq(guild_uuid)) + .select(GuildBuilder::as_select()) + .get_result(conn) + .await?; + + guild_builder.build(conn).await + } + + pub async fn fetch_amount( + pool: &deadpool::managed::Pool< + AsyncDieselConnectionManager, + Conn, + >, + offset: i64, + amount: i64, + ) -> Result, Error> { + // Fetch guild data from database + let mut conn = pool.get().await?; + + use guilds::dsl; + let guild_builders: Vec = load_or_empty( + dsl::guilds + .select(GuildBuilder::as_select()) + .order_by(dsl::uuid) + .offset(offset) + .limit(amount) + .load(&mut conn) + .await, + )?; + + // Process each guild concurrently + let guild_futures = guild_builders.iter().map(async move |g| { + let mut conn = pool.get().await?; + g.clone().build(&mut conn).await + }); + + // Execute all futures concurrently and collect results + futures::future::try_join_all(guild_futures).await + } + + pub async fn new(conn: &mut Conn, name: String, owner_uuid: Uuid) -> Result { + let guild_uuid = Uuid::now_v7(); + + let guild_builder = GuildBuilder { + uuid: guild_uuid, + name: name.clone(), + description: None, + icon: None, + owner_uuid, + }; + + insert_into(guilds::table) + .values(guild_builder) + .execute(conn) + .await?; + + let member_uuid = Uuid::now_v7(); + + let member = MemberBuilder { + uuid: member_uuid, + nickname: None, + user_uuid: owner_uuid, + guild_uuid, + }; + + insert_into(guild_members::table) + .values(member) + .execute(conn) + .await?; + + Ok(Guild { + uuid: guild_uuid, + name, + description: None, + icon: None, + owner_uuid, + roles: vec![], + member_count: 1, + }) + } + + pub async fn get_invites(&self, conn: &mut Conn) -> Result, Error> { + use invites::dsl; + let invites = load_or_empty( + dsl::invites + .filter(dsl::guild_uuid.eq(self.uuid)) + .select(Invite::as_select()) + .load(conn) + .await, + )?; + + Ok(invites) + } + + pub async fn create_invite( + &self, + conn: &mut Conn, + user_uuid: Uuid, + custom_id: Option, + ) -> Result { + let invite_id; + + if let Some(id) = custom_id { + invite_id = id; + if invite_id.len() > 32 { + return Err(Error::BadRequest("MAX LENGTH".to_string())); + } + } else { + let charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + + invite_id = random_string::generate(8, charset); + } + + let invite = Invite { + id: invite_id, + user_uuid, + guild_uuid: self.uuid, + }; + + insert_into(invites::table) + .values(invite.clone()) + .execute(conn) + .await?; + + Ok(invite) + } + + // FIXME: Horrible security + pub async fn set_icon( + &mut self, + bunny_cdn: &bunny_api_tokio::Client, + conn: &mut Conn, + cdn_url: Url, + icon: BytesMut, + ) -> Result<(), Error> { + let icon_clone = icon.clone(); + let image_type = task::spawn_blocking(move || image_check(icon_clone)).await??; + + if let Some(icon) = &self.icon { + let relative_url = icon.path().trim_start_matches('/'); + + bunny_cdn.storage.delete(relative_url).await?; + } + + let path = format!("icons/{}/icon.{}", self.uuid, image_type); + + bunny_cdn.storage.upload(path.clone(), icon.into()).await?; + + let icon_url = cdn_url.join(&path)?; + + use guilds::dsl; + update(guilds::table) + .filter(dsl::uuid.eq(self.uuid)) + .set(dsl::icon.eq(icon_url.as_str())) + .execute(conn) + .await?; + + self.icon = Some(icon_url); + + Ok(()) + } +} diff --git a/src/objects/invite.rs b/src/objects/invite.rs new file mode 100644 index 0000000..5e0827e --- /dev/null +++ b/src/objects/invite.rs @@ -0,0 +1,30 @@ +use diesel::{ExpressionMethods, Insertable, QueryDsl, Queryable, Selectable, SelectableHelper}; +use diesel_async::RunQueryDsl; +use serde::Serialize; +use uuid::Uuid; + +use crate::{Conn, error::Error, schema::invites}; + +/// Server invite struct +#[derive(Clone, Serialize, Queryable, Selectable, Insertable)] +pub struct Invite { + /// case-sensitive alphanumeric string with a fixed length of 8 characters, can be up to 32 characters for custom invites + pub id: String, + /// User that created the invite + pub user_uuid: Uuid, + /// UUID of the guild that the invite belongs to + pub guild_uuid: Uuid, +} + +impl Invite { + pub async fn fetch_one(conn: &mut Conn, invite_id: String) -> Result { + use invites::dsl; + let invite: Invite = dsl::invites + .filter(dsl::id.eq(invite_id)) + .select(Invite::as_select()) + .get_result(conn) + .await?; + + Ok(invite) + } +} diff --git a/src/objects/me.rs b/src/objects/me.rs new file mode 100644 index 0000000..6af5bce --- /dev/null +++ b/src/objects/me.rs @@ -0,0 +1,232 @@ +use actix_web::web::BytesMut; +use diesel::{ExpressionMethods, QueryDsl, Queryable, Selectable, SelectableHelper, update}; +use diesel_async::RunQueryDsl; +use serde::Serialize; +use tokio::task; +use url::Url; +use uuid::Uuid; + +use crate::{ + Conn, Data, + error::Error, + schema::{guild_members, guilds, users}, + utils::{EMAIL_REGEX, USERNAME_REGEX, image_check}, +}; + +use super::{Guild, guild::GuildBuilder, load_or_empty, member::MemberBuilder}; + +#[derive(Serialize, Queryable, Selectable)] +#[diesel(table_name = users)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct Me { + pub uuid: Uuid, + pub username: String, + pub display_name: Option, + avatar: Option, + pronouns: Option, + about: Option, + pub email: String, + pub email_verified: bool, +} + +impl Me { + pub async fn get(conn: &mut Conn, user_uuid: Uuid) -> Result { + use users::dsl; + let me: Me = dsl::users + .filter(dsl::uuid.eq(user_uuid)) + .select(Me::as_select()) + .get_result(conn) + .await?; + + Ok(me) + } + + pub async fn fetch_memberships(&self, conn: &mut Conn) -> Result, Error> { + use guild_members::dsl; + let memberships: Vec = load_or_empty( + dsl::guild_members + .filter(dsl::user_uuid.eq(self.uuid)) + .select(MemberBuilder::as_select()) + .load(conn) + .await, + )?; + + let mut guilds: Vec = vec![]; + + for membership in memberships { + use guilds::dsl; + guilds.push( + dsl::guilds + .filter(dsl::uuid.eq(membership.guild_uuid)) + .select(GuildBuilder::as_select()) + .get_result(conn) + .await? + .build(conn) + .await?, + ) + } + + Ok(guilds) + } + + pub async fn set_avatar( + &mut self, + data: &Data, + cdn_url: Url, + avatar: BytesMut, + ) -> Result<(), Error> { + let avatar_clone = avatar.clone(); + let image_type = task::spawn_blocking(move || image_check(avatar_clone)).await??; + + let mut conn = data.pool.get().await?; + + if let Some(avatar) = &self.avatar { + let avatar_url: Url = avatar.parse()?; + + let relative_url = avatar_url.path().trim_start_matches('/'); + + data.bunny_cdn.storage.delete(relative_url).await?; + } + + let path = format!("avatar/{}/avatar.{}", self.uuid, image_type); + + data.bunny_cdn + .storage + .upload(path.clone(), avatar.into()) + .await?; + + let avatar_url = cdn_url.join(&path)?; + + use users::dsl; + update(users::table) + .filter(dsl::uuid.eq(self.uuid)) + .set(dsl::avatar.eq(avatar_url.as_str())) + .execute(&mut conn) + .await?; + + if data.get_cache_key(self.uuid.to_string()).await.is_ok() { + data.del_cache_key(self.uuid.to_string()).await? + } + + self.avatar = Some(avatar_url.to_string()); + + Ok(()) + } + + pub async fn verify_email(&self, conn: &mut Conn) -> Result<(), Error> { + use users::dsl; + update(users::table) + .filter(dsl::uuid.eq(self.uuid)) + .set(dsl::email_verified.eq(true)) + .execute(conn) + .await?; + + Ok(()) + } + + pub async fn set_username(&mut self, data: &Data, new_username: String) -> Result<(), Error> { + if !USERNAME_REGEX.is_match(&new_username) { + return Err(Error::BadRequest("Invalid username".to_string())); + } + + let mut conn = data.pool.get().await?; + + use users::dsl; + update(users::table) + .filter(dsl::uuid.eq(self.uuid)) + .set(dsl::username.eq(new_username.as_str())) + .execute(&mut conn) + .await?; + + if data.get_cache_key(self.uuid.to_string()).await.is_ok() { + data.del_cache_key(self.uuid.to_string()).await? + } + + self.username = new_username; + + Ok(()) + } + + pub async fn set_display_name( + &mut self, + data: &Data, + new_display_name: String, + ) -> Result<(), Error> { + let mut conn = data.pool.get().await?; + + use users::dsl; + update(users::table) + .filter(dsl::uuid.eq(self.uuid)) + .set(dsl::display_name.eq(new_display_name.as_str())) + .execute(&mut conn) + .await?; + + if data.get_cache_key(self.uuid.to_string()).await.is_ok() { + data.del_cache_key(self.uuid.to_string()).await? + } + + self.display_name = Some(new_display_name); + + Ok(()) + } + + pub async fn set_email(&mut self, data: &Data, new_email: String) -> Result<(), Error> { + if !EMAIL_REGEX.is_match(&new_email) { + return Err(Error::BadRequest("Invalid username".to_string())); + } + + let mut conn = data.pool.get().await?; + + use users::dsl; + update(users::table) + .filter(dsl::uuid.eq(self.uuid)) + .set(( + dsl::email.eq(new_email.as_str()), + dsl::email_verified.eq(false), + )) + .execute(&mut conn) + .await?; + + if data.get_cache_key(self.uuid.to_string()).await.is_ok() { + data.del_cache_key(self.uuid.to_string()).await? + } + + self.email = new_email; + + Ok(()) + } + + pub async fn set_pronouns(&mut self, data: &Data, new_pronouns: String) -> Result<(), Error> { + let mut conn = data.pool.get().await?; + + use users::dsl; + update(users::table) + .filter(dsl::uuid.eq(self.uuid)) + .set((dsl::pronouns.eq(new_pronouns.as_str()),)) + .execute(&mut conn) + .await?; + + if data.get_cache_key(self.uuid.to_string()).await.is_ok() { + data.del_cache_key(self.uuid.to_string()).await? + } + + Ok(()) + } + + pub async fn set_about(&mut self, data: &Data, new_about: String) -> Result<(), Error> { + let mut conn = data.pool.get().await?; + + use users::dsl; + update(users::table) + .filter(dsl::uuid.eq(self.uuid)) + .set((dsl::about.eq(new_about.as_str()),)) + .execute(&mut conn) + .await?; + + if data.get_cache_key(self.uuid.to_string()).await.is_ok() { + data.del_cache_key(self.uuid.to_string()).await? + } + + Ok(()) + } +} diff --git a/src/objects/member.rs b/src/objects/member.rs new file mode 100644 index 0000000..f18e726 --- /dev/null +++ b/src/objects/member.rs @@ -0,0 +1,125 @@ +use diesel::{ + ExpressionMethods, Insertable, QueryDsl, Queryable, Selectable, SelectableHelper, insert_into, +}; +use diesel_async::RunQueryDsl; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{Conn, Data, error::Error, schema::guild_members}; + +use super::{User, load_or_empty}; + +#[derive(Serialize, Queryable, Selectable, Insertable)] +#[diesel(table_name = guild_members)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct MemberBuilder { + pub uuid: Uuid, + pub nickname: Option, + pub user_uuid: Uuid, + pub guild_uuid: Uuid, +} + +impl MemberBuilder { + async fn build(&self, data: &Data) -> Result { + let user = User::fetch_one(data, self.user_uuid).await?; + + Ok(Member { + uuid: self.uuid, + nickname: self.nickname.clone(), + user_uuid: self.user_uuid, + guild_uuid: self.guild_uuid, + user, + }) + } +} + +#[derive(Serialize, Deserialize)] +pub struct Member { + pub uuid: Uuid, + pub nickname: Option, + pub user_uuid: Uuid, + pub guild_uuid: Uuid, + user: User, +} + +impl Member { + pub async fn count(conn: &mut Conn, guild_uuid: Uuid) -> Result { + use guild_members::dsl; + let count: i64 = dsl::guild_members + .filter(dsl::guild_uuid.eq(guild_uuid)) + .count() + .get_result(conn) + .await?; + + Ok(count) + } + + pub async fn check_membership( + conn: &mut Conn, + user_uuid: Uuid, + guild_uuid: Uuid, + ) -> Result<(), Error> { + use guild_members::dsl; + dsl::guild_members + .filter(dsl::user_uuid.eq(user_uuid)) + .filter(dsl::guild_uuid.eq(guild_uuid)) + .select(MemberBuilder::as_select()) + .get_result(conn) + .await?; + + Ok(()) + } + + pub async fn fetch_one(data: &Data, user_uuid: Uuid, guild_uuid: Uuid) -> Result { + let mut conn = data.pool.get().await?; + + use guild_members::dsl; + let member: MemberBuilder = dsl::guild_members + .filter(dsl::user_uuid.eq(user_uuid)) + .filter(dsl::guild_uuid.eq(guild_uuid)) + .select(MemberBuilder::as_select()) + .get_result(&mut conn) + .await?; + + member.build(data).await + } + + pub async fn fetch_all(data: &Data, guild_uuid: Uuid) -> Result, Error> { + let mut conn = data.pool.get().await?; + + use guild_members::dsl; + let member_builders: Vec = load_or_empty( + dsl::guild_members + .filter(dsl::guild_uuid.eq(guild_uuid)) + .select(MemberBuilder::as_select()) + .load(&mut conn) + .await, + )?; + + let member_futures = member_builders + .iter() + .map(async move |m| m.build(data).await); + + futures::future::try_join_all(member_futures).await + } + + pub async fn new(data: &Data, user_uuid: Uuid, guild_uuid: Uuid) -> Result { + let mut conn = data.pool.get().await?; + + let member_uuid = Uuid::now_v7(); + + let member = MemberBuilder { + uuid: member_uuid, + guild_uuid, + user_uuid, + nickname: None, + }; + + insert_into(guild_members::table) + .values(&member) + .execute(&mut conn) + .await?; + + member.build(data).await + } +} diff --git a/src/objects/message.rs b/src/objects/message.rs new file mode 100644 index 0000000..6c1700a --- /dev/null +++ b/src/objects/message.rs @@ -0,0 +1,40 @@ +use diesel::{Insertable, Queryable, Selectable}; +use serde::Serialize; +use uuid::Uuid; + +use crate::{Data, error::Error, schema::messages}; + +use super::User; + +#[derive(Clone, Queryable, Selectable, Insertable)] +#[diesel(table_name = messages)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct MessageBuilder { + pub uuid: Uuid, + pub channel_uuid: Uuid, + pub user_uuid: Uuid, + pub message: String, +} + +impl MessageBuilder { + pub async fn build(&self, data: &Data) -> Result { + let user = User::fetch_one(data, self.user_uuid).await?; + + Ok(Message { + uuid: self.uuid, + channel_uuid: self.channel_uuid, + user_uuid: self.user_uuid, + message: self.message.clone(), + user, + }) + } +} + +#[derive(Clone, Serialize)] +pub struct Message { + uuid: Uuid, + channel_uuid: Uuid, + user_uuid: Uuid, + message: String, + user: User, +} diff --git a/src/objects/mod.rs b/src/objects/mod.rs new file mode 100644 index 0000000..7b45957 --- /dev/null +++ b/src/objects/mod.rs @@ -0,0 +1,156 @@ +use lettre::{ + AsyncSmtpTransport, AsyncTransport, Message as Email, Tokio1Executor, + message::{Mailbox, MessageBuilder as EmailBuilder}, + transport::smtp::authentication::Credentials, +}; +use log::debug; +use serde::Deserialize; +use uuid::Uuid; + +mod channel; +mod email_token; +mod guild; +mod invite; +mod me; +mod member; +mod message; +mod password_reset_token; +mod role; +mod user; + +pub use channel::Channel; +pub use email_token::EmailToken; +pub use guild::Guild; +pub use invite::Invite; +pub use me::Me; +pub use member::Member; +pub use message::Message; +pub use password_reset_token::PasswordResetToken; +pub use role::Role; +pub use user::User; + +use crate::error::Error; + +pub trait HasUuid { + fn uuid(&self) -> &Uuid; +} + +pub trait HasIsAbove { + fn is_above(&self) -> Option<&Uuid>; +} + +fn load_or_empty( + query_result: Result, diesel::result::Error>, +) -> Result, diesel::result::Error> { + match query_result { + Ok(vec) => Ok(vec), + Err(diesel::result::Error::NotFound) => Ok(Vec::new()), + Err(e) => Err(e), + } +} + +#[derive(PartialEq, Eq, Clone)] +pub enum MailTls { + StartTls, + Tls, +} + +impl From for MailTls { + fn from(value: String) -> Self { + match &*value.to_lowercase() { + "starttls" => Self::StartTls, + _ => Self::Tls, + } + } +} + +#[derive(Clone)] +pub struct MailClient { + creds: Credentials, + smtp_server: String, + mbox: Mailbox, + tls: MailTls, +} + +impl MailClient { + pub fn new>( + creds: Credentials, + smtp_server: String, + mbox: String, + tls: T, + ) -> Result { + Ok(Self { + creds, + smtp_server, + mbox: mbox.parse()?, + tls: tls.into(), + }) + } + + pub fn message_builder(&self) -> EmailBuilder { + Email::builder().from(self.mbox.clone()) + } + + pub async fn send_mail(&self, email: Email) -> Result<(), Error> { + let mailer: AsyncSmtpTransport = match self.tls { + MailTls::StartTls => { + AsyncSmtpTransport::::starttls_relay(&self.smtp_server)? + .credentials(self.creds.clone()) + .build() + } + MailTls::Tls => AsyncSmtpTransport::::relay(&self.smtp_server)? + .credentials(self.creds.clone()) + .build(), + }; + + let response = mailer.send(email).await?; + + debug!("mail sending response: {:?}", response); + + Ok(()) + } +} + +#[derive(Clone, Copy)] +pub enum Permissions { + SendMessage = 1, + CreateChannel = 2, + DeleteChannel = 4, + ManageChannel = 8, + CreateRole = 16, + DeleteRole = 32, + ManageRole = 64, + CreateInvite = 128, + ManageInvite = 256, + ManageServer = 512, + ManageMember = 1024, +} + +impl Permissions { + pub fn fetch_permissions(permissions: i64) -> Vec { + let all_perms = vec![ + Self::SendMessage, + Self::CreateChannel, + Self::DeleteChannel, + Self::ManageChannel, + Self::CreateRole, + Self::DeleteRole, + Self::ManageRole, + Self::CreateInvite, + Self::ManageInvite, + Self::ManageServer, + Self::ManageMember, + ]; + + all_perms + .into_iter() + .filter(|p| permissions & (*p as i64) != 0) + .collect() + } +} + +#[derive(Deserialize)] +pub struct StartAmountQuery { + pub start: Option, + pub amount: Option, +} diff --git a/src/objects/password_reset_token.rs b/src/objects/password_reset_token.rs new file mode 100644 index 0000000..e3c7bca --- /dev/null +++ b/src/objects/password_reset_token.rs @@ -0,0 +1,160 @@ +use argon2::{ + PasswordHasher, + password_hash::{SaltString, rand_core::OsRng}, +}; +use chrono::Utc; +use diesel::{ + ExpressionMethods, QueryDsl, Queryable, Selectable, SelectableHelper, delete, dsl::now, + insert_into, update, +}; +use diesel_async::RunQueryDsl; +use lettre::message::MultiPart; +use uuid::Uuid; + +use crate::{ + Conn, Data, + error::Error, + schema::{password_reset_tokens, users}, + utils::{PASSWORD_REGEX, generate_refresh_token, global_checks, user_uuid_from_identifier}, +}; + +#[derive(Selectable, Queryable)] +#[diesel(table_name = password_reset_tokens)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct PasswordResetToken { + user_uuid: Uuid, + pub token: String, + pub created_at: chrono::DateTime, +} + +impl PasswordResetToken { + pub async fn get(conn: &mut Conn, token: String) -> Result { + use password_reset_tokens::dsl; + let password_reset_token = dsl::password_reset_tokens + .filter(dsl::token.eq(token)) + .select(PasswordResetToken::as_select()) + .get_result(conn) + .await?; + + Ok(password_reset_token) + } + + pub async fn get_with_identifier( + conn: &mut Conn, + identifier: String, + ) -> Result { + let user_uuid = user_uuid_from_identifier(conn, &identifier).await?; + + use password_reset_tokens::dsl; + let password_reset_token = dsl::password_reset_tokens + .filter(dsl::user_uuid.eq(user_uuid)) + .select(PasswordResetToken::as_select()) + .get_result(conn) + .await?; + + Ok(password_reset_token) + } + + #[allow(clippy::new_ret_no_self)] + pub async fn new(data: &Data, identifier: String) -> Result<(), Error> { + let token = generate_refresh_token()?; + + let mut conn = data.pool.get().await?; + + let user_uuid = user_uuid_from_identifier(&mut conn, &identifier).await?; + + global_checks(data, user_uuid).await?; + + use users::dsl as udsl; + let (username, email_address): (String, String) = udsl::users + .filter(udsl::uuid.eq(user_uuid)) + .select((udsl::username, udsl::email)) + .get_result(&mut conn) + .await?; + + use password_reset_tokens::dsl; + insert_into(password_reset_tokens::table) + .values(( + dsl::user_uuid.eq(user_uuid), + dsl::token.eq(&token), + dsl::created_at.eq(now), + )) + .execute(&mut conn) + .await?; + + let mut reset_endpoint = data.config.web.frontend_url.join("reset-password")?; + + reset_endpoint.set_query(Some(&format!("token={}", token))); + + let email = data + .mail_client + .message_builder() + .to(email_address.parse()?) + .subject(format!("{} Password Reset", data.config.instance.name)) + .multipart(MultiPart::alternative_plain_html( + format!("{} Password Reset\n\nHello, {}!\nSomeone requested a password reset for your Gorb account.\nClick the button below within 24 hours to reset your password.\n\n{}\n\nIf you didn't request a password reset, don't worry, your account is safe and you can safely ignore this email.\n\nThanks, The gorb team.", data.config.instance.name, username, reset_endpoint), + format!(r#"

{} Password Reset

Hello, {}!

Someone requested a password reset for your Gorb account.

Click the button below within 24 hours to reset your password.

RESET PASSWORD

If you didn't request a password reset, don't worry, your account is safe and you can safely ignore this email.

"#, data.config.instance.name, username, reset_endpoint) + ))?; + + data.mail_client.send_mail(email).await?; + + Ok(()) + } + + pub async fn set_password(&self, data: &Data, password: String) -> Result<(), Error> { + if !PASSWORD_REGEX.is_match(&password) { + return Err(Error::BadRequest( + "Please provide a valid password".to_string(), + )); + } + + let salt = SaltString::generate(&mut OsRng); + + let hashed_password = data + .argon2 + .hash_password(password.as_bytes(), &salt) + .map_err(|e| Error::PasswordHashError(e.to_string()))?; + + let mut conn = data.pool.get().await?; + + use users::dsl; + update(users::table) + .filter(dsl::uuid.eq(self.user_uuid)) + .set(dsl::password.eq(hashed_password.to_string())) + .execute(&mut conn) + .await?; + + let (username, email_address): (String, String) = dsl::users + .filter(dsl::uuid.eq(self.user_uuid)) + .select((dsl::username, dsl::email)) + .get_result(&mut conn) + .await?; + + let login_page = data.config.web.frontend_url.join("login")?; + + let email = data + .mail_client + .message_builder() + .to(email_address.parse()?) + .subject(format!("Your {} Password has been Reset", data.config.instance.name)) + .multipart(MultiPart::alternative_plain_html( + format!("{} Password Reset Confirmation\n\nHello, {}!\nYour password has been successfully reset for your Gorb account.\nIf you did not initiate this change, please click the link below to reset your password immediately.\n\n{}\n\nThanks, The gorb team.", data.config.instance.name, username, login_page), + format!(r#"

{} Password Reset Confirmation

Hello, {}!

Your password has been successfully reset for your Gorb account.

If you did not initiate this change, please click the button below to reset your password immediately.

RESET PASSWORD
"#, data.config.instance.name, username, login_page) + ))?; + + data.mail_client.send_mail(email).await?; + + self.delete(&mut conn).await + } + + pub async fn delete(&self, conn: &mut Conn) -> Result<(), Error> { + use password_reset_tokens::dsl; + delete(password_reset_tokens::table) + .filter(dsl::user_uuid.eq(self.user_uuid)) + .filter(dsl::token.eq(&self.token)) + .execute(conn) + .await?; + + Ok(()) + } +} diff --git a/src/objects/role.rs b/src/objects/role.rs new file mode 100644 index 0000000..f67dc6d --- /dev/null +++ b/src/objects/role.rs @@ -0,0 +1,96 @@ +use diesel::{ + ExpressionMethods, Insertable, QueryDsl, Queryable, Selectable, SelectableHelper, insert_into, + update, +}; +use diesel_async::RunQueryDsl; +use serde::Serialize; +use uuid::Uuid; + +use crate::{Conn, error::Error, schema::roles, utils::order_by_is_above}; + +use super::{HasIsAbove, HasUuid, load_or_empty}; + +#[derive(Serialize, Clone, Queryable, Selectable, Insertable)] +#[diesel(table_name = roles)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct Role { + uuid: Uuid, + guild_uuid: Uuid, + name: String, + color: i32, + is_above: Option, + permissions: i64, +} + +impl HasUuid for Role { + fn uuid(&self) -> &Uuid { + self.uuid.as_ref() + } +} + +impl HasIsAbove for Role { + fn is_above(&self) -> Option<&Uuid> { + self.is_above.as_ref() + } +} + +impl Role { + pub async fn fetch_all(conn: &mut Conn, guild_uuid: Uuid) -> Result, Error> { + use roles::dsl; + let roles: Vec = load_or_empty( + dsl::roles + .filter(dsl::guild_uuid.eq(guild_uuid)) + .select(Role::as_select()) + .load(conn) + .await, + )?; + + Ok(roles) + } + + pub async fn fetch_one(conn: &mut Conn, role_uuid: Uuid) -> Result { + use roles::dsl; + let role: Role = dsl::roles + .filter(dsl::uuid.eq(role_uuid)) + .select(Role::as_select()) + .get_result(conn) + .await?; + + Ok(role) + } + + pub async fn new(conn: &mut Conn, guild_uuid: Uuid, name: String) -> Result { + let role_uuid = Uuid::now_v7(); + + let roles = Self::fetch_all(conn, guild_uuid).await?; + + let roles_ordered = order_by_is_above(roles).await?; + + let last_role = roles_ordered.last(); + + let new_role = Role { + uuid: role_uuid, + guild_uuid, + name, + color: 16777215, + is_above: None, + permissions: 0, + }; + + insert_into(roles::table) + .values(new_role.clone()) + .execute(conn) + .await?; + + if let Some(old_last_role) = last_role { + use roles::dsl; + update(roles::table) + .filter(dsl::uuid.eq(old_last_role.uuid)) + .set(dsl::is_above.eq(new_role.uuid)) + .execute(conn) + .await?; + } + + Ok(new_role) + } +} diff --git a/src/objects/user.rs b/src/objects/user.rs new file mode 100644 index 0000000..98e5e80 --- /dev/null +++ b/src/objects/user.rs @@ -0,0 +1,60 @@ +use diesel::{ExpressionMethods, QueryDsl, Queryable, Selectable, SelectableHelper}; +use diesel_async::RunQueryDsl; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{Conn, Data, error::Error, schema::users}; + +use super::load_or_empty; + +#[derive(Deserialize, Serialize, Clone, Queryable, Selectable)] +#[diesel(table_name = users)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct User { + uuid: Uuid, + username: String, + display_name: Option, + avatar: Option, + pronouns: Option, + about: Option, +} + +impl User { + pub async fn fetch_one(data: &Data, user_uuid: Uuid) -> Result { + let mut conn = data.pool.get().await?; + + if let Ok(cache_hit) = data.get_cache_key(user_uuid.to_string()).await { + return Ok(serde_json::from_str(&cache_hit)?); + } + + use users::dsl; + let user: User = dsl::users + .filter(dsl::uuid.eq(user_uuid)) + .select(User::as_select()) + .get_result(&mut conn) + .await?; + + data.set_cache_key(user_uuid.to_string(), user.clone(), 1800) + .await?; + + Ok(user) + } + + pub async fn fetch_amount( + conn: &mut Conn, + offset: i64, + amount: i64, + ) -> Result, Error> { + use users::dsl; + let users: Vec = load_or_empty( + dsl::users + .limit(amount) + .offset(offset) + .select(User::as_select()) + .load(conn) + .await, + )?; + + Ok(users) + } +} diff --git a/src/structs.rs b/src/structs.rs deleted file mode 100644 index b68aaf5..0000000 --- a/src/structs.rs +++ /dev/null @@ -1,1437 +0,0 @@ -use actix_web::web::BytesMut; -use argon2::{ - PasswordHasher, - password_hash::{SaltString, rand_core::OsRng}, -}; -use chrono::Utc; -use diesel::{ - ExpressionMethods, QueryDsl, Selectable, SelectableHelper, delete, - dsl::now, - insert_into, - prelude::{Insertable, Queryable}, - update, -}; -use diesel_async::{RunQueryDsl, pooled_connection::AsyncDieselConnectionManager}; -use lettre::{ - AsyncSmtpTransport, AsyncTransport, Message as Email, Tokio1Executor, - message::{Mailbox, MessageBuilder as EmailBuilder, MultiPart}, - transport::smtp::authentication::Credentials, -}; -use log::debug; -use serde::{Deserialize, Serialize}; -use tokio::task; -use url::Url; -use uuid::Uuid; - -use crate::{ - error::Error, schema::*, utils::{ - generate_refresh_token, global_checks, image_check, order_by_is_above, user_uuid_from_identifier, CHANNEL_REGEX, EMAIL_REGEX, PASSWORD_REGEX, USERNAME_REGEX - }, Conn, Data -}; - -pub trait HasUuid { - fn uuid(&self) -> &Uuid; -} - -pub trait HasIsAbove { - fn is_above(&self) -> Option<&Uuid>; -} - -fn load_or_empty( - query_result: Result, diesel::result::Error>, -) -> Result, diesel::result::Error> { - match query_result { - Ok(vec) => Ok(vec), - Err(diesel::result::Error::NotFound) => Ok(Vec::new()), - Err(e) => Err(e), - } -} - -#[derive(PartialEq, Eq, Clone)] -pub enum MailTls { - StartTls, - Tls, -} - -impl From for MailTls { - fn from(value: String) -> Self { - match &*value.to_lowercase() { - "starttls" => Self::StartTls, - _ => Self::Tls, - } - } -} - -#[derive(Clone)] -pub struct MailClient { - creds: Credentials, - smtp_server: String, - mbox: Mailbox, - tls: MailTls, -} - -impl MailClient { - pub fn new>( - creds: Credentials, - smtp_server: String, - mbox: String, - tls: T, - ) -> Result { - Ok(Self { - creds, - smtp_server, - mbox: mbox.parse()?, - tls: tls.into(), - }) - } - - pub fn message_builder(&self) -> EmailBuilder { - Email::builder().from(self.mbox.clone()) - } - - pub async fn send_mail(&self, email: Email) -> Result<(), Error> { - let mailer: AsyncSmtpTransport = match self.tls { - MailTls::StartTls => { - AsyncSmtpTransport::::starttls_relay(&self.smtp_server)? - .credentials(self.creds.clone()) - .build() - } - MailTls::Tls => AsyncSmtpTransport::::relay(&self.smtp_server)? - .credentials(self.creds.clone()) - .build(), - }; - - let response = mailer.send(email).await?; - - debug!("mail sending response: {:?}", response); - - Ok(()) - } -} - -#[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, - is_above: Option, -} - -impl ChannelBuilder { - async fn build(self, conn: &mut Conn) -> Result { - use self::channel_permissions::dsl::*; - let channel_permission: Vec = 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, - pub is_above: Option, - pub permissions: Vec, -} - -#[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, - Conn, - >, - guild_uuid: Uuid, - ) -> Result, Error> { - let mut conn = pool.get().await?; - - use channels::dsl; - let channel_builders: Vec = 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 { - 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, - guild_uuid: Uuid, - name: String, - description: Option, - ) -> Result { - 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; - delete(channels::table) - .filter(dsl::uuid.eq(self.uuid)) - .execute(&mut conn) - .await?; - - if data.get_cache_key(self.uuid.to_string()).await.is_ok() { - data.del_cache_key(self.uuid.to_string()).await?; - } - - Ok(()) - } - - pub async fn fetch_messages( - &self, - data: &Data, - amount: i64, - offset: i64, - ) -> Result, Error> { - let mut conn = data.pool.get().await?; - - use messages::dsl; - let messages: Vec = 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 { - 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 = match dsl::channels - .filter(dsl::is_above.eq(self.uuid)) - .select(dsl::uuid) - .get_result(&mut conn) - .await - { - Ok(r) => Ok(Some(r)), - Err(e) if e == 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::)) - .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(e) if e == 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(()) - } -} - -#[derive(Clone, Copy)] -pub enum Permissions { - SendMessage = 1, - CreateChannel = 2, - DeleteChannel = 4, - ManageChannel = 8, - CreateRole = 16, - DeleteRole = 32, - ManageRole = 64, - CreateInvite = 128, - ManageInvite = 256, - ManageServer = 512, - ManageMember = 1024, -} - -impl Permissions { - pub fn fetch_permissions(permissions: i64) -> Vec { - let all_perms = vec![ - Self::SendMessage, - Self::CreateChannel, - Self::DeleteChannel, - Self::ManageChannel, - Self::CreateRole, - Self::DeleteRole, - Self::ManageRole, - Self::CreateInvite, - Self::ManageInvite, - Self::ManageServer, - Self::ManageMember, - ]; - - all_perms - .into_iter() - .filter(|p| permissions & (*p as i64) != 0) - .collect() - } -} - -#[derive(Serialize, Queryable, Selectable, Insertable, Clone)] -#[diesel(table_name = guilds)] -#[diesel(check_for_backend(diesel::pg::Pg))] -struct GuildBuilder { - uuid: Uuid, - name: String, - description: Option, - icon: Option, - owner_uuid: Uuid, -} - -impl GuildBuilder { - async fn build(self, conn: &mut Conn) -> Result { - let member_count = Member::count(conn, self.uuid).await?; - - let roles = Role::fetch_all(conn, self.uuid).await?; - - Ok(Guild { - uuid: self.uuid, - name: self.name, - description: self.description, - icon: self.icon.and_then(|i| i.parse().ok()), - owner_uuid: self.owner_uuid, - roles, - member_count, - }) - } -} - -#[derive(Serialize)] -pub struct Guild { - pub uuid: Uuid, - name: String, - description: Option, - icon: Option, - owner_uuid: Uuid, - pub roles: Vec, - member_count: i64, -} - -impl Guild { - pub async fn fetch_one(conn: &mut Conn, guild_uuid: Uuid) -> Result { - use guilds::dsl; - let guild_builder: GuildBuilder = dsl::guilds - .filter(dsl::uuid.eq(guild_uuid)) - .select(GuildBuilder::as_select()) - .get_result(conn) - .await?; - - guild_builder.build(conn).await - } - - pub async fn fetch_amount( - pool: &deadpool::managed::Pool< - AsyncDieselConnectionManager, - Conn, - >, - offset: i64, - amount: i64, - ) -> Result, Error> { - // Fetch guild data from database - let mut conn = pool.get().await?; - - use guilds::dsl; - let guild_builders: Vec = load_or_empty( - dsl::guilds - .select(GuildBuilder::as_select()) - .order_by(dsl::uuid) - .offset(offset) - .limit(amount) - .load(&mut conn) - .await, - )?; - - // Process each guild concurrently - let guild_futures = guild_builders.iter().map(async move |g| { - let mut conn = pool.get().await?; - g.clone().build(&mut conn).await - }); - - // Execute all futures concurrently and collect results - futures::future::try_join_all(guild_futures).await - } - - pub async fn new(conn: &mut Conn, name: String, owner_uuid: Uuid) -> Result { - let guild_uuid = Uuid::now_v7(); - - let guild_builder = GuildBuilder { - uuid: guild_uuid, - name: name.clone(), - description: None, - icon: None, - owner_uuid, - }; - - insert_into(guilds::table) - .values(guild_builder) - .execute(conn) - .await?; - - let member_uuid = Uuid::now_v7(); - - let member = MemberBuilder { - uuid: member_uuid, - nickname: None, - user_uuid: owner_uuid, - guild_uuid, - }; - - insert_into(guild_members::table) - .values(member) - .execute(conn) - .await?; - - Ok(Guild { - uuid: guild_uuid, - name, - description: None, - icon: None, - owner_uuid, - roles: vec![], - member_count: 1, - }) - } - - pub async fn get_invites(&self, conn: &mut Conn) -> Result, Error> { - use invites::dsl; - let invites = load_or_empty( - dsl::invites - .filter(dsl::guild_uuid.eq(self.uuid)) - .select(Invite::as_select()) - .load(conn) - .await, - )?; - - Ok(invites) - } - - pub async fn create_invite( - &self, - conn: &mut Conn, - user_uuid: Uuid, - custom_id: Option, - ) -> Result { - let invite_id; - - if let Some(id) = custom_id { - invite_id = id; - if invite_id.len() > 32 { - return Err(Error::BadRequest("MAX LENGTH".to_string())); - } - } else { - let charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - - invite_id = random_string::generate(8, charset); - } - - let invite = Invite { - id: invite_id, - user_uuid, - guild_uuid: self.uuid, - }; - - insert_into(invites::table) - .values(invite.clone()) - .execute(conn) - .await?; - - Ok(invite) - } - - // FIXME: Horrible security - pub async fn set_icon( - &mut self, - bunny_cdn: &bunny_api_tokio::Client, - conn: &mut Conn, - cdn_url: Url, - icon: BytesMut, - ) -> Result<(), Error> { - let icon_clone = icon.clone(); - let image_type = task::spawn_blocking(move || image_check(icon_clone)).await??; - - if let Some(icon) = &self.icon { - let relative_url = icon.path().trim_start_matches('/'); - - bunny_cdn.storage.delete(relative_url).await?; - } - - let path = format!("icons/{}/icon.{}", self.uuid, image_type); - - bunny_cdn.storage.upload(path.clone(), icon.into()).await?; - - let icon_url = cdn_url.join(&path)?; - - use guilds::dsl; - update(guilds::table) - .filter(dsl::uuid.eq(self.uuid)) - .set(dsl::icon.eq(icon_url.as_str())) - .execute(conn) - .await?; - - self.icon = Some(icon_url); - - Ok(()) - } -} - -#[derive(Serialize, Clone, Queryable, Selectable, Insertable)] -#[diesel(table_name = roles)] -#[diesel(check_for_backend(diesel::pg::Pg))] -pub struct Role { - uuid: Uuid, - guild_uuid: Uuid, - name: String, - color: i32, - is_above: Option, - permissions: i64, -} - -impl HasUuid for Role { - fn uuid(&self) -> &Uuid { - self.uuid.as_ref() - } -} - -impl HasIsAbove for Role { - fn is_above(&self) -> Option<&Uuid> { - self.is_above.as_ref() - } -} - -impl Role { - pub async fn fetch_all(conn: &mut Conn, guild_uuid: Uuid) -> Result, Error> { - use roles::dsl; - let roles: Vec = load_or_empty( - dsl::roles - .filter(dsl::guild_uuid.eq(guild_uuid)) - .select(Role::as_select()) - .load(conn) - .await, - )?; - - Ok(roles) - } - - pub async fn fetch_one(conn: &mut Conn, role_uuid: Uuid) -> Result { - use roles::dsl; - let role: Role = dsl::roles - .filter(dsl::uuid.eq(role_uuid)) - .select(Role::as_select()) - .get_result(conn) - .await?; - - Ok(role) - } - - pub async fn new(conn: &mut Conn, guild_uuid: Uuid, name: String) -> Result { - let role_uuid = Uuid::now_v7(); - - let roles = Self::fetch_all(conn, guild_uuid).await?; - - let roles_ordered = order_by_is_above(roles).await?; - - let last_role = roles_ordered.last(); - - let new_role = Role { - uuid: role_uuid, - guild_uuid, - name, - color: 16777215, - is_above: None, - permissions: 0, - }; - - insert_into(roles::table) - .values(new_role.clone()) - .execute(conn) - .await?; - - if let Some(old_last_role) = last_role { - use roles::dsl; - update(roles::table) - .filter(dsl::uuid.eq(old_last_role.uuid)) - .set(dsl::is_above.eq(new_role.uuid)) - .execute(conn) - .await?; - } - - Ok(new_role) - } -} - -#[derive(Serialize, Queryable, Selectable, Insertable)] -#[diesel(table_name = guild_members)] -#[diesel(check_for_backend(diesel::pg::Pg))] -pub struct MemberBuilder { - pub uuid: Uuid, - pub nickname: Option, - pub user_uuid: Uuid, - pub guild_uuid: Uuid, -} - -impl MemberBuilder { - async fn build(&self, data: &Data) -> Result { - let user = User::fetch_one(data, self.user_uuid).await?; - - Ok(Member { - uuid: self.uuid, - nickname: self.nickname.clone(), - user_uuid: self.user_uuid, - guild_uuid: self.guild_uuid, - user, - }) - } -} - -#[derive(Serialize, Deserialize)] -pub struct Member { - pub uuid: Uuid, - pub nickname: Option, - pub user_uuid: Uuid, - pub guild_uuid: Uuid, - user: User, -} - -impl Member { - async fn count(conn: &mut Conn, guild_uuid: Uuid) -> Result { - use guild_members::dsl; - let count: i64 = dsl::guild_members - .filter(dsl::guild_uuid.eq(guild_uuid)) - .count() - .get_result(conn) - .await?; - - Ok(count) - } - - pub async fn check_membership( - conn: &mut Conn, - user_uuid: Uuid, - guild_uuid: Uuid, - ) -> Result<(), Error> { - use guild_members::dsl; - dsl::guild_members - .filter(dsl::user_uuid.eq(user_uuid)) - .filter(dsl::guild_uuid.eq(guild_uuid)) - .select(MemberBuilder::as_select()) - .get_result(conn) - .await?; - - Ok(()) - } - - pub async fn fetch_one(data: &Data, user_uuid: Uuid, guild_uuid: Uuid) -> Result { - let mut conn = data.pool.get().await?; - - use guild_members::dsl; - let member: MemberBuilder = dsl::guild_members - .filter(dsl::user_uuid.eq(user_uuid)) - .filter(dsl::guild_uuid.eq(guild_uuid)) - .select(MemberBuilder::as_select()) - .get_result(&mut conn) - .await?; - - member.build(data).await - } - - pub async fn fetch_all(data: &Data, guild_uuid: Uuid) -> Result, Error> { - let mut conn = data.pool.get().await?; - - use guild_members::dsl; - let member_builders: Vec = load_or_empty( - dsl::guild_members - .filter(dsl::guild_uuid.eq(guild_uuid)) - .select(MemberBuilder::as_select()) - .load(&mut conn) - .await, - )?; - - let member_futures = member_builders - .iter() - .map(async move |m| m.build(data).await); - - futures::future::try_join_all(member_futures).await - } - - pub async fn new(data: &Data, user_uuid: Uuid, guild_uuid: Uuid) -> Result { - let mut conn = data.pool.get().await?; - - let member_uuid = Uuid::now_v7(); - - let member = MemberBuilder { - uuid: member_uuid, - guild_uuid, - user_uuid, - nickname: None, - }; - - insert_into(guild_members::table) - .values(&member) - .execute(&mut conn) - .await?; - - member.build(data).await - } -} - -#[derive(Clone, Queryable, Selectable, Insertable)] -#[diesel(table_name = messages)] -#[diesel(check_for_backend(diesel::pg::Pg))] -pub struct MessageBuilder { - uuid: Uuid, - channel_uuid: Uuid, - user_uuid: Uuid, - message: String, -} - -impl MessageBuilder { - pub async fn build(&self, data: &Data) -> Result { - let user = User::fetch_one(data, self.user_uuid).await?; - - Ok(Message { - uuid: self.uuid, - channel_uuid: self.channel_uuid, - user_uuid: self.user_uuid, - message: self.message.clone(), - user, - }) - } -} - -#[derive(Clone, Serialize)] -pub struct Message { - uuid: Uuid, - channel_uuid: Uuid, - user_uuid: Uuid, - message: String, - user: User, -} - -/// Server invite struct -#[derive(Clone, Serialize, Queryable, Selectable, Insertable)] -pub struct Invite { - /// case-sensitive alphanumeric string with a fixed length of 8 characters, can be up to 32 characters for custom invites - id: String, - /// User that created the invite - user_uuid: Uuid, - /// UUID of the guild that the invite belongs to - pub guild_uuid: Uuid, -} - -impl Invite { - pub async fn fetch_one(conn: &mut Conn, invite_id: String) -> Result { - use invites::dsl; - let invite: Invite = dsl::invites - .filter(dsl::id.eq(invite_id)) - .select(Invite::as_select()) - .get_result(conn) - .await?; - - Ok(invite) - } -} - -#[derive(Deserialize, Serialize, Clone, Queryable, Selectable)] -#[diesel(table_name = users)] -#[diesel(check_for_backend(diesel::pg::Pg))] -pub struct User { - uuid: Uuid, - username: String, - display_name: Option, - avatar: Option, - pronouns: Option, - about: Option, -} - -impl User { - pub async fn fetch_one(data: &Data, user_uuid: Uuid) -> Result { - let mut conn = data.pool.get().await?; - - if let Ok(cache_hit) = data.get_cache_key(user_uuid.to_string()).await { - return Ok(serde_json::from_str(&cache_hit)?); - } - - use users::dsl; - let user: User = dsl::users - .filter(dsl::uuid.eq(user_uuid)) - .select(User::as_select()) - .get_result(&mut conn) - .await?; - - data.set_cache_key(user_uuid.to_string(), user.clone(), 1800) - .await?; - - Ok(user) - } - - pub async fn fetch_amount( - conn: &mut Conn, - offset: i64, - amount: i64, - ) -> Result, Error> { - use users::dsl; - let users: Vec = load_or_empty( - dsl::users - .limit(amount) - .offset(offset) - .select(User::as_select()) - .load(conn) - .await, - )?; - - Ok(users) - } -} - -#[derive(Serialize, Queryable, Selectable)] -#[diesel(table_name = users)] -#[diesel(check_for_backend(diesel::pg::Pg))] -pub struct Me { - pub uuid: Uuid, - username: String, - display_name: Option, - avatar: Option, - pronouns: Option, - about: Option, - email: String, - pub email_verified: bool, -} - -impl Me { - pub async fn get(conn: &mut Conn, user_uuid: Uuid) -> Result { - use users::dsl; - let me: Me = dsl::users - .filter(dsl::uuid.eq(user_uuid)) - .select(Me::as_select()) - .get_result(conn) - .await?; - - Ok(me) - } - - pub async fn fetch_memberships(&self, conn: &mut Conn) -> Result, Error> { - use guild_members::dsl; - let memberships: Vec = load_or_empty( - dsl::guild_members - .filter(dsl::user_uuid.eq(self.uuid)) - .select(MemberBuilder::as_select()) - .load(conn) - .await - )?; - - let mut guilds: Vec = vec![]; - - for membership in memberships { - use guilds::dsl; - guilds.push( - dsl::guilds - .filter(dsl::uuid.eq(membership.guild_uuid)) - .select(GuildBuilder::as_select()) - .get_result(conn) - .await? - .build(conn) - .await?, - ) - } - - Ok(guilds) - } - - pub async fn set_avatar( - &mut self, - data: &Data, - cdn_url: Url, - avatar: BytesMut, - ) -> Result<(), Error> { - let avatar_clone = avatar.clone(); - let image_type = task::spawn_blocking(move || image_check(avatar_clone)).await??; - - let mut conn = data.pool.get().await?; - - if let Some(avatar) = &self.avatar { - let avatar_url: Url = avatar.parse()?; - - let relative_url = avatar_url.path().trim_start_matches('/'); - - data.bunny_cdn.storage.delete(relative_url).await?; - } - - let path = format!("avatar/{}/avatar.{}", self.uuid, image_type); - - data. - bunny_cdn - .storage - .upload(path.clone(), avatar.into()) - .await?; - - let avatar_url = cdn_url.join(&path)?; - - use users::dsl; - update(users::table) - .filter(dsl::uuid.eq(self.uuid)) - .set(dsl::avatar.eq(avatar_url.as_str())) - .execute(&mut conn) - .await?; - - if data.get_cache_key(self.uuid.to_string()).await.is_ok() { - data.del_cache_key(self.uuid.to_string()).await? - } - - self.avatar = Some(avatar_url.to_string()); - - Ok(()) - } - - pub async fn verify_email(&self, conn: &mut Conn) -> Result<(), Error> { - use users::dsl; - update(users::table) - .filter(dsl::uuid.eq(self.uuid)) - .set(dsl::email_verified.eq(true)) - .execute(conn) - .await?; - - Ok(()) - } - - pub async fn set_username( - &mut self, - data: &Data, - new_username: String, - ) -> Result<(), Error> { - if !USERNAME_REGEX.is_match(&new_username) { - return Err(Error::BadRequest("Invalid username".to_string())); - } - - let mut conn = data.pool.get().await?; - - use users::dsl; - update(users::table) - .filter(dsl::uuid.eq(self.uuid)) - .set(dsl::username.eq(new_username.as_str())) - .execute(&mut conn) - .await?; - - if data.get_cache_key(self.uuid.to_string()).await.is_ok() { - data.del_cache_key(self.uuid.to_string()).await? - } - - self.username = new_username; - - Ok(()) - } - - pub async fn set_display_name( - &mut self, - data: &Data, - new_display_name: String, - ) -> Result<(), Error> { - let mut conn = data.pool.get().await?; - - use users::dsl; - update(users::table) - .filter(dsl::uuid.eq(self.uuid)) - .set(dsl::display_name.eq(new_display_name.as_str())) - .execute(&mut conn) - .await?; - - if data.get_cache_key(self.uuid.to_string()).await.is_ok() { - data.del_cache_key(self.uuid.to_string()).await? - } - - self.display_name = Some(new_display_name); - - Ok(()) - } - - pub async fn set_email(&mut self, data: &Data, new_email: String) -> Result<(), Error> { - if !EMAIL_REGEX.is_match(&new_email) { - return Err(Error::BadRequest("Invalid username".to_string())); - } - - let mut conn = data.pool.get().await?; - - use users::dsl; - update(users::table) - .filter(dsl::uuid.eq(self.uuid)) - .set(( - dsl::email.eq(new_email.as_str()), - dsl::email_verified.eq(false), - )) - .execute(&mut conn) - .await?; - - if data.get_cache_key(self.uuid.to_string()).await.is_ok() { - data.del_cache_key(self.uuid.to_string()).await? - } - - self.email = new_email; - - Ok(()) - } - - pub async fn set_pronouns(&mut self, data: &Data, new_pronouns: String) -> Result<(), Error> { - let mut conn = data.pool.get().await?; - - use users::dsl; - update(users::table) - .filter(dsl::uuid.eq(self.uuid)) - .set(( - dsl::pronouns.eq(new_pronouns.as_str()), - )) - .execute(&mut conn) - .await?; - - if data.get_cache_key(self.uuid.to_string()).await.is_ok() { - data.del_cache_key(self.uuid.to_string()).await? - } - - Ok(()) - } - - pub async fn set_about(&mut self, data: &Data, new_about: String) -> Result<(), Error> { - let mut conn = data.pool.get().await?; - - use users::dsl; - update(users::table) - .filter(dsl::uuid.eq(self.uuid)) - .set(( - dsl::about.eq(new_about.as_str()), - )) - .execute(&mut conn) - .await?; - - if data.get_cache_key(self.uuid.to_string()).await.is_ok() { - data.del_cache_key(self.uuid.to_string()).await? - } - - Ok(()) - } -} - -#[derive(Deserialize)] -pub struct StartAmountQuery { - pub start: Option, - pub amount: Option, -} - -#[derive(Selectable, Queryable)] -#[diesel(table_name = email_tokens)] -#[diesel(check_for_backend(diesel::pg::Pg))] -pub struct EmailToken { - user_uuid: Uuid, - pub token: String, - pub created_at: chrono::DateTime, -} - -impl EmailToken { - pub async fn get(conn: &mut Conn, user_uuid: Uuid) -> Result { - use email_tokens::dsl; - let email_token = dsl::email_tokens - .filter(dsl::user_uuid.eq(user_uuid)) - .select(EmailToken::as_select()) - .get_result(conn) - .await?; - - Ok(email_token) - } - - #[allow(clippy::new_ret_no_self)] - pub async fn new(data: &Data, me: Me) -> Result<(), Error> { - let token = generate_refresh_token()?; - - let mut conn = data.pool.get().await?; - - use email_tokens::dsl; - insert_into(email_tokens::table) - .values(( - dsl::user_uuid.eq(me.uuid), - dsl::token.eq(&token), - dsl::created_at.eq(now), - )) - .execute(&mut conn) - .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#"

Verify your {} Account

Hello, {}!

Thanks for creating a new account on Gorb.

The final step to create your account is to verify your email address by clicking the button below, within 24 hours.

VERIFY ACCOUNT

If you didn't ask to verify this address, you can safely ignore this email.

"#, data.config.instance.name, me.username, verify_endpoint) - ))?; - - data.mail_client.send_mail(email).await?; - - Ok(()) - } - - pub async fn delete(&self, conn: &mut Conn) -> Result<(), Error> { - use email_tokens::dsl; - delete(email_tokens::table) - .filter(dsl::user_uuid.eq(self.user_uuid)) - .filter(dsl::token.eq(&self.token)) - .execute(conn) - .await?; - - Ok(()) - } -} - -#[derive(Selectable, Queryable)] -#[diesel(table_name = password_reset_tokens)] -#[diesel(check_for_backend(diesel::pg::Pg))] -pub struct PasswordResetToken { - user_uuid: Uuid, - pub token: String, - pub created_at: chrono::DateTime, -} - -impl PasswordResetToken { - pub async fn get(conn: &mut Conn, token: String) -> Result { - use password_reset_tokens::dsl; - let password_reset_token = dsl::password_reset_tokens - .filter(dsl::token.eq(token)) - .select(PasswordResetToken::as_select()) - .get_result(conn) - .await?; - - Ok(password_reset_token) - } - - pub async fn get_with_identifier( - conn: &mut Conn, - identifier: String, - ) -> Result { - let user_uuid = user_uuid_from_identifier(conn, &identifier).await?; - - use password_reset_tokens::dsl; - let password_reset_token = dsl::password_reset_tokens - .filter(dsl::user_uuid.eq(user_uuid)) - .select(PasswordResetToken::as_select()) - .get_result(conn) - .await?; - - Ok(password_reset_token) - } - - #[allow(clippy::new_ret_no_self)] - pub async fn new(data: &Data, identifier: String) -> Result<(), Error> { - let token = generate_refresh_token()?; - - let mut conn = data.pool.get().await?; - - let user_uuid = user_uuid_from_identifier(&mut conn, &identifier).await?; - - global_checks(data, user_uuid).await?; - - use users::dsl as udsl; - let (username, email_address): (String, String) = udsl::users - .filter(udsl::uuid.eq(user_uuid)) - .select((udsl::username, udsl::email)) - .get_result(&mut conn) - .await?; - - use password_reset_tokens::dsl; - insert_into(password_reset_tokens::table) - .values(( - dsl::user_uuid.eq(user_uuid), - dsl::token.eq(&token), - dsl::created_at.eq(now), - )) - .execute(&mut conn) - .await?; - - let mut reset_endpoint = data.config.web.frontend_url.join("reset-password")?; - - reset_endpoint.set_query(Some(&format!("token={}", token))); - - let email = data - .mail_client - .message_builder() - .to(email_address.parse()?) - .subject(format!("{} Password Reset", data.config.instance.name)) - .multipart(MultiPart::alternative_plain_html( - format!("{} Password Reset\n\nHello, {}!\nSomeone requested a password reset for your Gorb account.\nClick the button below within 24 hours to reset your password.\n\n{}\n\nIf you didn't request a password reset, don't worry, your account is safe and you can safely ignore this email.\n\nThanks, The gorb team.", data.config.instance.name, username, reset_endpoint), - format!(r#"

{} Password Reset

Hello, {}!

Someone requested a password reset for your Gorb account.

Click the button below within 24 hours to reset your password.

RESET PASSWORD

If you didn't request a password reset, don't worry, your account is safe and you can safely ignore this email.

"#, data.config.instance.name, username, reset_endpoint) - ))?; - - data.mail_client.send_mail(email).await?; - - Ok(()) - } - - pub async fn set_password(&self, data: &Data, password: String) -> Result<(), Error> { - if !PASSWORD_REGEX.is_match(&password) { - return Err(Error::BadRequest( - "Please provide a valid password".to_string(), - )); - } - - let salt = SaltString::generate(&mut OsRng); - - let hashed_password = data - .argon2 - .hash_password(password.as_bytes(), &salt) - .map_err(|e| Error::PasswordHashError(e.to_string()))?; - - let mut conn = data.pool.get().await?; - - use users::dsl; - update(users::table) - .filter(dsl::uuid.eq(self.user_uuid)) - .set(dsl::password.eq(hashed_password.to_string())) - .execute(&mut conn) - .await?; - - let (username, email_address): (String, String) = dsl::users - .filter(dsl::uuid.eq(self.user_uuid)) - .select((dsl::username, dsl::email)) - .get_result(&mut conn) - .await?; - - let login_page = data.config.web.frontend_url.join("login")?; - - let email = data - .mail_client - .message_builder() - .to(email_address.parse()?) - .subject(format!("Your {} Password has been Reset", data.config.instance.name)) - .multipart(MultiPart::alternative_plain_html( - format!("{} Password Reset Confirmation\n\nHello, {}!\nYour password has been successfully reset for your Gorb account.\nIf you did not initiate this change, please click the link below to reset your password immediately.\n\n{}\n\nThanks, The gorb team.", data.config.instance.name, username, login_page), - format!(r#"

{} Password Reset Confirmation

Hello, {}!

Your password has been successfully reset for your Gorb account.

If you did not initiate this change, please click the button below to reset your password immediately.

RESET PASSWORD
"#, data.config.instance.name, username, login_page) - ))?; - - data.mail_client.send_mail(email).await?; - - self.delete(&mut conn).await - } - - pub async fn delete(&self, conn: &mut Conn) -> Result<(), Error> { - use password_reset_tokens::dsl; - delete(password_reset_tokens::table) - .filter(dsl::user_uuid.eq(self.user_uuid)) - .filter(dsl::token.eq(&self.token)) - .execute(conn) - .await?; - - Ok(()) - } -} diff --git a/src/utils.rs b/src/utils.rs index 3bf7332..3172cec 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -19,8 +19,8 @@ use crate::{ Conn, Data, config::Config, error::Error, + objects::{HasIsAbove, HasUuid}, schema::users, - structs::{HasIsAbove, HasUuid}, }; pub static EMAIL_REGEX: LazyLock = LazyLock::new(|| { From c01570707de0277397b7e99cf2ff193a68742d54 Mon Sep 17 00:00:00 2001 From: Radical Date: Mon, 2 Jun 2025 00:30:10 +0200 Subject: [PATCH 29/38] style: cargo clippy --- src/main.rs | 2 +- src/objects/channel.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main.rs b/src/main.rs index 9a24d3d..540a237 100644 --- a/src/main.rs +++ b/src/main.rs @@ -155,7 +155,7 @@ async fn main() -> Result<(), Error> { App::new() .app_data(web::Data::new(data.clone())) .wrap(cors) - .service(api::web(&data.config.web.backend_url.path())) + .service(api::web(data.config.web.backend_url.path())) }) .bind((web.ip, web.port))? .run() diff --git a/src/objects/channel.rs b/src/objects/channel.rs index c9b5f1f..9b756f2 100644 --- a/src/objects/channel.rs +++ b/src/objects/channel.rs @@ -309,7 +309,7 @@ impl Channel { .await { Ok(r) => Ok(Some(r)), - Err(e) if e == diesel::result::Error::NotFound => Ok(None), + Err(diesel::result::Error::NotFound) => Ok(None), Err(e) => Err(e), }?; @@ -328,7 +328,7 @@ impl Channel { .await { Ok(r) => Ok(r), - Err(e) if e == diesel::result::Error::NotFound => Ok(0), + Err(diesel::result::Error::NotFound) => Ok(0), Err(e) => Err(e), }?; From 4cbe551061d5a0feb82eb4a993629cbd707adcc0 Mon Sep 17 00:00:00 2001 From: Radical Date: Mon, 2 Jun 2025 17:50:11 +0200 Subject: [PATCH 30/38] fix: make custom id optional --- src/api/v1/guilds/uuid/invites/mod.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/api/v1/guilds/uuid/invites/mod.rs b/src/api/v1/guilds/uuid/invites/mod.rs index f4f06bc..bb8269c 100644 --- a/src/api/v1/guilds/uuid/invites/mod.rs +++ b/src/api/v1/guilds/uuid/invites/mod.rs @@ -12,7 +12,7 @@ use crate::{ #[derive(Deserialize)] struct InviteRequest { - custom_id: String, + custom_id: Option, } #[get("{uuid}/invites")] @@ -46,7 +46,7 @@ pub async fn get( pub async fn create( req: HttpRequest, path: web::Path<(Uuid,)>, - invite_request: web::Json>, + invite_request: web::Json, data: web::Data, ) -> Result { let headers = req.headers(); @@ -65,9 +65,7 @@ pub async fn create( let guild = Guild::fetch_one(&mut conn, guild_uuid).await?; - let custom_id = invite_request.as_ref().map(|ir| ir.custom_id.clone()); - - let invite = guild.create_invite(&mut conn, uuid, custom_id).await?; + let invite = guild.create_invite(&mut conn, uuid, invite_request.custom_id.clone()).await?; Ok(HttpResponse::Ok().json(invite)) } From b223dff4ba6734c692236cdd3d029590859704c7 Mon Sep 17 00:00:00 2001 From: Radical Date: Tue, 3 Jun 2025 11:01:33 +0000 Subject: [PATCH 31/38] feat: move email tokens to valkey No need to have them in permanent DB storage when they are temporary --- .../down.sql | 7 +++ .../up.sql | 2 + src/api/v1/auth/verify_email.rs | 13 ++--- src/objects/email_token.rs | 47 ++++++------------- src/schema.rs | 11 ----- 5 files changed, 27 insertions(+), 53 deletions(-) create mode 100644 migrations/2025-06-03-103311_remove_email_tokens/down.sql create mode 100644 migrations/2025-06-03-103311_remove_email_tokens/up.sql diff --git a/migrations/2025-06-03-103311_remove_email_tokens/down.sql b/migrations/2025-06-03-103311_remove_email_tokens/down.sql new file mode 100644 index 0000000..e8f0350 --- /dev/null +++ b/migrations/2025-06-03-103311_remove_email_tokens/down.sql @@ -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) +); diff --git a/migrations/2025-06-03-103311_remove_email_tokens/up.sql b/migrations/2025-06-03-103311_remove_email_tokens/up.sql new file mode 100644 index 0000000..b41afe5 --- /dev/null +++ b/migrations/2025-06-03-103311_remove_email_tokens/up.sql @@ -0,0 +1,2 @@ +-- Your SQL goes here +DROP TABLE email_tokens; diff --git a/src/api/v1/auth/verify_email.rs b/src/api/v1/auth/verify_email.rs index 0f23649..e596500 100644 --- a/src/api/v1/auth/verify_email.rs +++ b/src/api/v1/auth/verify_email.rs @@ -46,20 +46,15 @@ pub async fn get( let me = Me::get(&mut conn, uuid).await?; - let email_token = EmailToken::get(&mut conn, me.uuid).await?; + let email_token = EmailToken::get(&data, me.uuid).await?; if query.token != email_token.token { return Ok(HttpResponse::Unauthorized().finish()); } - if Utc::now().signed_duration_since(email_token.created_at) > Duration::hours(24) { - email_token.delete(&mut conn).await?; - return Ok(HttpResponse::Gone().finish()); - } - me.verify_email(&mut conn).await?; - email_token.delete(&mut conn).await?; + email_token.delete(&data).await?; Ok(HttpResponse::Ok().finish()) } @@ -90,9 +85,9 @@ pub async fn post(req: HttpRequest, data: web::Data) -> Result Duration::hours(1) { - email_token.delete(&mut conn).await?; + email_token.delete(&data).await?; } else { return Err(Error::TooManyRequests( "Please allow 1 hour before sending a new email".to_string(), diff --git a/src/objects/email_token.rs b/src/objects/email_token.rs index e458cf7..f55de8c 100644 --- a/src/objects/email_token.rs +++ b/src/objects/email_token.rs @@ -1,19 +1,13 @@ use chrono::Utc; -use diesel::{ - ExpressionMethods, QueryDsl, Queryable, Selectable, SelectableHelper, delete, dsl::now, - insert_into, -}; -use diesel_async::RunQueryDsl; use lettre::message::MultiPart; +use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::{Conn, Data, error::Error, schema::email_tokens, utils::generate_refresh_token}; +use crate::{Data, error::Error, utils::generate_refresh_token}; use super::Me; -#[derive(Selectable, Queryable)] -#[diesel(table_name = email_tokens)] -#[diesel(check_for_backend(diesel::pg::Pg))] +#[derive(Serialize, Deserialize)] pub struct EmailToken { user_uuid: Uuid, pub token: String, @@ -21,13 +15,8 @@ pub struct EmailToken { } impl EmailToken { - pub async fn get(conn: &mut Conn, user_uuid: Uuid) -> Result { - use email_tokens::dsl; - let email_token = dsl::email_tokens - .filter(dsl::user_uuid.eq(user_uuid)) - .select(EmailToken::as_select()) - .get_result(conn) - .await?; + pub async fn get(data: &Data, user_uuid: Uuid) -> Result { + let email_token = serde_json::from_str(&data.get_cache_key(format!("{}_email_verify", user_uuid)).await?)?; Ok(email_token) } @@ -36,17 +25,14 @@ impl EmailToken { pub async fn new(data: &Data, me: Me) -> Result<(), Error> { let token = generate_refresh_token()?; - let mut conn = data.pool.get().await?; + 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() + }; - use email_tokens::dsl; - insert_into(email_tokens::table) - .values(( - dsl::user_uuid.eq(me.uuid), - dsl::token.eq(&token), - dsl::created_at.eq(now), - )) - .execute(&mut conn) - .await?; + 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")?; @@ -67,13 +53,8 @@ impl EmailToken { Ok(()) } - pub async fn delete(&self, conn: &mut Conn) -> Result<(), Error> { - use email_tokens::dsl; - delete(email_tokens::table) - .filter(dsl::user_uuid.eq(self.user_uuid)) - .filter(dsl::token.eq(&self.token)) - .execute(conn) - .await?; + pub async fn delete(&self, data: &Data) -> Result<(), Error> { + data.del_cache_key(format!("{}_email_verify", self.user_uuid)).await?; Ok(()) } diff --git a/src/schema.rs b/src/schema.rs index 09ea7a3..09fa08a 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -31,15 +31,6 @@ diesel::table! { } } -diesel::table! { - email_tokens (token, user_uuid) { - #[max_length = 64] - token -> Varchar, - user_uuid -> Uuid, - created_at -> Timestamptz, - } -} - diesel::table! { guild_members (uuid) { uuid -> Uuid, @@ -155,7 +146,6 @@ diesel::joinable!(access_tokens -> refresh_tokens (refresh_token)); diesel::joinable!(access_tokens -> users (uuid)); diesel::joinable!(channel_permissions -> channels (channel_uuid)); diesel::joinable!(channels -> guilds (guild_uuid)); -diesel::joinable!(email_tokens -> users (user_uuid)); diesel::joinable!(guild_members -> guilds (guild_uuid)); diesel::joinable!(guild_members -> users (user_uuid)); diesel::joinable!(guilds -> users (owner_uuid)); @@ -173,7 +163,6 @@ diesel::allow_tables_to_appear_in_same_query!( access_tokens, channel_permissions, channels, - email_tokens, guild_members, guilds, instance_permissions, From 419f37b108075eab5c67ad03c51bbfd8dc7e4a42 Mon Sep 17 00:00:00 2001 From: Radical Date: Tue, 3 Jun 2025 11:03:52 +0000 Subject: [PATCH 32/38] feat: move password reset tokens to valkey Also just as useless to keep in DB --- .../down.sql | 7 ++ .../up.sql | 2 + src/api/v1/auth/reset_password.rs | 15 +---- src/objects/password_reset_token.rs | 64 ++++++++----------- src/schema.rs | 11 ---- 5 files changed, 37 insertions(+), 62 deletions(-) create mode 100644 migrations/2025-06-03-110142_remove_password_reset_tokens/down.sql create mode 100644 migrations/2025-06-03-110142_remove_password_reset_tokens/up.sql diff --git a/migrations/2025-06-03-110142_remove_password_reset_tokens/down.sql b/migrations/2025-06-03-110142_remove_password_reset_tokens/down.sql new file mode 100644 index 0000000..009d9e4 --- /dev/null +++ b/migrations/2025-06-03-110142_remove_password_reset_tokens/down.sql @@ -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) +); diff --git a/migrations/2025-06-03-110142_remove_password_reset_tokens/up.sql b/migrations/2025-06-03-110142_remove_password_reset_tokens/up.sql new file mode 100644 index 0000000..181d7c5 --- /dev/null +++ b/migrations/2025-06-03-110142_remove_password_reset_tokens/up.sql @@ -0,0 +1,2 @@ +-- Your SQL goes here +DROP TABLE password_reset_tokens; diff --git a/src/api/v1/auth/reset_password.rs b/src/api/v1/auth/reset_password.rs index 4373a82..444266c 100644 --- a/src/api/v1/auth/reset_password.rs +++ b/src/api/v1/auth/reset_password.rs @@ -26,13 +26,11 @@ struct Query { /// #[get("/reset-password")] pub async fn get(query: web::Query, data: web::Data) -> Result { - let mut conn = data.pool.get().await?; - if let Ok(password_reset_token) = - PasswordResetToken::get_with_identifier(&mut conn, query.identifier.clone()).await + 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(&mut conn).await?; + password_reset_token.delete(&data).await?; } else { return Err(Error::TooManyRequests( "Please allow 1 hour before sending a new email".to_string(), @@ -74,15 +72,8 @@ pub async fn post( reset_password: web::Json, data: web::Data, ) -> Result { - let mut conn = data.pool.get().await?; - let password_reset_token = - PasswordResetToken::get(&mut conn, reset_password.token.clone()).await?; - - if Utc::now().signed_duration_since(password_reset_token.created_at) > Duration::hours(24) { - password_reset_token.delete(&mut conn).await?; - return Ok(HttpResponse::Gone().finish()); - } + PasswordResetToken::get(&data, reset_password.token.clone()).await?; password_reset_token .set_password(&data, reset_password.password.clone()) diff --git a/src/objects/password_reset_token.rs b/src/objects/password_reset_token.rs index e3c7bca..0376d88 100644 --- a/src/objects/password_reset_token.rs +++ b/src/objects/password_reset_token.rs @@ -4,23 +4,21 @@ use argon2::{ }; use chrono::Utc; use diesel::{ - ExpressionMethods, QueryDsl, Queryable, Selectable, SelectableHelper, delete, dsl::now, - insert_into, update, + ExpressionMethods, QueryDsl, update, }; use diesel_async::RunQueryDsl; use lettre::message::MultiPart; +use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::{ - Conn, Data, + Data, error::Error, - schema::{password_reset_tokens, users}, + schema::users, utils::{PASSWORD_REGEX, generate_refresh_token, global_checks, user_uuid_from_identifier}, }; -#[derive(Selectable, Queryable)] -#[diesel(table_name = password_reset_tokens)] -#[diesel(check_for_backend(diesel::pg::Pg))] +#[derive(Serialize, Deserialize)] pub struct PasswordResetToken { user_uuid: Uuid, pub token: String, @@ -28,29 +26,22 @@ pub struct PasswordResetToken { } impl PasswordResetToken { - pub async fn get(conn: &mut Conn, token: String) -> Result { - use password_reset_tokens::dsl; - let password_reset_token = dsl::password_reset_tokens - .filter(dsl::token.eq(token)) - .select(PasswordResetToken::as_select()) - .get_result(conn) - .await?; + pub async fn get(data: &Data, token: String) -> Result { + let user_uuid: Uuid = serde_json::from_str(&data.get_cache_key(format!("{}", token)).await?)?; + let password_reset_token = serde_json::from_str(&data.get_cache_key(format!("{}_password_reset", user_uuid)).await?)?; Ok(password_reset_token) } pub async fn get_with_identifier( - conn: &mut Conn, + data: &Data, identifier: String, ) -> Result { - let user_uuid = user_uuid_from_identifier(conn, &identifier).await?; + let mut conn = data.pool.get().await?; - use password_reset_tokens::dsl; - let password_reset_token = dsl::password_reset_tokens - .filter(dsl::user_uuid.eq(user_uuid)) - .select(PasswordResetToken::as_select()) - .get_result(conn) - .await?; + let user_uuid = user_uuid_from_identifier(&mut conn, &identifier).await?; + + let password_reset_token = serde_json::from_str(&data.get_cache_key(format!("{}_password_reset", user_uuid)).await?)?; Ok(password_reset_token) } @@ -72,15 +63,14 @@ impl PasswordResetToken { .get_result(&mut conn) .await?; - use password_reset_tokens::dsl; - insert_into(password_reset_tokens::table) - .values(( - dsl::user_uuid.eq(user_uuid), - dsl::token.eq(&token), - dsl::created_at.eq(now), - )) - .execute(&mut conn) - .await?; + let password_reset_token = PasswordResetToken { + user_uuid, + token: token.clone(), + created_at: Utc::now(), + }; + + data.set_cache_key(format!("{}_password_reset", user_uuid), password_reset_token, 86400).await?; + data.set_cache_key(token.clone(), user_uuid, 86400).await?; let mut reset_endpoint = data.config.web.frontend_url.join("reset-password")?; @@ -144,16 +134,12 @@ impl PasswordResetToken { data.mail_client.send_mail(email).await?; - self.delete(&mut conn).await + self.delete(&data).await } - pub async fn delete(&self, conn: &mut Conn) -> Result<(), Error> { - use password_reset_tokens::dsl; - delete(password_reset_tokens::table) - .filter(dsl::user_uuid.eq(self.user_uuid)) - .filter(dsl::token.eq(&self.token)) - .execute(conn) - .await?; + pub async fn delete(&self, data: &Data) -> Result<(), Error> { + data.del_cache_key(format!("{}_password_reset", &self.user_uuid)).await?; + data.del_cache_key(format!("{}", &self.token)).await?; Ok(()) } diff --git a/src/schema.rs b/src/schema.rs index 09fa08a..aaef9c1 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -80,15 +80,6 @@ diesel::table! { } } -diesel::table! { - password_reset_tokens (token, user_uuid) { - #[max_length = 64] - token -> Varchar, - user_uuid -> Uuid, - created_at -> Timestamptz, - } -} - diesel::table! { refresh_tokens (token) { #[max_length = 64] @@ -154,7 +145,6 @@ diesel::joinable!(invites -> guilds (guild_uuid)); diesel::joinable!(invites -> users (user_uuid)); diesel::joinable!(messages -> channels (channel_uuid)); diesel::joinable!(messages -> users (user_uuid)); -diesel::joinable!(password_reset_tokens -> users (user_uuid)); diesel::joinable!(refresh_tokens -> users (uuid)); diesel::joinable!(role_members -> guild_members (member_uuid)); diesel::joinable!(roles -> guilds (guild_uuid)); @@ -168,7 +158,6 @@ diesel::allow_tables_to_appear_in_same_query!( instance_permissions, invites, messages, - password_reset_tokens, refresh_tokens, role_members, roles, From 05885418760e4bd786e9b25611edd686c183f1a8 Mon Sep 17 00:00:00 2001 From: Radical Date: Fri, 6 Jun 2025 17:20:02 +0200 Subject: [PATCH 33/38] feat: move ownership to member column instead of table column --- .../down.sql | 14 ++++++++++++++ .../up.sql | 14 ++++++++++++++ src/objects/guild.rs | 6 +----- src/objects/member.rs | 4 ++++ src/schema.rs | 3 +-- 5 files changed, 34 insertions(+), 7 deletions(-) create mode 100644 migrations/2025-06-06-145916_guild_ownership_changes/down.sql create mode 100644 migrations/2025-06-06-145916_guild_ownership_changes/up.sql diff --git a/migrations/2025-06-06-145916_guild_ownership_changes/down.sql b/migrations/2025-06-06-145916_guild_ownership_changes/down.sql new file mode 100644 index 0000000..21a08c9 --- /dev/null +++ b/migrations/2025-06-06-145916_guild_ownership_changes/down.sql @@ -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; diff --git a/migrations/2025-06-06-145916_guild_ownership_changes/up.sql b/migrations/2025-06-06-145916_guild_ownership_changes/up.sql new file mode 100644 index 0000000..b94323f --- /dev/null +++ b/migrations/2025-06-06-145916_guild_ownership_changes/up.sql @@ -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; diff --git a/src/objects/guild.rs b/src/objects/guild.rs index f5e973d..7d55595 100644 --- a/src/objects/guild.rs +++ b/src/objects/guild.rs @@ -26,7 +26,6 @@ pub struct GuildBuilder { name: String, description: Option, icon: Option, - owner_uuid: Uuid, } impl GuildBuilder { @@ -40,7 +39,6 @@ impl GuildBuilder { name: self.name, description: self.description, icon: self.icon.and_then(|i| i.parse().ok()), - owner_uuid: self.owner_uuid, roles, member_count, }) @@ -53,7 +51,6 @@ pub struct Guild { name: String, description: Option, icon: Option, - owner_uuid: Uuid, pub roles: Vec, member_count: i64, } @@ -110,7 +107,6 @@ impl Guild { name: name.clone(), description: None, icon: None, - owner_uuid, }; insert_into(guilds::table) @@ -125,6 +121,7 @@ impl Guild { nickname: None, user_uuid: owner_uuid, guild_uuid, + is_owner: true, }; insert_into(guild_members::table) @@ -137,7 +134,6 @@ impl Guild { name, description: None, icon: None, - owner_uuid, roles: vec![], member_count: 1, }) diff --git a/src/objects/member.rs b/src/objects/member.rs index f18e726..67312e4 100644 --- a/src/objects/member.rs +++ b/src/objects/member.rs @@ -17,6 +17,7 @@ pub struct MemberBuilder { pub nickname: Option, pub user_uuid: Uuid, pub guild_uuid: Uuid, + pub is_owner: bool, } impl MemberBuilder { @@ -28,6 +29,7 @@ impl MemberBuilder { nickname: self.nickname.clone(), user_uuid: self.user_uuid, guild_uuid: self.guild_uuid, + is_owner: self.is_owner, user, }) } @@ -39,6 +41,7 @@ pub struct Member { pub nickname: Option, pub user_uuid: Uuid, pub guild_uuid: Uuid, + pub is_owner: bool, user: User, } @@ -113,6 +116,7 @@ impl Member { guild_uuid, user_uuid, nickname: None, + is_owner: false, }; insert_into(guild_members::table) diff --git a/src/schema.rs b/src/schema.rs index aaef9c1..c7a350c 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -38,13 +38,13 @@ diesel::table! { user_uuid -> Uuid, #[max_length = 100] nickname -> Nullable, + is_owner -> Bool, } } diesel::table! { guilds (uuid) { uuid -> Uuid, - owner_uuid -> Uuid, #[max_length = 100] name -> Varchar, #[max_length = 300] @@ -139,7 +139,6 @@ diesel::joinable!(channel_permissions -> channels (channel_uuid)); diesel::joinable!(channels -> guilds (guild_uuid)); diesel::joinable!(guild_members -> guilds (guild_uuid)); diesel::joinable!(guild_members -> users (user_uuid)); -diesel::joinable!(guilds -> users (owner_uuid)); diesel::joinable!(instance_permissions -> users (uuid)); diesel::joinable!(invites -> guilds (guild_uuid)); diesel::joinable!(invites -> users (user_uuid)); From 95c942eee419c89a4dad069f307ca366890ebaaa Mon Sep 17 00:00:00 2001 From: Radical Date: Fri, 6 Jun 2025 17:49:06 +0200 Subject: [PATCH 34/38] feat: use permission system --- src/api/v1/channels/uuid/mod.rs | 14 ++-- src/api/v1/guilds/uuid/channels.rs | 10 +-- src/api/v1/guilds/uuid/icon.rs | 10 ++- src/api/v1/guilds/uuid/invites/mod.rs | 10 ++- src/api/v1/guilds/uuid/roles/mod.rs | 10 +-- src/objects/member.rs | 22 ++++-- src/objects/mod.rs | 39 +---------- src/objects/role.rs | 98 +++++++++++++++++++++++++-- 8 files changed, 133 insertions(+), 80 deletions(-) diff --git a/src/api/v1/channels/uuid/mod.rs b/src/api/v1/channels/uuid/mod.rs index 1cb20c7..bece6ed 100644 --- a/src/api/v1/channels/uuid/mod.rs +++ b/src/api/v1/channels/uuid/mod.rs @@ -4,11 +4,7 @@ pub mod messages; pub mod socket; use crate::{ - Data, - api::v1::auth::check_access_token, - error::Error, - objects::{Channel, Member}, - utils::{get_auth_header, global_checks}, + 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; @@ -59,7 +55,9 @@ pub async fn delete( let channel = Channel::fetch_one(&data, channel_uuid).await?; - Member::check_membership(&mut conn, uuid, channel.guild_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?; @@ -125,7 +123,9 @@ pub async fn patch( let mut channel = Channel::fetch_one(&data, channel_uuid).await?; - Member::check_membership(&mut conn, uuid, channel.guild_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?; diff --git a/src/api/v1/guilds/uuid/channels.rs b/src/api/v1/guilds/uuid/channels.rs index 083553a..db895e4 100644 --- a/src/api/v1/guilds/uuid/channels.rs +++ b/src/api/v1/guilds/uuid/channels.rs @@ -1,9 +1,5 @@ use crate::{ - Data, - api::v1::auth::check_access_token, - error::Error, - objects::{Channel, Member}, - utils::{get_auth_header, global_checks, order_by_is_above}, + 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}; @@ -74,9 +70,9 @@ pub async fn create( global_checks(&data, uuid).await?; - Member::check_membership(&mut conn, uuid, guild_uuid).await?; + let member = Member::check_membership(&mut conn, uuid, guild_uuid).await?; - // FIXME: Logic to check permissions, should probably be done in utils.rs + member.check_permission(&data, Permissions::CreateChannel).await?; let channel = Channel::new( data.clone(), diff --git a/src/api/v1/guilds/uuid/icon.rs b/src/api/v1/guilds/uuid/icon.rs index 5025416..4061585 100644 --- a/src/api/v1/guilds/uuid/icon.rs +++ b/src/api/v1/guilds/uuid/icon.rs @@ -5,11 +5,7 @@ use futures_util::StreamExt as _; use uuid::Uuid; use crate::{ - Data, - api::v1::auth::check_access_token, - error::Error, - objects::{Guild, Member}, - utils::{get_auth_header, global_checks}, + 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 @@ -36,7 +32,9 @@ pub async fn upload( global_checks(&data, uuid).await?; - Member::check_membership(&mut conn, uuid, guild_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?; diff --git a/src/api/v1/guilds/uuid/invites/mod.rs b/src/api/v1/guilds/uuid/invites/mod.rs index bb8269c..eb8d2ce 100644 --- a/src/api/v1/guilds/uuid/invites/mod.rs +++ b/src/api/v1/guilds/uuid/invites/mod.rs @@ -3,11 +3,7 @@ use serde::Deserialize; use uuid::Uuid; use crate::{ - Data, - api::v1::auth::check_access_token, - error::Error, - objects::{Guild, Member}, - utils::{get_auth_header, global_checks}, + api::v1::auth::check_access_token, error::Error, objects::{Guild, Member, Permissions}, utils::{get_auth_header, global_checks}, Data }; #[derive(Deserialize)] @@ -61,7 +57,9 @@ pub async fn create( global_checks(&data, uuid).await?; - Member::check_membership(&mut conn, uuid, guild_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?; diff --git a/src/api/v1/guilds/uuid/roles/mod.rs b/src/api/v1/guilds/uuid/roles/mod.rs index 717b30b..c33f144 100644 --- a/src/api/v1/guilds/uuid/roles/mod.rs +++ b/src/api/v1/guilds/uuid/roles/mod.rs @@ -3,11 +3,7 @@ use actix_web::{HttpRequest, HttpResponse, get, post, web}; use serde::Deserialize; use crate::{ - Data, - api::v1::auth::check_access_token, - error::Error, - objects::{Member, Role}, - utils::{get_auth_header, global_checks, order_by_is_above}, + 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; @@ -70,9 +66,9 @@ pub async fn create( global_checks(&data, uuid).await?; - Member::check_membership(&mut conn, uuid, guild_uuid).await?; + let member = Member::check_membership(&mut conn, uuid, guild_uuid).await?; - // FIXME: Logic to check permissions, should probably be done in utils.rs + member.check_permission(&data, Permissions::CreateRole).await?; let role = Role::new(&mut conn, guild_uuid, role_info.name.clone()).await?; diff --git a/src/objects/member.rs b/src/objects/member.rs index 67312e4..20bc848 100644 --- a/src/objects/member.rs +++ b/src/objects/member.rs @@ -5,7 +5,7 @@ use diesel_async::RunQueryDsl; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::{Conn, Data, error::Error, schema::guild_members}; +use crate::{error::Error, objects::{Permissions, Role}, schema::guild_members, Conn, Data}; use super::{User, load_or_empty}; @@ -21,7 +21,7 @@ pub struct MemberBuilder { } impl MemberBuilder { - async fn build(&self, data: &Data) -> Result { + pub async fn build(&self, data: &Data) -> Result { let user = User::fetch_one(data, self.user_uuid).await?; Ok(Member { @@ -33,6 +33,18 @@ impl MemberBuilder { user, }) } + + pub async fn check_permission(&self, data: &Data, permission: Permissions) -> Result<(), Error> { + if !self.is_owner { + let roles = Role::fetch_from_member(&data, self.uuid).await?; + let allowed = roles.iter().any(|r| r.permissions & permission as i64 != 0); + if !allowed { + return Err(Error::Forbidden("Not allowed".to_string())) + } + } + + Ok(()) + } } #[derive(Serialize, Deserialize)] @@ -61,16 +73,16 @@ impl Member { conn: &mut Conn, user_uuid: Uuid, guild_uuid: Uuid, - ) -> Result<(), Error> { + ) -> Result { use guild_members::dsl; - dsl::guild_members + let member_builder = dsl::guild_members .filter(dsl::user_uuid.eq(user_uuid)) .filter(dsl::guild_uuid.eq(guild_uuid)) .select(MemberBuilder::as_select()) .get_result(conn) .await?; - Ok(()) + Ok(member_builder) } pub async fn fetch_one(data: &Data, user_uuid: Uuid, guild_uuid: Uuid) -> Result { diff --git a/src/objects/mod.rs b/src/objects/mod.rs index 7b45957..30a0a64 100644 --- a/src/objects/mod.rs +++ b/src/objects/mod.rs @@ -27,6 +27,7 @@ pub use member::Member; pub use message::Message; pub use password_reset_token::PasswordResetToken; pub use role::Role; +pub use role::Permissions; pub use user::User; use crate::error::Error; @@ -111,44 +112,6 @@ impl MailClient { } } -#[derive(Clone, Copy)] -pub enum Permissions { - SendMessage = 1, - CreateChannel = 2, - DeleteChannel = 4, - ManageChannel = 8, - CreateRole = 16, - DeleteRole = 32, - ManageRole = 64, - CreateInvite = 128, - ManageInvite = 256, - ManageServer = 512, - ManageMember = 1024, -} - -impl Permissions { - pub fn fetch_permissions(permissions: i64) -> Vec { - let all_perms = vec![ - Self::SendMessage, - Self::CreateChannel, - Self::DeleteChannel, - Self::ManageChannel, - Self::CreateRole, - Self::DeleteRole, - Self::ManageRole, - Self::CreateInvite, - Self::ManageInvite, - Self::ManageServer, - Self::ManageMember, - ]; - - all_perms - .into_iter() - .filter(|p| permissions & (*p as i64) != 0) - .collect() - } -} - #[derive(Deserialize)] pub struct StartAmountQuery { pub start: Option, diff --git a/src/objects/role.rs b/src/objects/role.rs index f67dc6d..a78798a 100644 --- a/src/objects/role.rs +++ b/src/objects/role.rs @@ -3,14 +3,14 @@ use diesel::{ update, }; use diesel_async::RunQueryDsl; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::{Conn, error::Error, schema::roles, utils::order_by_is_above}; +use crate::{error::Error, schema::{role_members, roles}, utils::order_by_is_above, Conn, Data}; use super::{HasIsAbove, HasUuid, load_or_empty}; -#[derive(Serialize, Clone, Queryable, Selectable, Insertable)] +#[derive(Deserialize, Serialize, Clone, Queryable, Selectable, Insertable)] #[diesel(table_name = roles)] #[diesel(check_for_backend(diesel::pg::Pg))] pub struct Role { @@ -19,7 +19,28 @@ pub struct Role { name: String, color: i32, is_above: Option, - permissions: i64, + pub permissions: i64, +} + +#[derive(Serialize, Clone, Queryable, Selectable, Insertable)] +#[diesel(table_name = role_members)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct RoleMember { + role_uuid: Uuid, + member_uuid: Uuid, +} + +impl RoleMember { + async fn fetch_role(&self, conn: &mut Conn) -> Result { + use roles::dsl; + let role: Role = dsl::roles + .filter(dsl::uuid.eq(self.role_uuid)) + .select(Role::as_select()) + .get_result(conn) + .await?; + + Ok(role) + } } impl HasUuid for Role { @@ -48,6 +69,33 @@ impl Role { Ok(roles) } + pub async fn fetch_from_member(data: &Data, member_uuid: Uuid) -> Result, Error> { + if let Ok(roles) = data.get_cache_key(format!("{}_roles", member_uuid)).await { + return Ok(serde_json::from_str(&roles)?) + } + + let mut conn = data.pool.get().await?; + + use role_members::dsl; + let role_memberships: Vec = load_or_empty( + dsl::role_members + .filter(dsl::member_uuid.eq(member_uuid)) + .select(RoleMember::as_select()) + .load(&mut conn) + .await, + )?; + + let mut roles = vec![]; + + for membership in role_memberships { + roles.push(membership.fetch_role(&mut conn).await?); + } + + data.set_cache_key(format!("{}_roles", member_uuid), roles.clone(), 300).await?; + + Ok(roles) + } + pub async fn fetch_one(conn: &mut Conn, role_uuid: Uuid) -> Result { use roles::dsl; let role: Role = dsl::roles @@ -59,6 +107,10 @@ impl Role { Ok(role) } + pub async fn fetch_permissions(&self) -> Vec { + Permissions::fetch_permissions(self.permissions.clone()) + } + pub async fn new(conn: &mut Conn, guild_uuid: Uuid, name: String) -> Result { let role_uuid = Uuid::now_v7(); @@ -94,3 +146,41 @@ impl Role { Ok(new_role) } } + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Permissions { + SendMessage = 1, + CreateChannel = 2, + DeleteChannel = 4, + ManageChannel = 8, + CreateRole = 16, + DeleteRole = 32, + ManageRole = 64, + CreateInvite = 128, + ManageInvite = 256, + ManageServer = 512, + ManageMember = 1024, +} + +impl Permissions { + pub fn fetch_permissions(permissions: i64) -> Vec { + let all_perms = vec![ + Self::SendMessage, + Self::CreateChannel, + Self::DeleteChannel, + Self::ManageChannel, + Self::CreateRole, + Self::DeleteRole, + Self::ManageRole, + Self::CreateInvite, + Self::ManageInvite, + Self::ManageServer, + Self::ManageMember, + ]; + + all_perms + .into_iter() + .filter(|p| permissions & (*p as i64) != 0) + .collect() + } +} From 8dca22de3a0a824dd318814a29c5c351ea0ced1d Mon Sep 17 00:00:00 2001 From: Radical Date: Fri, 6 Jun 2025 18:16:25 +0200 Subject: [PATCH 35/38] fix: make channel deletion work --- src/objects/channel.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/objects/channel.rs b/src/objects/channel.rs index 9b756f2..7a31150 100644 --- a/src/objects/channel.rs +++ b/src/objects/channel.rs @@ -198,15 +198,34 @@ impl Channel { let mut conn = data.pool.get().await?; use channels::dsl; + update(channels::table) + .filter(dsl::is_above.eq(self.uuid)) + .set(dsl::is_above.eq(None::)) + .execute(&mut conn) + .await?; delete(channels::table) .filter(dsl::uuid.eq(self.uuid)) .execute(&mut conn) .await?; + update(channels::table) + .filter(dsl::is_above.eq(self.uuid)) + .set(dsl::is_above.eq(self.is_above)) + .execute(&mut conn) + .await?; 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(()) } From f752cddd73d522b4011418ca3a5b75cdfd048388 Mon Sep 17 00:00:00 2001 From: Radical Date: Fri, 6 Jun 2025 18:19:40 +0200 Subject: [PATCH 36/38] fix: add missing match statements --- src/objects/channel.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/objects/channel.rs b/src/objects/channel.rs index 7a31150..4d52353 100644 --- a/src/objects/channel.rs +++ b/src/objects/channel.rs @@ -198,20 +198,32 @@ impl Channel { let mut conn = data.pool.get().await?; use channels::dsl; - update(channels::table) + match update(channels::table) .filter(dsl::is_above.eq(self.uuid)) .set(dsl::is_above.eq(None::)) .execute(&mut conn) - .await?; + .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?; - update(channels::table) + + match update(channels::table) .filter(dsl::is_above.eq(self.uuid)) .set(dsl::is_above.eq(self.is_above)) .execute(&mut conn) - .await?; + .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?; From 407460d2aa1391ac8a50731f970fb8c4622c589a Mon Sep 17 00:00:00 2001 From: Radical Date: Wed, 25 Jun 2025 13:25:39 +0200 Subject: [PATCH 37/38] style: use const generic for token length instead of multiple functions Simplifies codebase a bit and avoids having to add another function in future if we need another length of token --- src/api/v1/auth/login.rs | 6 +++--- src/api/v1/auth/refresh.rs | 6 +++--- src/api/v1/auth/register.rs | 6 +++--- src/objects/email_token.rs | 4 ++-- src/objects/password_reset_token.rs | 6 +++--- src/utils.rs | 10 ++-------- 6 files changed, 16 insertions(+), 22 deletions(-) diff --git a/src/api/v1/auth/login.rs b/src/api/v1/auth/login.rs index e190c2f..ac6c1ad 100644 --- a/src/api/v1/auth/login.rs +++ b/src/api/v1/auth/login.rs @@ -11,7 +11,7 @@ use crate::{ error::Error, schema::*, utils::{ - PASSWORD_REGEX, generate_access_token, generate_refresh_token, new_refresh_token_cookie, + PASSWORD_REGEX, generate_token, new_refresh_token_cookie, user_uuid_from_identifier, }, }; @@ -59,8 +59,8 @@ pub async fn response( )); } - let refresh_token = generate_refresh_token()?; - let access_token = generate_access_token()?; + let refresh_token = generate_token::<32>()?; + let access_token = generate_token::<16>()?; let current_time = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as i64; diff --git a/src/api/v1/auth/refresh.rs b/src/api/v1/auth/refresh.rs index 63e150e..1f4f406 100644 --- a/src/api/v1/auth/refresh.rs +++ b/src/api/v1/auth/refresh.rs @@ -11,7 +11,7 @@ use crate::{ access_tokens::{self, dsl}, refresh_tokens::{self, dsl as rdsl}, }, - utils::{generate_access_token, generate_refresh_token, new_refresh_token_cookie}, + utils::{generate_token, new_refresh_token_cookie}, }; use super::Response; @@ -55,7 +55,7 @@ pub async fn res(req: HttpRequest, data: web::Data) -> Result 1987200 { - let new_refresh_token = generate_refresh_token()?; + let new_refresh_token = generate_token::<32>()?; match update(refresh_tokens::table) .filter(rdsl::token.eq(&refresh_token)) @@ -75,7 +75,7 @@ pub async fn res(req: HttpRequest, data: web::Data) -> Result()?; update(access_tokens::table) .filter(dsl::refresh_token.eq(&refresh_token)) diff --git a/src/api/v1/auth/register.rs b/src/api/v1/auth/register.rs index 66e2989..1d28088 100644 --- a/src/api/v1/auth/register.rs +++ b/src/api/v1/auth/register.rs @@ -20,7 +20,7 @@ use crate::{ users::{self, dsl as udsl}, }, utils::{ - EMAIL_REGEX, PASSWORD_REGEX, USERNAME_REGEX, generate_access_token, generate_refresh_token, + EMAIL_REGEX, PASSWORD_REGEX, USERNAME_REGEX, generate_token, new_refresh_token_cookie, }, }; @@ -120,8 +120,8 @@ pub async fn res( .execute(&mut conn) .await?; - let refresh_token = generate_refresh_token()?; - let access_token = generate_access_token()?; + let refresh_token = generate_token::<32>()?; + let access_token = generate_token::<16>()?; let current_time = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as i64; diff --git a/src/objects/email_token.rs b/src/objects/email_token.rs index f55de8c..4ec6b7e 100644 --- a/src/objects/email_token.rs +++ b/src/objects/email_token.rs @@ -3,7 +3,7 @@ use lettre::message::MultiPart; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::{Data, error::Error, utils::generate_refresh_token}; +use crate::{Data, error::Error, utils::generate_token}; use super::Me; @@ -23,7 +23,7 @@ impl EmailToken { #[allow(clippy::new_ret_no_self)] pub async fn new(data: &Data, me: Me) -> Result<(), Error> { - let token = generate_refresh_token()?; + let token = generate_token::<32>()?; let email_token = EmailToken { user_uuid: me.uuid, diff --git a/src/objects/password_reset_token.rs b/src/objects/password_reset_token.rs index 0376d88..e14d25a 100644 --- a/src/objects/password_reset_token.rs +++ b/src/objects/password_reset_token.rs @@ -12,10 +12,10 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::{ - Data, error::Error, schema::users, - utils::{PASSWORD_REGEX, generate_refresh_token, global_checks, user_uuid_from_identifier}, + utils::{generate_token, global_checks, user_uuid_from_identifier, PASSWORD_REGEX}, + Data }; #[derive(Serialize, Deserialize)] @@ -48,7 +48,7 @@ impl PasswordResetToken { #[allow(clippy::new_ret_no_self)] pub async fn new(data: &Data, identifier: String) -> Result<(), Error> { - let token = generate_refresh_token()?; + let token = generate_token::<32>()?; let mut conn = data.pool.get().await?; diff --git a/src/utils.rs b/src/utils.rs index 3172cec..7a5581a 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -115,14 +115,8 @@ pub fn new_refresh_token_cookie(config: &Config, refresh_token: String) -> Cooki .finish() } -pub fn generate_access_token() -> Result { - let mut buf = [0u8; 16]; - fill(&mut buf)?; - Ok(encode(buf)) -} - -pub fn generate_refresh_token() -> Result { - let mut buf = [0u8; 32]; +pub fn generate_token() -> Result { + let mut buf = [0u8; N]; fill(&mut buf)?; Ok(encode(buf)) } From 36d3a18b0857691765c0d803dceef0f427f44bf4 Mon Sep 17 00:00:00 2001 From: Radical Date: Wed, 25 Jun 2025 14:33:05 +0200 Subject: [PATCH 38/38] build: update dependencies --- Cargo.toml | 8 ++++---- src/api/v1/guilds/uuid/icon.rs | 2 +- src/main.rs | 11 +++-------- src/objects/guild.rs | 6 +++--- src/objects/me.rs | 5 ++--- 5 files changed, 13 insertions(+), 19 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 30b5827..1c5f34b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,15 +27,15 @@ regex = "1.11" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" simple_logger = "5.0.0" -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.17", features = ["serde", "v7"] } random-string = "1.1" actix-ws = "0.3.0" futures-util = "0.3.31" -bunny-api-tokio = "0.3.0" +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 } @@ -43,7 +43,7 @@ diesel-async = { version = "0.5", features = ["deadpool", "postgres", "async-con diesel_migrations = { version = "2.2.0", features = ["postgres"] } thiserror = "2.0.12" actix-multipart = "0.7.2" -lettre = { version = "0.11.16", features = ["tokio1", "tokio1-native-tls"] } +lettre = { version = "0.11", features = ["tokio1", "tokio1-native-tls"] } chrono = { version = "0.4.41", features = ["serde"] } [dependencies.tokio] diff --git a/src/api/v1/guilds/uuid/icon.rs b/src/api/v1/guilds/uuid/icon.rs index 4061585..0860435 100644 --- a/src/api/v1/guilds/uuid/icon.rs +++ b/src/api/v1/guilds/uuid/icon.rs @@ -45,7 +45,7 @@ pub async fn upload( guild .set_icon( - &data.bunny_cdn, + &data.bunny_storage, &mut conn, data.config.bunny.cdn_url.clone(), bytes, diff --git a/src/main.rs b/src/main.rs index 540a237..47794e3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,7 +40,7 @@ pub struct Data { pub config: Config, pub argon2: Argon2<'static>, pub start_time: SystemTime, - pub bunny_cdn: bunny_api_tokio::Client, + pub bunny_storage: bunny_api_tokio::EdgeStorageClient, pub mail_client: MailClient, } @@ -65,14 +65,9 @@ async fn main() -> Result<(), Error> { let cache_pool = redis::Client::open(config.cache_database.url())?; - let mut bunny_cdn = bunny_api_tokio::Client::new("").await?; - let bunny = config.bunny.clone(); - bunny_cdn - .storage - .init(bunny.api_key, bunny.endpoint, bunny.storage_zone) - .await?; + let bunny_storage = bunny_api_tokio::EdgeStorageClient::new(bunny.api_key, bunny.endpoint, bunny.storage_zone).await?; let mail = config.mail.clone(); @@ -122,7 +117,7 @@ async fn main() -> Result<(), Error> { // 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_cdn, + bunny_storage, mail_client, }; diff --git a/src/objects/guild.rs b/src/objects/guild.rs index 7d55595..47058ee 100644 --- a/src/objects/guild.rs +++ b/src/objects/guild.rs @@ -188,7 +188,7 @@ impl Guild { // FIXME: Horrible security pub async fn set_icon( &mut self, - bunny_cdn: &bunny_api_tokio::Client, + bunny_storage: &bunny_api_tokio::EdgeStorageClient, conn: &mut Conn, cdn_url: Url, icon: BytesMut, @@ -199,12 +199,12 @@ impl Guild { if let Some(icon) = &self.icon { let relative_url = icon.path().trim_start_matches('/'); - bunny_cdn.storage.delete(relative_url).await?; + bunny_storage.delete(relative_url).await?; } let path = format!("icons/{}/icon.{}", self.uuid, image_type); - bunny_cdn.storage.upload(path.clone(), icon.into()).await?; + bunny_storage.upload(path.clone(), icon.into()).await?; let icon_url = cdn_url.join(&path)?; diff --git a/src/objects/me.rs b/src/objects/me.rs index 6af5bce..e183c5d 100644 --- a/src/objects/me.rs +++ b/src/objects/me.rs @@ -85,13 +85,12 @@ impl Me { let relative_url = avatar_url.path().trim_start_matches('/'); - data.bunny_cdn.storage.delete(relative_url).await?; + data.bunny_storage.delete(relative_url).await?; } let path = format!("avatar/{}/avatar.{}", self.uuid, image_type); - data.bunny_cdn - .storage + data.bunny_storage .upload(path.clone(), avatar.into()) .await?;