Compare commits
1 Commits
main
...
e0f003d5a8
| Author | SHA1 | Date | |
|---|---|---|---|
|
e0f003d5a8
|
@@ -84,40 +84,3 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
- name: Run tailwindcss
|
||||
run: tailwindcss --input style/tailwind.css
|
||||
|
||||
docker-build:
|
||||
runs-on: ubuntu-latest-docker
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to Gitea container registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.registry }}
|
||||
username: ${{ env.actions_user }}
|
||||
password: ${{ secrets.CONTAINER_REGISTRY_TOKEN }}
|
||||
- name: Get Image Name
|
||||
id: get-image-name
|
||||
run: |
|
||||
echo "IMAGE_NAME=$(echo ${{ env.registry }}/${{ gitea.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ steps.get-image-name.outputs.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=sha
|
||||
type=ref,event=branch
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -7,14 +7,3 @@
|
||||
/result
|
||||
|
||||
.env
|
||||
|
||||
# Anything the config crate looks for
|
||||
config.ini
|
||||
config.json
|
||||
config.json5
|
||||
config.ron
|
||||
config.toml
|
||||
config.yaml
|
||||
config.yml
|
||||
|
||||
/migrations/.diesel_lock
|
||||
|
||||
1164
Cargo.lock
generated
1164
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
34
Cargo.toml
34
Cargo.toml
@@ -9,25 +9,13 @@ edition = "2024"
|
||||
build = "src/build.rs"
|
||||
|
||||
[dependencies]
|
||||
axum-login = { version = "0.18.0", optional = true }
|
||||
cfg-if = "1.0.4"
|
||||
chrono = { version = "0.4.45", features = ["serde"] }
|
||||
config = { version = "0.15.24", optional = true }
|
||||
diesel = { version = "2.3.10", optional = true, features = ["chrono"] }
|
||||
diesel-async = { version = "0.9.1", optional = true, features = ["postgres", "deadpool", "migrations"] }
|
||||
diesel_migrations = { version = "2.3.2", optional = true }
|
||||
dioxus = { version = "0.7.10", features = ["router", "fullstack"] }
|
||||
dioxus-html = "0.7.10"
|
||||
diesel = { version = "2.3.10", optional = true, features = [ "postgres" ] }
|
||||
diesel_migrations = { version = "2.3.2", optional = true, features = [ "postgres" ] }
|
||||
dioxus = { version = "0.7.9", features = ["router", "fullstack"] }
|
||||
dotenvy = { version = "0.15.7", optional = true }
|
||||
fred = { version = "10.1.0", optional = true }
|
||||
lucide-dioxus = { version = "3.11.0", features = ["notifications", "account"] }
|
||||
pbkdf2 = { version = "0.13.0", optional = true, features = ["getrandom", "phc"] }
|
||||
rand = "0.10.1"
|
||||
lucide-dioxus = "3.11.0"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
thiserror = "2.0.18"
|
||||
tower-http = { version = "0.7.0", optional = true, features = ["fs"] }
|
||||
tokio = { version = "1.52.3", optional = true }
|
||||
tower-sessions-redis-store = { version = "0.16.0", optional = true }
|
||||
tracing = "0.1.44"
|
||||
|
||||
[features]
|
||||
@@ -35,25 +23,11 @@ default = ["web"]
|
||||
web = ["dioxus/web"]
|
||||
server = [
|
||||
"dioxus/server",
|
||||
"dep:axum-login",
|
||||
"dep:config",
|
||||
"dep:diesel",
|
||||
"dep:diesel-async",
|
||||
"dep:diesel_migrations",
|
||||
"dep:dotenvy",
|
||||
"dep:fred",
|
||||
"dep:pbkdf2",
|
||||
"dep:tokio",
|
||||
"dep:tower-http",
|
||||
"dep:tower-sessions-redis-store",
|
||||
]
|
||||
|
||||
# Disabled until supported
|
||||
# desktop = ["dioxus/desktop"]
|
||||
# mobile = ["dioxus/mobile"]
|
||||
|
||||
# Enable wasm_js in getrandom when building for wasm32
|
||||
# This is a workaround for rand not exposing a wasm_js target
|
||||
# https://github.com/rust-random/rand/issues/1694#issuecomment-3846362044
|
||||
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
|
||||
getrandom = { version = "0.4.3", features = ["wasm_js"] }
|
||||
|
||||
@@ -18,6 +18,3 @@ script = []
|
||||
# Javascript code file
|
||||
# serve: [dev-server] only
|
||||
script = []
|
||||
|
||||
[web]
|
||||
pre_compress = true
|
||||
|
||||
99
Dockerfile
99
Dockerfile
@@ -1,99 +0,0 @@
|
||||
FROM rust:slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install a few dependencies
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
curl \
|
||||
unzip && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN rustup target add wasm32-unknown-unknown
|
||||
|
||||
# Install dioxus-cli
|
||||
RUN curl -sSL https://dioxus.dev/install.sh | bash
|
||||
|
||||
ENV DAISYUI_PATH=/opt/daisyui/daisyui/
|
||||
ENV DAISYUI_THEME_PATH=/opt/daisyui/theme/
|
||||
ENV NODE_PATH=$DAISYUI_PATH:$DAISYUI_THEME_PATH
|
||||
|
||||
# Install DaisyUI bundles
|
||||
RUN mkdir -p $DAISYUI_PATH $DAISYUI_THEME_PATH && \
|
||||
cd $DAISYUI_PATH && \
|
||||
curl -sLO https://github.com/saadeghi/daisyui/releases/latest/download/daisyui.js && \
|
||||
cd $DAISYUI_THEME_PATH && \
|
||||
curl -sLO https://github.com/saadeghi/daisyui/releases/latest/download/daisyui-theme.js
|
||||
|
||||
# Install Tailwind CSS CLI
|
||||
RUN curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 && \
|
||||
install tailwindcss-linux-x64 /usr/local/bin/tailwindcss && \
|
||||
rm tailwindcss-linux-x64
|
||||
|
||||
# Include Rust binaries in PATH
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
|
||||
# Copy project dependency manifests
|
||||
COPY Cargo.toml Cargo.lock Dioxus.toml /app/
|
||||
|
||||
# Create dummy files to force cargo to build the dependencies
|
||||
# main() content fixes "failed to find intrinsics to enable `clone_ref` function"
|
||||
# See https://github.com/trunk-rs/trunk/issues/951#issuecomment-2816819963
|
||||
RUN mkdir /app/src && \
|
||||
echo "fn main() { dioxus::logger::initialize_default(); }" > /app/src/main.rs && \
|
||||
echo "fn main() {}" > /app/src/build.rs
|
||||
|
||||
# Prebuild dependencies
|
||||
RUN dx build --release --locked --web
|
||||
|
||||
# Remove assets from the initial build
|
||||
RUN rm -rf /app/target/dx/libretunes/release/web/public
|
||||
|
||||
RUN rm -rf /app/src
|
||||
|
||||
COPY --exclude=assets/tailwind.css assets /app/assets
|
||||
COPY style /app/style
|
||||
COPY migrations /app/migrations
|
||||
COPY src /app/src
|
||||
|
||||
# Copy necessary git files for getting build version
|
||||
RUN mkdir /app/.git
|
||||
COPY .git/HEAD .git/refs /app/.git
|
||||
|
||||
# dx build will run tailwindcss anyways, but doing it first here is faster and clearer if it fails
|
||||
RUN tailwindcss --input style/tailwind.css --output assets/tailwind.css
|
||||
|
||||
# Touch files to force rebuild
|
||||
RUN touch /app/src/main.rs && touch /app/src/build.rs
|
||||
|
||||
# Actually build
|
||||
RUN dx build --release --locked --web
|
||||
|
||||
# Use ldd to list all dependencies of the server, then copy them to /app/libs
|
||||
RUN mkdir /app/libs && ldd /app/target/dx/libretunes/release/web/server | grep "=> /" | \
|
||||
awk '{print $3}' | xargs -I '{}' cp '{}' /app/libs
|
||||
|
||||
# Build the final image
|
||||
FROM scratch
|
||||
|
||||
LABEL license="MIT"
|
||||
LABEL description="LibreTunes, an open-source browser audio player and \
|
||||
library manager built for collaborative listening."
|
||||
|
||||
# Copy the binary and assets to the final image
|
||||
COPY --from=builder /app/target/dx/libretunes/release/web/server /libretunes
|
||||
COPY --from=builder /app/target/dx/libretunes/release/web/public /public
|
||||
|
||||
ENV LIBRETUNES_SERVER_PUBLIC_PATH=/public
|
||||
ENV DIOXUS_PUBLIC_PATH=/public
|
||||
|
||||
# Copy libraries to /lib64
|
||||
COPY --from=builder /app/libs /lib64
|
||||
COPY --from=builder /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 /lib64/ld-linux-x86-64.so.2
|
||||
|
||||
ENV LD_LIBRARY_PATH=/lib64
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT [ "/libretunes" ]
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 121 KiB |
@@ -1 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <svg viewBox="0 0 254 254" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"><clipPath clipPathUnits="userSpaceOnUse" id="clippath"><path fill="#4032a8" d="m 9.028871e-5,480.0001 v 0 C 9.028871e-5,214.90342 214.90341,1.0022047e-4 480.00009,1.0022047e-4 v 0 c 127.30393,0 249.39377,50.57128477953 339.41122,140.58874977953 90.01752,90.01746 140.58875,212.10733 140.58875,339.41125 v 0 c 0,265.09665 -214.90332,479.99997 -479.99997,479.99997 v 0 C 214.90341,960.00007 9.028871e-5,745.09675 9.028871e-5,480.0001 Z" fill-rule="evenodd" style="display:inline;fill:#4032a8;fill-opacity:.495496"/></clipPath><g><g style="fill:none;stroke:none;stroke-linecap:square;stroke-miterlimit:10" transform="matrix(0.26458333,0,0,0.26458333,0,4.5672766)"><g clip-path="url(#p.0)" transform="translate(0,-17.262248)"><path fill="#4032a8" d="m 9.028871e-5,480.0001 v 0 C 9.028871e-5,214.90342 214.90341,1e-4 480.00009,1e-4 v 0 c 127.30393,0 249.39377,50.571285 339.41122,140.58875 90.01752,90.01746 140.58875,212.10733 140.58875,339.41125 v 0 c 0,265.09665 -214.90332,479.99997 -479.99997,479.99997 v 0 C 214.90341,960.00007 9.028871e-5,745.09675 9.028871e-5,480.0001 Z" fill-rule="evenodd"/><path stroke="#ffffff" stroke-width="4" stroke-linejoin="round" stroke-linecap="butt" d="m 60.00009,480.0001 v 0 c 0,-231.9596 188.0404,-420 420,-420 v 0 c 111.3909,0 218.21957,44.24988 296.98483,123.01516 78.76532,78.76527 123.01514,185.59392 123.01514,296.98486 v 0 c 0,231.95956 -188.0404,419.99997 -419.99997,419.99997 v 0 c -231.9596,0 -420,-188.0404 -420,-419.99997 z" fill-rule="evenodd" style="stroke-width:10.6666668;stroke-dasharray:none"/><path stroke="#ffffff" stroke-width="4" stroke-linejoin="round" stroke-linecap="butt" d="m 120.00009,480.0001 v 0 c 0,-198.82251 161.17749,-360 360,-360 v 0 c 95.47794,0 187.04532,37.92846 254.55844,105.44157 67.51312,67.51309 105.44153,159.0805 105.44153,254.55844 v 0 c 0,198.82248 -161.17749,359.99997 -359.99997,359.99997 v 0 c -198.82251,0 -360,-161.17749 -360,-359.99997 z" fill-rule="evenodd" style="stroke-width:10.6666668;stroke-dasharray:none"/><path fill="#ffffff" d="m 319.01666,480.00092 v 0 c 0,-88.90915 72.0751,-160.98425 160.98425,-160.98425 v 0 c 42.69568,0 83.64264,16.96078 113.83307,47.15121 30.19037,30.1904 47.15118,71.13736 47.15118,113.83304 v 0 c 0,88.90918 -72.07507,160.98425 -160.98425,160.98425 v 0 c -88.90915,0 -160.98425,-72.07507 -160.98425,-160.98425 z" fill-rule="evenodd" style="display:inline"/><path stroke="#ffffff" stroke-width="4" stroke-linejoin="round" stroke-linecap="butt" d="m 180.00009,480.0001 v 0 c 0,-165.68542 134.31458,-300 300,-300 v 0 c 79.56497,0 155.87112,31.60704 212.13205,87.86795 56.26092,56.26092 87.86792,132.56711 87.86792,212.13205 v 0 c 0,165.6854 -134.31458,299.99997 -299.99997,299.99997 v 0 c -165.68542,0 -300,-134.31458 -300,-299.99997 z" fill-rule="evenodd" style="stroke-width:10.6666668;stroke-dasharray:none"/><path fill="#ffffff" d="M 427.60658,-273.65095 504.05265,93.386828 478.34507,135.07715 570.21183,450.80446 544.78065,501.65549 662.47712,1028.614 477.52388,620.42282 508.81779,566.37301 393.66837,305.06321 429.7686,227.61648 308.24258,-46.542095 Z" fill-rule="evenodd" style="stroke-width:1.16873" clip-path="url(#clippath)"/></g></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 3.3 KiB |
62
flake.lock
generated
62
flake.lock
generated
@@ -1,35 +1,5 @@
|
||||
{
|
||||
"nodes": {
|
||||
"daisyui": {
|
||||
"locked": {
|
||||
"lastModified": 1785801268,
|
||||
"narHash": "sha256-dqE+kAW1Smsf+H1Yfzy6DbYHST/sWfz3Ghzw7M331k8=",
|
||||
"ref": "refs/heads/main",
|
||||
"rev": "fd3f4af492599cf639c4d5f52f9521f0054e5e9e",
|
||||
"revCount": 3,
|
||||
"type": "git",
|
||||
"url": "https://git.mregirouard.com/nix/daisyui.git"
|
||||
},
|
||||
"original": {
|
||||
"type": "git",
|
||||
"url": "https://git.mregirouard.com/nix/daisyui.git"
|
||||
}
|
||||
},
|
||||
"dx-build": {
|
||||
"locked": {
|
||||
"lastModified": 1785888038,
|
||||
"narHash": "sha256-zhUWl+BXYBBIHuLsjlLh+8RL6ymrUCLCnBL7I6/BAGw=",
|
||||
"ref": "refs/heads/main",
|
||||
"rev": "c3fbf020a501226c0ef7c1f6a394d9799c3cf52f",
|
||||
"revCount": 3,
|
||||
"type": "git",
|
||||
"url": "https://gitea.mregirouard.com/nix/dx-build.git"
|
||||
},
|
||||
"original": {
|
||||
"type": "git",
|
||||
"url": "https://gitea.mregirouard.com/nix/dx-build.git"
|
||||
}
|
||||
},
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
@@ -50,11 +20,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1785747939,
|
||||
"narHash": "sha256-D740uKsMbgsfK2oaDenJLLPIZfq7W0/g4KN/Fls8eKs=",
|
||||
"lastModified": 1780930886,
|
||||
"narHash": "sha256-rppURzHviaQN131F+nLiLdGfcb0uCd9gGP0E5+iw9MI=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "104240a772428cc2e20d8fd86c9ddbb886bbaff2",
|
||||
"rev": "8c3cede7ddc26bd659d2d383b5610efbd2c7a16e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -66,12 +36,9 @@
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"daisyui": "daisyui",
|
||||
"dx-build": "dx-build",
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay",
|
||||
"tailwindcss-with-pkgs": "tailwindcss-with-pkgs"
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
@@ -81,11 +48,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1785820796,
|
||||
"narHash": "sha256-sZXy8mzUMi2cOGulhoW4HWAZB6JhXOAx1x8J4auZFWk=",
|
||||
"lastModified": 1781061510,
|
||||
"narHash": "sha256-tVuGHgt/TsWu1rUAqEL+eWRIJJHtiPE2+yQ63b+/WTU=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "b6916ba032e02122d6ed3064f40cabe937363d43",
|
||||
"rev": "d286e9691bb03045febbf8304a658eab1487d1b5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -108,21 +75,6 @@
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"tailwindcss-with-pkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1785801205,
|
||||
"narHash": "sha256-yfKa3/wK/m1Gwjc923Fvpa/k44MjQOOR3eUfjqLn+qI=",
|
||||
"ref": "refs/heads/main",
|
||||
"rev": "f28fd97c86f37a6078599338fa9c6a0327821b9d",
|
||||
"revCount": 3,
|
||||
"type": "git",
|
||||
"url": "https://git.mregirouard.com/nix/tailwindcss-with-pkgs.git"
|
||||
},
|
||||
"original": {
|
||||
"type": "git",
|
||||
"url": "https://git.mregirouard.com/nix/tailwindcss-with-pkgs.git"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
|
||||
79
flake.nix
79
flake.nix
@@ -8,12 +8,6 @@
|
||||
url = "github:oxalica/rust-overlay";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
daisyui.url = "git+https://git.mregirouard.com/nix/daisyui.git";
|
||||
|
||||
tailwindcss-with-pkgs.url = "git+https://git.mregirouard.com/nix/tailwindcss-with-pkgs.git";
|
||||
|
||||
dx-build.url = "git+https://gitea.mregirouard.com/nix/dx-build.git";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, rust-overlay, flake-utils, ... }@inputs:
|
||||
@@ -41,12 +35,43 @@
|
||||
overlays = [
|
||||
(import rust-overlay)
|
||||
wasm-bindgen-overlay
|
||||
inputs.daisyui.overlays.default
|
||||
inputs.tailwindcss-with-pkgs.overlays.default
|
||||
inputs.dx-build.overlays.default
|
||||
];
|
||||
};
|
||||
|
||||
daisyui-version = "v5.5.23";
|
||||
|
||||
daisyui = pkgs.stdenvNoCC.mkDerivation {
|
||||
name = "daisyui";
|
||||
|
||||
src = pkgs.fetchurl {
|
||||
url = "https://github.com/saadeghi/daisyui/releases/download/${daisyui-version}/daisyui.js";
|
||||
sha256 = "sha256-yu/8ebzKXMfrcHJw2FzcXNzwYOF1hC+nufrTaPOMWeA=";
|
||||
};
|
||||
|
||||
unpackPhase = "true";
|
||||
|
||||
installPhase = ''
|
||||
mkdir "$out"
|
||||
cp "$src" "$out/daisyui.js"
|
||||
'';
|
||||
};
|
||||
|
||||
daisyui-theme = pkgs.stdenvNoCC.mkDerivation {
|
||||
name = "daisyui-theme";
|
||||
|
||||
src = pkgs.fetchurl {
|
||||
url = "https://github.com/saadeghi/daisyui/releases/download/${daisyui-version}/daisyui-theme.js";
|
||||
sha256 = "sha256-p/ofznslFSKQ87UuP4XV4bvU7xmAkci/WRlAznJt9MY=";
|
||||
};
|
||||
|
||||
unpackPhase = "true";
|
||||
|
||||
installPhase = ''
|
||||
mkdir "$out"
|
||||
cp "$src" "$out/daisyui-theme.js"
|
||||
'';
|
||||
};
|
||||
|
||||
rust = pkgs.rust-bin.stable.latest.default.override {
|
||||
targets = [ "wasm32-unknown-unknown" ];
|
||||
};
|
||||
@@ -57,16 +82,15 @@
|
||||
wasm-bindgen-cli_0_2_123
|
||||
binaryen
|
||||
lld
|
||||
(tailwindcss_4.withPackages [daisyui daisyui-theme])
|
||||
tailwindcss_4
|
||||
postgresql
|
||||
];
|
||||
|
||||
rev = if builtins.hasAttr "rev" self then self.rev else self.dirtyRev;
|
||||
|
||||
pkg-info = (pkgs.lib.trivial.importTOML ./Cargo.toml).package;
|
||||
in
|
||||
rec {
|
||||
devShells.default = pkgs.mkShell {
|
||||
DAISYUI_PATH = "${daisyui}";
|
||||
DAISYUI_THEME_PATH = "${daisyui-theme}";
|
||||
|
||||
buildInputs = with pkgs; [
|
||||
diesel-cli
|
||||
] ++ build-pkgs;
|
||||
@@ -74,23 +98,34 @@
|
||||
|
||||
packages.default = packages.web;
|
||||
|
||||
packages.web = pkgs.rustPlatform.buildDioxusWebApp {
|
||||
pname = pkg-info.name;
|
||||
version = pkg-info.version;
|
||||
packages.web = pkgs.rustPlatform.buildRustPackage {
|
||||
name = "libretunes";
|
||||
src = ./.;
|
||||
|
||||
GIT_REV = rev;
|
||||
DAISYUI_PATH = "${daisyui}";
|
||||
DAISYUI_THEME_PATH = "${daisyui-theme}";
|
||||
|
||||
cargoLock.lockFile = ./Cargo.lock;
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
(tailwindcss_4.withPackages [daisyui daisyui-theme])
|
||||
wasm-bindgen-cli_0_2_123
|
||||
];
|
||||
makeWrapper
|
||||
] ++ build-pkgs;
|
||||
|
||||
preBuild = ''
|
||||
buildPhase = ''
|
||||
# dx build will run tailwindcss anyways, but doing it first here is faster and clearer if it fails
|
||||
tailwindcss --input style/tailwind.css --output assets/tailwind.css
|
||||
|
||||
dx build --release --frozen --web
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
mkdir -p "$out/bin"
|
||||
install -t "$out" target/dx/libretunes/release/web/server
|
||||
|
||||
cp -r target/dx/libretunes/release/web/public "$out/public"
|
||||
|
||||
makeWrapper "$out/server" "$out/bin/libretunes" \
|
||||
--set DIOXUS_PUBLIC_PATH "$out/public"
|
||||
'';
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
DROP INDEX users_username_idx;
|
||||
DROP TABLE users;
|
||||
@@ -1,8 +0,0 @@
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY UNIQUE NOT NULL GENERATED ALWAYS AS IDENTITY,
|
||||
username VARCHAR UNIQUE NOT NULL,
|
||||
hashed_password VARCHAR NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX users_username_idx ON users(username);
|
||||
@@ -1,94 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::models::user::{User, UserCredentials};
|
||||
use crate::util::error::Result;
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "server")] {
|
||||
|
||||
use dioxus::server::axum::Extension;
|
||||
|
||||
use crate::server::{auth::{AuthSession, create_user}, config::Config, database::DbPool};
|
||||
use crate::util::error::{AuthError, Contextualize, Error, ErrorType};
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/api/v1/auth/signup", mut auth: Extension<AuthSession>, db_pool: Extension<DbPool>, config: Extension<Config>)]
|
||||
pub async fn signup(credentials: UserCredentials) -> Result<User> {
|
||||
if !config.auth.open_signup {
|
||||
return Err(Error::message_here("Signup is disabled"));
|
||||
}
|
||||
|
||||
// Don't allow signup when already logged in
|
||||
if auth.user.is_some() {
|
||||
return Err(Error::message_here("Log out before creating an account"));
|
||||
}
|
||||
|
||||
let hashed_creds = credentials
|
||||
.try_hash()
|
||||
.map_err(|e| Error::message_here(e.to_string()))
|
||||
.err_context("Error hashing new user credentials")?;
|
||||
|
||||
let mut db_conn = db_pool
|
||||
.get()
|
||||
.await
|
||||
.err_context("Failed to get database pool connection")?;
|
||||
|
||||
let new_user = create_user(&mut db_conn, &hashed_creds)
|
||||
.await
|
||||
.err_context("Error creating user")?;
|
||||
|
||||
// Don't return this to the client, logging in immediately isn't strictly necessary
|
||||
if let Err(e) = auth.login(&new_user).await {
|
||||
tracing::warn!("Failed to log in user after creating: {e}");
|
||||
}
|
||||
|
||||
Ok(new_user.into())
|
||||
}
|
||||
|
||||
#[post("/api/v1/auth/login", mut auth: Extension<AuthSession>)]
|
||||
pub async fn login(credentials: UserCredentials) -> Result<User> {
|
||||
let db_user = match auth.authenticate(credentials).await {
|
||||
Ok(Some(db_user)) => Ok(db_user),
|
||||
Ok(None) => Err(Error::new_here(ErrorType::Auth(
|
||||
AuthError::InvalidCredentials,
|
||||
))),
|
||||
Err(axum_login::Error::Session(e)) => Err(Error::new_here(ErrorType::Auth(
|
||||
AuthError::Error(format!("Session error: {e}")),
|
||||
))),
|
||||
Err(axum_login::Error::Backend(e)) => Err(e),
|
||||
}
|
||||
.err_context("Error authenticating")?;
|
||||
|
||||
auth.login(&db_user)
|
||||
.await
|
||||
.map_err(|e| Error::new_here(ErrorType::Auth(AuthError::Error(e.to_string()))))
|
||||
.err_context("Error logging in")?;
|
||||
|
||||
Ok(db_user.into())
|
||||
}
|
||||
|
||||
#[post("/api/v1/auth/logout", mut auth: Extension<AuthSession>)]
|
||||
pub async fn logout() -> Result<()> {
|
||||
match auth.logout().await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(axum_login::Error::Session(e)) => Err(Error::new_here(ErrorType::Auth(
|
||||
AuthError::Error(format!("Session error: {e}")),
|
||||
))),
|
||||
Err(axum_login::Error::Backend(e)) => Err(e),
|
||||
}
|
||||
.err_context("Error logging out")
|
||||
}
|
||||
|
||||
/// Retrieve the currently logged-in user, or `None` if unauthenticated
|
||||
#[get("/api/v1/auth/user", auth: Extension<AuthSession>)]
|
||||
pub async fn get_user() -> Result<Option<User>> {
|
||||
Ok(auth.user.clone().map(Into::into))
|
||||
}
|
||||
|
||||
/// Check if open signup is enabled
|
||||
#[get("/api/v1/auth/open-signup", config: Extension<Config>)]
|
||||
pub async fn open_signup() -> Result<bool> {
|
||||
Ok(config.auth.open_signup)
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::util::error::Result;
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "server")] {
|
||||
|
||||
use dioxus::server::axum::Extension;
|
||||
use diesel_async::{AsyncConnection, SimpleAsyncConnection};
|
||||
|
||||
use crate::{
|
||||
server::{auth::AuthSession, config::Config, database::DbPool},
|
||||
util::error::{Contextualize, Error, ErrorType},
|
||||
};
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/api/v1/health", db_pool: Option<Extension<DbPool>>, auth: Option<Extension<AuthSession>>, config: Option<Extension<Config>>)]
|
||||
pub async fn health_check() -> Result<bool> {
|
||||
if auth.is_none() {
|
||||
return Err(Error::new_here(ErrorType::HttpServer(
|
||||
"Unable to retrieve auth session middleware".to_owned(),
|
||||
)));
|
||||
}
|
||||
|
||||
if config.is_none() {
|
||||
return Err(Error::new_here(ErrorType::HttpServer(
|
||||
"Unable to retrieve server config".to_owned(),
|
||||
)));
|
||||
}
|
||||
|
||||
let Some(db_pool) = db_pool else {
|
||||
return Err(Error::new_here(ErrorType::HttpServer(
|
||||
"Unable to retrieve database connection pool".to_owned(),
|
||||
)));
|
||||
};
|
||||
|
||||
let mut db_conn = db_pool
|
||||
.get()
|
||||
.await
|
||||
.err_context("Failed to get database pool connection")?;
|
||||
|
||||
db_conn
|
||||
.begin_test_transaction()
|
||||
.await
|
||||
.err_context("Failed to create database transaction")?;
|
||||
|
||||
db_conn
|
||||
.batch_execute("SELECT 1;")
|
||||
.await
|
||||
.err_context("Failed to execute database health check query")?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
@@ -1,2 +1 @@
|
||||
pub mod auth;
|
||||
pub mod health;
|
||||
|
||||
|
||||
32
src/app.rs
32
src/app.rs
@@ -1,32 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
pub const LOGO_SVG: Asset = asset!("/assets/logo.svg");
|
||||
pub const LOGO_ICO: Asset = asset!("/assets/favicon.ico");
|
||||
const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css");
|
||||
|
||||
#[derive(Debug, Clone, Routable, PartialEq)]
|
||||
#[rustfmt::skip]
|
||||
enum Route {
|
||||
#[route("/")]
|
||||
Home {},
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn App() -> Element {
|
||||
rsx! {
|
||||
document::Link { rel: "icon", href: LOGO_ICO }
|
||||
document::Link { rel: "stylesheet", href: TAILWIND_CSS }
|
||||
Router::<Route> {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Home page
|
||||
#[component]
|
||||
fn Home() -> Element {
|
||||
rsx! {
|
||||
p {
|
||||
class: "text-lg",
|
||||
"Hello, world!"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::env;
|
||||
use std::process::Command;
|
||||
use std::env;
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=migrations");
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::app::LOGO_SVG;
|
||||
|
||||
#[component]
|
||||
pub fn Footer() -> Element {
|
||||
rsx! {
|
||||
footer {
|
||||
class: "footer md:footer-horizontal fixed bottom-0 text-base-content/70 bg-base-300 items-center p-4",
|
||||
|
||||
aside {
|
||||
class: "flex items-center",
|
||||
|
||||
img {
|
||||
class: "w-12 h-12",
|
||||
src: LOGO_SVG,
|
||||
}
|
||||
|
||||
div {
|
||||
p {
|
||||
class: "text-xl font-bold",
|
||||
"LibreTunes",
|
||||
}
|
||||
|
||||
a {
|
||||
class: "text-sm",
|
||||
href: env!("CARGO_PKG_REPOSITORY"),
|
||||
"v" {env!("CARGO_PKG_VERSION")},
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::util::error::Error;
|
||||
|
||||
/// A simple styled form card. Parses form fields into the provided generic type, and calls the
|
||||
/// callback when submitted via button or enter key.
|
||||
#[component]
|
||||
pub fn Form<T>(
|
||||
/// Heading above the form
|
||||
title: String,
|
||||
/// Form content, placed inside a `fieldset`
|
||||
children: Element,
|
||||
/// Text displayed on the form button
|
||||
action_message: String,
|
||||
/// Form-related error. Set to a parsing error on parse failure, or can be used to display any
|
||||
/// submission error. Clears on each submit. Displays as a card between the inputs and action
|
||||
/// button, opens a modal on click.
|
||||
#[props(default)]
|
||||
error: Signal<Option<Error>>,
|
||||
/// Whether the form should display in a "loading" state, disables the action button and
|
||||
/// displays a loading animation instead. For best UX, especially on pages the user might open
|
||||
/// directly as opposed to navigate to within the app, initialize to true. This component will
|
||||
/// set it to false when run. This sequence will discourage users from submitting the form
|
||||
/// before the page is ready to handle it. Can also be used to indicate background work is
|
||||
/// taking place in the `callback`, but this is not done automatically.
|
||||
#[props(default)]
|
||||
loading: Signal<bool>,
|
||||
/// Content to display below the action button
|
||||
#[props(default)]
|
||||
extra_content: Option<Element>,
|
||||
/// Form submission callback. Called with the deserialized form inputs.
|
||||
onsubmit: Callback<T>,
|
||||
) -> Element
|
||||
where
|
||||
T: DeserializeOwned + 'static,
|
||||
{
|
||||
// Button is initialized to loading, then set back when WASM is finished loading (use_effect
|
||||
// runs only on the client)
|
||||
use_effect(move || loading.set(false));
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
class: "card card-xl w-full sm:w-100 sm:h-fit bg-base-200",
|
||||
|
||||
form {
|
||||
onsubmit: move |evt| {
|
||||
evt.prevent_default();
|
||||
|
||||
let data: T = match evt.data.parsed_values() {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
let e = Error::message_here(e.to_string())
|
||||
.with_context("Failed to parse form inputs");
|
||||
|
||||
error.set(Some(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
error.set(None);
|
||||
|
||||
onsubmit.call(data);
|
||||
},
|
||||
|
||||
class: "card-body gap-3",
|
||||
|
||||
h1 {
|
||||
class: "card-title",
|
||||
{title}
|
||||
}
|
||||
|
||||
fieldset {
|
||||
class: "fieldset pt-0 gap-1",
|
||||
{children}
|
||||
}
|
||||
|
||||
{error().map(|e| e.as_alert())}
|
||||
|
||||
div {
|
||||
class: "card-actions gap-4",
|
||||
|
||||
button {
|
||||
class: "btn btn-primary btn-block",
|
||||
disabled: loading(),
|
||||
|
||||
if loading() {
|
||||
span {
|
||||
class: "loading loading-dots"
|
||||
}
|
||||
} else {
|
||||
{action_message}
|
||||
}
|
||||
}
|
||||
|
||||
{extra_content}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// A simple styled input box
|
||||
#[component]
|
||||
pub fn FormInput(
|
||||
/// The placeholder text and label. When text is entered in the input, the label is shown above
|
||||
/// the input box.
|
||||
label: String,
|
||||
#[props(default)] name: Option<String>,
|
||||
#[props(default)] required: bool,
|
||||
#[props(default)] r#type: String,
|
||||
children: Element,
|
||||
) -> Element {
|
||||
let name = name.unwrap_or(label.to_lowercase());
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
class: "group/field",
|
||||
|
||||
p {
|
||||
class: "opacity-0 not-group-has-placeholder-shown/field:opacity-70 transition-opacity pl-2",
|
||||
{label.clone()}
|
||||
}
|
||||
|
||||
label {
|
||||
class: "input w-full",
|
||||
|
||||
{children}
|
||||
|
||||
input {
|
||||
name,
|
||||
required,
|
||||
r#type,
|
||||
placeholder: label,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn UsernameInput() -> Element {
|
||||
rsx! {
|
||||
FormInput {
|
||||
label: "Username",
|
||||
required: true,
|
||||
|
||||
lucide_dioxus::UserRound {
|
||||
class: "opacity-50",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn PasswordInput() -> Element {
|
||||
rsx! {
|
||||
FormInput {
|
||||
label: "Password",
|
||||
required: true,
|
||||
r#type: "password",
|
||||
|
||||
lucide_dioxus::KeyRound {
|
||||
class: "opacity-50",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1 @@
|
||||
pub mod footer;
|
||||
pub mod form;
|
||||
pub mod form_input;
|
||||
|
||||
pub use footer::*;
|
||||
pub use form::*;
|
||||
pub use form_input::*;
|
||||
|
||||
56
src/main.rs
56
src/main.rs
@@ -1,39 +1,49 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
pub mod api;
|
||||
pub mod app;
|
||||
pub mod components;
|
||||
pub mod models;
|
||||
pub mod pages;
|
||||
pub mod util;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub mod schema;
|
||||
pub mod util;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub mod server;
|
||||
|
||||
use crate::app::App;
|
||||
const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css");
|
||||
|
||||
fn tracing_setup() {
|
||||
#[derive(Debug, Clone, Routable, PartialEq)]
|
||||
#[rustfmt::skip]
|
||||
enum Route {
|
||||
#[route("/")]
|
||||
Home {},
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn App() -> Element {
|
||||
rsx! {
|
||||
document::Link { rel: "stylesheet", href: TAILWIND_CSS }
|
||||
Router::<Route> {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Home page
|
||||
#[component]
|
||||
fn Home() -> Element {
|
||||
rsx! {
|
||||
p {
|
||||
class: "text-lg",
|
||||
"Hello, world!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
#[cfg(debug_assertions)]
|
||||
dioxus::logger::init(tracing::Level::DEBUG).expect("Failed to initialize tracing logger");
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
dioxus::logger::init(tracing::Level::INFO).expect("Failed to initialize tracing logger");
|
||||
}
|
||||
#[cfg(feature = "server")]
|
||||
server::main();
|
||||
|
||||
#[cfg(not(feature = "server"))]
|
||||
fn main() {
|
||||
tracing_setup();
|
||||
dioxus::launch(App);
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
#[tokio::main]
|
||||
async fn main() -> std::process::ExitCode {
|
||||
tracing_setup();
|
||||
|
||||
let Err(e) = server::main().await;
|
||||
tracing::error!("Server main failed:\n{e}");
|
||||
|
||||
std::process::ExitCode::FAILURE
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
pub mod user;
|
||||
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
//! Various user types. Some types marked server-only to help prevent
|
||||
//! leaking passwords to the frontend
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Standard informational user type, contains no password information
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "server", derive(Queryable, Selectable, Identifiable))]
|
||||
#[cfg_attr(feature = "server", diesel(table_name = crate::schema::users,
|
||||
check_for_backend(diesel::pg::Pg)))]
|
||||
pub struct User {
|
||||
pub id: i32,
|
||||
pub username: String,
|
||||
pub created_at: chrono::DateTime<chrono::Local>,
|
||||
}
|
||||
|
||||
/// Plaintext user credentials, used for login/signup form
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct UserCredentials {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "server")] {
|
||||
|
||||
use diesel::{
|
||||
deserialize::{FromSql, FromSqlRow},
|
||||
expression::AsExpression,
|
||||
prelude::*,
|
||||
serialize::ToSql,
|
||||
sql_types,
|
||||
};
|
||||
use pbkdf2::{
|
||||
PasswordHasher, PasswordVerifier, Pbkdf2, password_hash::Error::PasswordInvalid,
|
||||
phc::PasswordHash,
|
||||
};
|
||||
|
||||
use crate::util::error::{Error, Result};
|
||||
|
||||
/// Newtype for a `String`-represented hashed password
|
||||
#[derive(Clone, Debug, AsExpression, FromSqlRow)]
|
||||
#[diesel(sql_type = sql_types::Text)]
|
||||
pub struct HashedPassword(String);
|
||||
|
||||
/// Get a `Pbkdf2` instance for hashing
|
||||
fn get_pbkdf2() -> Pbkdf2 {
|
||||
use pbkdf2::{Algorithm, Params};
|
||||
|
||||
if cfg!(test) {
|
||||
// Use lower security in testing mode so it doesn't take as long
|
||||
// `Params::new` panics only if `rounds` < `MIN_ROUNDS`
|
||||
Pbkdf2::new(Algorithm::default(), Params::new(Params::MIN_ROUNDS).expect("failed creating Pbkdf2 Params"))
|
||||
} else {
|
||||
// Default uses a sufficiently secure configuration
|
||||
Pbkdf2::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl HashedPassword {
|
||||
/// Check a password attempt against this hashed password
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `Ok(true)` for a correct password
|
||||
/// `Ok(false)` for an incorrect password
|
||||
/// `Err` for a hashing error
|
||||
pub fn check(&self, password_attempt: String) -> Result<bool> {
|
||||
let pw_hash = PasswordHash::new(&self.0)
|
||||
.map_err(|e| Error::message_here(format!("Error parsing `HashedPassword`: {e}")))?;
|
||||
|
||||
match get_pbkdf2().verify_password(password_attempt.as_bytes(), &pw_hash) {
|
||||
Ok(()) => Ok(true),
|
||||
Err(PasswordInvalid) => Ok(false),
|
||||
Err(e) => Err(Error::message_here(format!(
|
||||
"Error comparing password attempt against hash: {e}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the "session auth hash" for `axum-login`, just the hashed password as bytes
|
||||
pub fn auth_hash(&self) -> &[u8] {
|
||||
self.0.as_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
impl<DB> FromSql<diesel::sql_types::Text, DB> for HashedPassword
|
||||
where
|
||||
DB: diesel::backend::Backend,
|
||||
String: FromSql<sql_types::Text, DB>,
|
||||
{
|
||||
fn from_sql(bytes: DB::RawValue<'_>) -> diesel::deserialize::Result<Self> {
|
||||
Ok(Self(String::from_sql(bytes)?))
|
||||
}
|
||||
}
|
||||
|
||||
impl<DB> ToSql<diesel::sql_types::Text, DB> for HashedPassword
|
||||
where
|
||||
DB: diesel::backend::Backend,
|
||||
String: ToSql<sql_types::Text, DB>,
|
||||
{
|
||||
fn to_sql<'b>(
|
||||
&'b self,
|
||||
out: &mut diesel::serialize::Output<'b, '_, DB>,
|
||||
) -> diesel::serialize::Result {
|
||||
self.0.to_sql(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// User as it appears in the database, with hashed password
|
||||
#[derive(Clone, Debug, Identifiable, Queryable, Selectable)]
|
||||
#[diesel(table_name = crate::schema::users, check_for_backend(diesel::pg::Pg))]
|
||||
pub struct DbUser {
|
||||
pub id: i32,
|
||||
pub username: String,
|
||||
pub hashed_password: HashedPassword,
|
||||
pub created_at: chrono::DateTime<chrono::Local>,
|
||||
}
|
||||
|
||||
impl From<DbUser> for User {
|
||||
fn from(db_user: DbUser) -> Self {
|
||||
User {
|
||||
id: db_user.id,
|
||||
username: db_user.username,
|
||||
created_at: db_user.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// User credentials with hashed password
|
||||
#[derive(Clone, Debug, Insertable, Queryable, Selectable)]
|
||||
#[diesel(table_name = crate::schema::users, check_for_backend(diesel::pg::Pg))]
|
||||
pub struct HashedUserCredentials {
|
||||
username: String,
|
||||
hashed_password: HashedPassword,
|
||||
}
|
||||
|
||||
impl From<DbUser> for HashedUserCredentials {
|
||||
fn from(db_user: DbUser) -> Self {
|
||||
HashedUserCredentials {
|
||||
username: db_user.username,
|
||||
hashed_password: db_user.hashed_password,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UserCredentials {
|
||||
/// Attempt to convert into `HashedUserCredentials` by hashing the password. Yields a PBKDF2
|
||||
/// error on failure.
|
||||
pub fn try_hash(self) -> Result<HashedUserCredentials, pbkdf2::password_hash::Error> {
|
||||
let hashed_password = get_pbkdf2().hash_password(self.password.as_bytes())?;
|
||||
|
||||
Ok(HashedUserCredentials {
|
||||
username: self.username,
|
||||
hashed_password: HashedPassword(hashed_password.to_string()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1 @@
|
||||
// @generated automatically by Diesel CLI.
|
||||
|
||||
diesel::table! {
|
||||
users (id) {
|
||||
id -> Int4,
|
||||
username -> Varchar,
|
||||
hashed_password -> Varchar,
|
||||
created_at -> Timestamptz,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
use axum_login::{AuthManagerLayer, AuthUser, AuthnBackend, UserId};
|
||||
use diesel::prelude::*;
|
||||
use diesel_async::RunQueryDsl;
|
||||
use tower_sessions_redis_store::RedisStore;
|
||||
|
||||
use crate::models::user::{DbUser, HashedUserCredentials, UserCredentials};
|
||||
use crate::server::{
|
||||
database::{DbConn, DbPool},
|
||||
key_val_store::KeyValPool,
|
||||
};
|
||||
use crate::util::error::{Contextualize, Error, Result};
|
||||
|
||||
pub type AuthLayer = AuthManagerLayer<AuthBackend, RedisStore<KeyValPool>>;
|
||||
pub type AuthSession = axum_login::AuthSession<AuthBackend>;
|
||||
|
||||
impl AuthUser for DbUser {
|
||||
type Id = i32;
|
||||
|
||||
fn id(&self) -> Self::Id {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn session_auth_hash(&self) -> &[u8] {
|
||||
self.hashed_password.auth_hash()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthBackend {
|
||||
pub db_pool: DbPool,
|
||||
}
|
||||
|
||||
impl AuthnBackend for AuthBackend {
|
||||
type User = DbUser;
|
||||
type Credentials = UserCredentials;
|
||||
type Error = Error;
|
||||
|
||||
async fn authenticate(
|
||||
&self,
|
||||
attempt_creds: Self::Credentials,
|
||||
) -> Result<Option<Self::User>, Self::Error> {
|
||||
let mut db_conn = self
|
||||
.db_pool
|
||||
.get()
|
||||
.await
|
||||
.err_context("Failed to get database pool connection")?;
|
||||
|
||||
let user = get_user_by_username(&mut db_conn, attempt_creds.username)
|
||||
.await
|
||||
.err_context("Error fetching user for authentication check")?;
|
||||
|
||||
let Some(user) = user else { return Ok(None) };
|
||||
|
||||
let password_result = user
|
||||
.hashed_password
|
||||
.check(attempt_creds.password)
|
||||
.err_context("Error checking user password attempt")?;
|
||||
|
||||
if password_result {
|
||||
Ok(Some(user))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
|
||||
let mut db_conn = self
|
||||
.db_pool
|
||||
.get()
|
||||
.await
|
||||
.err_context("Failed to get database pool connection")?;
|
||||
|
||||
get_user_by_id(&mut db_conn, *user_id)
|
||||
.await
|
||||
.err_context("Failed fetching user for session")
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_user(
|
||||
db_conn: &mut DbConn,
|
||||
credentials: &HashedUserCredentials,
|
||||
) -> Result<DbUser> {
|
||||
diesel::insert_into(crate::schema::users::table)
|
||||
.values(credentials)
|
||||
.get_result(db_conn)
|
||||
.await
|
||||
.err_context("Error creating user")
|
||||
}
|
||||
|
||||
pub async fn get_user_by_id(db_conn: &mut DbConn, id: i32) -> Result<Option<DbUser>> {
|
||||
crate::schema::users::table
|
||||
.find(id)
|
||||
.first(db_conn)
|
||||
.await
|
||||
.optional()
|
||||
.err_context("Error fetching user from database by id")
|
||||
}
|
||||
|
||||
pub async fn get_user_by_username(
|
||||
db_conn: &mut DbConn,
|
||||
username: String,
|
||||
) -> Result<Option<DbUser>> {
|
||||
crate::schema::users::table
|
||||
.filter(crate::schema::users::username.eq(username))
|
||||
.first(db_conn)
|
||||
.await
|
||||
.optional()
|
||||
.err_context("Error fetching user from database by username")
|
||||
}
|
||||
|
||||
/// Create the authentication middleware layer
|
||||
pub fn build_auth_layer(db_pool: DbPool, key_val_pool: KeyValPool, use_secure: bool) -> AuthLayer {
|
||||
use axum_login::{AuthManagerLayerBuilder, tower_sessions::SessionManagerLayer};
|
||||
use tower_sessions_redis_store::RedisStore;
|
||||
|
||||
let auth_session_store = RedisStore::new(key_val_pool);
|
||||
let session_layer = SessionManagerLayer::new(auth_session_store).with_secure(use_secure);
|
||||
|
||||
let auth_backend = AuthBackend { db_pool };
|
||||
|
||||
AuthManagerLayerBuilder::new(auth_backend, session_layer).build()
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use config::{ConfigBuilder, ConfigError, builder::DefaultState};
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Enable secure cookies by default only in release mode
|
||||
/// (Secure cookies can't be set over HTTP. Rough assumption: development is done over HTTP)
|
||||
const DEFAULT_COOKIES_SECURE: bool = cfg!(not(debug_assertions));
|
||||
|
||||
// Simple newtype to avoid showing secrets with `Debug` / `Display`
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct SecretString(String);
|
||||
|
||||
impl SecretString {
|
||||
pub fn expose(&self) -> &String {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for SecretString {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SecretString {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "*****")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SecretString {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "*****")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AuthConfig {
|
||||
pub open_signup: bool,
|
||||
pub cookies_secure: bool,
|
||||
}
|
||||
|
||||
/// Build a connection URI from parts
|
||||
fn format_uri(
|
||||
scheme: &str,
|
||||
username: Option<&String>,
|
||||
password: Option<&String>,
|
||||
host: &str,
|
||||
port: Option<u16>,
|
||||
path: Option<&String>,
|
||||
) -> String {
|
||||
let mut url = format!("{scheme}://");
|
||||
|
||||
if let Some(username) = username {
|
||||
url.push_str(username);
|
||||
|
||||
if let Some(password) = password {
|
||||
url.push_str(&format!(":{password}"));
|
||||
}
|
||||
|
||||
url.push('@');
|
||||
}
|
||||
|
||||
url.push_str(host);
|
||||
|
||||
if let Some(port) = port {
|
||||
url.push_str(&format!(":{port}"));
|
||||
}
|
||||
|
||||
if let Some(path) = path {
|
||||
url.push_str(&format!("/{path}"));
|
||||
}
|
||||
|
||||
url
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct DatabaseConfig {
|
||||
#[serde(flatten)]
|
||||
connection: DatabaseConnectionConfig,
|
||||
}
|
||||
|
||||
impl DatabaseConfig {
|
||||
/// Get the configured database connection URI
|
||||
pub fn connection_uri(&self) -> String {
|
||||
self.connection.as_uri()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum DatabaseConnectionConfig {
|
||||
FromUrl {
|
||||
url: SecretString,
|
||||
},
|
||||
FromParts {
|
||||
host: String,
|
||||
port: Option<u16>,
|
||||
database: Option<String>,
|
||||
username: Option<String>,
|
||||
password: Option<SecretString>,
|
||||
},
|
||||
}
|
||||
|
||||
impl DatabaseConnectionConfig {
|
||||
/// Convert this configuration into the Postgres connection URI
|
||||
pub fn as_uri(&self) -> String {
|
||||
match self {
|
||||
Self::FromUrl { url } => url.expose().clone(),
|
||||
Self::FromParts {
|
||||
host,
|
||||
port,
|
||||
database,
|
||||
username,
|
||||
password,
|
||||
} => format_uri(
|
||||
"postgres",
|
||||
username.as_ref(),
|
||||
password.as_ref().map(|s| s.expose()),
|
||||
host,
|
||||
*port,
|
||||
database.as_ref(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum KeyValStoreConnectionConfig {
|
||||
FromUrl {
|
||||
url: SecretString,
|
||||
},
|
||||
FromParts {
|
||||
scheme: Option<String>,
|
||||
host: String,
|
||||
port: Option<u16>,
|
||||
database: Option<String>,
|
||||
username: Option<String>,
|
||||
password: Option<SecretString>,
|
||||
},
|
||||
}
|
||||
|
||||
impl KeyValStoreConnectionConfig {
|
||||
/// Convert this configuration into the Redis connection URI
|
||||
pub fn as_uri(&self) -> String {
|
||||
match self {
|
||||
Self::FromUrl { url } => url.expose().clone(),
|
||||
Self::FromParts {
|
||||
scheme,
|
||||
host,
|
||||
port,
|
||||
database,
|
||||
username,
|
||||
password,
|
||||
} => format_uri(
|
||||
scheme.as_deref().unwrap_or("redis"),
|
||||
username.as_ref(),
|
||||
password.as_ref().map(|s| s.expose().clone()).as_ref(),
|
||||
host,
|
||||
*port,
|
||||
database.as_ref(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct KeyValStoreConfig {
|
||||
#[serde(flatten)]
|
||||
connection: KeyValStoreConnectionConfig,
|
||||
}
|
||||
|
||||
impl KeyValStoreConfig {
|
||||
/// Get the configured database connection URI
|
||||
pub fn connection_uri(&self) -> String {
|
||||
self.connection.as_uri()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ServerConfig {
|
||||
pub public_path: PathBuf,
|
||||
pub host: IpAddr,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
impl ServerConfig {
|
||||
pub fn serve_addr(&self) -> SocketAddr {
|
||||
SocketAddr::new(self.host, self.port)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
/// Top-level application configuration
|
||||
pub struct Config {
|
||||
pub auth: AuthConfig,
|
||||
pub database: DatabaseConfig,
|
||||
pub key_val_store: KeyValStoreConfig,
|
||||
pub server: ServerConfig,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Parse configuration from the expected files and environment variables
|
||||
pub fn from_env() -> Result<Self, ConfigError> {
|
||||
use config::{Environment, File};
|
||||
|
||||
let pkg_name = env!("CARGO_PKG_NAME");
|
||||
|
||||
Self::defaults()?
|
||||
.add_source(File::with_name(&format!("/etc/{pkg_name}/config")).required(false))
|
||||
.add_source(File::with_name(&format!("/etc/{pkg_name}")).required(false))
|
||||
.add_source(File::with_name("config").required(false))
|
||||
.add_source(Environment::with_prefix(pkg_name).separator("_"))
|
||||
.build()?
|
||||
.try_deserialize()
|
||||
}
|
||||
|
||||
/// Generate a `config::ConfigBuilder` from default values
|
||||
fn defaults() -> Result<ConfigBuilder<DefaultState>, ConfigError> {
|
||||
config::Config::builder()
|
||||
.set_default(
|
||||
"server.port",
|
||||
dioxus::cli_config::server_port().unwrap_or(8080),
|
||||
)?
|
||||
.set_default(
|
||||
"server.host",
|
||||
dioxus::cli_config::server_ip()
|
||||
.unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED))
|
||||
.to_string(),
|
||||
)?
|
||||
.set_default("auth.open_signup", false)?
|
||||
.set_default("auth.cookies_secure", DEFAULT_COOKIES_SECURE)?
|
||||
.set_default("server.public_path", default_public_dir())
|
||||
}
|
||||
|
||||
/// Log any configuration-related warning messages, such as differences between user-configured
|
||||
/// values and those supplied by the Dioxus CLI
|
||||
pub fn log_warnings(&self) {
|
||||
if let Some(dx_port) = dioxus::cli_config::server_port()
|
||||
&& dx_port != self.server.port
|
||||
{
|
||||
tracing::warn!(
|
||||
"Your configured server port ({}) doesn't match the one specified from environment \
|
||||
variables set by the Dioxus CLI ({dx_port}). If you are intending to use the dx tool, \
|
||||
please do not specify a port in the configuration.",
|
||||
self.server.port
|
||||
);
|
||||
}
|
||||
|
||||
if let Ok(dx_public_path) = std::env::var("DIOXUS_PUBLIC_PATH")
|
||||
&& dx_public_path != self.server.public_path
|
||||
{
|
||||
tracing::warn!(
|
||||
"Your configured server public path ({}) doesn't match the one specified from environment \
|
||||
variables set by the Dioxus CLI ({dx_public_path}). If you are intending to use the dx tool, \
|
||||
please do not specify a public_path in the configuration.",
|
||||
self.server.public_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provide a sane default for the public path, using the same sources as Dioxus does internally.
|
||||
/// Checks the `DIOXUS_PUBLIC_PATH` environment variable, then tries relative to the path of this
|
||||
/// executable
|
||||
fn default_public_dir() -> Option<String> {
|
||||
std::env::var("DIOXUS_PUBLIC_PATH").ok().or_else(|| {
|
||||
std::env::current_exe().ok().and_then(|path| {
|
||||
path.parent()
|
||||
.expect("current executable path must have a parent")
|
||||
.join("public")
|
||||
.into_os_string()
|
||||
.into_string()
|
||||
.ok()
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
use diesel_async::{
|
||||
AsyncMigrationHarness, AsyncPgConnection,
|
||||
pooled_connection::{AsyncDieselConnectionManager, deadpool::Pool},
|
||||
};
|
||||
use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations};
|
||||
|
||||
use crate::util::error::{Contextualize, Error, ErrorType};
|
||||
|
||||
pub const DB_MIGRATIONS: EmbeddedMigrations = embed_migrations!();
|
||||
|
||||
pub type DbPool = Pool<AsyncPgConnection>;
|
||||
pub type DbConn = AsyncPgConnection;
|
||||
|
||||
/// Connect to the database using the given URI, and perform migrations
|
||||
pub async fn setup<S: Into<String>>(database_uri: S) -> Result<DbPool, Error> {
|
||||
let pool_manager = AsyncDieselConnectionManager::<AsyncPgConnection>::new(database_uri);
|
||||
|
||||
let pool = Pool::builder(pool_manager)
|
||||
.build()
|
||||
// At time of writing only the `NoRuntimeSpecified` error is possible from the builder,
|
||||
// which should only occur when configuring timeouts without a `Runtime`
|
||||
.map_err(|e| ErrorType::Database(e.to_string()))
|
||||
.err_context("Error creating pool for database connections")?;
|
||||
|
||||
tracing::debug!("Establishing connection to database for migrations...");
|
||||
|
||||
let migration_conn = pool
|
||||
.get()
|
||||
.await
|
||||
.err_context("Failed to get connection to database")?;
|
||||
|
||||
tracing::debug!("Running migrations...");
|
||||
|
||||
AsyncMigrationHarness::new(migration_conn)
|
||||
.run_pending_migrations(DB_MIGRATIONS)
|
||||
.map_err(|e| ErrorType::Database(e.to_string()))
|
||||
.err_context("Failed to run pending database migrations")?;
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
use fred::prelude::*;
|
||||
|
||||
use crate::util::error::{Contextualize, Error, ErrorType};
|
||||
|
||||
const KEY_VAL_POOL_SIZE: usize = 4;
|
||||
|
||||
pub type KeyValPool = Pool;
|
||||
|
||||
pub async fn setup(connection_uri: &str) -> Result<KeyValPool, Error> {
|
||||
let config = Config::from_url(connection_uri)
|
||||
.map_err(|e| ErrorType::KeyValStore(e.to_string()))
|
||||
.err_context("Error creating key-value store config")?;
|
||||
|
||||
let pool = Builder::from_config(config)
|
||||
.build_pool(KEY_VAL_POOL_SIZE)
|
||||
// At time of writing the only error that could occur here is if config is not provided.
|
||||
// Since we're building a pool `from_config`, this shouldn't be possible
|
||||
.map_err(|e| ErrorType::KeyValStore(e.to_string()))
|
||||
.err_context("Error creating pool for key-value store")?;
|
||||
|
||||
tracing::debug!("Establishing connection to key-value store...");
|
||||
|
||||
pool.init()
|
||||
.await
|
||||
.map_err(|e| ErrorType::KeyValStore(e.to_string()))
|
||||
.err_context("Error connecting to key-value store")?;
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
@@ -1,106 +1,6 @@
|
||||
use dioxus::{
|
||||
fullstack::axum::{self, Router, middleware::from_fn},
|
||||
prelude::{DioxusRouterExt, ServeConfig},
|
||||
server::axum::Extension,
|
||||
};
|
||||
use tokio::net::TcpListener;
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
|
||||
use crate::App;
|
||||
use crate::app::LOGO_ICO;
|
||||
use crate::server::{
|
||||
auth::build_auth_layer,
|
||||
config::Config,
|
||||
database::{self, DbPool},
|
||||
key_val_store::{self, KeyValPool},
|
||||
require_auth_mw::require_auth_middleware,
|
||||
};
|
||||
use crate::util::error::{Contextualize, Error, ErrorType, Result};
|
||||
|
||||
// Build the `axum::Router` and attach config, database, and key/val store as extensions
|
||||
pub fn build_router(config: Config, db_pool: DbPool, key_val_pool: KeyValPool) -> Router {
|
||||
let favicon_path = {
|
||||
// Resolve the favicon path
|
||||
let asset_path = LOGO_ICO.resolve();
|
||||
|
||||
// If the asset path starts with "/", strip it. Otherwise it behaves as an "absolute path"
|
||||
// and replaces the base path in the join operation. This is necessary because Dioxus will
|
||||
// produce a path like "/assets/" in the call to `resolve`
|
||||
let asset_path_rel = asset_path.strip_prefix("/").unwrap_or(&asset_path);
|
||||
|
||||
config.server.public_path.join(asset_path_rel)
|
||||
};
|
||||
|
||||
let auth_layer = build_auth_layer(db_pool.clone(), key_val_pool, config.auth.cookies_secure);
|
||||
|
||||
let public_path = config.server.public_path.clone();
|
||||
|
||||
let serve_assets = ServeDir::new(public_path.join("assets"));
|
||||
let serve_index = ServeFile::new(public_path.join("index.html"));
|
||||
let serve_favicon = ServeFile::new(favicon_path);
|
||||
|
||||
let router = Router::new()
|
||||
.serve_api_application(ServeConfig::new(), App)
|
||||
.layer(from_fn(require_auth_middleware))
|
||||
.layer(Extension(config))
|
||||
.layer(Extension(db_pool))
|
||||
.layer(auth_layer);
|
||||
|
||||
// In release mode, serve precompressed assets. In debug mode, serve WASM patch files.
|
||||
if cfg!(debug_assertions) {
|
||||
let serve_wasm = ServeDir::new(public_path.join("wasm"));
|
||||
|
||||
router
|
||||
.nest_service("/wasm", serve_wasm)
|
||||
.nest_service("/assets", serve_assets)
|
||||
.nest_service("/index.html", serve_index)
|
||||
.nest_service("/favicon.ico", serve_favicon)
|
||||
} else {
|
||||
router
|
||||
.nest_service("/assets", serve_assets.precompressed_br())
|
||||
.nest_service("/index.html", serve_index)
|
||||
.nest_service("/favicon.ico", serve_favicon.precompressed_br())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn main() -> Result<std::convert::Infallible> {
|
||||
pub fn main() {
|
||||
#[cfg(feature = "server")]
|
||||
if let Err(e) = dotenvy::dotenv() {
|
||||
tracing::warn!("Error reading .env: {e}");
|
||||
}
|
||||
|
||||
tracing::debug!("Loading configuration...");
|
||||
let config = Config::from_env()
|
||||
.map_err(|e| Error::message_here(e.to_string()))
|
||||
.err_context("Failed to load config")?;
|
||||
|
||||
tracing::debug!("Loaded configuration: {config:#?}");
|
||||
config.log_warnings();
|
||||
|
||||
let db_pool = database::setup(config.database.connection_uri())
|
||||
.await
|
||||
.err_context("Failed database setup")?;
|
||||
|
||||
let key_val_pool = key_val_store::setup(&config.key_val_store.connection_uri())
|
||||
.await
|
||||
.err_context("Failed key-value store setup")?;
|
||||
|
||||
let addr = config.server.serve_addr();
|
||||
|
||||
tracing::info!("Setup complete, building router...");
|
||||
let router = build_router(config, db_pool, key_val_pool);
|
||||
|
||||
tracing::info!("Listening on {addr}...");
|
||||
let listener = TcpListener::bind(addr)
|
||||
.await
|
||||
.map_err(|e| ErrorType::HttpServer(e.to_string()))
|
||||
.err_context(format!("Failed to bind to {addr}"))?;
|
||||
|
||||
axum::serve(listener, router)
|
||||
.await
|
||||
.map_err(|e| ErrorType::HttpServer(e.to_string()))
|
||||
.err_context("HTTP server error")?;
|
||||
|
||||
Err(Error::new_here(ErrorType::HttpServer(
|
||||
"axum::serve should never return".to_owned(),
|
||||
)))
|
||||
}
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
pub mod database;
|
||||
pub mod key_val_store;
|
||||
pub mod main;
|
||||
pub mod require_auth_mw;
|
||||
|
||||
pub use main::main;
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
use dioxus::fullstack::{
|
||||
axum::{body::Body, extract::Request, middleware::Next},
|
||||
extract::FromRequestParts,
|
||||
http::Response,
|
||||
};
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::server::auth::AuthSession;
|
||||
|
||||
const ALLOWED_PATHS: [&str; 7] = [
|
||||
"/login",
|
||||
"/signup",
|
||||
"/api/v1/auth/login",
|
||||
"/api/v1/auth/signup",
|
||||
"/api/v1/auth/user",
|
||||
"/api/v1/auth/open-signup",
|
||||
"/api/v1/health",
|
||||
];
|
||||
|
||||
/// Axum middleware to redirect an unauthenticated request to /login unless it matches one of the allowed paths
|
||||
pub async fn require_auth_middleware(
|
||||
req: Request,
|
||||
next: Next,
|
||||
) -> Result<Response<Body>, (StatusCode, &'static str)> {
|
||||
let path = req.uri().path();
|
||||
|
||||
if ALLOWED_PATHS.contains(&path) {
|
||||
let response = next.run(req).await;
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
let (mut parts, body) = req.into_parts();
|
||||
|
||||
let auth_session = AuthSession::from_request_parts(&mut parts, &()).await?;
|
||||
|
||||
if auth_session.user.is_none() {
|
||||
let response = Response::builder()
|
||||
.status(StatusCode::TEMPORARY_REDIRECT)
|
||||
.header("Location", "/login")
|
||||
.body(Body::empty())
|
||||
.map_err(|_| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to build response",
|
||||
)
|
||||
})?;
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
let req = Request::from_parts(parts, body);
|
||||
let response = next.run(req).await;
|
||||
Ok(response)
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
use std::fmt;
|
||||
use std::panic::Location;
|
||||
|
||||
use dioxus::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A location in the source code
|
||||
/// A thin wrapper over `std::panic::Location`, which isn't `Serialize` or `Deserialize`
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ErrorLocation {
|
||||
file: String,
|
||||
line: u32,
|
||||
}
|
||||
|
||||
impl ErrorLocation {
|
||||
/// Creates a new `ErrorLocation` with the file and line number of the caller.
|
||||
#[track_caller]
|
||||
pub fn here() -> Self {
|
||||
let location = Location::caller();
|
||||
|
||||
ErrorLocation {
|
||||
file: location.file().to_string(),
|
||||
line: location.line(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a link to the source code based on the repository URL from Cargo and the Git commit
|
||||
/// from the build script. Uses a format supported by GitHub, Gitea, and GitLab.
|
||||
pub fn source_link(&self) -> String {
|
||||
format!(
|
||||
"{}/blob/{}/{}#L{}",
|
||||
env!("CARGO_PKG_REPOSITORY"),
|
||||
env!("GIT_REV"),
|
||||
self.file,
|
||||
self.line
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ErrorLocation {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}:{}", self.file, self.line)
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T, E = Error> = std::result::Result<T, E>;
|
||||
|
||||
/// Generate a random string to use as an id for the modal
|
||||
/// This allows multiple toast/modal to be present
|
||||
fn rand_modal_id() -> String {
|
||||
use rand::RngExt;
|
||||
|
||||
let mut rng = rand::rng();
|
||||
|
||||
let random_str = (0..5)
|
||||
.map(|_| rng.sample(rand::distr::Alphanumeric) as char)
|
||||
.collect::<String>();
|
||||
|
||||
format!("err-modal-{random_str}")
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, thiserror::Error)]
|
||||
pub struct Error {
|
||||
#[source]
|
||||
/// The error type and data
|
||||
source: ErrorType,
|
||||
|
||||
/// The location where the error was created
|
||||
from: ErrorLocation,
|
||||
|
||||
/// Context added to the error, and location where it was added
|
||||
context: Vec<(ErrorLocation, String)>,
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Creates a new `Error` at this location with the given error type
|
||||
#[track_caller]
|
||||
pub fn new_here(source: ErrorType) -> Self {
|
||||
Error::new(source, ErrorLocation::here())
|
||||
}
|
||||
|
||||
/// Creates a new `Error` with the given location and error type
|
||||
pub fn new(source: ErrorType, from: ErrorLocation) -> Self {
|
||||
Error {
|
||||
source,
|
||||
from,
|
||||
context: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new basic `Error` at this location with the given message
|
||||
#[track_caller]
|
||||
pub fn message_here<S: Into<String>>(message: S) -> Self {
|
||||
Error::new(ErrorType::Error(message.into()), ErrorLocation::here())
|
||||
}
|
||||
|
||||
/// Adds a context message to the error
|
||||
#[track_caller]
|
||||
pub fn with_context(mut self, context: impl Into<String>) -> Self {
|
||||
self.context.push((ErrorLocation::here(), context.into()));
|
||||
self
|
||||
}
|
||||
|
||||
/// Retrieve the "top" message of the error. Uses either the most recent context entry, or the
|
||||
/// source message if no context has been added.
|
||||
pub fn top_message(&self) -> String {
|
||||
if let Some((_location, message)) = self.context.last() {
|
||||
message.clone()
|
||||
} else {
|
||||
self.source.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the original cause of this error
|
||||
/// Like `std::error::Error::source` but doesn't return an `Option`
|
||||
pub fn source(&self) -> &ErrorType {
|
||||
&self.source
|
||||
}
|
||||
|
||||
/// Display this error as a modal dialog activated by a checkbox with the given id
|
||||
pub fn as_modal(&self, id: String) -> Element {
|
||||
rsx! {
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
class: "modal-toggle",
|
||||
id: &id,
|
||||
}
|
||||
|
||||
div {
|
||||
class: "modal",
|
||||
role: "dialog",
|
||||
|
||||
div {
|
||||
class: "modal-box border border-error bg-soft-error max-w-200",
|
||||
|
||||
h2 {
|
||||
class: "flex items-center gap-3 text-lg",
|
||||
|
||||
lucide_dioxus::CircleAlert {
|
||||
class: "shrink-0",
|
||||
}
|
||||
{self.top_message()}
|
||||
}
|
||||
|
||||
p {
|
||||
class: "text-base-content/70 py-3",
|
||||
"Details"
|
||||
}
|
||||
|
||||
div {
|
||||
class: "md:grid md:grid-cols-[fit-content(calc(var(--spacing)*30))_auto] md:gap-1 md:gap-x-2 mb-6",
|
||||
|
||||
for (location, message) in self.context.iter().rev().chain(std::iter::once(&(
|
||||
self.from.clone(),
|
||||
self.source.to_string(),
|
||||
))) {
|
||||
a {
|
||||
class: "text-base-content/80 interact underline",
|
||||
target: "_blank",
|
||||
href: location.source_link(),
|
||||
{location.to_string()}
|
||||
}
|
||||
|
||||
p {
|
||||
class: "ml-3 md:ml-0",
|
||||
{message.to_string()}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
class: "text-base-content/50 interact underline",
|
||||
target: "_blank",
|
||||
href: format!("{}/issues/new", env!("CARGO_PKG_REPOSITORY")),
|
||||
"Report an issue"
|
||||
}
|
||||
|
||||
label {
|
||||
class: "absolute right-1 top-1 interact hover:bg-base-100/70 p-1 rounded-full",
|
||||
r#for: &id,
|
||||
lucide_dioxus::X {
|
||||
class: "size-7 md:size-5",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
label {
|
||||
class: "modal-backdrop cursor-pointer",
|
||||
r#for: id,
|
||||
"Close",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert this error into an alert label, tied to the given modal id
|
||||
fn as_alert_for(&self, modal_id: String) -> Element {
|
||||
rsx! {
|
||||
label {
|
||||
class: "alert alert-error alert-soft cursor-pointer",
|
||||
role: "alert",
|
||||
r#for: modal_id,
|
||||
lucide_dioxus::CircleAlert {}
|
||||
|
||||
p {
|
||||
class: "max-w-120 text-ellipsis line-clamp-3",
|
||||
{self.top_message()}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert this error to a toast message, which opens a modal when clicked
|
||||
pub fn as_toast(&self) -> Element {
|
||||
let modal_id = rand_modal_id();
|
||||
|
||||
rsx! {
|
||||
{self.as_modal(modal_id.clone())}
|
||||
|
||||
div {
|
||||
class: "toast z-99",
|
||||
{self.as_alert_for(modal_id)}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert this error into an alert label, which opens a modal when clicked
|
||||
pub fn as_alert(&self) -> Element {
|
||||
let modal_id = rand_modal_id();
|
||||
|
||||
rsx! {
|
||||
{self.as_modal(modal_id.clone())}
|
||||
{self.as_alert_for(modal_id)}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
// Write the error type and its context
|
||||
writeln!(f, "Error: {}", self.top_message())?;
|
||||
write!(f, "Context:")?;
|
||||
|
||||
for (location, message) in self.context.iter().rev().chain(std::iter::once(&(
|
||||
self.from.clone(),
|
||||
self.source.to_string(),
|
||||
))) {
|
||||
write!(f, "\n - {location}: {message}")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ServerFnError> for Error {
|
||||
#[track_caller]
|
||||
fn from(err: ServerFnError) -> Error {
|
||||
Error::new_here(ErrorType::ServerFnError(err))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
impl dioxus_fullstack::AsStatusCode for Error {
|
||||
fn as_status_code(&self) -> StatusCode {
|
||||
match &self.source {
|
||||
ErrorType::Auth(AuthError::InvalidCredentials | AuthError::Unauthorized) => {
|
||||
StatusCode::UNAUTHORIZED
|
||||
}
|
||||
ErrorType::Database(msg) if *msg == (diesel::result::Error::NotFound).to_string() => {
|
||||
StatusCode::NOT_FOUND
|
||||
}
|
||||
ErrorType::ServerFnError(e) => e.as_status_code(),
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Contextualize<R> {
|
||||
/// Add context to the `Result` if it is an `Err`.
|
||||
#[track_caller]
|
||||
fn err_context(self, context: impl Into<String>) -> R;
|
||||
}
|
||||
|
||||
impl<T, E: Into<Error>> Contextualize<Result<T>> for std::result::Result<T, E> {
|
||||
#[track_caller]
|
||||
fn err_context(self, context: impl Into<String>) -> Result<T> {
|
||||
// Closures can't (currently) `track_caller`, so a simple map_err doesn't work
|
||||
// See https://github.com/rust-lang/rust/issues/87417
|
||||
match self {
|
||||
Ok(e) => Ok(e),
|
||||
Err(e) => Err(e.into().with_context(context)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Contextualize<Result<T>> for Option<T> {
|
||||
#[track_caller]
|
||||
fn err_context(self, context: impl Into<String>) -> Result<T> {
|
||||
self.ok_or(Error::new_here(ErrorType::Error(context.into())))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, E: Into<Error>> Contextualize<Result<T>> for E {
|
||||
#[track_caller]
|
||||
fn err_context(self, context: impl Into<String>) -> Result<T> {
|
||||
Err(self.into().with_context(context))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error, Deserialize, Serialize)]
|
||||
pub enum ErrorType {
|
||||
#[error("Authentication error: {0}")]
|
||||
Auth(AuthError),
|
||||
|
||||
// Using string to represent Diesel errors, because Diesel's Error type is not `Serialize`,
|
||||
// and Diesel is only available on the server
|
||||
#[error("Database error: {0}")]
|
||||
Database(String),
|
||||
|
||||
#[error("{0}")]
|
||||
Error(String),
|
||||
|
||||
#[error("Server function error: {0}")]
|
||||
ServerFnError(ServerFnError),
|
||||
|
||||
// Using string to represent Fred errors, because Fred's Error type is not `Serialize`,
|
||||
// and Fred is only available on the server
|
||||
#[error("Key-value store error: {0}")]
|
||||
KeyValStore(String),
|
||||
|
||||
#[error("HTTP server error: {0}")]
|
||||
HttpServer(String),
|
||||
}
|
||||
|
||||
impl From<ErrorType> for Error {
|
||||
#[track_caller]
|
||||
fn from(err: ErrorType) -> Self {
|
||||
Error::new_here(err)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
impl From<diesel::result::Error> for Error {
|
||||
#[track_caller]
|
||||
fn from(err: diesel::result::Error) -> Self {
|
||||
Error::new_here(ErrorType::Database(format!("{err}")))
|
||||
}
|
||||
}
|
||||
|
||||
// This would capture any `deapool::PoolError` and treat it as a database error
|
||||
// but we're only using `deadpool` for our database, so it's fine
|
||||
#[cfg(feature = "server")]
|
||||
impl From<diesel_async::pooled_connection::deadpool::PoolError> for Error {
|
||||
#[track_caller]
|
||||
fn from(err: diesel_async::pooled_connection::deadpool::PoolError) -> Self {
|
||||
Error::new_here(ErrorType::Database(format!("{err}")))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
impl From<fred::error::Error> for Error {
|
||||
#[track_caller]
|
||||
fn from(err: fred::error::Error) -> Self {
|
||||
Error::new_here(ErrorType::KeyValStore(format!("{err}")))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error, Deserialize, Serialize)]
|
||||
pub enum AuthError {
|
||||
#[error("Invalid credentials")]
|
||||
InvalidCredentials,
|
||||
|
||||
#[error("{0}")]
|
||||
Error(String),
|
||||
|
||||
#[error("Unauthorized")]
|
||||
Unauthorized,
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
pub mod error;
|
||||
|
||||
|
||||
18
style/daisyui-theme-wrapper.js
Normal file
18
style/daisyui-theme-wrapper.js
Normal file
@@ -0,0 +1,18 @@
|
||||
let daisyui_theme = null;
|
||||
|
||||
try {
|
||||
daisyui_theme = require('daisyui/theme');
|
||||
} catch (e) { }
|
||||
|
||||
if (daisyui_theme == null) {
|
||||
try {
|
||||
daisyui_theme = require(`${process.env.DAISYUI_THEME_PATH}/daisyui-theme.js`);
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
if (daisyui_theme == null) {
|
||||
console.error('Could not find DaisyUI/theme');
|
||||
throw new Error('Could not find DaisyUI/theme');
|
||||
}
|
||||
|
||||
export default daisyui_theme.default || daisyui_theme;
|
||||
18
style/daisyui-wrapper.js
Normal file
18
style/daisyui-wrapper.js
Normal file
@@ -0,0 +1,18 @@
|
||||
let daisyui = null;
|
||||
|
||||
try {
|
||||
daisyui = require('daisyui');
|
||||
} catch (e) { }
|
||||
|
||||
if (daisyui == null) {
|
||||
try {
|
||||
daisyui = require(`${process.env.DAISYUI_PATH}/daisyui.js`);
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
if (daisyui == null) {
|
||||
console.error('Could not find DaisyUI');
|
||||
throw new Error('Could not find DaisyUI');
|
||||
}
|
||||
|
||||
export default daisyui.default || daisyui;
|
||||
@@ -1,29 +1,19 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@plugin "daisyui" {
|
||||
@plugin "./daisyui-wrapper.js" {
|
||||
themes: all;
|
||||
};
|
||||
|
||||
@plugin "daisyui-theme" {
|
||||
@plugin "./daisyui-theme-wrapper.js" {
|
||||
};
|
||||
|
||||
@source not "*";
|
||||
@source "./src/**/*.{rs,html,css}";
|
||||
|
||||
@theme {
|
||||
/* Copied out of DaisyUI theme, which doesn't make the color available */
|
||||
--color-soft-error: color-mix(in oklab, var(--color-error, var(--color-base-content)) 8%, var(--color-base-100));
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.interact {
|
||||
@apply cursor-pointer;
|
||||
@apply hover:text-base-content/70;
|
||||
@apply active:text-primary active:translate-y-[.5px] active:shadow-(--btn-shadow);
|
||||
}
|
||||
|
||||
.input:focus-within {
|
||||
outline-width: 1px;
|
||||
outline-offset: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user